From 7440306c021faa4a386da6ac5feb9e4218a02983 Mon Sep 17 00:00:00 2001 From: Kenneth VanderLinde Date: Sun, 3 Nov 2024 19:44:45 -0800 Subject: [PATCH 01/14] Add section to CodeTimer This saves us from needing a top-level `timer.start()`/`timer.stop()` pair. --- src/main/java/net/rptools/lib/CodeTimer.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/net/rptools/lib/CodeTimer.java b/src/main/java/net/rptools/lib/CodeTimer.java index 1973abd31b..8a0649de88 100644 --- a/src/main/java/net/rptools/lib/CodeTimer.java +++ b/src/main/java/net/rptools/lib/CodeTimer.java @@ -41,8 +41,11 @@ public static void using(String name, TimedSection ca stack.addLast(timer); try { + timer.start(""); callback.call(timer); } finally { + timer.stop(""); + final var lastTimer = stack.removeLast(); assert lastTimer == timer : "Timer stack is corrupted"; From 3d29a7fbcc28f645441a403c212a680327379071 Mon Sep 17 00:00:00 2001 From: Kenneth VanderLinde Date: Fri, 1 Nov 2024 09:50:55 -0700 Subject: [PATCH 02/14] Move AffineTransform variable to where it is needed --- .../maptool/client/ui/zone/renderer/ZoneRenderer.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/net/rptools/maptool/client/ui/zone/renderer/ZoneRenderer.java b/src/main/java/net/rptools/maptool/client/ui/zone/renderer/ZoneRenderer.java index b91fb4fe9d..391e9bfef5 100644 --- a/src/main/java/net/rptools/maptool/client/ui/zone/renderer/ZoneRenderer.java +++ b/src/main/java/net/rptools/maptool/client/ui/zone/renderer/ZoneRenderer.java @@ -935,10 +935,6 @@ public void renderZone(Graphics2D g2d, PlayerView view) { // Calculations timer.start("calcs-1"); - AffineTransform af = new AffineTransform(); - af.translate(zoneScale.getOffsetX(), zoneScale.getOffsetY()); - af.scale(getScale(), getScale()); - if (visibleScreenArea == null) { timer.start("ZoneRenderer-getVisibleArea"); Area a = zoneView.getVisibleArea(view); @@ -946,6 +942,9 @@ public void renderZone(Graphics2D g2d, PlayerView view) { timer.start("createTransformedArea"); if (!a.isEmpty()) { + AffineTransform af = new AffineTransform(); + af.translate(zoneScale.getOffsetX(), zoneScale.getOffsetY()); + af.scale(getScale(), getScale()); visibleScreenArea = a.createTransformedArea(af); } timer.stop("createTransformedArea"); From 1bdc612139ac93082ec8689869c78c7e171c0630 Mon Sep 17 00:00:00 2001 From: Kenneth VanderLinde Date: Fri, 1 Nov 2024 09:28:04 -0700 Subject: [PATCH 03/14] Introduce new topology types `MaskTopology` represents legacy masks as polygons. This is used in lighting and pathfinding algorithms, though the original `Area` is maintained and serialized in the model for the sake of existing tools and macros. `WallTopology` is a new form of topology, made of thin walls that form a graph. Edge wall (edge) blocks both vision and movement from any direction. The plan is to extend this in the future to control vision and movement separately, and to add directionality to the wall, but for now only the basic option is available. `WallTopologyConverter` ensures that `WallTopology` can be serialized without exposing the implementation details, and to make the XML look reasonable. --- build.gradle | 2 + src/main/java/net/rptools/lib/FileUtil.java | 2 + .../java/net/rptools/lib/GeometryUtil.java | 8 + .../converters/WallTopologyConverter.java | 174 +++++ .../maptool/model/topology/MaskTopology.java | 246 +++++++ .../maptool/model/topology/Topology.java | 23 + .../maptool/model/topology/VisionResult.java | 20 + .../maptool/model/topology/WallTopology.java | 633 ++++++++++++++++++ src/main/proto/data_transfer_objects.proto | 21 +- 9 files changed, 1128 insertions(+), 1 deletion(-) create mode 100644 src/main/java/net/rptools/maptool/model/converters/WallTopologyConverter.java create mode 100644 src/main/java/net/rptools/maptool/model/topology/MaskTopology.java create mode 100644 src/main/java/net/rptools/maptool/model/topology/Topology.java create mode 100644 src/main/java/net/rptools/maptool/model/topology/VisionResult.java create mode 100644 src/main/java/net/rptools/maptool/model/topology/WallTopology.java diff --git a/build.gradle b/build.gradle index d3bfe5e7d6..bdfbe7242f 100644 --- a/build.gradle +++ b/build.gradle @@ -481,6 +481,8 @@ dependencies { implementation 'com.github.jknack:handlebars:4.3.1' implementation 'com.github.jknack:handlebars-helpers:4.3.1' + implementation 'org.jgrapht:jgrapht-core:1.5.2' + // Built In Add-on Libraries implementation 'com.github.RPTools:maptool-builtin-addons:1.3' diff --git a/src/main/java/net/rptools/lib/FileUtil.java b/src/main/java/net/rptools/lib/FileUtil.java index 2e8c1624e0..45ecb8e511 100644 --- a/src/main/java/net/rptools/lib/FileUtil.java +++ b/src/main/java/net/rptools/lib/FileUtil.java @@ -44,6 +44,7 @@ import net.rptools.maptool.client.ui.token.BarTokenOverlay; import net.rptools.maptool.model.AStarCellPointConverter; import net.rptools.maptool.model.ShapeType; +import net.rptools.maptool.model.converters.WallTopologyConverter; import org.apache.commons.io.FileUtils; import org.apache.commons.io.IOUtils; import org.apache.logging.log4j.LogManager; @@ -484,6 +485,7 @@ public static XStream getConfiguredXStream() { XStream.setupDefaultSecurity(xStream); xStream.allowTypesByWildcard(new String[] {"net.rptools.**", "java.awt.**", "sun.awt.**"}); xStream.registerConverter(new AStarCellPointConverter()); + xStream.registerConverter(new WallTopologyConverter(xStream)); xStream.addImmutableType(ShapeType.class, true); xStream.addImmutableType(BarTokenOverlay.Side.class, true); return xStream; diff --git a/src/main/java/net/rptools/lib/GeometryUtil.java b/src/main/java/net/rptools/lib/GeometryUtil.java index 8926a9e961..5eeb853ad7 100644 --- a/src/main/java/net/rptools/lib/GeometryUtil.java +++ b/src/main/java/net/rptools/lib/GeometryUtil.java @@ -171,4 +171,12 @@ public static Collection toJtsPolygons(Area area) { return toPolygonizer(area).getPolygons(); } + + public static Point2D coordinateToPoint2D(Coordinate coordinate) { + return new Point2D.Double(coordinate.getX(), coordinate.getY()); + } + + public static Coordinate point2DToCoordinate(Point2D point2D) { + return new Coordinate(point2D.getX(), point2D.getY()); + } } diff --git a/src/main/java/net/rptools/maptool/model/converters/WallTopologyConverter.java b/src/main/java/net/rptools/maptool/model/converters/WallTopologyConverter.java new file mode 100644 index 0000000000..2398b3405e --- /dev/null +++ b/src/main/java/net/rptools/maptool/model/converters/WallTopologyConverter.java @@ -0,0 +1,174 @@ +/* + * This software Copyright by the RPTools.net development team, and + * licensed under the Affero GPL Version 3 or, at your option, any later + * version. + * + * MapTool Source Code is distributed in the hope that it will be + * useful, but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * You should have received a copy of the GNU Affero General Public + * License * along with this source Code. If not, please visit + * and specifically the Affero license + * text at . + */ +package net.rptools.maptool.model.converters; + +import com.thoughtworks.xstream.XStream; +import com.thoughtworks.xstream.annotations.XStreamAsAttribute; +import com.thoughtworks.xstream.annotations.XStreamImplicit; +import com.thoughtworks.xstream.converters.MarshallingContext; +import com.thoughtworks.xstream.converters.UnmarshallingContext; +import com.thoughtworks.xstream.converters.collections.AbstractCollectionConverter; +import com.thoughtworks.xstream.io.HierarchicalStreamReader; +import com.thoughtworks.xstream.io.HierarchicalStreamWriter; +import java.awt.geom.Point2D; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import net.rptools.maptool.model.GUID; +import net.rptools.maptool.model.topology.WallTopology; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Converts {@link WallTopology} to a nice representation that is independent of the runtime + * representation. + * + *

The format of a serialized {@link WallTopology} looks like this: + * + *

{@code
+ * 
+ *   
+ *     
+ *   
+ *   
+ *     
+ *   
+ *   
+ *     
+ *   
+ * 
+ * 
+ *   
+ *   
+ * 
+ * ...
+ * }
+ */ +public class WallTopologyConverter extends AbstractCollectionConverter { + private static final Logger log = LogManager.getLogger(WallTopologyConverter.class); + + public WallTopologyConverter(XStream xStream) { + super(xStream.getMapper()); + xStream.processAnnotations(GraphRepresentation.class); + } + + @Override + public boolean canConvert(Class type) { + return WallTopology.class.isAssignableFrom(type); + } + + @Override + public void marshal(Object source, HierarchicalStreamWriter writer, MarshallingContext context) { + var walls = (WallTopology) source; + var representation = new GraphRepresentation(); + + walls + .getVertices() + .sorted(Comparator.comparingInt(WallTopology.Vertex::getZIndex)) + .map( + vertex -> + new VertexRepresentation( + vertex.id(), vertex.getPosition().getX(), vertex.getPosition().getY())) + .forEach(representation.vertices.list::add); + walls + .getWalls() + .sorted(Comparator.comparingInt(WallTopology.Wall::getZIndex)) + .map(wall -> new WallRepresentation(wall.from().id(), wall.to().id())) + .forEach(representation.walls.list::add); + + context.convertAnother(representation); + } + + @Override + public Object unmarshal(HierarchicalStreamReader reader, UnmarshallingContext context) { + var walls = new WallTopology(); + var representation = + (GraphRepresentation) context.convertAnother(walls, GraphRepresentation.class); + for (var vertexRepresentation : representation.vertices.list) { + try { + var vertex = walls.createVertex(new GUID(vertexRepresentation.id)); + vertex.setPosition(vertexRepresentation.x, vertexRepresentation.y); + } catch (WallTopology.GraphException e) { + log.error( + "A vertex with ID {} is already defined; skipping this one", + vertexRepresentation.id, + e); + } + } + for (var wallRepresentation : representation.walls.list) { + try { + walls.createWall(new GUID(wallRepresentation.from), new GUID(wallRepresentation.to)); + } catch (WallTopology.GraphException e) { + log.error( + "A wall with vertices ({}, {}) is already defined; skipping this one", + wallRepresentation.from, + wallRepresentation.to, + e); + } + } + return walls; + } + + // region Intermediate representation that contains only the essence of the wall graph. + + private static final class GraphRepresentation { + public final VerticesRepresentation vertices = new VerticesRepresentation(); + public final WallsRepresentation walls = new WallsRepresentation(); + } + + private static final class VerticesRepresentation { + @XStreamImplicit(itemFieldName = "vertex") + public final List list = new ArrayList<>(); + } + + private static final class WallsRepresentation { + @XStreamImplicit(itemFieldName = "wall") + public final List list = new ArrayList<>(); + } + + private static final class VertexRepresentation { + @XStreamAsAttribute public final String id; + @XStreamAsAttribute public final double x; + @XStreamAsAttribute public final double y; + + public VertexRepresentation(GUID id, double x, double y) { + this.id = id.toString(); + this.x = x; + this.y = y; + } + } + + private static final class WallRepresentation { + @XStreamAsAttribute public final String from; + @XStreamAsAttribute public final String to; + + public WallRepresentation(GUID from, GUID to) { + this.from = from.toString(); + this.to = to.toString(); + } + } + + private static final class PointRepresentation { + @XStreamAsAttribute public final double x; + @XStreamAsAttribute public final double y; + + public PointRepresentation(Point2D position) { + this.x = position.getX(); + this.y = position.getY(); + } + } + + // endregion +} diff --git a/src/main/java/net/rptools/maptool/model/topology/MaskTopology.java b/src/main/java/net/rptools/maptool/model/topology/MaskTopology.java new file mode 100644 index 0000000000..8c91611e5c --- /dev/null +++ b/src/main/java/net/rptools/maptool/model/topology/MaskTopology.java @@ -0,0 +1,246 @@ +/* + * This software Copyright by the RPTools.net development team, and + * licensed under the Affero GPL Version 3 or, at your option, any later + * version. + * + * MapTool Source Code is distributed in the hope that it will be + * useful, but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * You should have received a copy of the GNU Affero General Public + * License * along with this source Code. If not, please visit + * and specifically the Affero license + * text at . + */ +package net.rptools.maptool.model.topology; + +import java.awt.geom.Area; +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import net.rptools.lib.GeometryUtil; +import net.rptools.maptool.client.ui.zone.vbl.Facing; +import net.rptools.maptool.model.Zone.TopologyType; +import org.locationtech.jts.algorithm.Orientation; +import org.locationtech.jts.algorithm.RayCrossingCounter; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.CoordinateSequence; +import org.locationtech.jts.geom.Envelope; +import org.locationtech.jts.geom.LinearRing; +import org.locationtech.jts.geom.Location; +import org.locationtech.jts.geom.Polygon; + +public final class MaskTopology implements Topology { + private final TopologyType type; + private final Polygon polygon; + + private MaskTopology(TopologyType type, Polygon polygon) { + this.type = type; + this.polygon = polygon; + } + + public static MaskTopology create(TopologyType type, Polygon polygon) { + // Build a new polygon with the same rings, but with orientation enforced. + // Exterior much be counterclockwise, while holes must be clockwise. + var boundary = polygon.getExteriorRing(); + if (Orientation.isCCW(boundary.getCoordinateSequence())) { + boundary = boundary.reverse(); + } + final var holeCount = polygon.getNumInteriorRing(); + var holes = new LinearRing[holeCount]; + for (int i = 0; i < holeCount; ++i) { + var hole = polygon.getInteriorRingN(i); + if (!Orientation.isCCW(hole.getCoordinates())) { + hole = hole.reverse(); + } + holes[i] = hole; + } + polygon = GeometryUtil.getGeometryFactory().createPolygon(boundary, holes); + + return new MaskTopology(type, polygon); + } + + public static List createFromLegacy(TopologyType type, Area area) { + var masks = new ArrayList(); + if (area == null || area.isEmpty()) { + return masks; + } + + var polys = GeometryUtil.toJtsPolygons(area); + for (var poly : polys) { + masks.add(MaskTopology.create(type, poly)); + } + return masks; + } + + public TopologyType getType() { + return type; + } + + public Polygon getPolygon() { + return polygon; + } + + @Override + public VisionResult addSegments(Coordinate origin, Envelope bounds, Consumer sink) { + // Convenience for adding rings to the result. + BiConsumer add = + (ring, facing) -> + getFacingSegments(ring.getCoordinateSequence(), facing, origin, bounds, sink); + + // This might look weird to have separate switches for inside and out, but it's much + // cleaner. + boolean isInside = + Location.EXTERIOR + != RayCrossingCounter.locatePointInRing( + origin, polygon.getExteriorRing().getCoordinateSequence()); + + if (!isInside) { + switch (type) { + case WALL_VBL -> { + // Just the frontside of the exterior needs to be added. + add.accept(polygon.getExteriorRing(), Facing.OCEAN_SIDE_FACES_ORIGIN); + } + case HILL_VBL -> { + // Frontside of holes and backside of interior will block. All handled the same. + add.accept(polygon.getExteriorRing(), Facing.ISLAND_SIDE_FACES_ORIGIN); + for (var i = 0; i < polygon.getNumInteriorRing(); ++i) { + add.accept(polygon.getInteriorRingN(i), Facing.ISLAND_SIDE_FACES_ORIGIN); + } + } + case PIT_VBL -> { + // Pit VBL does nothing when looked at from the outside. + } + case COVER_VBL -> { + // Works just like Wall VBL when viewed from outside. + add.accept(polygon.getExteriorRing(), Facing.OCEAN_SIDE_FACES_ORIGIN); + } + case MBL -> { + // MBL does not take part in vision. + } + } + } else { + // Now check if we're in a hole. If not, we're inside the mask proper. + int holeIndex = -1; + for (var i = 0; i < polygon.getNumInteriorRing(); ++i) { + // Holes are open, i.e., do not contain their boundary. + var location = + RayCrossingCounter.locatePointInRing( + origin, polygon.getInteriorRingN(i).getCoordinateSequence()); + if (Location.INTERIOR == location) { + holeIndex = i; + break; + } + } + + switch (type) { + case WALL_VBL -> { + if (holeIndex < 0) { + // Point is not in any hole, so vision is completely blocked. + return VisionResult.CompletelyObscured; + } else { + // Point is in a hole, so vision is blocked by the boundary of that hole. + add.accept(polygon.getInteriorRingN(holeIndex), Facing.OCEAN_SIDE_FACES_ORIGIN); + } + } + case HILL_VBL -> { + // If not in a hole, no blocking. If in a hole, blocks by the frontside of all other holes + // as well as the exterior. + if (holeIndex >= 0) { + add.accept(polygon.getExteriorRing(), Facing.ISLAND_SIDE_FACES_ORIGIN); + for (int i = 0; i < polygon.getNumInteriorRing(); ++i) { + if (i != holeIndex) { + add.accept(polygon.getInteriorRingN(i), Facing.ISLAND_SIDE_FACES_ORIGIN); + } + } + } + } + case PIT_VBL -> { + // Not blocking if in a hole. Otherwise, block by the boundary and each hole. + if (holeIndex < 0) { + add.accept(polygon.getExteriorRing(), Facing.ISLAND_SIDE_FACES_ORIGIN); + for (int i = 0; i < polygon.getNumInteriorRing(); ++i) { + add.accept(polygon.getInteriorRingN(i), Facing.ISLAND_SIDE_FACES_ORIGIN); + } + } + } + case COVER_VBL -> { + // If in a hole, block by the boundary of that hole. If not in any hole, only block by the + // exterior in cases of peninsulas and block by the backside of any hole. + if (holeIndex < 0) { + add.accept(polygon.getExteriorRing(), Facing.OCEAN_SIDE_FACES_ORIGIN); + for (int i = 0; i < polygon.getNumInteriorRing(); ++i) { + add.accept(polygon.getInteriorRingN(i), Facing.OCEAN_SIDE_FACES_ORIGIN); + } + } else { + add.accept(polygon.getInteriorRingN(holeIndex), Facing.OCEAN_SIDE_FACES_ORIGIN); + } + } + case MBL -> { + // MBL does not take part in vision. + } + } + } + + return VisionResult.Possible; + } + + private static void getFacingSegments( + CoordinateSequence vertices, + Facing facing, + Coordinate origin, + Envelope bounds, + Consumer sink) { + if (vertices.size() == 0) { + return; + } + + final var requiredOrientation = + facing == Facing.ISLAND_SIDE_FACES_ORIGIN + ? Orientation.CLOCKWISE + : Orientation.COUNTERCLOCKWISE; + + final Coordinate previous = new Coordinate(); + final Coordinate current = new Coordinate(); + + List currentSegmentPoints = new ArrayList<>(); + for (int i = 1; i < vertices.size(); ++i) { + assert currentSegmentPoints.size() == 0 || currentSegmentPoints.size() >= 2; + + vertices.getCoordinate(i - 1, previous); + vertices.getCoordinate(i, current); + + final var shouldIncludeFace = + // Don't need to be especially precise with the vision check. + bounds.intersects(previous, current) + && requiredOrientation == Orientation.index(origin, previous, current); + + if (shouldIncludeFace) { + // Since we're including this face, the existing segment can be extended. + if (currentSegmentPoints.isEmpty()) { + // Also need the first point. + currentSegmentPoints.add(new Coordinate(previous)); + } + currentSegmentPoints.add(new Coordinate(current)); + } else if (!currentSegmentPoints.isEmpty()) { + // Since we're skipping this face, the segment is broken and we must start a new one. + var string = currentSegmentPoints; + if (requiredOrientation != Orientation.COUNTERCLOCKWISE) { + string = string.reversed(); + } + sink.accept(string.toArray(Coordinate[]::new)); + currentSegmentPoints.clear(); + } + } + + assert currentSegmentPoints.size() == 0 || currentSegmentPoints.size() >= 2; + if (!currentSegmentPoints.isEmpty()) { + var string = currentSegmentPoints; + if (requiredOrientation != Orientation.COUNTERCLOCKWISE) { + string = string.reversed(); + } + sink.accept(string.toArray(Coordinate[]::new)); + } + } +} diff --git a/src/main/java/net/rptools/maptool/model/topology/Topology.java b/src/main/java/net/rptools/maptool/model/topology/Topology.java new file mode 100644 index 0000000000..f562c08b7e --- /dev/null +++ b/src/main/java/net/rptools/maptool/model/topology/Topology.java @@ -0,0 +1,23 @@ +/* + * This software Copyright by the RPTools.net development team, and + * licensed under the Affero GPL Version 3 or, at your option, any later + * version. + * + * MapTool Source Code is distributed in the hope that it will be + * useful, but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * You should have received a copy of the GNU Affero General Public + * License * along with this source Code. If not, please visit + * and specifically the Affero license + * text at . + */ +package net.rptools.maptool.model.topology; + +import java.util.function.Consumer; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Envelope; + +public interface Topology { + VisionResult addSegments(Coordinate origin, Envelope bounds, Consumer sink); +} diff --git a/src/main/java/net/rptools/maptool/model/topology/VisionResult.java b/src/main/java/net/rptools/maptool/model/topology/VisionResult.java new file mode 100644 index 0000000000..a02ab4a22c --- /dev/null +++ b/src/main/java/net/rptools/maptool/model/topology/VisionResult.java @@ -0,0 +1,20 @@ +/* + * This software Copyright by the RPTools.net development team, and + * licensed under the Affero GPL Version 3 or, at your option, any later + * version. + * + * MapTool Source Code is distributed in the hope that it will be + * useful, but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * You should have received a copy of the GNU Affero General Public + * License * along with this source Code. If not, please visit + * and specifically the Affero license + * text at . + */ +package net.rptools.maptool.model.topology; + +public enum VisionResult { + Possible, + CompletelyObscured +} diff --git a/src/main/java/net/rptools/maptool/model/topology/WallTopology.java b/src/main/java/net/rptools/maptool/model/topology/WallTopology.java new file mode 100644 index 0000000000..d6528efedf --- /dev/null +++ b/src/main/java/net/rptools/maptool/model/topology/WallTopology.java @@ -0,0 +1,633 @@ +/* + * This software Copyright by the RPTools.net development team, and + * licensed under the Affero GPL Version 3 or, at your option, any later + * version. + * + * MapTool Source Code is distributed in the hope that it will be + * useful, but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * You should have received a copy of the GNU Affero General Public + * License * along with this source Code. If not, please visit + * and specifically the Affero license + * text at . + */ +package net.rptools.maptool.model.topology; + +import java.awt.geom.Point2D; +import java.util.*; +import java.util.function.Consumer; +import java.util.stream.Stream; +import net.rptools.lib.GeometryUtil; +import net.rptools.maptool.model.GUID; +import net.rptools.maptool.server.proto.VertexDto; +import net.rptools.maptool.server.proto.WallDto; +import net.rptools.maptool.server.proto.WallTopologyDto; +import net.rptools.maptool.server.proto.drawing.DoublePointDto; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jgrapht.Graph; +import org.jgrapht.graph.builder.GraphTypeBuilder; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Envelope; +import org.locationtech.jts.geom.LineSegment; + +/** + * Topology based on thin connected walls. + * + *

Wall topology forms a graph where the walls themselves are the edges of the graph. + */ +public final class WallTopology implements Topology { + private static final Logger log = LogManager.getLogger(WallTopology.class); + + public static final class GraphException extends Exception { + public GraphException(String message, Throwable cause) { + super(message, cause); + } + } + + /** + * Represents the end of one or more walls. + * + *

Multiple {@code Vertex} instances may be created for a single vertex. All such instances can + * be used interchangeably and with compare equal. + */ + public final class Vertex { + private final GUID id; + private final VertexInternal internal; + + private Vertex(GUID id, VertexInternal internal) { + this.id = id; + this.internal = internal; + } + + private WallTopology getOriginator() { + return WallTopology.this; + } + + @Override + public boolean equals(Object obj) { + return obj instanceof Vertex vertex && Objects.equals(id, vertex.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + public GUID id() { + return id; + } + + public int getZIndex() { + return internal.zIndex(); + } + + public Point2D getPosition() { + var position = new Point2D.Double(); + position.setLocation(internal.position()); + return position; + } + + public void setPosition(double x, double y) { + internal.position().setLocation(x, y); + } + + public void setPosition(Point2D position) { + setPosition(position.getX(), position.getY()); + } + } + + /** + * Represents a single wall. + * + *

Multiple {@code Wall} instances may be created for a single wall. All such instances can be + * used interchangeably and will compare equal. + */ + public final class Wall { + private final Vertex from; + private final Vertex to; + + private Wall(Vertex from, Vertex to) { + this.from = from; + this.to = to; + } + + private WallTopology getOriginator() { + return WallTopology.this; + } + + @Override + public boolean equals(Object obj) { + return obj instanceof Wall wall + && Objects.equals(from, wall.from) + && Objects.equals(to, wall.to); + } + + @Override + public int hashCode() { + return Objects.hash(from, to); + } + + public Vertex from() { + return from; + } + + public Vertex to() { + return to; + } + + public int getZIndex() { + return Math.max(from.getZIndex(), to.getZIndex()); + } + + /** + * Represent this wall as a {@link LineSegment}. + * + *

This is a convenience method for getting the coordinates from {@link #from()} and {@link + * #to()}. Since {@link LineSegment} cannot carry user data, the result does not include any + * attached wall data. + * + * @return The {@link LineSegment} representation of the wall. + */ + public LineSegment asSegment() { + return new LineSegment( + GeometryUtil.point2DToCoordinate(from.getPosition()), + GeometryUtil.point2DToCoordinate(to.getPosition())); + } + } + + /** + * Internal payload for a vertex. + * + *

Users of {@link WallTopology} will interact with the {@link Vertex} wrapper rather than with + * {@code VertexInternal} directly. + * + *

The position is intentionally mutable as vertices are free to move about the plane without + * affecting the graph structure. + */ + private static final class VertexInternal { + private final GUID id; + private final Point2D position; + private int zIndex; + + public VertexInternal(GUID id, Point2D position) { + this.id = id; + this.position = position; + this.zIndex = -1; + } + + public GUID id() { + return id; + } + + public Point2D position() { + return position; + } + + public int zIndex() { + return zIndex; + } + } + + /** + * Internal representation of a wall. + * + *

Even though the underlying graph is undirected, walls still have a concept of a direction + * This is only important when considering orientation, which is why it is not part of the graph + * structure. + * + *

Uses of {@link WallTopology} will consume {@link Wall} instead of {@code WallInternal}. That + * representation has all IDs already resolved to {@link Vertex}. + * + * @param from The ID of the source vertex. + * @param to The ID of the target vertex. + */ + private record WallInternal(GUID from, GUID to) { + @Override + public boolean equals(Object obj) { + return this == obj; + } + + @Override + public int hashCode() { + return System.identityHashCode(this); + } + } + + /** + * Return type for {@link #string(Point2D, Consumer)} that ensures walls are built in a valid + * fashion. + */ + public final class StringBuilder { + // These vertices and walls are not added to the graph until build() is called. + private final Point2D firstVertexPosition; + private final ArrayList vertexPositions = new ArrayList<>(); + + private StringBuilder(Point2D startingPoint) { + this.firstVertexPosition = new Point2D.Double(startingPoint.getX(), startingPoint.getY()); + } + + public void push(Point2D nextPoint) { + this.vertexPositions.add(new Point2D.Double(nextPoint.getX(), nextPoint.getY())); + } + + /** Finish the string and return the last vertex. */ + public void build() { + if (vertexPositions.isEmpty()) { + // Insufficient points to add to the graph, otherwise vertices would dangle. + return; + } + + var previousVertex = createVertex(); + previousVertex.setPosition(firstVertexPosition); + + for (int i = 0; i < vertexPositions.size(); ++i) { + var currentPosition = vertexPositions.get(i); + + var currentVertex = createVertex(); + currentVertex.setPosition(currentPosition); + + try { + createWallImpl(previousVertex.id(), currentVertex.id()); + } catch (GraphException e) { + throw new RuntimeException( + "Unexpected scenario: wall already exists despite vertex being new", e); + } + + previousVertex = currentVertex; + } + } + } + + // Vertices are identified by GUID. Attached data is maintained in vertexInternalById + private final Graph graph; + private final Map vertexInternalById = new LinkedHashMap<>(); + private transient int nextZIndex = 0; + + public WallTopology() { + this.graph = + GraphTypeBuilder.undirected() + .allowingMultipleEdges(false) + .allowingSelfLoops(false) + .weighted(false) + .buildGraph(); + } + + public WallTopology(WallTopology other) { + this(); + + other + .getVertices() + .sorted(Comparator.comparingInt(Vertex::getZIndex)) + .forEach( + vertex -> { + VertexInternal newVertexInternal; + try { + newVertexInternal = createVertexImpl(vertex.id()); + } catch (GraphException e) { + log.error( + "Unexpected scenario: graph has duplicate vertex ID {}. Skipping.", + vertex.id(), + e); + return; + } + newVertexInternal.position.setLocation(vertex.getPosition()); + }); + + other + .getWalls() + .sorted(Comparator.comparingInt(Wall::getZIndex)) + .forEach( + wall -> { + try { + createWallImpl(wall.from().id(), wall.to().id()); + } catch (GraphException e) { + log.error( + "Unexpected scenario: topology has duplicate wall ({}, {}). Skipping.", + wall.from().id(), + wall.to().id(), + e); + } + }); + } + + private VertexInternal createVertexImpl(GUID id) throws GraphException { + var added = graph.addVertex(id); + if (!added) { + throw new GraphException("Vertex with that GUID is already in the graph", null); + } + + // Because we store the vertices in a LinkedHashMap, they will always be iterated in insertion + // order. We also include a z-index so that other components can put vertices in other data + // structures and sort them again by z-order. + var data = new VertexInternal(id, new Point2D.Double()); + data.zIndex = ++nextZIndex; + var previous = vertexInternalById.put(id, data); + assert previous == null : "Invariant not held"; + + return data; + } + + private WallInternal createWallImpl(GUID fromId, GUID toId) throws GraphException { + var wall = new WallInternal(fromId, toId); + + boolean added; + try { + added = graph.addEdge(fromId, toId, wall); + } catch (IllegalArgumentException e) { + throw new GraphException("One of the vertices does not exist in the graph", e); + } + + if (!added) { + throw new GraphException("Wall with those vertices is already in the graph", null); + } + return wall; + } + + // region These exist to support serialization. They are little loose for general use. + + /** + * Adds a new vertex to the graph. + * + * @param id The ID of the vertex + * @throws GraphException If a vertex with ID {@code id} already exists. + */ + public Vertex createVertex(GUID id) throws GraphException { + var vertexInternal = createVertexImpl(id); + return new Vertex(id, vertexInternal); + } + + public void createWall(GUID fromId, GUID toId) throws GraphException { + createWallImpl(fromId, toId); + } + + // endregion + + // region Other builder methods + + private Vertex createVertex() { + var id = new GUID(); + VertexInternal vertexInternal; + try { + vertexInternal = createVertexImpl(id); + } catch (GraphException e) { + throw new RuntimeException("Impossible case: vertex already exists with new GUID", e); + } + return new Vertex(id, vertexInternal); + } + + public void string(Point2D startingPoint, Consumer action) { + var builder = new StringBuilder(startingPoint); + action.accept(builder); + builder.build(); + } + + // endregion + + // region Main interface + + public Stream getVertices() { + return this.graph.vertexSet().stream() + .map( + id -> { + var vertexInternal = vertexInternalById.get(id); + return new Vertex(id, vertexInternal); + }); + } + + public Stream getWalls() { + return this.graph.edgeSet().stream() + .map( + wall -> { + var fromId = wall.from(); + var toId = wall.to(); + var fromInternal = vertexInternalById.get(fromId); + var toInternal = vertexInternalById.get(toId); + return new Wall(new Vertex(fromId, fromInternal), new Vertex(toId, toInternal)); + }); + } + + public void removeVertex(Vertex vertex) { + if (this != vertex.getOriginator()) { + throw new RuntimeException("Foreign vertex"); + } + + graph.removeVertex(vertex.id()); + vertexInternalById.remove(vertex.id()); + + removeDanglingVertices(); + } + + public void removeWall(Wall wall) { + if (this != wall.getOriginator()) { + throw new RuntimeException("Wall is not part of this topology"); + } + + var removedWall = graph.removeEdge(wall.from().id(), wall.to().id()); + if (removedWall != null) { + removeDanglingVertices(); + } + } + + public Wall brandNewWall() { + var from = createVertex(); + var to = createVertex(); + + WallInternal newWall; + try { + newWall = createWallImpl(from.id(), to.id()); + } catch (GraphException e) { + throw new RuntimeException( + "Unexpected scenario: wall already exists despite both vertices being new", e); + } + + return new Wall(from, to); + } + + public Wall newWallStartingAt(Vertex from) { + if (this != from.getOriginator()) { + throw new RuntimeException("Vertex is not part of this topology"); + } + + var newVertex = createVertex(); + + WallInternal newWall; + try { + newWall = createWallImpl(from.id(), newVertex.id()); + } catch (GraphException e) { + throw new RuntimeException( + "Unexpected scenario: wall already exists despite the one vertex being new", e); + } + + return new Wall(from, newVertex); + } + + public Vertex splitWall(Wall wall) { + if (this != wall.getOriginator()) { + throw new RuntimeException("Wall is not part of this topology"); + } + + // Remove wall and replace with two new walls connected through a new vertex. + + var removed = graph.removeEdge(wall.from().id(), wall.to().id()); + if (removed == null) { + throw new RuntimeException("Wall does not exist"); + } + // Don't trust the contents of `wall`, instead use `removed`. + + var newVertex = createVertex(); + + try { + createWallImpl(removed.from(), newVertex.id()); + createWallImpl(newVertex.id(), removed.to()); + } catch (GraphException e) { + throw new RuntimeException("Unexpected scenario: wall already exists for new vertex", e); + } + + return newVertex; + } + + /** + * Merge {@code first} with {@code second}. + * + *

How this merger is done is up to the implementation. Either {@code first} or {@code second} + * may remaining in the graph while the other is removed, or both may be removed and replaced with + * a new vertex. Callers should use the return value if the new or surviving vertex is required. + * + * @param first The vertex to remove by merging. + * @param second The vertex to augment by merging {@code remove} into it. + * @return The merged vertex. + */ + public Vertex merge(Vertex first, Vertex second) { + if (this != first.getOriginator()) { + throw new RuntimeException("Foreign vertex"); + } + if (this != second.getOriginator()) { + throw new RuntimeException("Foreign vertex"); + } + + // Current implementation is to remove `first` and keep `second. + + // A copy is essential since graph.edgesOf() returns a live set. + var incidentWalls = new ArrayList<>(graph.edgesOf(first.id())); + + var wasRemoved = graph.removeVertex(first.id()); + if (!wasRemoved) { + // Psych! The vertex does not exist. + throw new RuntimeException("Unable unable merge vertex that does not exist!"); + } + + // Anything that used to point to or from `first` now has to point to or from `second`. + for (var oldWall : incidentWalls) { + var firstIsSource = oldWall.from().equals(first.id()); + var neighbour = firstIsSource ? oldWall.to() : oldWall.from(); + + // Three cases: + // 1. `neighbour` is `second`. Drop such contracted walls. + // 2. A wall already exists from `neighbour` to `second`. Merge into the existing wall. + // 3. No wall exists from `neighbour` to `second`. Copy the wall over. + + if (neighbour.equals(second.id())) { + // The wall was contracted. Drop it as redundant. + continue; + } + + var existingWall = graph.getEdge(second.id(), neighbour); // Order doesn't matter. + if (existingWall == null) { + // Simply duplicate the wall for `second` instead of `first`. + try { + var from = firstIsSource ? second.id() : neighbour; + var to = firstIsSource ? neighbour : second.id(); + createWallImpl(from, to); + } catch (GraphException e) { + throw new RuntimeException( + "Unexpected scenario: wall already exists despite not existing", e); + } + } else { + // TODO When walls have data to merge, do it here, accounting for whether the walls point + // in the same or opposite direction. + // Merge obsolete wall into the kept wall. + var secondIsSource = existingWall.from().equals(second.id()); + var reversed = firstIsSource != secondIsSource; + } + } + + removeDanglingVertices(); + + return second; + } + + // endregion + + private void removeDanglingVertices() { + var verticesToRemove = new ArrayList(); + for (var vertex : graph.vertexSet()) { + if (graph.edgesOf(vertex).isEmpty()) { + verticesToRemove.add(vertex); + } + } + graph.removeAllVertices(verticesToRemove); + + for (var vertexId : verticesToRemove) { + vertexInternalById.remove(vertexId); + } + } + + @Override + public VisionResult addSegments(Coordinate origin, Envelope bounds, Consumer sink) { + getWalls() + .forEach( + wall -> { + var segment = wall.asSegment(); + // Very rough bounds check so we don't include everything. + if (bounds.intersects(segment.p0, segment.p1)) { + sink.accept(new Coordinate[] {segment.p0, segment.p1}); + } + }); + return VisionResult.Possible; + } + + public WallTopologyDto toDto() { + var builder = WallTopologyDto.newBuilder(); + for (var vertex : this.vertexInternalById.values()) { + builder.addVertices( + VertexDto.newBuilder() + .setId(vertex.id().toString()) + .setPosition( + DoublePointDto.newBuilder() + .setX(vertex.position().getX()) + .setY(vertex.position().getY()))); + } + for (var wall : this.graph.edgeSet()) { + // No wall data to send yet. + builder.addWalls( + WallDto.newBuilder().setFrom(wall.from().toString()).setTo(wall.to().toString())); + } + return builder.build(); + } + + public static WallTopology fromDto(WallTopologyDto dto) { + var topology = new WallTopology(); + for (var vertexDto : dto.getVerticesList()) { + topology.createVertex(); + Vertex vertex; + try { + vertex = topology.createVertex(new GUID(vertexDto.getId())); + } catch (GraphException e) { + log.error("Unexpected error while adding vertex to graph", e); + continue; + } + + vertex.setPosition(vertexDto.getPosition().getX(), vertexDto.getPosition().getY()); + } + for (var wallDto : dto.getWallsList()) { + try { + topology.createWall(new GUID(wallDto.getFrom()), new GUID(wallDto.getTo())); + } catch (GraphException e) { + log.error("Unexpected error while adding wall to topology", e); + } + } + return topology; + } +} diff --git a/src/main/proto/data_transfer_objects.proto b/src/main/proto/data_transfer_objects.proto index 7f8941be33..547316a120 100644 --- a/src/main/proto/data_transfer_objects.proto +++ b/src/main/proto/data_transfer_objects.proto @@ -711,4 +711,23 @@ message TokenFootPrintDto { bool is_default = 4;; double scale = 5; bool localize_name = 6; -} \ No newline at end of file +} + +// region WallTopology graph + +message VertexDto { + string id = 1; + DoublePointDto position = 2; +} + +message WallDto { + string from = 1; + string to = 2; +} + +message WallTopologyDto { + repeated VertexDto vertices = 1; + repeated WallDto walls = 2; +} + +// ednregion \ No newline at end of file From f77eeee18acf6908aa018845c3345eb9947a3c30 Mon Sep 17 00:00:00 2001 From: Kenneth VanderLinde Date: Fri, 1 Nov 2024 09:38:08 -0700 Subject: [PATCH 04/14] Mark legacy masks as masks, including in server commands As time goes on, we will rely less and less on masks and more and more on walls. Still, walls cannot completely replace masks and so masks need to remain a support, if separate, type into the future. --- .../maptool/client/ClientMessageHandler.java | 14 ++++----- .../client/ServerCommandClientImpl.java | 15 ++++----- .../client/functions/Topology_Functions.java | 23 +++++++------- .../client/tool/drawing/TopologyTool.java | 14 ++++----- .../ui/drawpanel/DrawPanelPopupMenu.java | 3 +- .../ui/token/dialog/edit/EditTokenDialog.java | 10 +++--- .../token/dialog/edit/TokenTopologyPanel.java | 2 +- .../maptool/client/ui/zone/ZoneView.java | 8 ++--- .../client/ui/zone/renderer/SelectionSet.java | 10 +++--- .../client/ui/zone/renderer/ZoneRenderer.java | 4 +-- .../maptool/client/ui/zone/vbl/TokenVBL.java | 2 +- .../utilities/DungeonDraftImporter.java | 12 +++---- .../java/net/rptools/maptool/model/Token.java | 28 ++++++++++------- .../java/net/rptools/maptool/model/Zone.java | 31 +++++++++++-------- ...yChanged.java => MaskTopologyChanged.java} | 2 +- .../rptools/maptool/server/ServerCommand.java | 8 ++--- .../maptool/server/ServerMessageHandler.java | 8 ++--- src/main/proto/data_transfer_objects.proto | 2 +- src/main/proto/message.proto | 2 +- src/main/proto/message_types.proto | 2 +- 20 files changed, 106 insertions(+), 94 deletions(-) rename src/main/java/net/rptools/maptool/model/zones/{TopologyChanged.java => MaskTopologyChanged.java} (93%) diff --git a/src/main/java/net/rptools/maptool/client/ClientMessageHandler.java b/src/main/java/net/rptools/maptool/client/ClientMessageHandler.java index 9a63174092..8d51c45f54 100644 --- a/src/main/java/net/rptools/maptool/client/ClientMessageHandler.java +++ b/src/main/java/net/rptools/maptool/client/ClientMessageHandler.java @@ -101,7 +101,7 @@ public void handleMessage(String id, byte[] message) { log.debug("{} got: {}", id, msgType); switch (msgType) { - case UPDATE_TOPOLOGY_MSG -> handle(msg.getUpdateTopologyMsg()); + case UPDATE_MASK_TOPOLOGY_MSG -> handle(msg.getUpdateMaskTopologyMsg()); case BOOT_PLAYER_MSG -> handle(msg.getBootPlayerMsg()); case CHANGE_ZONE_DISPLAY_NAME_MSG -> handle(msg.getChangeZoneDisplayNameMsg()); case CLEAR_ALL_DRAWINGS_MSG -> handle(msg.getClearAllDrawingsMsg()); @@ -1015,16 +1015,16 @@ private void handle(ChangeZoneDisplayNameMsg changeZoneDisplayNameMsg) { }); } - private void handle(UpdateTopologyMsg updateTopologyMsg) { + private void handle(UpdateMaskTopologyMsg updateMaskTopologyMsg) { EventQueue.invokeLater( () -> { - var zoneGUID = GUID.valueOf(updateTopologyMsg.getZoneGuid()); - var area = Mapper.map(updateTopologyMsg.getArea()); - var erase = updateTopologyMsg.getErase(); - var topologyType = Zone.TopologyType.valueOf(updateTopologyMsg.getType().name()); + var zoneGUID = GUID.valueOf(updateMaskTopologyMsg.getZoneGuid()); + var area = Mapper.map(updateMaskTopologyMsg.getArea()); + var erase = updateMaskTopologyMsg.getErase(); + var topologyType = Zone.TopologyType.valueOf(updateMaskTopologyMsg.getType().name()); var zone = client.getCampaign().getZone(zoneGUID); - zone.updateTopology(area, erase, topologyType); + zone.updateMaskTopology(area, erase, topologyType); }); } diff --git a/src/main/java/net/rptools/maptool/client/ServerCommandClientImpl.java b/src/main/java/net/rptools/maptool/client/ServerCommandClientImpl.java index 0ecbb2f4dd..5d697c5ff1 100644 --- a/src/main/java/net/rptools/maptool/client/ServerCommandClientImpl.java +++ b/src/main/java/net/rptools/maptool/client/ServerCommandClientImpl.java @@ -415,17 +415,18 @@ public void toggleTokenMoveWaypoint(GUID zoneGUID, GUID tokenGUID, ZonePoint cp) } @Override - public void updateTopology(Zone zone, Area area, boolean erase, Zone.TopologyType topologyType) { + public void updateMaskTopology( + Zone zone, Area area, boolean erase, Zone.TopologyType topologyType) { var msg = - UpdateTopologyMsg.newBuilder() + UpdateMaskTopologyMsg.newBuilder() .setZoneGuid(zone.getId().toString()) .setArea(Mapper.map(area)) .setErase(erase) .setType(TopologyTypeDto.valueOf(topologyType.name())); // Update locally as well. - zone.updateTopology(area, erase, topologyType); - makeServerCall(Message.newBuilder().setUpdateTopologyMsg(msg).build()); + zone.updateMaskTopology(area, erase, topologyType); + makeServerCall(Message.newBuilder().setUpdateMaskTopologyMsg(msg).build()); } public void exposePCArea(GUID zoneGUID) { @@ -652,8 +653,8 @@ public void toggleLightSourceOnToken(Token token, boolean toggleOn, LightSource .build()); } - @Override - public void setTokenTopology(Token token, @Nullable Area area, Zone.TopologyType topologyType) { + public void setTokenMaskTopology( + Token token, @Nullable Area area, Zone.TopologyType topologyType) { if (area == null) { // Will be converted back to null on the other end. area = new Area(); @@ -661,7 +662,7 @@ public void setTokenTopology(Token token, @Nullable Area area, Zone.TopologyType updateTokenProperty( token, - Token.Update.setTopology, + Token.Update.setMaskTopology, TokenPropertyValueDto.newBuilder().setTopologyType(topologyType.name()).build(), TokenPropertyValueDto.newBuilder().setArea(Mapper.map(area)).build()); } diff --git a/src/main/java/net/rptools/maptool/client/functions/Topology_Functions.java b/src/main/java/net/rptools/maptool/client/functions/Topology_Functions.java index a7e208d2f5..ec525a47f5 100644 --- a/src/main/java/net/rptools/maptool/client/functions/Topology_Functions.java +++ b/src/main/java/net/rptools/maptool/client/functions/Topology_Functions.java @@ -290,7 +290,8 @@ private void childEvaluateDrawEraseTopology(String functionName, List pa default -> null; }; if (newArea != null) { - MapTool.serverCommand().updateTopology(renderer.getZone(), newArea, erase, topologyType); + MapTool.serverCommand() + .updateMaskTopology(renderer.getZone(), newArea, erase, topologyType); } } } @@ -349,7 +350,7 @@ private Object childEvaluateGetTopology(String functionName, List parame Area topologyArea = new Area(); for (int i = 0; i < topologyArray.size(); i++) { JsonObject topologyObject = topologyArray.get(i).getAsJsonObject(); - Area tempTopologyArea = getTopology(renderer, topologyObject, topologyType, functionName); + Area tempTopologyArea = getMaskTopology(renderer, topologyObject, topologyType, functionName); topologyArea.add(tempTopologyArea); } @@ -405,7 +406,7 @@ private JsonArray childEvaluateGetTokenTopology( } JsonArray allShapes = new JsonArray(); - Area topologyArea = token.getTopology(topologyType); + Area topologyArea = token.getMaskTopology(topologyType); if (topologyArea != null) { var areaShape = getAreaShapeObject(topologyArea); if (areaShape != null) { @@ -515,7 +516,7 @@ private int childEvaluateSetTokenTopology( } } // Replace with new topology - MapTool.serverCommand().setTokenTopology(token, tokenTopology, topologyType); + MapTool.serverCommand().setTokenMaskTopology(token, tokenTopology, topologyType); return results; } @@ -594,21 +595,21 @@ private void childEvaluateTransferTopology( Zone zone = MapTool.getFrame().getCurrentZoneRenderer().getZone(); if (topologyFromToken) { - var newMapTopology = token.getTransformedTopology(topologyType); + var newMapTopology = token.getTransformedMaskTopology(topologyType); if (newMapTopology != null) { - MapTool.serverCommand().updateTopology(zone, newMapTopology, false, topologyType); + MapTool.serverCommand().updateMaskTopology(zone, newMapTopology, false, topologyType); } if (delete) { - MapTool.serverCommand().setTokenTopology(token, null, topologyType); + MapTool.serverCommand().setTokenMaskTopology(token, null, topologyType); } } else { Area topology = TokenVBL.getTopology_underToken(zone, token, topologyType); MapTool.serverCommand() - .setTokenTopology( + .setTokenMaskTopology( token, TokenVBL.transformTopology_toToken(zone, token, topology), topologyType); if (delete) { - MapTool.serverCommand().updateTopology(zone, topology, true, topologyType); + MapTool.serverCommand().updateMaskTopology(zone, topology, true, topologyType); } } } @@ -1065,7 +1066,7 @@ private void applyScale( * @return the topology area. * @throws ParserException If the minimum required parameters are not present in the JSON. */ - private Area getTopology( + private Area getMaskTopology( ZoneRenderer renderer, JsonObject topologyObject, Zone.TopologyType topologyType, @@ -1154,7 +1155,7 @@ private Area getTopology( // Note: when multiple modes are requested, the overlap between each topology is returned. var zone = renderer.getZone(); - var topology = zone.getTopology(topologyType); + var topology = zone.getMaskTopology(topologyType); area.intersect(topology); return area; diff --git a/src/main/java/net/rptools/maptool/client/tool/drawing/TopologyTool.java b/src/main/java/net/rptools/maptool/client/tool/drawing/TopologyTool.java index 25fe190848..80f074e9b9 100644 --- a/src/main/java/net/rptools/maptool/client/tool/drawing/TopologyTool.java +++ b/src/main/java/net/rptools/maptool/client/tool/drawing/TopologyTool.java @@ -103,7 +103,7 @@ private void submit(Shape shape) { } MapTool.serverCommand() - .updateTopology(getZone(), area, isEraser(), AppStatePersisted.getTopologyTypes()); + .updateMaskTopology(getZone(), area, isEraser(), AppStatePersisted.getTopologyTypes()); } private Area getTokenTopology(Zone.TopologyType topologyType) { @@ -111,7 +111,7 @@ private Area getTokenTopology(Zone.TopologyType topologyType) { Area tokenTopology = new Area(); for (Token topologyToken : topologyTokens) { - tokenTopology.add(topologyToken.getTransformedTopology(topologyType)); + tokenTopology.add(topologyToken.getTransformedMaskTopology(topologyType)); } return tokenTopology; @@ -142,19 +142,19 @@ public void paintOverlay(ZoneRenderer renderer, Graphics2D g) { g2.fill(getTokenTopology(Zone.TopologyType.COVER_VBL)); g2.setColor(AppStyle.topologyTerrainColor); - g2.fill(zone.getTopology(Zone.TopologyType.MBL)); + g2.fill(zone.getMaskTopology(Zone.TopologyType.MBL)); g2.setColor(AppStyle.topologyColor); - g2.fill(zone.getTopology(Zone.TopologyType.WALL_VBL)); + g2.fill(zone.getMaskTopology(Zone.TopologyType.WALL_VBL)); g2.setColor(AppStyle.hillVblColor); - g2.fill(zone.getTopology(Zone.TopologyType.HILL_VBL)); + g2.fill(zone.getMaskTopology(Zone.TopologyType.HILL_VBL)); g2.setColor(AppStyle.pitVblColor); - g2.fill(zone.getTopology(Zone.TopologyType.PIT_VBL)); + g2.fill(zone.getMaskTopology(Zone.TopologyType.PIT_VBL)); g2.setColor(AppStyle.coverVblColor); - g2.fill(zone.getTopology(Zone.TopologyType.COVER_VBL)); + g2.fill(zone.getMaskTopology(Zone.TopologyType.COVER_VBL)); if (state != null) { var result = strategy.getShape(state, currentPoint, centerOnOrigin, false); diff --git a/src/main/java/net/rptools/maptool/client/ui/drawpanel/DrawPanelPopupMenu.java b/src/main/java/net/rptools/maptool/client/ui/drawpanel/DrawPanelPopupMenu.java index 840ba8cfc0..ebb7f6ec9e 100644 --- a/src/main/java/net/rptools/maptool/client/ui/drawpanel/DrawPanelPopupMenu.java +++ b/src/main/java/net/rptools/maptool/client/ui/drawpanel/DrawPanelPopupMenu.java @@ -591,7 +591,8 @@ private void VblTool(Drawable drawable, boolean pathOnly, boolean isEraser) { } MapTool.serverCommand() - .updateTopology(renderer.getZone(), area, isEraser, AppStatePersisted.getTopologyTypes()); + .updateMaskTopology( + renderer.getZone(), area, isEraser, AppStatePersisted.getTopologyTypes()); } private Path2D getPath(Drawable drawable) { diff --git a/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/EditTokenDialog.java b/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/EditTokenDialog.java index 509ec05741..5addfdccee 100644 --- a/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/EditTokenDialog.java +++ b/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/EditTokenDialog.java @@ -913,7 +913,7 @@ public boolean commit() { // TOPOLOGY for (final var type : Zone.TopologyType.values()) { - token.setTopology(type, getTokenTopologyPanel().getTopology(type)); + token.setMaskTopology(type, getTokenTopologyPanel().getTopology(type)); } token.setIsAlwaysVisible(getAlwaysVisibleButton().isSelected()); token.setAlwaysVisibleTolerance((int) getVisibilityToleranceSpinner().getValue()); @@ -940,7 +940,7 @@ public boolean commit() { MapTool.getFrame().resetTokenPanels(); // Jamz: TODO check if topology changed on token first - MapTool.getFrame().getCurrentZoneRenderer().getZone().tokenTopologyChanged(); + MapTool.getFrame().getCurrentZoneRenderer().getZone().tokenMaskTopologyChanged(); return true; } @@ -1371,9 +1371,9 @@ public void initTokenTopologyPanel() { final var topology = getTokenTopologyPanel().getTopology(type); if (topology != null) { MapTool.serverCommand() - .updateTopology( + .updateMaskTopology( MapTool.getFrame().getCurrentZoneRenderer().getZone(), - getTokenTopologyPanel().getToken().getTransformedTopology(topology), + getTokenTopologyPanel().getToken().getTransformedMaskTopology(topology), false, type); } @@ -1400,7 +1400,7 @@ public void initTokenTopologyPanel() { getTokenTopologyPanel().putCustomTopology(type, newTokenTopology); if (removeFromMap) { - MapTool.serverCommand().updateTopology(zone, mapTopology, true, type); + MapTool.serverCommand().updateMaskTopology(zone, mapTopology, true, type); } } diff --git a/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/TokenTopologyPanel.java b/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/TokenTopologyPanel.java index 8965cff476..3a7d1e5d0e 100644 --- a/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/TokenTopologyPanel.java +++ b/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/TokenTopologyPanel.java @@ -496,7 +496,7 @@ public void setToken(Token token) { tokenTopologiesOriginal.clear(); tokenTopologiesOptimized.clear(); for (final var type : Zone.TopologyType.values()) { - final var topology = token.getTopology(type); + final var topology = token.getMaskTopology(type); if (topology != null) { selectedTopologyTypes.add(type); tokenTopologiesOriginal.put(type, topology); diff --git a/src/main/java/net/rptools/maptool/client/ui/zone/ZoneView.java b/src/main/java/net/rptools/maptool/client/ui/zone/ZoneView.java index 8a3b7f0b66..bb885bd68a 100644 --- a/src/main/java/net/rptools/maptool/client/ui/zone/ZoneView.java +++ b/src/main/java/net/rptools/maptool/client/ui/zone/ZoneView.java @@ -35,10 +35,10 @@ import net.rptools.maptool.events.MapToolEventBus; import net.rptools.maptool.model.*; import net.rptools.maptool.model.player.Player; +import net.rptools.maptool.model.zones.MaskTopologyChanged; import net.rptools.maptool.model.zones.TokensAdded; import net.rptools.maptool.model.zones.TokensChanged; import net.rptools.maptool.model.zones.TokensRemoved; -import net.rptools.maptool.model.zones.TopologyChanged; import net.rptools.maptool.model.zones.ZoneLightingChanged; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -246,11 +246,11 @@ public synchronized Area getTopology(Zone.TopologyType topologyType) { if (topology == null) { log.debug("ZoneView topology area for {} is null, generating...", topologyType.name()); - topology = new Area(zone.getTopology(topologyType)); + topology = new Area(zone.getMaskTopology(topologyType)); List topologyTokens = MapTool.getFrame().getCurrentZoneRenderer().getZone().getTokensWithTopology(topologyType); for (Token topologyToken : topologyTokens) { - topology.add(topologyToken.getTransformedTopology(topologyType)); + topology.add(topologyToken.getTransformedMaskTopology(topologyType)); } topologyAreas.put(topologyType, topology); @@ -865,7 +865,7 @@ public void flush(Token token) { } @Subscribe - private void onTopologyChanged(TopologyChanged event) { + private void onTopologyChanged(MaskTopologyChanged event) { if (event.zone() != this.zone) { return; } diff --git a/src/main/java/net/rptools/maptool/client/ui/zone/renderer/SelectionSet.java b/src/main/java/net/rptools/maptool/client/ui/zone/renderer/SelectionSet.java index 3f40659746..d9cc21e5ae 100644 --- a/src/main/java/net/rptools/maptool/client/ui/zone/renderer/SelectionSet.java +++ b/src/main/java/net/rptools/maptool/client/ui/zone/renderer/SelectionSet.java @@ -153,11 +153,11 @@ public void update(ZonePoint newAnchorPosition) { point, restrictMovement, terrainModifiersIgnored, - token.getTransformedTopology(Zone.TopologyType.WALL_VBL), - token.getTransformedTopology(Zone.TopologyType.HILL_VBL), - token.getTransformedTopology(Zone.TopologyType.PIT_VBL), - token.getTransformedTopology(Zone.TopologyType.COVER_VBL), - token.getTransformedTopology(Zone.TopologyType.MBL), + token.getTransformedMaskTopology(Zone.TopologyType.WALL_VBL), + token.getTransformedMaskTopology(Zone.TopologyType.HILL_VBL), + token.getTransformedMaskTopology(Zone.TopologyType.PIT_VBL), + token.getTransformedMaskTopology(Zone.TopologyType.COVER_VBL), + token.getTransformedMaskTopology(Zone.TopologyType.MBL), renderer); renderPathThreadPool.execute(renderPathTask); } diff --git a/src/main/java/net/rptools/maptool/client/ui/zone/renderer/ZoneRenderer.java b/src/main/java/net/rptools/maptool/client/ui/zone/renderer/ZoneRenderer.java index 391e9bfef5..f06351c9d6 100644 --- a/src/main/java/net/rptools/maptool/client/ui/zone/renderer/ZoneRenderer.java +++ b/src/main/java/net/rptools/maptool/client/ui/zone/renderer/ZoneRenderer.java @@ -505,7 +505,7 @@ public void commitMoveSelectionSet(GUID keyTokenId) { moveTimer.stop("updateTokenTree"); if (topologyTokenMoved) { - zone.tokenTopologyChanged(); + zone.tokenMaskTopologyChanged(); } }); } else { @@ -3504,7 +3504,7 @@ private void onFogChanged(FogChanged event) { } @Subscribe - private void onTopologyChanged(TopologyChanged event) { + private void onTopologyChanged(MaskTopologyChanged event) { if (event.zone() != this.zone) { return; } diff --git a/src/main/java/net/rptools/maptool/client/ui/zone/vbl/TokenVBL.java b/src/main/java/net/rptools/maptool/client/ui/zone/vbl/TokenVBL.java index 8395cb179d..0a43befc54 100644 --- a/src/main/java/net/rptools/maptool/client/ui/zone/vbl/TokenVBL.java +++ b/src/main/java/net/rptools/maptool/client/ui/zone/vbl/TokenVBL.java @@ -177,7 +177,7 @@ public static Area simplifyArea( public static Area getTopology_underToken( Zone zone, Token token, Zone.TopologyType topologyType) { - Area topologyOnMap = zone.getTopology(topologyType); + Area topologyOnMap = zone.getMaskTopology(topologyType); Rectangle footprintBounds = token.getBounds(zone); final AffineTransform captureArea = new AffineTransform(); diff --git a/src/main/java/net/rptools/maptool/client/utilities/DungeonDraftImporter.java b/src/main/java/net/rptools/maptool/client/utilities/DungeonDraftImporter.java index d9ea8b251a..64d4886981 100644 --- a/src/main/java/net/rptools/maptool/client/utilities/DungeonDraftImporter.java +++ b/src/main/java/net/rptools/maptool/client/utilities/DungeonDraftImporter.java @@ -212,8 +212,8 @@ public void importVTT() throws IOException { WALL_VBL_STROKE.createStrokedShape( getVBLPath(v.getAsJsonArray(), pixelsPerCell))); if (finalDo_transform) vblArea.transform(at); - zone.updateTopology(vblArea, false, Zone.TopologyType.WALL_VBL); - zone.updateTopology(vblArea, false, Zone.TopologyType.MBL); + zone.updateMaskTopology(vblArea, false, Zone.TopologyType.WALL_VBL); + zone.updateMaskTopology(vblArea, false, Zone.TopologyType.MBL); }); } @@ -224,8 +224,8 @@ public void importVTT() throws IOException { v -> { Area vblArea = new Area(getVBLPath(v.getAsJsonArray(), pixelsPerCell)); if (finalDo_transform) vblArea.transform(at); - zone.updateTopology(vblArea, false, Zone.TopologyType.HILL_VBL); - zone.updateTopology(vblArea, false, Zone.TopologyType.PIT_VBL); + zone.updateMaskTopology(vblArea, false, Zone.TopologyType.HILL_VBL); + zone.updateMaskTopology(vblArea, false, Zone.TopologyType.PIT_VBL); }); } @@ -248,8 +248,8 @@ public void importVTT() throws IOException { Area vblArea = new Area(DOOR_VBL_STROKE.createStrokedShape(getVBLPath(bounds, pixelsPerCell))); if (finalDo_transform) vblArea.transform(at); - zone.updateTopology(vblArea, false, Zone.TopologyType.WALL_VBL); - zone.updateTopology(vblArea, false, Zone.TopologyType.MBL); + zone.updateMaskTopology(vblArea, false, Zone.TopologyType.WALL_VBL); + zone.updateMaskTopology(vblArea, false, Zone.TopologyType.MBL); } }); } diff --git a/src/main/java/net/rptools/maptool/model/Token.java b/src/main/java/net/rptools/maptool/model/Token.java index 0395e011e9..0e760af7b4 100644 --- a/src/main/java/net/rptools/maptool/model/Token.java +++ b/src/main/java/net/rptools/maptool/model/Token.java @@ -206,7 +206,7 @@ public enum Update { setTerrainModifier, setTerrainModifierOperation, setTerrainModifiersIgnored, - setTopology, + setMaskTopology, setImageAsset, setPortraitImage, setCharsheetImage, @@ -265,12 +265,17 @@ public enum Update { private int alwaysVisibleTolerance = 2; // Default for # of regions (out of 9) that must be seen // before token is shown over FoW private boolean isAlwaysVisible = false; // Controls whether a Token is shown over VBL + + // region Topology masks + private Area vbl; private Area hillVbl; private Area pitVbl; private Area coverVbl; private Area mbl; + // endregion + private String name = ""; private Set ownerList = new HashSet<>(); @@ -1393,7 +1398,7 @@ public int getColorSensitivity() { * @param topologyType The type of topology to return. * @return the current topology of the token. */ - public Area getTopology(Zone.TopologyType topologyType) { + public Area getMaskTopology(Zone.TopologyType topologyType) { return switch (topologyType) { case WALL_VBL -> vbl; case HILL_VBL -> hillVbl; @@ -1409,8 +1414,8 @@ public Area getTopology(Zone.TopologyType topologyType) { * @param topologyType The type of topology to transform. * @return the transformed topology for the token */ - public Area getTransformedTopology(Zone.TopologyType topologyType) { - return getTransformedTopology(getTopology(topologyType)); + public Area getTransformedMaskTopology(Zone.TopologyType topologyType) { + return getTransformedMaskTopology(getMaskTopology(topologyType)); } /** @@ -1421,7 +1426,7 @@ public Area getTransformedTopology(Zone.TopologyType topologyType) { * @param topologyType The type of topology to set. * @param topology the topology area to set. */ - public void setTopology(Zone.TopologyType topologyType, @Nullable Area topology) { + public void setMaskTopology(Zone.TopologyType topologyType, @Nullable Area topology) { if (topology != null && topology.isEmpty()) { topology = null; } @@ -1433,7 +1438,6 @@ public void setTopology(Zone.TopologyType topologyType, @Nullable Area topology) case COVER_VBL -> coverVbl = topology; case MBL -> mbl = topology; } - ; if (!hasAnyTopology()) { vblColorSensitivity = -1; @@ -1447,7 +1451,7 @@ public void setTopology(Zone.TopologyType topologyType, @Nullable Area topology) * @return true if the token has the given type of topology. */ public boolean hasTopology(Zone.TopologyType topologyType) { - return getTopology(topologyType) != null; + return getMaskTopology(topologyType) != null; } /** @@ -1457,7 +1461,7 @@ public boolean hasTopology(Zone.TopologyType topologyType) { */ public boolean hasAnyTopology() { return Arrays.stream(Zone.TopologyType.values()) - .map(this::getTopology) + .map(this::getMaskTopology) .anyMatch(Objects::nonNull); } @@ -1470,7 +1474,7 @@ public boolean hasAnyTopology() { * @author Jamz * @since 1.4.1.5 */ - public Area getTransformedTopology(Area areaToTransform) { + public Area getTransformedMaskTopology(Area areaToTransform) { if (areaToTransform == null) { return null; } @@ -2847,10 +2851,10 @@ public void updateProperty(Zone zone, Update update, List .map(TerrainModifierOperation::valueOf) .collect(Collectors.toSet())); break; - case setTopology: + case setMaskTopology: { final var topologyType = Zone.TopologyType.valueOf(parameters.get(0).getTopologyType()); - setTopology(topologyType, Mapper.map(parameters.get(1).getArea())); + setMaskTopology(topologyType, Mapper.map(parameters.get(1).getArea())); topologyChanged = true; break; } @@ -2942,7 +2946,7 @@ public void updateProperty(Zone zone, Update update, List zone.tokenPanelChanged(this); } if (topologyChanged) { - zone.tokenTopologyChanged(); + zone.tokenMaskTopologyChanged(); } zone.tokenChanged(this); // fire Event.TOKEN_CHANGED, which updates topology if token has VBL } diff --git a/src/main/java/net/rptools/maptool/model/Zone.java b/src/main/java/net/rptools/maptool/model/Zone.java index 48e93499ea..d565f2742f 100644 --- a/src/main/java/net/rptools/maptool/model/Zone.java +++ b/src/main/java/net/rptools/maptool/model/Zone.java @@ -55,11 +55,11 @@ import net.rptools.maptool.model.zones.LabelAdded; import net.rptools.maptool.model.zones.LabelChanged; import net.rptools.maptool.model.zones.LabelRemoved; +import net.rptools.maptool.model.zones.MaskTopologyChanged; import net.rptools.maptool.model.zones.TokenEdited; import net.rptools.maptool.model.zones.TokensAdded; import net.rptools.maptool.model.zones.TokensChanged; import net.rptools.maptool.model.zones.TokensRemoved; -import net.rptools.maptool.model.zones.TopologyChanged; import net.rptools.maptool.model.zones.ZoneLightingChanged; import net.rptools.maptool.server.Mapper; import net.rptools.maptool.server.proto.DrawnElementListDto; @@ -362,6 +362,8 @@ public enum TopologyType { private DrawablePaint fogPaint; private transient UndoPerZone undo; + // region Topology masks + /** * The Wall VBL topology of the zone. Does not include token Wall VBL. Should really be called * wallVbl. @@ -380,6 +382,8 @@ public enum TopologyType { /** The MBL topology of the zone. Does not include token MBL. Should really be called mbl. */ private Area topologyTerrain = new Area(); + // endregion + // The 'board' layer, at the very bottom of the layer stack. // Itself has two sub-layers: // The top one is an optional texture, typically a pre-drawn map. @@ -662,11 +666,13 @@ public Zone(Zone zone, boolean keepIds) { boardPosition = (Point) zone.boardPosition.clone(); exposedArea = (Area) zone.exposedArea.clone(); - topology = (Area) zone.topology.clone(); - hillVbl = (Area) zone.hillVbl.clone(); - pitVbl = (Area) zone.pitVbl.clone(); - coverVbl = (Area) zone.coverVbl.clone(); - topologyTerrain = (Area) zone.topologyTerrain.clone(); + + topology = new Area(zone.topology); + hillVbl = new Area(zone.hillVbl); + pitVbl = new Area(zone.pitVbl); + coverVbl = new Area(zone.coverVbl); + topologyTerrain = new Area(zone.topologyTerrain); + aStarRounding = zone.aStarRounding; isVisible = zone.isVisible; hasFog = zone.hasFog; @@ -943,7 +949,7 @@ public boolean isTokenFootprintVisible(Token token) { // return combined.intersects(tokenSize); } - public Area getTopology(TopologyType topologyType) { + public Area getMaskTopology(TopologyType topologyType) { return switch (topologyType) { case WALL_VBL -> topology; case HILL_VBL -> hillVbl; @@ -959,7 +965,7 @@ public Area getTopology(TopologyType topologyType) { * @param area the area * @param topologyType the type of the topology */ - public void updateTopology(Area area, boolean erase, TopologyType topologyType) { + public void updateMaskTopology(Area area, boolean erase, TopologyType topologyType) { var topology = switch (topologyType) { case WALL_VBL -> this.topology; @@ -975,13 +981,12 @@ public void updateTopology(Area area, boolean erase, TopologyType topologyType) topology.add(area); } - new MapToolEventBus().getMainEventBus().post(new TopologyChanged(this)); + new MapToolEventBus().getMainEventBus().post(new MaskTopologyChanged(this)); } - /** Fire the event TOPOLOGY_CHANGED. */ - // TODO Remove this in favour of firing from token as it own its topology. - public void tokenTopologyChanged() { - new MapToolEventBus().getMainEventBus().post(new TopologyChanged(this)); + /** Fire the event {@link MaskTopologyChanged}. */ + public void tokenMaskTopologyChanged() { + new MapToolEventBus().getMainEventBus().post(new MaskTopologyChanged(this)); } /** diff --git a/src/main/java/net/rptools/maptool/model/zones/TopologyChanged.java b/src/main/java/net/rptools/maptool/model/zones/MaskTopologyChanged.java similarity index 93% rename from src/main/java/net/rptools/maptool/model/zones/TopologyChanged.java rename to src/main/java/net/rptools/maptool/model/zones/MaskTopologyChanged.java index fa05003a54..285bb5f646 100644 --- a/src/main/java/net/rptools/maptool/model/zones/TopologyChanged.java +++ b/src/main/java/net/rptools/maptool/model/zones/MaskTopologyChanged.java @@ -16,4 +16,4 @@ import net.rptools.maptool.model.Zone; -public record TopologyChanged(Zone zone) {} +public record MaskTopologyChanged(Zone zone) {} diff --git a/src/main/java/net/rptools/maptool/server/ServerCommand.java b/src/main/java/net/rptools/maptool/server/ServerCommand.java index 3d79005e1b..d3574172e3 100644 --- a/src/main/java/net/rptools/maptool/server/ServerCommand.java +++ b/src/main/java/net/rptools/maptool/server/ServerCommand.java @@ -42,14 +42,14 @@ public interface ServerCommand { void setFoW(GUID zoneGUID, Area area, Set selectedToks); - default void updateTopology( + default void updateMaskTopology( Zone zone, Area area, boolean erase, Set topologyTypes) { for (var topologyType : topologyTypes) { - updateTopology(zone, area, erase, topologyType); + updateMaskTopology(zone, area, erase, topologyType); } } - void updateTopology(Zone zone, Area area, boolean erase, Zone.TopologyType topologyType); + void updateMaskTopology(Zone zone, Area area, boolean erase, Zone.TopologyType topologyType); void enforceZoneView(GUID zoneGUID, int x, int y, double scale, int width, int height); @@ -195,7 +195,7 @@ default void updateTopology( */ void toggleLightSourceOnToken(Token token, boolean toggleOn, LightSource lightSource); - void setTokenTopology(Token token, @Nullable Area area, Zone.TopologyType topologyType); + void setTokenMaskTopology(Token token, @Nullable Area area, Zone.TopologyType topologyType); void updateTokenProperty(Token token, Token.Update update, int value); diff --git a/src/main/java/net/rptools/maptool/server/ServerMessageHandler.java b/src/main/java/net/rptools/maptool/server/ServerMessageHandler.java index 43959299f2..18db3134cb 100644 --- a/src/main/java/net/rptools/maptool/server/ServerMessageHandler.java +++ b/src/main/java/net/rptools/maptool/server/ServerMessageHandler.java @@ -78,8 +78,8 @@ public void handleMessage(String id, byte[] message) { } switch (msgType) { - case UPDATE_TOPOLOGY_MSG -> { - handle(msg.getUpdateTopologyMsg()); + case UPDATE_MASK_TOPOLOGY_MSG -> { + handle(msg.getUpdateMaskTopologyMsg()); sendToClients(id, msg); } case BRING_TOKENS_TO_FRONT_MSG -> handle(msg.getBringTokensToFrontMsg()); @@ -678,7 +678,7 @@ private void handle(BringTokensToFrontMsg bringTokensToFrontMsg) { }); } - private void handle(UpdateTopologyMsg updateTopologyMsg) { + private void handle(UpdateMaskTopologyMsg updateTopologyMsg) { EventQueue.invokeLater( () -> { var zoneGUID = GUID.valueOf(updateTopologyMsg.getZoneGuid()); @@ -686,7 +686,7 @@ private void handle(UpdateTopologyMsg updateTopologyMsg) { var erase = updateTopologyMsg.getErase(); var topologyType = Zone.TopologyType.valueOf(updateTopologyMsg.getType().name()); Zone zone = server.getCampaign().getZone(zoneGUID); - zone.updateTopology(area, erase, topologyType); + zone.updateMaskTopology(area, erase, topologyType); }); } diff --git a/src/main/proto/data_transfer_objects.proto b/src/main/proto/data_transfer_objects.proto index 547316a120..05da91a939 100644 --- a/src/main/proto/data_transfer_objects.proto +++ b/src/main/proto/data_transfer_objects.proto @@ -642,7 +642,7 @@ enum TokenUpdateDto { setTerrainModifier = 38; setTerrainModifierOperation = 39; setTerrainModifiersIgnored = 40; - setTopology = 41; + setMaskTopology = 41; setImageAsset = 42; setPortraitImage = 43; setCharsheetImage = 44; diff --git a/src/main/proto/message.proto b/src/main/proto/message.proto index 9250d1583f..ae8360f4d8 100644 --- a/src/main/proto/message.proto +++ b/src/main/proto/message.proto @@ -15,7 +15,7 @@ import "message_types.proto"; message Message { oneof message_type { - UpdateTopologyMsg update_topology_msg = 501; + UpdateMaskTopologyMsg update_mask_topology_msg = 501; BootPlayerMsg boot_player_msg = 2; BringTokensToFrontMsg bring_tokens_to_front_msg = 3; ChangeZoneDisplayNameMsg change_zone_display_name_msg = 4; diff --git a/src/main/proto/message_types.proto b/src/main/proto/message_types.proto index ab0c6b22e3..58d31ef6e6 100644 --- a/src/main/proto/message_types.proto +++ b/src/main/proto/message_types.proto @@ -16,7 +16,7 @@ import "data_transfer_objects.proto"; import "drawing_dto.proto"; import "gamedata.proto"; -message UpdateTopologyMsg { +message UpdateMaskTopologyMsg { string zone_guid = 1; AreaDto area = 2; bool erase = 3; From 7ef8eac2d838710dc4cfe1fc2b00840b3afbe436 Mon Sep 17 00:00:00 2001 From: Kenneth VanderLinde Date: Fri, 15 Nov 2024 11:01:40 -0800 Subject: [PATCH 05/14] Add WallTopology to Zone This includes adding extending `ZoneDto` to include `WallTopologyDto`. --- .../maptool/client/ClientMessageHandler.java | 12 ++++ .../client/ServerCommandClientImpl.java | 10 +++ .../maptool/client/ui/zone/ZoneView.java | 20 ++++-- .../client/ui/zone/renderer/ZoneRenderer.java | 21 ++++-- .../java/net/rptools/maptool/model/Zone.java | 64 +++++++++++++++++++ .../model/zones/WallTopologyChanged.java | 19 ++++++ .../rptools/maptool/server/ServerCommand.java | 3 + .../maptool/server/ServerMessageHandler.java | 15 +++++ src/main/proto/data_transfer_objects.proto | 1 + src/main/proto/message.proto | 3 +- src/main/proto/message_types.proto | 5 ++ 11 files changed, 163 insertions(+), 10 deletions(-) create mode 100644 src/main/java/net/rptools/maptool/model/zones/WallTopologyChanged.java diff --git a/src/main/java/net/rptools/maptool/client/ClientMessageHandler.java b/src/main/java/net/rptools/maptool/client/ClientMessageHandler.java index 8d51c45f54..0eba31c94e 100644 --- a/src/main/java/net/rptools/maptool/client/ClientMessageHandler.java +++ b/src/main/java/net/rptools/maptool/client/ClientMessageHandler.java @@ -64,6 +64,7 @@ import net.rptools.maptool.model.library.addon.AddOnLibraryImporter; import net.rptools.maptool.model.library.addon.TransferableAddOnLibrary; import net.rptools.maptool.model.player.Player; +import net.rptools.maptool.model.topology.WallTopology; import net.rptools.maptool.model.zones.TokensAdded; import net.rptools.maptool.model.zones.TokensRemoved; import net.rptools.maptool.model.zones.ZoneAdded; @@ -169,6 +170,7 @@ public void handleMessage(String id, byte[] message) { case UPDATE_EXPOSED_AREA_META_MSG -> handle(msg.getUpdateExposedAreaMetaMsg()); case UPDATE_TOKEN_MOVE_MSG -> handle(msg.getUpdateTokenMoveMsg()); case UPDATE_PLAYER_STATUS_MSG -> handle(msg.getUpdatePlayerStatusMsg()); + case SET_WALL_TOPOLOGY_MSG -> handle(msg.getSetWallTopologyMsg()); default -> log.warn(msgType + "not handled."); } log.debug(id + " handled: " + msgType); @@ -1060,4 +1062,14 @@ private void handle(UpdatePlayerStatusMsg updatePlayerStatusMsg) { final var eventBus = new MapToolEventBus().getMainEventBus(); eventBus.post(new PlayerStatusChanged(player)); } + + private void handle(SetWallTopologyMsg setWallTopologyMsg) { + EventQueue.invokeLater( + () -> { + var zoneId = new GUID(setWallTopologyMsg.getZoneGuid()); + var zone = client.getCampaign().getZone(zoneId); + var topology = WallTopology.fromDto(setWallTopologyMsg.getTopology()); + zone.replaceWalls(topology); + }); + } } diff --git a/src/main/java/net/rptools/maptool/client/ServerCommandClientImpl.java b/src/main/java/net/rptools/maptool/client/ServerCommandClientImpl.java index 5d697c5ff1..b1e99ded46 100644 --- a/src/main/java/net/rptools/maptool/client/ServerCommandClientImpl.java +++ b/src/main/java/net/rptools/maptool/client/ServerCommandClientImpl.java @@ -37,6 +37,7 @@ import net.rptools.maptool.model.gamedata.proto.GameDataValueDto; import net.rptools.maptool.model.library.addon.TransferableAddOnLibrary; import net.rptools.maptool.model.player.Player; +import net.rptools.maptool.model.topology.WallTopology; import net.rptools.maptool.server.Mapper; import net.rptools.maptool.server.ServerCommand; import net.rptools.maptool.server.ServerMessageHandler; @@ -414,6 +415,15 @@ public void toggleTokenMoveWaypoint(GUID zoneGUID, GUID tokenGUID, ZonePoint cp) makeServerCall(Message.newBuilder().setToggleTokenMoveWaypointMsg(msg).build()); } + public void replaceWalls(Zone zone, WallTopology walls) { + zone.replaceWalls(walls); + var msg = + SetWallTopologyMsg.newBuilder() + .setZoneGuid(zone.getId().toString()) + .setTopology(walls.toDto()); + makeServerCall(Message.newBuilder().setSetWallTopologyMsg(msg).build()); + } + @Override public void updateMaskTopology( Zone zone, Area area, boolean erase, Zone.TopologyType topologyType) { diff --git a/src/main/java/net/rptools/maptool/client/ui/zone/ZoneView.java b/src/main/java/net/rptools/maptool/client/ui/zone/ZoneView.java index bb885bd68a..558179dece 100644 --- a/src/main/java/net/rptools/maptool/client/ui/zone/ZoneView.java +++ b/src/main/java/net/rptools/maptool/client/ui/zone/ZoneView.java @@ -39,6 +39,7 @@ import net.rptools.maptool.model.zones.TokensAdded; import net.rptools.maptool.model.zones.TokensChanged; import net.rptools.maptool.model.zones.TokensRemoved; +import net.rptools.maptool.model.zones.WallTopologyChanged; import net.rptools.maptool.model.zones.ZoneLightingChanged; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -864,15 +865,26 @@ public void flush(Token token) { } } + private void onTopologyChanged() { + flush(); + topologyAreas.clear(); + topologyTrees.clear(); + } + @Subscribe - private void onTopologyChanged(MaskTopologyChanged event) { + private void onTopologyChanged(WallTopologyChanged event) { if (event.zone() != this.zone) { return; } + onTopologyChanged(); + } - flush(); - topologyAreas.clear(); - topologyTrees.clear(); + @Subscribe + private void onTopologyChanged(MaskTopologyChanged event) { + if (event.zone() != this.zone) { + return; + } + onTopologyChanged(); } @Subscribe diff --git a/src/main/java/net/rptools/maptool/client/ui/zone/renderer/ZoneRenderer.java b/src/main/java/net/rptools/maptool/client/ui/zone/renderer/ZoneRenderer.java index f06351c9d6..55cf30888b 100644 --- a/src/main/java/net/rptools/maptool/client/ui/zone/renderer/ZoneRenderer.java +++ b/src/main/java/net/rptools/maptool/client/ui/zone/renderer/ZoneRenderer.java @@ -3503,16 +3503,27 @@ private void onFogChanged(FogChanged event) { repaintDebouncer.dispatch(); } + private void onTopologyChanged() { + flushFog(); + flushLight(); + MapTool.getFrame().updateTokenTree(); // for any event + repaintDebouncer.dispatch(); + } + @Subscribe - private void onTopologyChanged(MaskTopologyChanged event) { + private void onTopologyChanged(WallTopologyChanged event) { if (event.zone() != this.zone) { return; } + onTopologyChanged(); + } - flushFog(); - flushLight(); - MapTool.getFrame().updateTokenTree(); // for any event - repaintDebouncer.dispatch(); + @Subscribe + private void onTopologyChanged(MaskTopologyChanged event) { + if (event.zone() != this.zone) { + return; + } + onTopologyChanged(); } private void markDrawableLayerDirty(Layer layer) { diff --git a/src/main/java/net/rptools/maptool/model/Zone.java b/src/main/java/net/rptools/maptool/model/Zone.java index d565f2742f..ab9ad7275a 100644 --- a/src/main/java/net/rptools/maptool/model/Zone.java +++ b/src/main/java/net/rptools/maptool/model/Zone.java @@ -46,6 +46,8 @@ import net.rptools.maptool.model.player.Player; import net.rptools.maptool.model.tokens.TokenMacroChanged; import net.rptools.maptool.model.tokens.TokenPanelChanged; +import net.rptools.maptool.model.topology.MaskTopology; +import net.rptools.maptool.model.topology.WallTopology; import net.rptools.maptool.model.zones.BoardChanged; import net.rptools.maptool.model.zones.DrawableAdded; import net.rptools.maptool.model.zones.DrawableRemoved; @@ -60,6 +62,7 @@ import net.rptools.maptool.model.zones.TokensAdded; import net.rptools.maptool.model.zones.TokensChanged; import net.rptools.maptool.model.zones.TokensRemoved; +import net.rptools.maptool.model.zones.WallTopologyChanged; import net.rptools.maptool.model.zones.ZoneLightingChanged; import net.rptools.maptool.server.Mapper; import net.rptools.maptool.server.proto.DrawnElementListDto; @@ -384,6 +387,9 @@ public enum TopologyType { // endregion + // The new take on topology. + private WallTopology walls = new WallTopology(); + // The 'board' layer, at the very bottom of the layer stack. // Itself has two sub-layers: // The top one is an optional texture, typically a pre-drawn map. @@ -672,6 +678,7 @@ public Zone(Zone zone, boolean keepIds) { pitVbl = new Area(zone.pitVbl); coverVbl = new Area(zone.coverVbl); topologyTerrain = new Area(zone.topologyTerrain); + walls = new WallTopology(zone.walls); aStarRounding = zone.aStarRounding; isVisible = zone.isVisible; @@ -949,6 +956,42 @@ public boolean isTokenFootprintVisible(Token token) { // return combined.intersects(tokenSize); } + public WallTopology getWalls() { + return walls; + } + + /** + * Get all masks. + * + *

This is particularly used for the sake of pathfinding. The provided types will be usually + * include just {@link TopologyType#MBL}, but based on preferences may also include all VBL types. + * The {@code exclude} parameter exists so a moving token will not be blocked by its own topology. + * All other tokens' topology will be included. + * + * @param types The type of masks to get. + * @param excluding + * @return + */ + public List getMasks(Set types, @Nullable GUID excluding) { + var masks = new ArrayList(); + + for (var type : types) { + var mapArea = getMaskTopology(type); + var tokenArea = getTokenMaskTopology(type, excluding); + if (mapArea != null) { + tokenArea.add(mapArea); + } + masks.addAll(MaskTopology.createFromLegacy(type, tokenArea)); + } + + return masks; + } + + public void replaceWalls(WallTopology walls) { + this.walls = walls; + new MapToolEventBus().getMainEventBus().post(new WallTopologyChanged(this)); + } + public Area getMaskTopology(TopologyType topologyType) { return switch (topologyType) { case WALL_VBL -> topology; @@ -989,6 +1032,21 @@ public void tokenMaskTopologyChanged() { new MapToolEventBus().getMainEventBus().post(new MaskTopologyChanged(this)); } + public Area getTokenMaskTopology(TopologyType type, @Nullable GUID excluding) { + var result = new Area(); + for (var token : getAllTokens()) { + if (excluding != null && excluding.equals(token.getId())) { + continue; + } + + var tokenArea = token.getTransformedMaskTopology(type); + if (tokenArea != null) { + result.add(tokenArea); + } + } + return result; + } + /** * Fire the event TOKEN_CHANGED * @@ -2108,6 +2166,10 @@ protected Object readResolve() { drawablesByLayer.put(Layer.OBJECT, objectDrawables); drawablesByLayer.put(Layer.BACKGROUND, backgroundDrawables); + if (walls == null) { + walls = new WallTopology(); + } + return this; } @@ -2214,6 +2276,7 @@ public static Zone fromDto(ZoneDto dto) { zone.pitVbl = Mapper.map(dto.getPitVbl()); zone.coverVbl = Mapper.map(dto.getCoverVbl()); zone.topologyTerrain = Mapper.map(dto.getTopologyTerrain()); + zone.walls = WallTopology.fromDto(dto.getWalls()); zone.backgroundPaint = DrawablePaint.fromDto(dto.getBackgroundPaint()); zone.mapAsset = dto.hasMapAsset() ? new MD5Key(dto.getMapAsset().getValue()) : null; zone.boardPosition.x = dto.getBoardPosition().getX(); @@ -2278,6 +2341,7 @@ public ZoneDto toDto() { dto.setPitVbl(Mapper.map(pitVbl)); dto.setCoverVbl(Mapper.map(coverVbl)); dto.setTopologyTerrain(Mapper.map(topologyTerrain)); + dto.setWalls(walls.toDto()); dto.setBackgroundPaint(backgroundPaint.toDto()); if (mapAsset != null) { dto.setMapAsset(StringValue.of(mapAsset.toString())); diff --git a/src/main/java/net/rptools/maptool/model/zones/WallTopologyChanged.java b/src/main/java/net/rptools/maptool/model/zones/WallTopologyChanged.java new file mode 100644 index 0000000000..5ec24a0145 --- /dev/null +++ b/src/main/java/net/rptools/maptool/model/zones/WallTopologyChanged.java @@ -0,0 +1,19 @@ +/* + * This software Copyright by the RPTools.net development team, and + * licensed under the Affero GPL Version 3 or, at your option, any later + * version. + * + * MapTool Source Code is distributed in the hope that it will be + * useful, but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * You should have received a copy of the GNU Affero General Public + * License * along with this source Code. If not, please visit + * and specifically the Affero license + * text at . + */ +package net.rptools.maptool.model.zones; + +import net.rptools.maptool.model.Zone; + +public record WallTopologyChanged(Zone zone) {} diff --git a/src/main/java/net/rptools/maptool/server/ServerCommand.java b/src/main/java/net/rptools/maptool/server/ServerCommand.java index d3574172e3..bf6a37e243 100644 --- a/src/main/java/net/rptools/maptool/server/ServerCommand.java +++ b/src/main/java/net/rptools/maptool/server/ServerCommand.java @@ -30,6 +30,7 @@ import net.rptools.maptool.model.gamedata.proto.GameDataValueDto; import net.rptools.maptool.model.library.addon.TransferableAddOnLibrary; import net.rptools.maptool.model.player.Player; +import net.rptools.maptool.model.topology.WallTopology; public interface ServerCommand { void bootPlayer(String player); @@ -42,6 +43,8 @@ public interface ServerCommand { void setFoW(GUID zoneGUID, Area area, Set selectedToks); + void replaceWalls(Zone zone, WallTopology walls); + default void updateMaskTopology( Zone zone, Area area, boolean erase, Set topologyTypes) { for (var topologyType : topologyTypes) { diff --git a/src/main/java/net/rptools/maptool/server/ServerMessageHandler.java b/src/main/java/net/rptools/maptool/server/ServerMessageHandler.java index 18db3134cb..d49e2aa56b 100644 --- a/src/main/java/net/rptools/maptool/server/ServerMessageHandler.java +++ b/src/main/java/net/rptools/maptool/server/ServerMessageHandler.java @@ -37,6 +37,7 @@ import net.rptools.maptool.model.drawing.Drawable; import net.rptools.maptool.model.drawing.DrawnElement; import net.rptools.maptool.model.drawing.Pen; +import net.rptools.maptool.model.topology.WallTopology; import net.rptools.maptool.model.zones.TokensAdded; import net.rptools.maptool.model.zones.TokensRemoved; import net.rptools.maptool.model.zones.ZoneAdded; @@ -257,6 +258,10 @@ public void handleMessage(String id, byte[] message) { handle(id, msg.getUpdatePlayerStatusMsg()); sendToClients(id, msg); } + case SET_WALL_TOPOLOGY_MSG -> { + handle(msg.getSetWallTopologyMsg()); + sendToClients(id, msg); + } default -> log.warn(msgType + " not handled."); } @@ -705,6 +710,16 @@ private void handle(String id, UpdatePlayerStatusMsg updatePlayerStatusMsg) { server.updatePlayerStatus(playerName, zoneId, loaded); } + private void handle(SetWallTopologyMsg setWallTopologyMsg) { + EventQueue.invokeLater( + () -> { + var zoneId = new GUID(setWallTopologyMsg.getZoneGuid()); + var zone = server.getCampaign().getZone(zoneId); + var topology = WallTopology.fromDto(setWallTopologyMsg.getTopology()); + zone.replaceWalls(topology); + }); + } + private void sendToClients(String excludedId, Message message) { server.broadcastMessage(new String[] {excludedId}, message); } diff --git a/src/main/proto/data_transfer_objects.proto b/src/main/proto/data_transfer_objects.proto index 05da91a939..7daec786ec 100644 --- a/src/main/proto/data_transfer_objects.proto +++ b/src/main/proto/data_transfer_objects.proto @@ -551,6 +551,7 @@ message ZoneDto { AreaDto pit_vbl = 24; AreaDto cover_vbl = 39; AreaDto topology_terrain = 25; + WallTopologyDto walls = 12; DrawablePaintDto background_paint = 26; google.protobuf.StringValue map_asset = 27; IntPointDto boardPosition = 28; diff --git a/src/main/proto/message.proto b/src/main/proto/message.proto index ae8360f4d8..04332ba34e 100644 --- a/src/main/proto/message.proto +++ b/src/main/proto/message.proto @@ -15,7 +15,7 @@ import "message_types.proto"; message Message { oneof message_type { - UpdateMaskTopologyMsg update_mask_topology_msg = 501; + UpdateMaskTopologyMsg update_mask_topology_msg = 1; BootPlayerMsg boot_player_msg = 2; BringTokensToFrontMsg bring_tokens_to_front_msg = 3; ChangeZoneDisplayNameMsg change_zone_display_name_msg = 4; @@ -89,5 +89,6 @@ message Message { RemoveDataMsg remove_data_msg = 73; UpdatePlayerStatusMsg update_player_status_msg = 74; SetCampaignLandingMapMsg set_campaign_landing_map_msg = 75; + SetWallTopologyMsg set_wall_topology_msg = 76; } } diff --git a/src/main/proto/message_types.proto b/src/main/proto/message_types.proto index 58d31ef6e6..b243de912e 100644 --- a/src/main/proto/message_types.proto +++ b/src/main/proto/message_types.proto @@ -23,6 +23,11 @@ message UpdateMaskTopologyMsg { TopologyTypeDto type = 4; } +message SetWallTopologyMsg { + string zone_guid = 1; + WallTopologyDto topology = 2; +} + message BootPlayerMsg { string player_name = 1; } From 13f274389f733c3d70b3d1c6eb3874d6e0104a15 Mon Sep 17 00:00:00 2001 From: Kenneth VanderLinde Date: Thu, 14 Nov 2024 21:39:24 -0800 Subject: [PATCH 06/14] Add data structures for prepared topologies For vision, the `NodedTopology` offers a fully-noded copy of the input topologies, both legacy masks and walls. The vision sweep requires endpoints to be place at every intersection, otherwise important steps are missed. While this was implicitly doable with masks by treating each mask separately, we need to be explicit about it with the introduction of walls since they can arbitrarily overlap with each other and with the masks. For pathfinding, the `MovementBlockingTopology` abstracts the creation of `PreparedGeometry`. A* really needs good `PreparedGeometry` to have acceptable performance, but this is only achievable if the input geometries are similar in type. This was true when using legacy masks as the masks would convert into `Polygonal` geometry. But wall topology converts to `Lineal` geometry, and mixing the two results in very poor performance. The `MovementBlockingTopology` will create separate `PreparedGeometry` for the `Polygonal` and `Lineal` inputs. While this requires two separate intersection checks, this is still much faster than trying to combine the two types. --- .../ui/zone/vbl/MovementBlockingTopology.java | 91 +++++++++ .../client/ui/zone/vbl/NodedTopology.java | 186 ++++++++++++++++++ .../java/net/rptools/maptool/model/Zone.java | 21 ++ 3 files changed, 298 insertions(+) create mode 100644 src/main/java/net/rptools/maptool/client/ui/zone/vbl/MovementBlockingTopology.java create mode 100644 src/main/java/net/rptools/maptool/client/ui/zone/vbl/NodedTopology.java diff --git a/src/main/java/net/rptools/maptool/client/ui/zone/vbl/MovementBlockingTopology.java b/src/main/java/net/rptools/maptool/client/ui/zone/vbl/MovementBlockingTopology.java new file mode 100644 index 0000000000..c37fbc7f18 --- /dev/null +++ b/src/main/java/net/rptools/maptool/client/ui/zone/vbl/MovementBlockingTopology.java @@ -0,0 +1,91 @@ +/* + * This software Copyright by the RPTools.net development team, and + * licensed under the Affero GPL Version 3 or, at your option, any later + * version. + * + * MapTool Source Code is distributed in the hope that it will be + * useful, but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * You should have received a copy of the GNU Affero General Public + * License * along with this source Code. If not, please visit + * and specifically the Affero license + * text at . + */ +package net.rptools.maptool.client.ui.zone.vbl; + +import java.util.List; +import net.rptools.lib.GeometryUtil; +import net.rptools.maptool.model.topology.MaskTopology; +import net.rptools.maptool.model.topology.Topology; +import net.rptools.maptool.model.topology.WallTopology; +import org.locationtech.jts.geom.Envelope; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryCollection; +import org.locationtech.jts.geom.Lineal; +import org.locationtech.jts.geom.MultiLineString; +import org.locationtech.jts.geom.MultiPolygon; +import org.locationtech.jts.geom.Polygonal; +import org.locationtech.jts.geom.prep.PreparedGeometry; +import org.locationtech.jts.geom.prep.PreparedGeometryFactory; +import org.locationtech.jts.operation.union.UnaryUnionOp; + +/** + * Collects a set of {@link Topology} into a form that is useful for pathfinding. + * + * @implNote The A* walker relies on the performance provided by {@link PreparedGeometry} in order + * to be effective on larger maps with complicated movement blocking. But {@code + * PreparedGeometry} sensitive to the types of geometries involved and does especially poorly + * with {@link GeometryCollection}. Since we know we have {@link Polygonal} masks and {@link + * Lineal} walls, we can prepare these separately so that the {@code PreparedGeometry} can work + * with a {@link MultiPolygon} and a {@link MultiLineString}. + */ +public class MovementBlockingTopology { + private final PreparedGeometry preparedMasks; + private final PreparedGeometry preparedWalls; + + // Note: when we add directional walls, those will need special support. A simple intersection + // will not suffice. + + public MovementBlockingTopology() { + var factory = GeometryUtil.getGeometryFactory(); + this.preparedMasks = PreparedGeometryFactory.prepare(factory.createPolygon()); + this.preparedWalls = PreparedGeometryFactory.prepare(factory.createLineString()); + } + + public MovementBlockingTopology(WallTopology walls, List masks) { + var factory = GeometryUtil.getGeometryFactory(); + + var maskPolygons = masks.stream().map(MaskTopology::getPolygon).toList(); + var maskUnion = new UnaryUnionOp(maskPolygons, factory).union(); + // maskUnion should be a Polygon or MultiPolygon which fares very well with prepared geometry. + this.preparedMasks = PreparedGeometryFactory.prepare(maskUnion); + + var wallGeometries = + walls.getWalls().map(wall -> wall.asSegment().toGeometry(factory)).toList(); + var wallUnion = new UnaryUnionOp(wallGeometries, factory).union(); + // wallUnion should be Lineal, which works well with prepared geometry. + this.preparedWalls = PreparedGeometryFactory.prepare(wallUnion); + } + + private List allGeometries() { + return List.of(preparedMasks, preparedWalls); + } + + public Envelope getEnvelope() { + var envelope = new Envelope(); + for (var geometry : allGeometries()) { + envelope.expandToInclude(geometry.getGeometry().getEnvelopeInternal()); + } + return envelope; + } + + public boolean intersects(Geometry other) { + for (var prepared : allGeometries()) { + if (prepared.intersects(other)) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/net/rptools/maptool/client/ui/zone/vbl/NodedTopology.java b/src/main/java/net/rptools/maptool/client/ui/zone/vbl/NodedTopology.java new file mode 100644 index 0000000000..c977841b65 --- /dev/null +++ b/src/main/java/net/rptools/maptool/client/ui/zone/vbl/NodedTopology.java @@ -0,0 +1,186 @@ +/* + * This software Copyright by the RPTools.net development team, and + * licensed under the Affero GPL Version 3 or, at your option, any later + * version. + * + * MapTool Source Code is distributed in the hope that it will be + * useful, but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * You should have received a copy of the GNU Affero General Public + * License * along with this source Code. If not, please visit + * and specifically the Affero license + * text at . + */ +package net.rptools.maptool.client.ui.zone.vbl; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.function.Consumer; +import net.rptools.lib.CodeTimer; +import net.rptools.lib.GeometryUtil; +import net.rptools.maptool.model.Zone; +import net.rptools.maptool.model.topology.MaskTopology; +import net.rptools.maptool.model.topology.Topology; +import net.rptools.maptool.model.topology.VisionResult; +import net.rptools.maptool.model.topology.WallTopology; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Envelope; +import org.locationtech.jts.geom.LinearRing; +import org.locationtech.jts.noding.NodedSegmentString; +import org.locationtech.jts.noding.snapround.SnapRoundingNoder; + +/** + * Utility for noding a set of {@link Topology}. + * + *

The result will contain a copy of every part of the original topologies, modified to add nodes + * at any intersection points. This makes it acceptable for use with vision sweeps. + */ +public class NodedTopology { + private final List preparedParts; + + private NodedTopology(List preparedParts) { + this.preparedParts = preparedParts; + } + + public VisionResult getSegments(Coordinate origin, Envelope bounds, Consumer sink) { + for (var preparedPart : preparedParts) { + var maskResult = preparedPart.addSegments(origin, bounds, sink); + if (maskResult == VisionResult.CompletelyObscured) { + return maskResult; + } + } + + return VisionResult.Possible; + } + + /** + * Merge a set of topologies into a single noded collection. + * + *

Every part from each input topology will be copied into the result, but modified with + * additional nodes at any intersection points. This makes it acceptable for use with vision + * sweeps. + * + *

This process assumes the individual topologies are valid. E.g., the masks are made up only + * of simple rings with the holes being completely within the boundary. + * + * @param walls The input walls. + * @param legacyMasks The legacy masks. + * @return The merged and noded topology. + */ + public static NodedTopology prepare(WallTopology walls, List legacyMasks) { + var preparedTopologies = new ArrayList(); + + CodeTimer.using( + "NodedTopology#prepare()", + timer -> { + var tempMasks = new ArrayList(); + + var strings = new ArrayList(); + + timer.start("collect walls"); + var tempWalls = new TempWalls(walls); + strings.addAll(tempWalls.walls); + timer.stop("collect walls"); + + timer.start("collect masks"); + for (var mask : legacyMasks) { + var tempMask = new TempMask(mask); + tempMasks.add(tempMask); + strings.add(tempMask.boundary); + strings.addAll(Arrays.asList(tempMask.holes)); + } + timer.stop("collect masks"); + + var noder = new SnapRoundingNoder(GeometryUtil.getPrecisionModel()); + + timer.start("compute nodes"); + noder.computeNodes(strings); + timer.stop("compute nodes"); + + // At this point, each string in `strings` has extra nodes added. These aren't part of its + // points, because that would make too much sense. Instead, we go through each and grab + // the complete set of nodes to make new strings. + + var factory = GeometryUtil.getGeometryFactory(); + + timer.start("prepare walls"); + { + var preparedWalls = new WallTopology(); + for (var wallString : tempWalls.walls) { + // String length will be at least 2. + var originalWall = (WallTopology.Wall) wallString.getData(); + timer.start("get noded coordinates"); + var coordinates = wallString.getNodedCoordinates(); + timer.stop("get noded coordinates"); + if (coordinates.length < 2) { + // This happens when we encounter a wall with vertices at the same location. + continue; + } + + timer.start("build noded string"); + preparedWalls.string( + GeometryUtil.coordinateToPoint2D(coordinates[0]), + builder -> { + for (var i = 1; i < coordinates.length; ++i) { + builder.push(GeometryUtil.coordinateToPoint2D(coordinates[i])); + } + }); + timer.stop("build noded string"); + } + preparedTopologies.add(preparedWalls); + } + timer.stop("prepare walls"); + + timer.start("prepare masks"); + for (var tempMask : tempMasks) { + var newBoundary = factory.createLinearRing(tempMask.boundary.getNodedCoordinates()); + var newHoles = new LinearRing[tempMask.holes.length]; + for (var i = 0; i < newHoles.length; ++i) { + newHoles[i] = factory.createLinearRing(tempMask.holes[i].getNodedCoordinates()); + } + // Make a new GUID. Even though this is conceptually the same topology, it is distinct. + preparedTopologies.add( + MaskTopology.create(tempMask.type, factory.createPolygon(newBoundary, newHoles))); + } + timer.stop("prepare masks"); + }); + + return new NodedTopology(preparedTopologies); + } + + private static final class TempWalls { + public final List walls; + + public TempWalls(WallTopology walls) { + this.walls = new ArrayList<>(); + walls + .getWalls() + .forEach( + wall -> { + var segment = wall.asSegment(); + var string = + new NodedSegmentString(new Coordinate[] {segment.p0, segment.p1}, wall); + this.walls.add(string); + }); + } + } + + private static final class TempMask { + public final Zone.TopologyType type; + public final NodedSegmentString boundary; + public final NodedSegmentString[] holes; + + public TempMask(MaskTopology mask) { + this.type = mask.getType(); + this.boundary = + new NodedSegmentString(mask.getPolygon().getExteriorRing().getCoordinates(), null); + this.holes = new NodedSegmentString[mask.getPolygon().getNumInteriorRing()]; + for (var i = 0; i < holes.length; ++i) { + this.holes[i] = + new NodedSegmentString(mask.getPolygon().getInteriorRingN(i).getCoordinates(), null); + } + } + } +} diff --git a/src/main/java/net/rptools/maptool/model/Zone.java b/src/main/java/net/rptools/maptool/model/Zone.java index ab9ad7275a..060ac1d9ab 100644 --- a/src/main/java/net/rptools/maptool/model/Zone.java +++ b/src/main/java/net/rptools/maptool/model/Zone.java @@ -32,6 +32,7 @@ import net.rptools.maptool.client.ui.zone.PlayerView; import net.rptools.maptool.client.ui.zone.ZoneView; import net.rptools.maptool.client.ui.zone.renderer.ZoneRenderer; +import net.rptools.maptool.client.ui.zone.vbl.NodedTopology; import net.rptools.maptool.events.MapToolEventBus; import net.rptools.maptool.language.I18N; import net.rptools.maptool.model.InitiativeList.TokenInitiative; @@ -390,6 +391,8 @@ public enum TopologyType { // The new take on topology. private WallTopology walls = new WallTopology(); + private transient @Nullable NodedTopology nodedTopology; + // The 'board' layer, at the very bottom of the layer stack. // Itself has two sub-layers: // The top one is an optional texture, typically a pre-drawn map. @@ -989,9 +992,25 @@ public List getMasks(Set types, @Nullable GUID exclu public void replaceWalls(WallTopology walls) { this.walls = walls; + this.nodedTopology = null; new MapToolEventBus().getMainEventBus().post(new WallTopologyChanged(this)); } + /** + * Packages legacy topology, including token topology, together with wall topology, adding nodes + * at any intersection points. + * + * @return + */ + public NodedTopology prepareNodedTopologies() { + if (nodedTopology == null) { + var legacyMasks = getMasks(EnumSet.allOf(TopologyType.class), null); + nodedTopology = NodedTopology.prepare(walls, legacyMasks); + } + + return nodedTopology; + } + public Area getMaskTopology(TopologyType topologyType) { return switch (topologyType) { case WALL_VBL -> topology; @@ -1024,11 +1043,13 @@ public void updateMaskTopology(Area area, boolean erase, TopologyType topologyTy topology.add(area); } + nodedTopology = null; new MapToolEventBus().getMainEventBus().post(new MaskTopologyChanged(this)); } /** Fire the event {@link MaskTopologyChanged}. */ public void tokenMaskTopologyChanged() { + nodedTopology = null; new MapToolEventBus().getMainEventBus().post(new MaskTopologyChanged(this)); } From cbfac77778fc2441daddb5109c60276790e192b8 Mon Sep 17 00:00:00 2001 From: Kenneth VanderLinde Date: Thu, 14 Nov 2024 21:36:08 -0800 Subject: [PATCH 07/14] Add tooling for wall topology A new topology tool is dedicated to working with `WallTopology`. It will render legacy topology for reference, but can only modify walls. Walls are drawn as stroked line segments with circular handles at the vertices. Both vertices and entire walls can be dragged. Vertices can be merged with one another, and can split existing walls. Shift-clicking will delete the hovered vertex or wall. If a vertex is deleted, all walls connected to it are deleted as well. Operations are expressed through a new `Rig` type that understands `Handle` specifically and `Movable` as a more general draggable concept. This rigging is only use for wall topology for now, but the hope is to one day apply it to drawings and perhaps even legacy mask topology in order to make those conveniently editable. Since topology type has no meaning for wall topology, the topology type selector is disabled when the wall topology tool is selected. --- .../net/rptools/maptool/client/AppStyle.java | 3 + .../rptools/maptool/client/ScreenPoint.java | 25 +- .../swing/TopologyModeSelectionPanel.java | 15 + .../maptool/client/tool/DefaultTool.java | 8 +- .../maptool/client/tool/StampTool.java | 6 +- .../maptool/client/tool/WallTopologyTool.java | 713 ++++++++++++++++++ .../tool/drawing/AbstractDrawingLikeTool.java | 2 +- .../client/tool/drawing/TopologyTool.java | 117 +-- .../maptool/client/tool/rig/Handle.java | 35 + .../maptool/client/tool/rig/Movable.java | 45 ++ .../rptools/maptool/client/tool/rig/Rig.java | 39 + .../rptools/maptool/client/tool/rig/Snap.java | 51 ++ .../client/tool/rig/WallTopologyRig.java | 396 ++++++++++ .../maptool/client/tool/rig/package-info.java | 2 + .../maptool/client/ui/ToolbarPanel.java | 34 +- .../maptool/client/ui/theme/Icons.java | 1 + .../client/ui/theme/RessourceManager.java | 2 + .../java/net/rptools/maptool/model/Grid.java | 19 +- .../net/rptools/maptool/model/SquareGrid.java | 14 + .../java/net/rptools/maptool/model/Zone.java | 4 - .../icons/rod_takehara/ribbon/Draw Wall.svg | 16 + .../client/image/tool/wall-topology.png | Bin 0 -> 5960 bytes .../rptools/maptool/language/i18n.properties | 2 + 23 files changed, 1462 insertions(+), 87 deletions(-) create mode 100644 src/main/java/net/rptools/maptool/client/tool/WallTopologyTool.java create mode 100644 src/main/java/net/rptools/maptool/client/tool/rig/Handle.java create mode 100644 src/main/java/net/rptools/maptool/client/tool/rig/Movable.java create mode 100644 src/main/java/net/rptools/maptool/client/tool/rig/Rig.java create mode 100644 src/main/java/net/rptools/maptool/client/tool/rig/Snap.java create mode 100644 src/main/java/net/rptools/maptool/client/tool/rig/WallTopologyRig.java create mode 100644 src/main/java/net/rptools/maptool/client/tool/rig/package-info.java create mode 100644 src/main/resources/net/rptools/maptool/client/icons/rod_takehara/ribbon/Draw Wall.svg create mode 100644 src/main/resources/net/rptools/maptool/client/image/tool/wall-topology.png diff --git a/src/main/java/net/rptools/maptool/client/AppStyle.java b/src/main/java/net/rptools/maptool/client/AppStyle.java index e1f3c82d2c..add625516e 100644 --- a/src/main/java/net/rptools/maptool/client/AppStyle.java +++ b/src/main/java/net/rptools/maptool/client/AppStyle.java @@ -37,6 +37,9 @@ public class AppStyle { public static Color selectionBoxFill = Color.blue; public static Color resizeBoxOutline = Color.red; public static Color resizeBoxFill = Color.yellow; + public static Color wallTopologyColor = new Color(200, 128, 0, 255); + public static Color wallTopologyOutlineColor = new Color(140, 88, 0, 128); + public static Color selectedWallTopologyColor = new Color(255, 182, 0, 255); public static Color topologyColor = new Color(0, 0, 255, 128); public static Color topologyAddColor = new Color(255, 0, 0, 128); public static Color topologyRemoveColor = new Color(255, 255, 255, 128); diff --git a/src/main/java/net/rptools/maptool/client/ScreenPoint.java b/src/main/java/net/rptools/maptool/client/ScreenPoint.java index c0df5c2bec..9d71d4e0a9 100644 --- a/src/main/java/net/rptools/maptool/client/ScreenPoint.java +++ b/src/main/java/net/rptools/maptool/client/ScreenPoint.java @@ -23,6 +23,12 @@ public ScreenPoint(double x, double y) { super(x, y); } + public static Point2D.Double convertToZone2d(ZoneRenderer renderer, double x, double y) { + double scale = renderer.getScale(); + return new Point2D.Double( + (x - renderer.getViewOffsetX()) / scale, (y - renderer.getViewOffsetY()) / scale); + } + /** * Translate the point from screen x,y to zone x,y. * @@ -33,23 +39,8 @@ public ScreenPoint(double x, double y) { * @return the {@link ZonePoint} representing the screen point. */ public static ZonePoint convertToZone(ZoneRenderer renderer, double x, double y) { - double scale = renderer.getScale(); - - double zX = x; - double zY = y; - - // Translate - zX -= renderer.getViewOffsetX(); - zY -= renderer.getViewOffsetY(); - - // Scale - zX = (int) Math.floor(zX / scale); - zY = (int) Math.floor(zY / scale); - - // System.out.println("s:" + scale + " x:" + x + " zx:" + zX + " c:" + (zX / scale) + " - " + - // Math.floor(zX / scale)); - - return new ZonePoint((int) zX, (int) zY); + var doublePrecision = convertToZone2d(renderer, x, y); + return new ZonePoint((int) Math.floor(doublePrecision.x), (int) Math.floor(doublePrecision.y)); } /** diff --git a/src/main/java/net/rptools/maptool/client/swing/TopologyModeSelectionPanel.java b/src/main/java/net/rptools/maptool/client/swing/TopologyModeSelectionPanel.java index 8d613b65c7..25e1ec395b 100644 --- a/src/main/java/net/rptools/maptool/client/swing/TopologyModeSelectionPanel.java +++ b/src/main/java/net/rptools/maptool/client/swing/TopologyModeSelectionPanel.java @@ -75,6 +75,21 @@ public TopologyModeSelectionPanel() { this.add(Box.createHorizontalStrut(5)); } + @Override + public void setEnabled(boolean enabled) { + super.setEnabled(enabled); + + if (enabled) { + for (var button : modeButtons.values()) { + button.setEnabled(true); + } + } else { + for (var button : modeButtons.values()) { + button.setEnabled(false); + } + } + } + private void createAndAddModeButton( Zone.TopologyType type, final Icons icon, diff --git a/src/main/java/net/rptools/maptool/client/tool/DefaultTool.java b/src/main/java/net/rptools/maptool/client/tool/DefaultTool.java index 3f5ce05c10..0a00595ec2 100644 --- a/src/main/java/net/rptools/maptool/client/tool/DefaultTool.java +++ b/src/main/java/net/rptools/maptool/client/tool/DefaultTool.java @@ -99,7 +99,11 @@ protected boolean isDraggingMap() { return isDraggingMap; } - /** Stop dragging the map. */ + /** + * Stop dragging the map. + * + *

Useful if the default behaviour is interfering with a tool. + */ protected void cancelMapDrag() { mapDragStart = null; isDraggingMap = false; @@ -200,7 +204,7 @@ public void actionPerformed(ActionEvent e) { @Override public void mousePressed(MouseEvent e) { // Potential map dragging - if (SwingUtilities.isRightMouseButton(e)) { + if (SwingUtilities.isRightMouseButton(e) && mapDragStart == null) { setDragStart(e.getX(), e.getY()); } } diff --git a/src/main/java/net/rptools/maptool/client/tool/StampTool.java b/src/main/java/net/rptools/maptool/client/tool/StampTool.java index 74cc10bf25..4995f79de4 100644 --- a/src/main/java/net/rptools/maptool/client/tool/StampTool.java +++ b/src/main/java/net/rptools/maptool/client/tool/StampTool.java @@ -1324,7 +1324,7 @@ public void finish() { public void dragTo(int mouseX, int mouseY, boolean lockAspectRatio, boolean snapSizeToGrid) { var currentZp = new ScreenPoint(mouseX, mouseY).convertToZone(renderer); if (snapSizeToGrid) { // snap size to grid - currentZp = getNearestVertex(currentZp); + currentZp = renderer.getZone().getGrid().getNearestVertex(currentZp); } else { // Keep the cursor at the same conceptual position in the drag handle. currentZp.x += dragOffsetX; @@ -1362,9 +1362,5 @@ public void dragTo(int mouseX, int mouseY, boolean lockAspectRatio, boolean snap renderer.repaint(); } - - private ZonePoint getNearestVertex(ZonePoint point) { - return renderer.getZone().getNearestVertex(point); - } } } diff --git a/src/main/java/net/rptools/maptool/client/tool/WallTopologyTool.java b/src/main/java/net/rptools/maptool/client/tool/WallTopologyTool.java new file mode 100644 index 0000000000..e242ad42e6 --- /dev/null +++ b/src/main/java/net/rptools/maptool/client/tool/WallTopologyTool.java @@ -0,0 +1,713 @@ +/* + * This software Copyright by the RPTools.net development team, and + * licensed under the Affero GPL Version 3 or, at your option, any later + * version. + * + * MapTool Source Code is distributed in the hope that it will be + * useful, but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * You should have received a copy of the GNU Affero General Public + * License * along with this source Code. If not, please visit + * and specifically the Affero license + * text at . + */ +package net.rptools.maptool.client.tool; + +import com.google.common.eventbus.Subscribe; +import java.awt.AlphaComposite; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.Paint; +import java.awt.event.MouseEvent; +import java.awt.geom.Ellipse2D; +import java.awt.geom.Path2D; +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import javax.annotation.Nullable; +import javax.swing.SwingUtilities; +import net.rptools.maptool.client.AppStyle; +import net.rptools.maptool.client.MapTool; +import net.rptools.maptool.client.ScreenPoint; +import net.rptools.maptool.client.swing.SwingUtil; +import net.rptools.maptool.client.tool.drawing.TopologyTool; +import net.rptools.maptool.client.tool.rig.Handle; +import net.rptools.maptool.client.tool.rig.Movable; +import net.rptools.maptool.client.tool.rig.Snap; +import net.rptools.maptool.client.tool.rig.WallTopologyRig; +import net.rptools.maptool.client.ui.zone.ZoneOverlay; +import net.rptools.maptool.client.ui.zone.renderer.ZoneRenderer; +import net.rptools.maptool.events.MapToolEventBus; +import net.rptools.maptool.model.topology.WallTopology; +import net.rptools.maptool.model.zones.WallTopologyChanged; + +public class WallTopologyTool extends DefaultTool implements ZoneOverlay { + private Point2D currentPosition = + new Point2D.Double(Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY); + + /** The current tool behaviour. Each operation enters a distinct mode so we don't cross-talk. */ + private ToolMode mode = new NilToolMode(); + + private final TopologyTool.MaskOverlay maskOverlay = new TopologyTool.MaskOverlay(); + + private double getHandleRadius() { + return 4.f; + } + + private double getHandleSelectDistance() { + var handleSelectDistance = getHandleRadius(); + var scale = renderer.getScale(); + if (scale < 1) { + return handleSelectDistance / renderer.getScale(); + } + return handleSelectDistance; + } + + private double getWallSelectDistance() { + // Wall select distance doesn't have to be identical to the handle select distance. We can tweak + // this for better UX if it helps. + return getHandleSelectDistance(); + } + + @Override + public String getTooltip() { + return "tool.walltopology.tooltip"; + } + + @Override + public String getInstructions() { + return "tool.walltopology.instructions"; + } + + @Override + public boolean isAvailable() { + return MapTool.getPlayer().isGM(); + } + + @Override + protected void attachTo(ZoneRenderer renderer) { + super.attachTo(renderer); + currentPosition = new Point2D.Double(Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY); + var rig = new WallTopologyRig(getHandleSelectDistance(), getWallSelectDistance()); + rig.setWalls(getZone().getWalls()); + changeToolMode(new BasicToolMode(this, rig)); + + new MapToolEventBus().getMainEventBus().register(this); + } + + @Override + protected void detachFrom(ZoneRenderer renderer) { + new MapToolEventBus().getMainEventBus().unregister(this); + changeToolMode(new NilToolMode()); + super.detachFrom(renderer); + } + + @Override + public void paintOverlay(ZoneRenderer renderer, Graphics2D g) { + // Paint legacy masks. This isn't strictly necessary, but I want to do it so that users can + // trace walls over masks if converting by hand. + maskOverlay.paintOverlay(renderer, g); + + Graphics2D g2 = (Graphics2D) g.create(); + SwingUtil.useAntiAliasing(g2); + g2.setComposite(AlphaComposite.SrcAtop); + g2.translate(renderer.getViewOffsetX(), renderer.getViewOffsetY()); + g2.scale(renderer.getScale(), renderer.getScale()); + + mode.paint(g2); + } + + @Override + protected void resetTool() { + if (!mode.cancel()) { + super.resetTool(); + } + renderer.repaint(); + } + + @Override + public void mouseMoved(MouseEvent e) { + super.mouseMoved(e); + mode.mouseMoved(updateCurrentPosition(e), getSnapMode(e), e); + renderer.repaint(); + } + + @Override + public void mouseDragged(MouseEvent e) { + if (mode.shouldAllowMapDrag(e)) { + super.mouseDragged(e); + } + mode.mouseMoved(updateCurrentPosition(e), getSnapMode(e), e); + renderer.repaint(); + } + + @Override + public void mousePressed(MouseEvent e) { + super.mousePressed(e); + mode.mousePressed(updateCurrentPosition(e), getSnapMode(e), e); + renderer.repaint(); + } + + @Override + public void mouseReleased(MouseEvent e) { + super.mouseReleased(e); + mode.mouseReleased(updateCurrentPosition(e), getSnapMode(e), e); + renderer.repaint(); + } + + private void changeToolMode(ToolMode newMode) { + mode.deactivate(); + mode = newMode; + mode.activate(); + } + + private Point2D getCurrentPosition() { + return currentPosition; + } + + private Point2D updateCurrentPosition(MouseEvent e) { + return currentPosition = ScreenPoint.convertToZone2d(renderer, e.getX(), e.getY()); + } + + private Snap getSnapMode(MouseEvent e) { + if (SwingUtil.isControlDown(e)) { + return Snap.fine(getZone().getGrid()); + } + return Snap.none(); + } + + @Subscribe + private void onTopologyChanged(WallTopologyChanged event) { + var rig = new WallTopologyRig(getHandleSelectDistance(), getWallSelectDistance()); + rig.setWalls(getZone().getWalls()); + changeToolMode(new BasicToolMode(this, rig)); + } + + private interface ToolMode { + void activate(); + + void deactivate(); + + /** + * Cancels the current tool mode. + * + * @return {@code true} if the tool mode has its own cancel behaviour; {@code false} if the + * regular behaviour (revert to pointer tool) should apply. + */ + boolean cancel(); + + boolean shouldAllowMapDrag(MouseEvent e); + + void mouseMoved(Point2D point, Snap snapMode, MouseEvent event); + + void mousePressed(Point2D point, Snap snapMode, MouseEvent event); + + void mouseReleased(Point2D point, Snap snapMode, MouseEvent event); + + void paint(Graphics2D g2); + } + + /** + * The mode that does nothing. + * + *

Convenient for when the tool is unattached. + */ + private static final class NilToolMode implements ToolMode { + @Override + public void activate() {} + + @Override + public void deactivate() {} + + @Override + public boolean cancel() { + return false; + } + + @Override + public void mouseMoved(Point2D point, Snap snapMode, MouseEvent event) {} + + @Override + public void mousePressed(Point2D point, Snap snapMode, MouseEvent event) {} + + @Override + public void mouseReleased(Point2D point, Snap snapMode, MouseEvent event) {} + + @Override + public boolean shouldAllowMapDrag(MouseEvent e) { + return true; + } + + @Override + public void paint(Graphics2D g2) {} + } + + private abstract static class ToolModeBase implements ToolMode { + protected final WallTopologyTool tool; + protected final WallTopologyRig rig; + + protected ToolModeBase(WallTopologyTool tool, WallTopologyRig rig) { + this.tool = tool; + this.rig = rig; + } + + @Override + public void activate() {} + + @Override + public void deactivate() {} + + @Override + public boolean cancel() { + return false; + } + + @Override + public void mouseMoved(Point2D point, Snap snapMode, MouseEvent event) {} + + @Override + public void mousePressed(Point2D point, Snap snapMode, MouseEvent event) {} + + @Override + public void mouseReleased(Point2D point, Snap snapMode, MouseEvent event) {} + + @Override + public boolean shouldAllowMapDrag(MouseEvent e) { + return true; + } + + /** + * Get a special paint for the handle if one is applicable. + * + * @param handle The handle to get the fill paint for. + * @return The paint for the handle. + */ + protected Paint getHandleFill(Handle handle) { + return Color.white; + } + + protected BasicStroke getHandleStroke() { + return new BasicStroke(1f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND); + } + + protected Paint getWallFill(Movable wall) { + return AppStyle.wallTopologyColor; + } + + protected void paintHandle(Graphics2D g2, Point2D point, Paint fill) { + var handleRadius = tool.getHandleRadius(); + var handleOutlineStroke = getHandleStroke(); + var handleOutlineColor = Color.black; + + var shape = + new Ellipse2D.Double( + point.getX() - handleRadius, + point.getY() - handleRadius, + 2 * handleRadius, + 2 * handleRadius); + + g2.setPaint(fill); + g2.fill(shape); + + g2.setStroke(handleOutlineStroke); + g2.setPaint(handleOutlineColor); + g2.draw(shape); + } + + @Override + public void paint(Graphics2D g2) { + var handleRadius = tool.getHandleRadius(); + + Rectangle2D bounds = g2.getClipBounds().getBounds2D(); + // Pad the bounds by a bit so handles whose center is just outside will still show up. + var padding = handleRadius; + bounds.setRect( + bounds.getX() - padding, + bounds.getY() - padding, + bounds.getWidth() + 2 * padding, + bounds.getHeight() + 2 * padding); + + var wallStroke = new BasicStroke(2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND); + var wallOutlineColor = AppStyle.wallTopologyOutlineColor; + var wallOutlineStroke = + new BasicStroke( + wallStroke.getLineWidth() + 2f, wallStroke.getEndCap(), wallStroke.getLineJoin()); + var walls = rig.getWallsWithin(bounds); + for (var wall : walls) { + var wallFill = getWallFill(wall); + // Draw it twice to get a black border effect. It's proven itself to be cheaper than using + // `Stroke.createStokedShape()` when there are many walls. + var from = wall.getSource().from().getPosition(); + var to = wall.getSource().to().getPosition(); + var shape = new Path2D.Double(); + shape.moveTo(from.getX(), from.getY()); + shape.lineTo(to.getX(), to.getY()); + + if (from.distance(to) > 4 * handleRadius) { + // Draw a fun little arrow indicating the wall direection. + // theta increases clockwise. + var theta = Math.atan2(to.getY() - from.getY(), to.getX() - from.getX()); + var arrowSize = handleRadius; + var vertex = + new Point2D.Double( + to.getX() - 4 * handleRadius * Math.cos(theta), + to.getY() - 4 * handleRadius * Math.sin(theta)); + shape.moveTo(vertex.getX(), vertex.getY()); + shape.lineTo( + vertex.getX() - arrowSize * Math.cos(theta - Math.PI / 4), + vertex.getY() - arrowSize * Math.sin(theta - Math.PI / 4)); + shape.moveTo(vertex.getX(), vertex.getY()); + shape.lineTo( + vertex.getX() - arrowSize * Math.cos(theta + Math.PI / 4), + vertex.getY() - arrowSize * Math.sin(theta + Math.PI / 4)); + } + + g2.setStroke(wallOutlineStroke); + g2.setPaint(wallOutlineColor); + g2.draw(shape); + g2.setStroke(wallStroke); + g2.setPaint(wallFill); + g2.draw(shape); + } + + var vertices = rig.getHandlesWithin(bounds); + for (var handle : vertices) { + paintHandle(g2, handle.getPosition(), getHandleFill(handle)); + } + } + } + + private static final class BasicToolMode extends ToolModeBase { + // The hovered handle. This is the candidate for any pending mouse event. E.g., a mouse pressed + // can start a drag operation on it. + private @Nullable WallTopologyRig.Element currentElement; + private @Nullable Point2D potentialSplitPoint; + + public BasicToolMode(WallTopologyTool tool, WallTopologyRig rig) { + super(tool, rig); + } + + @Override + public void activate() { + currentElement = rig.getNearbyElement(tool.getCurrentPosition()).orElse(null); + } + + @Override + public void mouseMoved(Point2D point, Snap snapMode, MouseEvent event) { + currentElement = rig.getNearbyElement(point).orElse(null); + + if (event.isAltDown() && currentElement instanceof WallTopologyRig.MovableWall movableWall) { + potentialSplitPoint = rig.getSplitPoint(movableWall, point); + } else { + potentialSplitPoint = null; + } + } + + @Override + public void mousePressed(Point2D point, Snap snapMode, MouseEvent event) { + if (SwingUtilities.isLeftMouseButton(event)) { + if (currentElement == null) { + // Grabbed blank space. Start a new wall, unless the delete modifier is held. + if (!SwingUtil.isShiftDown(event)) { + var newHandle = rig.addDegenerateWall(snapMode.snap(point)); + tool.changeToolMode( + new DragVertexToolMode(tool, rig, newHandle, newHandle.getPosition(), true)); + } + } else if (SwingUtil.isShiftDown(event)) { + currentElement.delete(); + MapTool.serverCommand().replaceWalls(tool.getZone(), rig.commit()); + currentElement = rig.getNearbyElement(tool.getCurrentPosition()).orElse(null); + } else if (event.isAltDown()) { + // Start drawing a new wall using the current handle or wall. + switch (currentElement) { + case WallTopologyRig.MovableVertex movableVertex -> { + var newVertex = + rig.addControlPoint(movableVertex.getSource(), movableVertex.getPosition()); + tool.changeToolMode(new DragVertexToolMode(tool, rig, newVertex, point, true)); + } + case WallTopologyRig.MovableWall movableWall -> { + var wallSplit = rig.splitAt(movableWall, point); + var dragHandle = rig.addControlPoint(wallSplit.getSource(), wallSplit.getPosition()); + tool.changeToolMode(new DragVertexToolMode(tool, rig, dragHandle, point, false)); + } + } + } else { + // No special modifiers. Grab the handle, i.e., start a drag. + switch (currentElement) { + case WallTopologyRig.MovableVertex movableVertex -> + tool.changeToolMode(new DragVertexToolMode(tool, rig, movableVertex, point, false)); + case WallTopologyRig.MovableWall movableWall -> + tool.changeToolMode(new DragWallToolMode(tool, rig, movableWall, point)); + } + } + } + } + + @Override + public Paint getHandleFill(Handle handle) { + // TODO Blue if alt is down. + if (currentElement != null && currentElement.isForSameElement(handle)) { + return Color.green; + } + return super.getHandleFill(handle); + } + + @Override + protected Paint getWallFill(Movable wall) { + if (currentElement != null && currentElement.isForSameElement(wall)) { + return AppStyle.selectedWallTopologyColor; + } + return super.getWallFill(wall); + } + + @Override + public void paint(Graphics2D g2) { + super.paint(g2); + + if (potentialSplitPoint != null) { + paintHandle(g2, potentialSplitPoint, Color.blue); + } + } + } + + private abstract static class DragToolMode> extends ToolModeBase { + // Flag used to avoid adding degenerate walls if the user randomly clicks nowhere. + protected boolean nonTrivialChange; + protected final Point2D originalMousePoint; + protected T movable; + + protected DragToolMode( + WallTopologyTool tool, WallTopologyRig rig, T movable, Point2D originalMousePoint) { + super(tool, rig); + this.nonTrivialChange = false; + this.movable = movable; + this.originalMousePoint = originalMousePoint; + } + + protected void setCurrentHandle(T newHandle, Point2D mousePoint) { + this.originalMousePoint.setLocation(mousePoint); + this.movable = newHandle; + } + + @Override + public final boolean cancel() { + // Revert to the original. + rig.setWalls(tool.getZone().getWalls()); + tool.changeToolMode(new BasicToolMode(tool, rig)); + return true; + } + + @Override + public void mouseReleased(Point2D point, Snap snapMode, MouseEvent event) { + if (SwingUtilities.isLeftMouseButton(event)) { + complete(); + } + } + + @Override + public void mouseMoved(Point2D point, Snap snapMode, MouseEvent event) { + nonTrivialChange = true; + movable.displace( + point.getX() - originalMousePoint.getX(), + point.getY() - originalMousePoint.getY(), + snapMode); + afterMove(event); + } + + @Override + public Paint getHandleFill(Handle handle) { + if (this.movable.isForSameElement(handle)) { + return Color.green; + } + return super.getHandleFill(handle); + } + + protected final void complete() { + if (!nonTrivialChange) { + cancel(); + return; + } + + movable.applyMove(); + beforeCommit(); + MapTool.serverCommand().replaceWalls(tool.getZone(), rig.commit()); + tool.changeToolMode(new BasicToolMode(tool, rig)); + } + + protected abstract void beforeCommit(); + + protected abstract void afterMove(MouseEvent event); + } + + private static final class DragWallToolMode extends DragToolMode { + public DragWallToolMode( + WallTopologyTool tool, + WallTopologyRig rig, + WallTopologyRig.MovableWall wall, + Point2D originalMousePoint) { + super(tool, rig, wall, originalMousePoint); + } + + @Override + public boolean shouldAllowMapDrag(MouseEvent e) { + return true; + } + + @Override + protected void afterMove(MouseEvent event) { + // Nothing to do. + } + + @Override + protected void beforeCommit() {} + + @Override + protected Paint getWallFill(Movable wall) { + if (wall.isForSameElement(this.movable)) { + return AppStyle.selectedWallTopologyColor; + } + return super.getWallFill(wall); + } + } + + private static final class DragVertexToolMode + extends DragToolMode { + private @Nullable WallTopologyRig.Element connectTo; + + /** + * The constant displacement from the mouse to the vertex. Will remain constant even as we add + * vertices or toggle snap-to-grid on or off. + */ + private final double offsetX, offsetY; + + public DragVertexToolMode( + WallTopologyTool tool, + WallTopologyRig rig, + WallTopologyRig.MovableVertex handle, + Point2D originalMousePoint, + boolean cancelIfTrivial) { + super(tool, rig, handle, originalMousePoint); + + this.offsetX = handle.getPosition().getX() - originalMousePoint.getX(); + this.offsetY = handle.getPosition().getY() - originalMousePoint.getY(); + + this.nonTrivialChange = !cancelIfTrivial; + } + + private void findConnectToHandle(MouseEvent event) { + connectTo = null; + + if (event.isAltDown()) { + // Add some leniency so the snapping feels good. + var extraSpace = 4.f; + connectTo = + rig.getNearbyElement( + tool.getCurrentPosition(), + extraSpace, + (WallTopologyRig.Element other) -> { + switch (other) { + case WallTopologyRig.MovableVertex movableVertex -> { + return !movable.isForSameElement(movableVertex); + } + case WallTopologyRig.MovableWall movableWall -> { + return !movable.isForSameElement(movableWall.getFrom()) + && !movable.isForSameElement(movableWall.getTo()); + } + } + }) + .orElse(null); + + switch (connectTo) { + case null -> { + /* Nothing to do */ + } + case WallTopologyRig.MovableVertex movableVertex -> { + // Snap the handle to the vertex it would connect to. + movable.moveTo(movableVertex.getPosition()); + } + case WallTopologyRig.MovableWall movableWall -> { + // Snap the handle to where we would split the wall. + movable.moveTo(rig.getSplitPoint(movableWall, tool.getCurrentPosition())); + } + } + } + } + + @Override + public boolean shouldAllowMapDrag(MouseEvent e) { + // Map drag conflicts with our extend action. + return false; + } + + @Override + public void mousePressed(Point2D point, Snap snapMode, MouseEvent event) { + if (SwingUtilities.isRightMouseButton(event)) { + nonTrivialChange = true; + var snapped = snapMode.snap(point); + tryMerge(); + + var newHandle = this.rig.addControlPoint(movable.getSource(), snapped); + + // Maintain the original offset regardless of what the actual cursor position is now. + var fakeMousePosition = + new Point2D.Double( + newHandle.getPosition().getX() - offsetX, newHandle.getPosition().getY() - offsetY); + + setCurrentHandle(newHandle, fakeMousePosition); + findConnectToHandle(event); + } + } + + @Override + protected void afterMove(MouseEvent event) { + findConnectToHandle(event); + } + + @Override + protected void beforeCommit() { + tryMerge(); + } + + @Override + protected Paint getWallFill(Movable wall) { + if (connectTo != null && connectTo.isForSameElement(wall)) { + return AppStyle.selectedWallTopologyColor; + } + return super.getWallFill(wall); + } + + @Override + public Paint getHandleFill(Handle handle) { + if (connectTo != null) { + // Both the connecting handle and current handle should show as connecting, i.e., blue. + if (movable.isForSameElement(handle) || connectTo.isForSameElement(handle)) { + return Color.blue; + } + } + return super.getHandleFill(handle); + } + + private void tryMerge() { + movable.applyMove(); + + switch (connectTo) { + case null -> { + /* Do nothing */ + } + case WallTopologyRig.MovableVertex movableVertex -> { + var newHandle = rig.mergeVertices(movableVertex, movable); + // Current handle's vertex may have just been eliminated. Use the returned one instead. + this.setCurrentHandle(newHandle, newHandle.getPosition()); + } + case WallTopologyRig.MovableWall movableWall -> { + // Split the wall, then merge with the new vertex. + var splitVertex = rig.splitAt(movableWall, movable.getPosition()); + var newVertex = rig.mergeVertices(splitVertex, movable); + // Vertex may have just been eliminated. Use the returned one instead. + this.setCurrentHandle(newVertex, newVertex.getPosition()); + } + } + } + } +} diff --git a/src/main/java/net/rptools/maptool/client/tool/drawing/AbstractDrawingLikeTool.java b/src/main/java/net/rptools/maptool/client/tool/drawing/AbstractDrawingLikeTool.java index 04632be58f..28570bb0c0 100644 --- a/src/main/java/net/rptools/maptool/client/tool/drawing/AbstractDrawingLikeTool.java +++ b/src/main/java/net/rptools/maptool/client/tool/drawing/AbstractDrawingLikeTool.java @@ -59,7 +59,7 @@ protected ZonePoint getPoint(MouseEvent e) { // is used for expand from center. zp = renderer.getCellCenterAt(sp); } else if (isSnapToGrid(e)) { - zp = renderer.getZone().getNearestVertex(zp); + zp = renderer.getZone().getGrid().getNearestVertex(zp); } return zp; } diff --git a/src/main/java/net/rptools/maptool/client/tool/drawing/TopologyTool.java b/src/main/java/net/rptools/maptool/client/tool/drawing/TopologyTool.java index 80f074e9b9..ae8d537188 100644 --- a/src/main/java/net/rptools/maptool/client/tool/drawing/TopologyTool.java +++ b/src/main/java/net/rptools/maptool/client/tool/drawing/TopologyTool.java @@ -20,14 +20,14 @@ import java.awt.Shape; import java.awt.event.MouseEvent; import java.awt.geom.Area; -import java.util.List; import javax.annotation.Nullable; import javax.swing.SwingUtilities; import net.rptools.maptool.client.AppStatePersisted; import net.rptools.maptool.client.AppStyle; import net.rptools.maptool.client.MapTool; +import net.rptools.maptool.client.swing.TopologyModeSelectionPanel; +import net.rptools.maptool.client.ui.zone.ZoneOverlay; import net.rptools.maptool.client.ui.zone.renderer.ZoneRenderer; -import net.rptools.maptool.model.Token; import net.rptools.maptool.model.Zone; import net.rptools.maptool.model.ZonePoint; @@ -36,6 +36,10 @@ public final class TopologyTool extends AbstractDrawingLikeTool { private final String tooltipKey; private final boolean isFilled; private final Strategy strategy; + private final TopologyModeSelectionPanel topologyModeSelectionPanel; + + /** Displays the topology that is on the map and tokens, i.e., */ + private final MaskOverlay maskOverlay; /** The current state of the tool. If {@code null}, nothing is being drawn right now. */ private @Nullable StateT state; @@ -45,13 +49,20 @@ public final class TopologyTool extends AbstractDrawingLikeTool { private boolean centerOnOrigin; public TopologyTool( - String instructionKey, String tooltipKey, boolean isFilled, Strategy strategy) { + String instructionKey, + String tooltipKey, + boolean isFilled, + Strategy strategy, + TopologyModeSelectionPanel topologyModeSelectionPanel) { this.instructionKey = instructionKey; this.tooltipKey = tooltipKey; this.isFilled = isFilled; this.strategy = strategy; // Consistency with topology tools before refactoring. Can be updated as part of #5002. this.centerOnOrigin = this.strategy instanceof OvalStrategy; + this.topologyModeSelectionPanel = topologyModeSelectionPanel; + + this.maskOverlay = new MaskOverlay(); } @Override @@ -69,6 +80,18 @@ public boolean isAvailable() { return MapTool.getPlayer().isGM(); } + @Override + protected void attachTo(ZoneRenderer renderer) { + topologyModeSelectionPanel.setEnabled(true); + super.attachTo(renderer); + } + + @Override + protected void detachFrom(ZoneRenderer renderer) { + topologyModeSelectionPanel.setEnabled(false); + super.detachFrom(renderer); + } + @Override protected boolean isLinearTool() { return strategy.isLinear(); @@ -106,56 +129,14 @@ private void submit(Shape shape) { .updateMaskTopology(getZone(), area, isEraser(), AppStatePersisted.getTopologyTypes()); } - private Area getTokenTopology(Zone.TopologyType topologyType) { - List topologyTokens = getZone().getTokensWithTopology(topologyType); - - Area tokenTopology = new Area(); - for (Token topologyToken : topologyTokens) { - tokenTopology.add(topologyToken.getTransformedMaskTopology(topologyType)); - } - - return tokenTopology; - } - @Override public void paintOverlay(ZoneRenderer renderer, Graphics2D g) { - if (!MapTool.getPlayer().isGM()) { - // Redundant check since the tool should not be available otherwise. - return; - } - - Zone zone = renderer.getZone(); + maskOverlay.paintOverlay(renderer, g); Graphics2D g2 = (Graphics2D) g.create(); g2.translate(renderer.getViewOffsetX(), renderer.getViewOffsetY()); g2.scale(renderer.getScale(), renderer.getScale()); - g2.setColor(AppStyle.tokenMblColor); - g2.fill(getTokenTopology(Zone.TopologyType.MBL)); - g2.setColor(AppStyle.tokenTopologyColor); - g2.fill(getTokenTopology(Zone.TopologyType.WALL_VBL)); - g2.setColor(AppStyle.tokenHillVblColor); - g2.fill(getTokenTopology(Zone.TopologyType.HILL_VBL)); - g2.setColor(AppStyle.tokenPitVblColor); - g2.fill(getTokenTopology(Zone.TopologyType.PIT_VBL)); - g2.setColor(AppStyle.tokenCoverVblColor); - g2.fill(getTokenTopology(Zone.TopologyType.COVER_VBL)); - - g2.setColor(AppStyle.topologyTerrainColor); - g2.fill(zone.getMaskTopology(Zone.TopologyType.MBL)); - - g2.setColor(AppStyle.topologyColor); - g2.fill(zone.getMaskTopology(Zone.TopologyType.WALL_VBL)); - - g2.setColor(AppStyle.hillVblColor); - g2.fill(zone.getMaskTopology(Zone.TopologyType.HILL_VBL)); - - g2.setColor(AppStyle.pitVblColor); - g2.fill(zone.getMaskTopology(Zone.TopologyType.PIT_VBL)); - - g2.setColor(AppStyle.coverVblColor); - g2.fill(zone.getMaskTopology(Zone.TopologyType.COVER_VBL)); - if (state != null) { var result = strategy.getShape(state, currentPoint, centerOnOrigin, false); if (result != null) { @@ -227,4 +208,48 @@ public void mousePressed(MouseEvent e) { super.mousePressed(e); } + + public static final class MaskOverlay implements ZoneOverlay { + @Override + public void paintOverlay(ZoneRenderer renderer, Graphics2D g) { + if (!MapTool.getPlayer().isGM()) { + // Redundant check since the tool should not be available otherwise. + return; + } + + Zone zone = renderer.getZone(); + + Graphics2D g2 = (Graphics2D) g.create(); + g2.translate(renderer.getViewOffsetX(), renderer.getViewOffsetY()); + g2.scale(renderer.getScale(), renderer.getScale()); + + g2.setColor(AppStyle.tokenMblColor); + g2.fill(zone.getTokenMaskTopology(Zone.TopologyType.MBL, null)); + g2.setColor(AppStyle.tokenTopologyColor); + g2.fill(zone.getTokenMaskTopology(Zone.TopologyType.WALL_VBL, null)); + g2.setColor(AppStyle.tokenHillVblColor); + g2.fill(zone.getTokenMaskTopology(Zone.TopologyType.HILL_VBL, null)); + g2.setColor(AppStyle.tokenPitVblColor); + g2.fill(zone.getTokenMaskTopology(Zone.TopologyType.PIT_VBL, null)); + g2.setColor(AppStyle.tokenCoverVblColor); + g2.fill(zone.getTokenMaskTopology(Zone.TopologyType.COVER_VBL, null)); + + g2.setColor(AppStyle.topologyTerrainColor); + g2.fill(zone.getMaskTopology(Zone.TopologyType.MBL)); + + g2.setColor(AppStyle.topologyColor); + g2.fill(zone.getMaskTopology(Zone.TopologyType.WALL_VBL)); + + g2.setColor(AppStyle.hillVblColor); + g2.fill(zone.getMaskTopology(Zone.TopologyType.HILL_VBL)); + + g2.setColor(AppStyle.pitVblColor); + g2.fill(zone.getMaskTopology(Zone.TopologyType.PIT_VBL)); + + g2.setColor(AppStyle.coverVblColor); + g2.fill(zone.getMaskTopology(Zone.TopologyType.COVER_VBL)); + + g2.dispose(); + } + } } diff --git a/src/main/java/net/rptools/maptool/client/tool/rig/Handle.java b/src/main/java/net/rptools/maptool/client/tool/rig/Handle.java new file mode 100644 index 0000000000..df0ac25254 --- /dev/null +++ b/src/main/java/net/rptools/maptool/client/tool/rig/Handle.java @@ -0,0 +1,35 @@ +/* + * This software Copyright by the RPTools.net development team, and + * licensed under the Affero GPL Version 3 or, at your option, any later + * version. + * + * MapTool Source Code is distributed in the hope that it will be + * useful, but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * You should have received a copy of the GNU Affero General Public + * License * along with this source Code. If not, please visit + * and specifically the Affero license + * text at . + */ +package net.rptools.maptool.client.tool.rig; + +import java.awt.geom.Point2D; + +/** A draggable node with a definite position in a rig. */ +public interface Handle extends Movable { + /** + * @return The position of the handle. + */ + Point2D getPosition(); + + /** + * Moves the handle to {@code point}. + * + *

Immmediately after this call, {@link #getPosition()} will be the same location as {@code + * point}. + * + * @param point The location to move the handle to. + */ + void moveTo(Point2D point); +} diff --git a/src/main/java/net/rptools/maptool/client/tool/rig/Movable.java b/src/main/java/net/rptools/maptool/client/tool/rig/Movable.java new file mode 100644 index 0000000000..2cd03cb6e3 --- /dev/null +++ b/src/main/java/net/rptools/maptool/client/tool/rig/Movable.java @@ -0,0 +1,45 @@ +/* + * This software Copyright by the RPTools.net development team, and + * licensed under the Affero GPL Version 3 or, at your option, any later + * version. + * + * MapTool Source Code is distributed in the hope that it will be + * useful, but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * You should have received a copy of the GNU Affero General Public + * License * along with this source Code. If not, please visit + * and specifically the Affero license + * text at . + */ +package net.rptools.maptool.client.tool.rig; + +public interface Movable { + Rig getParentRig(); + + T getSource(); + + /** + * Check whether {@code this} and {@code other} represent the same element in the same parent rig. + * + * @param other + * @return {@code true} if {@code this} and {@code other} represent the same vertex in the same + * rig. + */ + boolean isForSameElement(Movable other); + + /** + * Moves the handle by applying a displacement relative to its original position. + * + * @param displacementX The amount to displace the handle along in the x-direction. + * @param displacementY The amount to displace the handle along in the y-direction. + * @param snapMode The snap behaviour to apply. + */ + void displace(double displacementX, double displacementY, Snap snapMode); + + /** Commits the latest displacement from {@link #displace(double, double, Snap)} to the source. */ + void applyMove(); + + /** Delete the element from the parent rig. */ + void delete(); +} diff --git a/src/main/java/net/rptools/maptool/client/tool/rig/Rig.java b/src/main/java/net/rptools/maptool/client/tool/rig/Rig.java new file mode 100644 index 0000000000..5cbf2d28a1 --- /dev/null +++ b/src/main/java/net/rptools/maptool/client/tool/rig/Rig.java @@ -0,0 +1,39 @@ +/* + * This software Copyright by the RPTools.net development team, and + * licensed under the Affero GPL Version 3 or, at your option, any later + * version. + * + * MapTool Source Code is distributed in the hope that it will be + * useful, but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * You should have received a copy of the GNU Affero General Public + * License * along with this source Code. If not, please visit + * and specifically the Affero license + * text at . + */ +package net.rptools.maptool.client.tool.rig; + +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; + +/** A rig is a representation of a model in terms of handles and other movable elements. */ +public interface Rig { + default Optional> getNearbyHandle(Point2D point) { + return getNearbyHandle(point, 0., element -> true); + } + + Optional> getNearbyHandle( + Point2D point, double extraSpace, Predicate filter); + + List> getHandlesWithin(Rectangle2D bounds); + + default Optional getNearbyElement(Point2D point) { + return getNearbyElement(point, 0., element -> true); + } + + Optional getNearbyElement(Point2D point, double extraSpace, Predicate filter); +} diff --git a/src/main/java/net/rptools/maptool/client/tool/rig/Snap.java b/src/main/java/net/rptools/maptool/client/tool/rig/Snap.java new file mode 100644 index 0000000000..e4d90c039c --- /dev/null +++ b/src/main/java/net/rptools/maptool/client/tool/rig/Snap.java @@ -0,0 +1,51 @@ +/* + * This software Copyright by the RPTools.net development team, and + * licensed under the Affero GPL Version 3 or, at your option, any later + * version. + * + * MapTool Source Code is distributed in the hope that it will be + * useful, but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * You should have received a copy of the GNU Affero General Public + * License * along with this source Code. If not, please visit + * and specifically the Affero license + * text at . + */ +package net.rptools.maptool.client.tool.rig; + +import java.awt.geom.Point2D; +import net.rptools.maptool.model.Grid; +import net.rptools.maptool.model.ZonePoint; + +@FunctionalInterface +public interface Snap { + Point2D snap(Point2D point); + + static Snap none() { + return point -> point; + } + + static Snap vertex(Grid grid) { + return point -> { + var zonePoint = new ZonePoint((int) Math.floor(point.getX()), (int) Math.floor(point.getY())); + var result = grid.getNearestVertex(zonePoint); + return new Point2D.Double(result.x, result.y); + }; + } + + static Snap center(Grid grid) { + return point -> { + var zonePoint = new ZonePoint((int) Math.floor(point.getX()), (int) Math.floor(point.getY())); + var cellPoint = grid.convert(zonePoint); + return grid.getCellCenter(cellPoint); + }; + } + + static Snap fine(Grid grid) { + return point -> { + var zonePoint = new ZonePoint((int) Math.floor(point.getX()), (int) Math.floor(point.getY())); + return grid.snapFine(zonePoint); + }; + } +} diff --git a/src/main/java/net/rptools/maptool/client/tool/rig/WallTopologyRig.java b/src/main/java/net/rptools/maptool/client/tool/rig/WallTopologyRig.java new file mode 100644 index 0000000000..0804d56efc --- /dev/null +++ b/src/main/java/net/rptools/maptool/client/tool/rig/WallTopologyRig.java @@ -0,0 +1,396 @@ +/* + * This software Copyright by the RPTools.net development team, and + * licensed under the Affero GPL Version 3 or, at your option, any later + * version. + * + * MapTool Source Code is distributed in the hope that it will be + * useful, but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * + * You should have received a copy of the GNU Affero General Public + * License * along with this source Code. If not, please visit + * and specifically the Affero license + * text at . + */ +package net.rptools.maptool.client.tool.rig; + +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; +import net.rptools.lib.GeometryUtil; +import net.rptools.maptool.model.topology.WallTopology; +import net.rptools.maptool.model.topology.WallTopology.Vertex; +import net.rptools.maptool.model.topology.WallTopology.Wall; +import org.locationtech.jts.geom.Envelope; +import org.locationtech.jts.geom.LineSegment; + +public final class WallTopologyRig implements Rig> { + public sealed interface Element extends Movable + permits WallTopologyRig.MovableVertex, WallTopologyRig.MovableWall {} + + private WallTopology walls; + private final double vertexSelectDistance; + private final double wallSelectDistance; + + public WallTopologyRig(double vertexSelectDistance, double wallSelectDistance) { + this.walls = new WallTopology(); + this.vertexSelectDistance = vertexSelectDistance; + this.wallSelectDistance = wallSelectDistance; + updateShape(); + } + + public void setWalls(WallTopology walls) { + this.walls = new WallTopology(walls); + updateShape(); + } + + @Override + public List getHandlesWithin(Rectangle2D bounds) { + var envelope = + new Envelope( + bounds.getMinX(), bounds.getMaxX(), + bounds.getMinY(), bounds.getMaxY()); + + var results = new ArrayList(); + walls + .getVertices() + .forEach( + vertex -> { + if (envelope.contains(GeometryUtil.point2DToCoordinate(vertex.getPosition()))) { + results.add(new WallTopologyRig.MovableVertex(this, walls, vertex)); + } + }); + results.sort(Comparator.comparingInt(vertex -> vertex.getSource().getZIndex())); + return results; + } + + public List getWallsWithin(Rectangle2D bounds) { + var envelope = + new Envelope( + bounds.getMinX(), bounds.getMaxX(), + bounds.getMinY(), bounds.getMaxY()); + + var results = new ArrayList(); + walls + .getWalls() + .forEach( + wall -> { + var wallEnvelope = + new Envelope( + GeometryUtil.point2DToCoordinate(wall.from().getPosition()), + GeometryUtil.point2DToCoordinate(wall.to().getPosition())); + if (wallEnvelope.intersects(envelope)) { + results.add(new WallTopologyRig.MovableWall(this, walls, wall)); + } + }); + results.sort(Comparator.comparingInt(wall -> wall.getSource().getZIndex())); + return results; + } + + /** + * Convenience method for calling {@link #getNearbyHandle(Point2D, double, Predicate)} followed by + * {@link #getNearbyWall(Point2D, double, Predicate)}. + * + * @param point + * @param filter + * @return + */ + public Optional> getNearbyElement( + Point2D point, double extraSpace, Predicate> filter) { + var vertex = getNearbyHandle(point, extraSpace, filter); + if (vertex.isPresent()) { + return vertex; + } + return getNearbyWall(point, extraSpace, filter); + } + + @Override + public Optional getNearbyHandle( + Point2D point, double extraSpace, Predicate> filter) { + var selectDistance = vertexSelectDistance + extraSpace; + var candidatesVertices = + getHandlesWithin( + new Rectangle2D.Double( + point.getX() - selectDistance, + point.getY() - selectDistance, + 2 * selectDistance, + 2 * selectDistance)); + + // Reverse so we pick the highest z-index candidate. + for (var candidate : candidatesVertices.reversed()) { + if (filter.test(candidate) && point.distance(candidate.getPosition()) < selectDistance) { + return Optional.of(candidate); + } + } + + return Optional.empty(); + } + + public Optional getNearbyWall( + Point2D point, double extraSpace, Predicate> filter) { + var coordinate = GeometryUtil.point2DToCoordinate(point); + + var selectDistance = wallSelectDistance + extraSpace; + var bounds = + new Rectangle2D.Double( + point.getX() - selectDistance, + point.getY() - selectDistance, + 2 * selectDistance, + 2 * selectDistance); + var candidateWalls = getWallsWithin(bounds); + + // Reverse so we pick the highest-z-index candidate. + for (var wall : candidateWalls.reversed()) { + if (!filter.test(wall)) { + continue; + } + + var segment = wall.getSource().asSegment(); + var factor = segment.projectionFactor(coordinate); + if (factor < 0 || factor > 1) { + // Lies outside this segment. + continue; + } + + var pointAlong = segment.pointAlong(factor); + var distance = coordinate.distance(pointAlong); + if (distance <= selectDistance) { + return Optional.of(wall); + } + } + + return Optional.empty(); + } + + public WallTopologyRig.MovableVertex addDegenerateWall(Point2D point) { + var newWall = walls.brandNewWall(); + newWall.from().setPosition(point); + newWall.to().setPosition(point); + + updateShape(); + + return new WallTopologyRig.MovableVertex(this, walls, newWall.to()); + } + + public WallTopologyRig.MovableVertex addControlPoint(Vertex connectTo, Point2D point) { + var newWall = walls.newWallStartingAt(connectTo); + newWall.to().setPosition(point); + updateShape(); + return new WallTopologyRig.MovableVertex(this, walls, newWall.to()); + } + + /** + * Called whenever the source is changed in any way. + * + *

Although empty right now, if we ever add acceleration structures this would be the place to + * update them. + */ + private void updateShape() {} + + public WallTopology commit() { + // Submit a copy so further edits are not accidentally directly reflected in the model. + return new WallTopology(walls); + } + + /** + * Merge {@code one} with {@code two}. + * + *

The result will be a vertex that has all the walls of the originals, with data merged as + * needed. Whether a new vertex is created or one of the originals is updated is implementation + * defined, so make sure to use the return value as the resulting vertex. + * + * @param one The first of the vertices to merge. + * @param two The second of the vertices to merge. + * @return The surviving handle. + */ + public WallTopologyRig.MovableVertex mergeVertices(Handle one, Handle two) { + var survivor = walls.merge(one.getSource(), two.getSource()); + updateShape(); + return new WallTopologyRig.MovableVertex(this, walls, survivor); + } + + /** + * Get the point the wall would be split at if {@code splitAt(wall, reference)} were called. + * + * @param wall The wall to look for a split point on. + * @param reference A point close to the wall. + * @return The point on {@code wall} nearest to {@code reference}. + */ + public Point2D getSplitPoint(Movable wall, Point2D reference) { + var coordinate = GeometryUtil.point2DToCoordinate(reference); + var segment = wall.getSource().asSegment(); + var fraction = segment.segmentFraction(coordinate); + var projection = segment.pointAlong(fraction); + return GeometryUtil.coordinateToPoint2D(projection); + } + + /** + * Splits {@code wall} at the point nearest to {@code reference}. + * + * @param wall The wall to split. + * @param reference A point close to the wall. + * @return The new handle created as part of the split. + */ + public Handle splitAt(Movable wall, Point2D reference) { + var projection = getSplitPoint(wall, reference); + var newVertex = walls.splitWall(wall.getSource()); + newVertex.setPosition(projection); + return new WallTopologyRig.MovableVertex(this, walls, newVertex); + } + + public static final class MovableWall implements WallTopologyRig.Element, Movable { + private final WallTopologyRig parentRig; + private final WallTopology walls; + private final Wall source; + private final LineSegment originalSegment; + + private MovableWall(WallTopologyRig parentRig, WallTopology walls, Wall source) { + this.parentRig = parentRig; + this.walls = walls; + this.source = source; + this.originalSegment = source.asSegment(); + } + + @Override + public WallTopologyRig getParentRig() { + return parentRig; + } + + @Override + public Wall getSource() { + return source; + } + + public MovableVertex getFrom() { + return new MovableVertex(parentRig, walls, source.from()); + } + + public MovableVertex getTo() { + return new MovableVertex(parentRig, walls, source.to()); + } + + @Override + public void delete() { + walls.removeWall(source); + parentRig.updateShape(); + } + + @Override + public boolean isForSameElement(Movable other) { + return other instanceof WallTopologyRig.MovableWall movableWall + && this.getParentRig() == movableWall.getParentRig() + && this.source.from().id().equals(movableWall.source.from().id()) + && this.source.to().id().equals(movableWall.source.to().id()); + } + + @Override + public void displace(double displacementX, double displacementY, Snap snapMode) { + var newFrom = + new Point2D.Double( + originalSegment.p0.x + displacementX, originalSegment.p0.y + displacementY); + var newTo = + new Point2D.Double( + originalSegment.p1.x + displacementX, originalSegment.p1.y + displacementY); + + var snappedFrom = snapMode.snap(newFrom); + var fromOffsetX = snappedFrom.getX() - newFrom.getX(); + var fromOffsetY = snappedFrom.getY() - newFrom.getY(); + + var snappedTo = snapMode.snap(newTo); + var toOffsetX = snappedTo.getX() - newTo.getX(); + var toOffsetY = snappedTo.getY() - newTo.getY(); + + if ((fromOffsetX * fromOffsetX + fromOffsetY * fromOffsetY) + < (toOffsetX * toOffsetX + toOffsetY * toOffsetY)) { + source.from().setPosition(snappedFrom.getX(), snappedFrom.getY()); + source + .to() + .setPosition( + snappedFrom.getX() + (originalSegment.p1.x - originalSegment.p0.x), + snappedFrom.getY() + (originalSegment.p1.y - originalSegment.p0.y)); + } else { + source.to().setPosition(snappedTo.getX(), snappedTo.getY()); + source + .from() + .setPosition( + snappedTo.getX() + (originalSegment.p0.x - originalSegment.p1.x), + snappedTo.getY() + (originalSegment.p0.y - originalSegment.p1.y)); + } + + parentRig.updateShape(); + } + + @Override + public void applyMove() { + originalSegment.setCoordinates(source.asSegment()); + } + } + + public static final class MovableVertex + implements WallTopologyRig.Element, Handle { + private final WallTopologyRig parentRig; + private final WallTopology walls; + private final Vertex source; + private final Point2D originalPosition; + + private MovableVertex(WallTopologyRig parentRig, WallTopology walls, Vertex source) { + this.parentRig = parentRig; + this.walls = walls; + this.source = source; + this.originalPosition = this.source.getPosition(); + } + + @Override + public Vertex getSource() { + return source; + } + + @Override + public WallTopologyRig getParentRig() { + return parentRig; + } + + @Override + public Point2D getPosition() { + return source.getPosition(); + } + + @Override + public void delete() { + walls.removeVertex(source); + parentRig.updateShape(); + } + + @Override + public boolean isForSameElement(Movable other) { + return other instanceof WallTopologyRig.MovableVertex movableVertex + && this.getParentRig() == movableVertex.getParentRig() + && this.source.id().equals(movableVertex.source.id()); + } + + @Override + public void moveTo(Point2D point) { + source.setPosition(point.getX(), point.getY()); + parentRig.updateShape(); + } + + @Override + public void displace(double displacementX, double displacementY, Snap snapMode) { + Point2D point = + new Point2D.Double( + originalPosition.getX() + displacementX, originalPosition.getY() + displacementY); + point = snapMode.snap(point); + + moveTo(point); + } + + @Override + public void applyMove() { + originalPosition.setLocation(source.getPosition()); + } + } +} diff --git a/src/main/java/net/rptools/maptool/client/tool/rig/package-info.java b/src/main/java/net/rptools/maptool/client/tool/rig/package-info.java new file mode 100644 index 0000000000..f78c10edf2 --- /dev/null +++ b/src/main/java/net/rptools/maptool/client/tool/rig/package-info.java @@ -0,0 +1,2 @@ +/** Utilities for building tools that edit things on screen rather than just draw/erase. */ +package net.rptools.maptool.client.tool.rig; diff --git a/src/main/java/net/rptools/maptool/client/ui/ToolbarPanel.java b/src/main/java/net/rptools/maptool/client/ui/ToolbarPanel.java index 84c66e7124..76d6874b42 100644 --- a/src/main/java/net/rptools/maptool/client/ui/ToolbarPanel.java +++ b/src/main/java/net/rptools/maptool/client/ui/ToolbarPanel.java @@ -379,6 +379,12 @@ protected void activate() { private OptionPanel createTopologyPanel() { OptionPanel panel = new OptionPanel(); + final var topologyModeSelectionPanel = new TopologyModeSelectionPanel(); + topologyModeSelectionPanel.setEnabled(false); + + panel + .addTool(new WallTopologyTool()) + .setIcon(RessourceManager.getBigIcon(Icons.TOOLBAR_TOPOLOGY_WALL)); panel .addTool( @@ -386,7 +392,8 @@ private OptionPanel createTopologyPanel() { "tool.recttopology.instructions", "tool.recttopology.tooltip", true, - new RectangleStrategy())) + new RectangleStrategy(), + topologyModeSelectionPanel)) .setIcon(RessourceManager.getBigIcon(Icons.TOOLBAR_TOPOLOGY_BOX)); panel .addTool( @@ -394,7 +401,8 @@ private OptionPanel createTopologyPanel() { "tool.recttopology.instructions", "tool.recttopologyhollow.tooltip", false, - new RectangleStrategy())) + new RectangleStrategy(), + topologyModeSelectionPanel)) .setIcon(RessourceManager.getBigIcon(Icons.TOOLBAR_TOPOLOGY_BOX_HOLLOW)); panel .addTool( @@ -403,7 +411,8 @@ private OptionPanel createTopologyPanel() { "tool.ovaltopology.tooltip", true, // 10 steps to keep number of topology vertices reasonable. - new OvalStrategy(10))) + new OvalStrategy(10), + topologyModeSelectionPanel)) .setIcon(RessourceManager.getBigIcon(Icons.TOOLBAR_TOPOLOGY_OVAL)); panel .addTool( @@ -412,7 +421,8 @@ private OptionPanel createTopologyPanel() { "tool.ovaltopologyhollow.tooltip", false, // 10 steps to keep number of topology vertices reasonable. - new OvalStrategy(10))) + new OvalStrategy(10), + topologyModeSelectionPanel)) .setIcon(RessourceManager.getBigIcon(Icons.TOOLBAR_TOPOLOGY_OVAL_HOLLOW)); panel .addTool( @@ -420,7 +430,8 @@ private OptionPanel createTopologyPanel() { "tool.poly.instructions", "tool.polytopo.tooltip", true, - new PolyLineStrategy(false))) + new PolyLineStrategy(false), + topologyModeSelectionPanel)) .setIcon(RessourceManager.getBigIcon(Icons.TOOLBAR_TOPOLOGY_POLYGON)); panel .addTool( @@ -428,7 +439,8 @@ private OptionPanel createTopologyPanel() { "tool.poly.instructions", "tool.polylinetopo.tooltip", false, - new PolyLineStrategy(false))) + new PolyLineStrategy(false), + topologyModeSelectionPanel)) .setIcon(RessourceManager.getBigIcon(Icons.TOOLBAR_TOPOLOGY_POLYLINE)); panel .addTool( @@ -436,7 +448,8 @@ private OptionPanel createTopologyPanel() { "tool.crosstopology.instructions", "tool.crosstopology.tooltip", false, - new CrossStrategy())) + new CrossStrategy(), + topologyModeSelectionPanel)) .setIcon(RessourceManager.getBigIcon(Icons.TOOLBAR_TOPOLOGY_CROSS)); panel .addTool( @@ -444,7 +457,8 @@ private OptionPanel createTopologyPanel() { "tool.isorectangletopology.instructions", "tool.isorectangletopology.tooltip", true, - new IsoRectangleStrategy())) + new IsoRectangleStrategy(), + topologyModeSelectionPanel)) .setIcon(RessourceManager.getBigIcon(Icons.TOOLBAR_TOPOLOGY_DIAMOND)); panel .addTool( @@ -452,13 +466,13 @@ private OptionPanel createTopologyPanel() { "tool.isorectangletopology.instructions", "tool.isorectangletopologyhollow.tooltip", false, - new IsoRectangleStrategy())) + new IsoRectangleStrategy(), + topologyModeSelectionPanel)) .setIcon(RessourceManager.getBigIcon(Icons.TOOLBAR_TOPOLOGY_DIAMOND_HOLLOW)); // Add with separator to separate mode button group from shape button group. addSeparator(panel, 11); - final var topologyModeSelectionPanel = new TopologyModeSelectionPanel(); panel.add(topologyModeSelectionPanel); return panel; diff --git a/src/main/java/net/rptools/maptool/client/ui/theme/Icons.java b/src/main/java/net/rptools/maptool/client/ui/theme/Icons.java index c44e851010..4a144a8e5b 100644 --- a/src/main/java/net/rptools/maptool/client/ui/theme/Icons.java +++ b/src/main/java/net/rptools/maptool/client/ui/theme/Icons.java @@ -158,6 +158,7 @@ public enum Icons { TOOLBAR_TOKENSELECTION_NPC_ON, TOOLBAR_TOKENSELECTION_PC_OFF, TOOLBAR_TOKENSELECTION_PC_ON, + TOOLBAR_TOPOLOGY_WALL, TOOLBAR_TOPOLOGY_BOX, TOOLBAR_TOPOLOGY_BOX_HOLLOW, TOOLBAR_TOPOLOGY_CROSS, diff --git a/src/main/java/net/rptools/maptool/client/ui/theme/RessourceManager.java b/src/main/java/net/rptools/maptool/client/ui/theme/RessourceManager.java index 8ff6c4ef70..d58ad67531 100644 --- a/src/main/java/net/rptools/maptool/client/ui/theme/RessourceManager.java +++ b/src/main/java/net/rptools/maptool/client/ui/theme/RessourceManager.java @@ -186,6 +186,7 @@ public class RessourceManager { put(Icons.TOOLBAR_TOKENSELECTION_NPC_ON, IMAGE_DIR + "tool/select-npc-blue.png"); put(Icons.TOOLBAR_TOKENSELECTION_PC_OFF, IMAGE_DIR + "tool/select-pc-blue-off.png"); put(Icons.TOOLBAR_TOKENSELECTION_PC_ON, IMAGE_DIR + "tool/select-pc-blue.png"); + put(Icons.TOOLBAR_TOPOLOGY_WALL, IMAGE_DIR + "tool/wall-topology.png"); put(Icons.TOOLBAR_TOPOLOGY_BOX, IMAGE_DIR + "tool/top-blue-rect.png"); put(Icons.TOOLBAR_TOPOLOGY_BOX_HOLLOW, IMAGE_DIR + "tool/top-blue-hrect.png"); put(Icons.TOOLBAR_TOPOLOGY_CROSS, IMAGE_DIR + "tool/top-blue-cross.png"); @@ -412,6 +413,7 @@ public class RessourceManager { put(Icons.TOOLBAR_TOKENSELECTION_NPC_ON, ROD_ICONS + "ribbon/NPC.svg"); put(Icons.TOOLBAR_TOKENSELECTION_PC_OFF, ROD_ICONS + "ribbon/PC.svg"); put(Icons.TOOLBAR_TOKENSELECTION_PC_ON, ROD_ICONS + "ribbon/PC.svg"); + put(Icons.TOOLBAR_TOPOLOGY_WALL, ROD_ICONS + "ribbon/Draw Wall.svg"); put(Icons.TOOLBAR_TOPOLOGY_BOX, ROD_ICONS + "ribbon/Draw Rectangle.svg"); put(Icons.TOOLBAR_TOPOLOGY_BOX_HOLLOW, ROD_ICONS + "ribbon/Draw Hollow Rectangle.svg"); put(Icons.TOOLBAR_TOPOLOGY_CROSS, ROD_ICONS + "ribbon/Draw Cross.svg"); diff --git a/src/main/java/net/rptools/maptool/model/Grid.java b/src/main/java/net/rptools/maptool/model/Grid.java index 80bba9cb92..73ef155227 100644 --- a/src/main/java/net/rptools/maptool/model/Grid.java +++ b/src/main/java/net/rptools/maptool/model/Grid.java @@ -256,14 +256,29 @@ public Object clone() throws CloneNotSupportedException { public abstract ZonePoint convert(CellPoint cp); public ZonePoint getNearestVertex(ZonePoint point) { - int gridx = (int) Math.round((point.x - getOffsetX()) / getCellWidth()); - int gridy = (int) Math.round((point.y - getOffsetY()) / getCellHeight()); + double gridx = Math.round((point.x - getOffsetX()) / getCellWidth()); + double gridy = Math.round((point.y - getOffsetY()) / getCellHeight()); return new ZonePoint( (int) (gridx * getCellWidth() + getOffsetX()), (int) (gridy * getCellHeight() + getOffsetY())); } + /** + * Like {@link #getNearestVertex(ZonePoint)}, but can snap by sub-cell increments. + * + *

It is up to the implementation what a useful definition of "fine" is. By default, it is the + * same as {@link #getNearestVertex(ZonePoint)}. For square grids it is the same as snapping to a + * half-grid. + * + * @param point The point to snap. + * @return The snapped point. + */ + public Point2D snapFine(ZonePoint point) { + var vertex = getNearestVertex(point); + return new Point2D.Double(vertex.x, vertex.y); + } + public abstract GridCapabilities getCapabilities(); public int getTokenSpace() { diff --git a/src/main/java/net/rptools/maptool/model/SquareGrid.java b/src/main/java/net/rptools/maptool/model/SquareGrid.java index 34c13aa96a..12ee0a57e9 100644 --- a/src/main/java/net/rptools/maptool/model/SquareGrid.java +++ b/src/main/java/net/rptools/maptool/model/SquareGrid.java @@ -286,6 +286,20 @@ public CellPoint convert(ZonePoint zp) { return new CellPoint(newX, newY); } + @Override + public Point2D snapFine(ZonePoint point) { + double offsetX = getOffsetX(); + double offsetY = getOffsetY(); + + double stepX = getCellWidth() / 2.; + double stepY = getCellHeight() / 2.; + + double gridx = Math.round((point.x - offsetX) / stepX); + double gridy = Math.round((point.y - offsetY) / stepY); + + return new Point2D.Double(gridx * stepX + offsetX, gridy * stepY + offsetY); + } + @Override public ZoneWalker createZoneWalker() { WalkerMetric metric = diff --git a/src/main/java/net/rptools/maptool/model/Zone.java b/src/main/java/net/rptools/maptool/model/Zone.java index 060ac1d9ab..1741e2e5e0 100644 --- a/src/main/java/net/rptools/maptool/model/Zone.java +++ b/src/main/java/net/rptools/maptool/model/Zone.java @@ -1301,10 +1301,6 @@ public long getCreationTime() { return creationTime; } - public ZonePoint getNearestVertex(ZonePoint point) { - return grid.getNearestVertex(point); - } - /** * Returns the Area of the exposed fog for the current tokens (as determined by view.getTokens()). * This means if no tokens are current, the return value is the zone's global exposed fog area. If diff --git a/src/main/resources/net/rptools/maptool/client/icons/rod_takehara/ribbon/Draw Wall.svg b/src/main/resources/net/rptools/maptool/client/icons/rod_takehara/ribbon/Draw Wall.svg new file mode 100644 index 0000000000..10aad99ed0 --- /dev/null +++ b/src/main/resources/net/rptools/maptool/client/icons/rod_takehara/ribbon/Draw Wall.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/net/rptools/maptool/client/image/tool/wall-topology.png b/src/main/resources/net/rptools/maptool/client/image/tool/wall-topology.png new file mode 100644 index 0000000000000000000000000000000000000000..ec17210bcb048e72cd99cd0a48ad50bb768a934e GIT binary patch literal 5960 zcmeHKc{r497av4rDJ_;tHSeg9W_e}?Gm|0AV6vnrQxr4LJT#ca%wQPF(jr28Noke0 zi0ZAh(Pl~I)uM$Kp%U6eFDd!%8B$$e@ArLO*Z2O{xbC^nbMA9~_ql)fIcGd;ygXg> zw8v|sP$)f?D`Os*$0^4!E%061vJwW<4TQ}KVzH*9hJgoQsWt?qrnCV=?HgHLO#`I? zbgRG^4P>Q_a*S8gRLyIFJYhiI1Z4988+9Nz4xpFkwjAczJL&;%m*ra?qtt+xAXL`tMeW4~B{vV!mA zl6WYw*^+Q%JnCOn{ zO`N93N=e=KWRdtSe6;mRV}4#z9(q_@JY^=u;*9H>J~wSFmri+IaaNIWHY(l=PpWE7 zQQI!eI>_+9&hstH_U$x{w_`xlNY}P)+&_huUa08qjxlOjxU;&Ks#UY247JPE1YX4- zBhs%9tJ7*;)~IiJb$UCseLu@+O5D>kjqMYf6R)XP>t?pp*oG&wXBcNa^iT z&ivaJ0axVtlE;ntoAdIc=X|~t=ozr^Ijw5$lq1PgUG?|uP8ogm$f!N@&B`28{%V@| z$+RER+v@zAtD?zLFeNx}WH^Rf8d}`dZ%M)H3ReUfW8WWk|ToqoDF)=q>y?a-J zW%0AZnuOXpw?eVl*de+?4WoNlk6H~}RD6S(lQrV|?)KPs(NB8UmRRWMK2lGxDlB?# z=Q%l^9I^SF+K3SeYt)g#D0!4fA?nNK|J9{Y)G4`SX#YrtLXBYZeb_R#hdT`x32+>q zh>PIl0B6Nq>s5eqD^(rBTK zBgYD*rb>u@3jMsLPp16z&iR%d;+nD#~{2=I=}+p125-@@eq!H z7YOj*dPrr=NB}Yr&<{PNKG9+XKM#?LA|x=vj6{So)9)eXAz#n*KR^KU?*Mr4U;yF> zNtlWc9*jrA5CPar3c`lIi&C`9LP z+*v}Yj3b1RFYbKcPO!2@thg|O4I;y2ESXCpVY#FrGL{5WtO+(j6pl4S{sv->gbz}g z6ZWG>5CVlqhN#w9gl9v*lBqUCEQbpdupm4bhPh-4g~C-q@nD*>NFv~X9N-H$!3bU~ z3|1{DNv1h?vFI2gj__6E6~>VT0S7w9oiB`#e_in53lMJ^N68>$LxPAT5*Z>?mRexx-s*-3Nxc>-JP9(xp#R;(bm*AxwVK4$(&OnNO(er!%p_DdS(+^^2~bV&JEYe`VMIjV|r4?}msF{C5@w-u&Fck9dQ(MlG(J z3j@`OdXGBDe%HJiXoibj7fMkmoiWOxhT6Wv7$}FzSRTxwFLboEwe@m?J01W>V-~}~ z$F*YTmgQlapBYrxS9hFyQ(SD{lcY#6oQ>Cat7oRn@~eB`=+=U-dtBeOtiJhWhT>pj zT(V;0#&eY&O^TA{*lKD_?)>uu z^m0nn&Yot9XyU}2vu2lT)@HoXX;?Q6sz9E-Gj6+A#cA2VURPz<)!Ul}j}+8@PP=55 zs5?UU;oS`(cH#mB@o3k`V9EpUDgNG0GhR1R#*a;o4>MJkp&@SxCt`0lh zcM-SUo0a|GkKrizWrh0m`FU#3F&ogsLgrq5`{?833a`Mf0_TvBnkLHy`LXgXm1U_K zMInNdw(qZhBu{C(k*=Q>!`L?oqdm%wt95jE{$h9i8Krfn85n7@^F~I~_E?M3ic{QLExg{Cb97-`Q4o%dptgZo`}l(;d!^ zKdif?(#*!27u2dv@}7zt(G&J~tj^Ridld1x?=L2;er|Di=3}{Zn|$%uQ4X224>87? z!fD5?M;=prgw01WlA8Tem>Du)cl=^IKlX>`BM~42aP|fSP z{#T_Z6`R&nnylPg@yNbptIpPqGu#$)``~KdcR`wohw}4gVH_#>bFXSGxYFuAtuNB{ zkX!qy@-9oC++3XtLlSnm#5(X--ZC&NiK`!JY?68HUD@Sx-C6ckOKkn@)JsNoY|z?= z&_)k0&Qrwnj9~7I;qgQDYQr*9jSjTDnxt_lxyCesX}2K#LEI^+;foxzF4F`bX$#E-`IY8XQBH0hqAU; zOMPLJbs-bRSo*B^UEgm#wBSnfjH)!8z2ZnZyXI4mTb7Nz#KY+IoQ&7(GgCIy7Uyl| zJ`P4cTGv@Wo?E+{w`Enb4J*Eg^J z-n5utmx@&-b9S`5WK0UExuIWc$}QU2_UG*gqFeQc(Hp((vVAR%pB{cWSSV(nxRrBo z?uUds3O_%QTwb}sr`@}F0d3~lyEQBB6wN($+Uu?NhgkiNa~F(FYNwvdrkYUf}af( Mi|NTIa12cR7uDV6>Hq)$ literal 0 HcmV?d00001 diff --git a/src/main/resources/net/rptools/maptool/language/i18n.properties b/src/main/resources/net/rptools/maptool/language/i18n.properties index c4f57a1587..5826278b48 100644 --- a/src/main/resources/net/rptools/maptool/language/i18n.properties +++ b/src/main/resources/net/rptools/maptool/language/i18n.properties @@ -2771,6 +2771,8 @@ tool.isorectangletopology.instructions = LClick: set initial/final point, Shif tool.stamp.tooltip = Stamp tool tool.walltemplate.instructions = LClick: set starting cell, move mouse in direction of wall, second LClick to finish wall; Ctrl: move origin point tool.walltemplate.tooltip = Draw a wall template. +tool.walltopology.tooltip = Draw movement and vision blocking walls. +tool.walltopology.instructions = LClick: start new wall or grab wall / vertex, RClick: extend wall, Alt+LClick: extend or split wall / vertex, Alt+Drag: join with wall / vertex, Ctrl: snap-to-grid, Shift: delete wall / vertex tools.ai_selector.tooltip = Tokens will navigate around MBL, VBL (if selected), and account for tokens with terrain modifiers. tools.ignore_vbl_on_move.tooltip = Tokens will navigate around VBL if selected, otherwise they will freely move into VBL. tools.topology_mode_selection.cover_vbl.tooltip = Draw Cover Vision Blocking (Cover VB). From 7acbaad257bd7dc3ee43a10ae8b77aff8e328fda Mon Sep 17 00:00:00 2001 From: Kenneth VanderLinde Date: Fri, 1 Nov 2024 09:53:59 -0700 Subject: [PATCH 08/14] Use the new topologies in vision The vision algorithm now uses the `NodedTopology` provided by the `Zone` itself. No one uses the `AreaTree` or `AreaMeta` types anymore, so they and their caches in `ZoneView` have been removed. The JTS core of the vision sweep has also been split into its own method. `FogUtil.calculateVisibility()` wraps that method with conversions to and from AWT types. --- .../maptool/client/ui/zone/FogUtil.java | 176 +++++++++-------- .../maptool/client/ui/zone/ZoneView.java | 60 +----- .../maptool/client/ui/zone/vbl/AreaMeta.java | 175 ----------------- .../maptool/client/ui/zone/vbl/AreaTree.java | 166 ---------------- .../client/ui/zone/vbl/EndpointSet.java | 28 +-- .../ui/zone/vbl/VisibilityInspector.java | 28 +-- .../client/ui/zone/vbl/VisibilityProblem.java | 52 +++-- .../ui/zone/vbl/VisibilitySweepEndpoint.java | 10 +- .../zone/vbl/VisionBlockingAccumulator.java | 179 ------------------ 9 files changed, 159 insertions(+), 715 deletions(-) delete mode 100644 src/main/java/net/rptools/maptool/client/ui/zone/vbl/AreaMeta.java delete mode 100644 src/main/java/net/rptools/maptool/client/ui/zone/vbl/AreaTree.java delete mode 100644 src/main/java/net/rptools/maptool/client/ui/zone/vbl/VisionBlockingAccumulator.java diff --git a/src/main/java/net/rptools/maptool/client/ui/zone/FogUtil.java b/src/main/java/net/rptools/maptool/client/ui/zone/FogUtil.java index 77e903d1b4..c2d6639f4a 100644 --- a/src/main/java/net/rptools/maptool/client/ui/zone/FogUtil.java +++ b/src/main/java/net/rptools/maptool/client/ui/zone/FogUtil.java @@ -17,22 +17,22 @@ import java.awt.Point; import java.awt.Rectangle; import java.awt.geom.Area; +import java.awt.geom.Point2D; import java.util.ArrayList; -import java.util.EnumMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.function.Consumer; 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.NodedTopology; 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; import net.rptools.maptool.model.ExposedAreaMetaData; @@ -44,121 +44,119 @@ import net.rptools.maptool.model.Zone; import net.rptools.maptool.model.ZonePoint; import net.rptools.maptool.model.player.Player.Role; +import net.rptools.maptool.model.topology.VisionResult; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.locationtech.jts.awt.ShapeWriter; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.Envelope; import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LinearRing; public class FogUtil { private static final Logger log = LogManager.getLogger(FogUtil.class); private static final GeometryFactory geometryFactory = GeometryUtil.getGeometryFactory(); /** - * Return the visible area for an origin, a lightSourceArea and a VBL. + * Performs a vision sweep, producing a visibility polygon. * - * @param origin the vision origin. - * @param vision the lightSourceArea. - * @param wallVbl the VBL topology. - * @return the visible area. + * @param origin The origin point for the vision. + * @param visionBounds Some bounds on the vision. + * @param topology The topology to account for. + * @return The boundary of the visibility polygon. If no vision is possible - e.g., because {@code + * origin} is inside a Wall VBL mask - an empty ring is returned. If no topology was present, + * {@code null} is returned to indicate no restriction on vision. + */ + public static @Nullable LinearRing doVisionSweep( + Coordinate origin, Envelope visionBounds, NodedTopology topology) { + var timer = CodeTimer.get(); + + timer.start("create solver"); + final var problem = new VisibilityProblem(origin, visionBounds); + timer.stop("create solver"); + + timer.start("accumulate blocking walls"); + var visionResult = topology.getSegments(origin, visionBounds, problem::add); + if (visionResult == VisionResult.CompletelyObscured) { + // No vision possible. + return geometryFactory.createLinearRing(); + } + if (problem.isEmpty()) { + // No topology. + return null; + } + timer.stop("accumulate blocking walls"); + + timer.start("calculate visible area"); + final Coordinate[] visibilityPolygon; + try { + visibilityPolygon = problem.solve(); + } catch (Exception e) { + log.error("Unexpected error while calculating visible area.", e); + // Play it safe and don't consider anything to be visible. + return geometryFactory.createLinearRing(); + } + timer.stop("calculate visible area"); + + return geometryFactory.createLinearRing(visibilityPolygon); + } + + /** + * Figure out the visible area for a given topology, origin, and unblocked vision. + * + * @param origin The origin point of the vision. + * @param vision The area of the vision before blocking is applied. + * @param topology The topology to apply to the vision blocking. + * @return A subset of {@code vision}, restricted to the visibility polygon. Can be empty if + * vision is not possible. */ public static @Nonnull Area calculateVisibility( - Point origin, - Area vision, - AreaTree wallVbl, - AreaTree hillVbl, - AreaTree pitVbl, - AreaTree coverVbl) { + Point2D origin, Area vision, NodedTopology topology) { var timer = CodeTimer.get(); timer.start("FogUtil::calculateVisibility"); - var originCoordinate = new Coordinate(origin.x, origin.y); - try { - timer.start("get vision bounds"); + var cOrigin = new Coordinate(origin.getX(), origin.getY()); + Envelope visionBounds; - { + try { + timer.start("get vision bounds"); var awtBounds = vision.getBounds2D(); visionBounds = new Envelope( new Coordinate(awtBounds.getMinX(), awtBounds.getMinY()), new Coordinate(awtBounds.getMaxX(), awtBounds.getMaxY())); - } - timer.stop("get vision bounds"); - - /* - * Find the visible area for each topology type independently. - * - * In principle, we could also combine all the vision blocking segments for all topology types - * and run the sweep algorithm once. But this is subject to some pathological cases that JTS - * cannot handle. These cases do not exist within a single type of topology, but can arise when - * we combine them. - */ - - List visibilityPolygons = new ArrayList<>(); - var topologies = new EnumMap(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); - for (final var topology : topologies.entrySet()) { - - timer.start("get pooled vision blocking set"); - final var solver = new VisibilityProblem(originCoordinate, visionBounds); - timer.stop("get pooled vision blocking set"); - - timer.start("accumulate blocking walls"); - final var accumulator = - new VisionBlockingAccumulator(originCoordinate, visionBounds, solver); - - final var isVisionCompletelyBlocked = - accumulator.add(topology.getKey(), topology.getValue()); - timer.stop("accumulate blocking walls"); - if (!isVisionCompletelyBlocked) { - // Vision has been completely blocked by this topology. Short circuit. - return new Area(); - } - - timer.start("calculate visible area"); - final Coordinate[] visibleArea; - try { - visibleArea = solver.solve(); - } catch (Exception e) { - log.error("Unexpected error while calculating visible area.", e); - // Play it safe and dont consider anything to be visible. - return new Area(); - } - timer.stop("calculate visible area"); - - timer.start("add visibility polygon"); - if (visibleArea != null) { - visibilityPolygons.add(visibleArea); - } - timer.stop("add visibility polygon"); + } finally { + timer.stop("get vision bounds"); } - if (visibilityPolygons.isEmpty()) { - return vision; + LinearRing visibilityPolygon; + try { + timer.start("doVisionSweep()"); + visibilityPolygon = doVisionSweep(cOrigin, visionBounds, topology); + } finally { + timer.stop("doVisionSweep()"); } - // We have to intersect all the results in order to find the true remaining visible area. - timer.start("clone existing vision"); - vision = new Area(vision); - timer.stop("clone existing vision"); - timer.start("combine visibility polygons with vision"); - // We intersect in AWT space because JTS can be really finicky about intersection precision. - var shapeWriter = new ShapeWriter(); - for (var visibilityPolygon : visibilityPolygons) { - // Even though linear ring is just the boundary, the Area constructor uses the entire - // enclosed region. - var area = - new Area(shapeWriter.toShape(geometryFactory.createLinearRing(visibilityPolygon))); - vision.intersect(area); + Area blockedVision; + try { + timer.start("combine visibility polygon with vision"); + if (visibilityPolygon == null) { + // There was no topology, so we can just use the original area. + blockedVision = new Area(vision); + } else if (visibilityPolygon.isEmpty()) { + // Vision is not possible. + blockedVision = new Area(); + } else { + // We intersect in AWT space because JTS can be finicky about intersection precision. + var shapeWriter = new ShapeWriter(); + // The linear ring is just the boundary, but the resulting shape is the enclosed region. + blockedVision = new Area(shapeWriter.toShape(visibilityPolygon)); + blockedVision.intersect(vision); + } + } finally { + timer.stop("combine visibility polygon with vision"); } - timer.stop("combine visibility polygons with vision"); - - // For simplicity, this catches some of the edge cases - return vision; + return blockedVision; } finally { timer.stop("FogUtil::calculateVisibility"); } diff --git a/src/main/java/net/rptools/maptool/client/ui/zone/ZoneView.java b/src/main/java/net/rptools/maptool/client/ui/zone/ZoneView.java index 558179dece..11c1018e47 100644 --- a/src/main/java/net/rptools/maptool/client/ui/zone/ZoneView.java +++ b/src/main/java/net/rptools/maptool/client/ui/zone/ZoneView.java @@ -31,7 +31,6 @@ import net.rptools.maptool.client.ui.zone.IlluminationModel.ContributedLight; import net.rptools.maptool.client.ui.zone.IlluminationModel.LightInfo; import net.rptools.maptool.client.ui.zone.Illuminator.LitArea; -import net.rptools.maptool.client.ui.zone.vbl.AreaTree; import net.rptools.maptool.events.MapToolEventBus; import net.rptools.maptool.model.*; import net.rptools.maptool.model.player.Player; @@ -153,9 +152,6 @@ private void addLightSourceToken(Token token, Set roles) { private final Map topologyAreas = new EnumMap<>(Zone.TopologyType.class); - private final Map topologyTrees = - new EnumMap<>(Zone.TopologyType.class); - /** * Construct ZoneView from zone. Build lightSourceMap, and add ZoneView to Zone as listener. * @@ -238,7 +234,7 @@ public boolean isUsingVision() { *

The topology is cached and should only regenerate when not yet present, which should happen * on flush calls. * - * @param topologyType The type of topology tree to get. + * @param topologyType The type of topology to get. * @return the area of the topology. */ public synchronized Area getTopology(Zone.TopologyType topologyType) { @@ -260,33 +256,6 @@ public synchronized Area getTopology(Zone.TopologyType topologyType) { return topology; } - /** - * Get the topology tree of the requested type. - * - *

The topology tree is cached and should only regenerate when the tree is not present, which - * should happen on flush calls. - * - *

This method is equivalent to building an AreaTree from the results of getTopology(), but the - * results are cached. - * - * @param topologyType The type of topology tree to get. - * @return the AreaTree (topology tree). - */ - private synchronized AreaTree getTopologyTree(Zone.TopologyType topologyType) { - var topologyTree = topologyTrees.get(topologyType); - - if (topologyTree == null) { - log.debug("ZoneView topology tree for {} is null, generating...", topologyType.name()); - - var topology = getTopology(topologyType); - - topologyTree = new AreaTree(topology); - topologyTrees.put(topologyType, topologyTree); - } - - return topologyTree; - } - private IlluminationModel getIlluminationModel(IlluminationKey illuminationKey) { final var illuminationModel = illuminationModels.computeIfAbsent(illuminationKey, key -> new IlluminationModel()); @@ -348,13 +317,7 @@ private List calculateLitAreaForLightSource( if (!lightSource.isIgnoresVBL()) { lightSourceVisibleArea = - FogUtil.calculateVisibility( - p, - lightSourceArea, - getTopologyTree(Zone.TopologyType.WALL_VBL), - getTopologyTree(Zone.TopologyType.HILL_VBL), - getTopologyTree(Zone.TopologyType.PIT_VBL), - getTopologyTree(Zone.TopologyType.COVER_VBL)); + FogUtil.calculateVisibility(p, lightSourceArea, zone.prepareNodedTopologies()); } if (lightSourceVisibleArea.isEmpty()) { // Nothing illuminated for this source. @@ -593,14 +556,7 @@ private Area getTokenVisibleArea(@Nonnull Token token) { Point p = FogUtil.calculateVisionCenter(token, zone); Area visibleArea = sight.getVisionShape(token, zone); visibleArea.transform(AffineTransform.getTranslateInstance(p.x, p.y)); - tokenVisibleArea = - FogUtil.calculateVisibility( - p, - visibleArea, - getTopologyTree(Zone.TopologyType.WALL_VBL), - getTopologyTree(Zone.TopologyType.HILL_VBL), - getTopologyTree(Zone.TopologyType.PIT_VBL), - getTopologyTree(Zone.TopologyType.COVER_VBL)); + tokenVisibleArea = FogUtil.calculateVisibility(p, visibleArea, zone.prepareNodedTopologies()); tokenVisibleAreaCache.put(token.getId(), tokenVisibleArea); } @@ -687,12 +643,7 @@ public List getDrawableAuras(PlayerView view) { if (!lightSource.isIgnoresVBL()) { visibleArea = FogUtil.calculateVisibility( - p, - lightSourceArea, - getTopologyTree(Zone.TopologyType.WALL_VBL), - getTopologyTree(Zone.TopologyType.HILL_VBL), - getTopologyTree(Zone.TopologyType.PIT_VBL), - getTopologyTree(Zone.TopologyType.COVER_VBL)); + p, lightSourceArea, zone.prepareNodedTopologies()); } // This needs to be cached somehow @@ -868,7 +819,6 @@ public void flush(Token token) { private void onTopologyChanged() { flush(); topologyAreas.clear(); - topologyTrees.clear(); } @Subscribe @@ -938,7 +888,6 @@ private void onTokensRemoved(TokensRemoved event) { if (event.tokens().stream().anyMatch(Token::hasAnyTopology)) { flush(); topologyAreas.clear(); - topologyTrees.clear(); } } @@ -969,7 +918,6 @@ private void processTokenAddChangeEvent(List tokens) { if (tokens.stream().anyMatch(Token::hasAnyTopology)) { flush(); topologyAreas.clear(); - topologyTrees.clear(); } } diff --git a/src/main/java/net/rptools/maptool/client/ui/zone/vbl/AreaMeta.java b/src/main/java/net/rptools/maptool/client/ui/zone/vbl/AreaMeta.java deleted file mode 100644 index ce4c56f96c..0000000000 --- a/src/main/java/net/rptools/maptool/client/ui/zone/vbl/AreaMeta.java +++ /dev/null @@ -1,175 +0,0 @@ -/* - * This software Copyright by the RPTools.net development team, and - * licensed under the Affero GPL Version 3 or, at your option, any later - * version. - * - * MapTool Source Code is distributed in the hope that it will be - * useful, but WITHOUT ANY WARRANTY; without even the implied warranty - * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * - * You should have received a copy of the GNU Affero General Public - * License * along with this source Code. If not, please visit - * and specifically the Affero license - * text at . - */ -package net.rptools.maptool.client.ui.zone.vbl; - -import java.util.ArrayList; -import java.util.List; -import java.util.function.Consumer; -import net.rptools.lib.GeometryUtil; -import org.locationtech.jts.algorithm.InteriorPointArea; -import org.locationtech.jts.algorithm.Orientation; -import org.locationtech.jts.algorithm.PointLocation; -import org.locationtech.jts.geom.Coordinate; -import org.locationtech.jts.geom.CoordinateArrays; -import org.locationtech.jts.geom.Envelope; -import org.locationtech.jts.geom.LinearRing; -import org.locationtech.jts.geom.Location; - -/** Represents the boundary of a piece of topology. */ -public class AreaMeta { - private final Coordinate[] vertices; - - // region These fields are built from `vertices` and exist only for performance reasons. - - private final Coordinate interiorPoint; - private final Envelope boundingBox; - private final boolean isOcean; - - // endregion - - /** - * Creates an AreaMeta that represents the entire plane. - * - *

Since this is a sea of nothing, it counts as an ocean despite having no endpoints. - */ - public AreaMeta() { - this.vertices = new Coordinate[0]; - this.interiorPoint = new Coordinate(0, 0); - this.boundingBox = null; - this.isOcean = true; - } - - public AreaMeta(LinearRing ring) { - vertices = ring.getCoordinates(); - assert vertices.length >= 4; // Yes, 4, because a ring duplicates its first element as its last. - - // Creating a Polygon here is a necessary evil since JTS does not seem to expose the interior - // point algorithm for a plain ring. But it doesn't cost very much thankfully. - this.interiorPoint = - InteriorPointArea.getInteriorPoint(GeometryUtil.getGeometryFactory().createPolygon(ring)); - boundingBox = CoordinateArrays.envelope(vertices); - isOcean = Orientation.isCCW(vertices); - } - - public double getBoundingBoxArea() { - if (vertices.length == 0) { - return Double.POSITIVE_INFINITY; - } - - return boundingBox.getArea(); - } - - public Coordinate getInteriorPoint() { - return interiorPoint; - } - - public boolean contains(Coordinate point) { - if (vertices.length == 0) { - return true; - } - - if (!boundingBox.contains(point)) { - return false; - } - - // Oceans (holes) are open (do not include their boundary). This makes masks like Wall VBL - // function correctly by ensuring any intersection with the mask counts as being inside. - // On the other hand it is not sufficient to make Pit VBL behave correctly on the boundary. - // though it doesn't break vision. - final var location = PointLocation.locateInRing(point, vertices); - if (isOcean) { - return location == Location.INTERIOR; - } else { - return location != Location.EXTERIOR; - } - } - - /** - * Find all sections of the boundary that block vision. - * - *

For each line segment, the exterior region will be on one side of the segment while the - * interior region will be on the other side. One of these regions will be an island and one will - * be an ocean depending on {@link #isOcean()}. The {@code facing} parameter uses this fact to - * control whether a segment should be included in the result, based on whether the origin is on - * the island-side of the line segment or on its ocean-side. - * - *

If {@code origin} is colinear with a line segment, that segment will never be returned. - * - * @param origin The vision origin, which is the point by which line segment orientation is - * measured. - * @param facing Whether the island-side or the ocean-side of the returned segments must face - * {@code origin}. - * @param visionBounds The bounding box for vision, used to avoid adding unnecessary far away - * segments. - * @param resultConsumer Each produced segment string will be sent to this consumer. - */ - public void getFacingSegments( - Coordinate origin, - Facing facing, - Envelope visionBounds, - Consumer> resultConsumer) { - if (vertices.length == 0) { - return; - } - - final var requiredOrientation = - facing == Facing.ISLAND_SIDE_FACES_ORIGIN - ? Orientation.CLOCKWISE - : Orientation.COUNTERCLOCKWISE; - - List currentSegmentPoints = new ArrayList<>(); - for (int i = 1; i < vertices.length; ++i) { - assert currentSegmentPoints.size() == 0 || currentSegmentPoints.size() >= 2; - - final var previous = vertices[i - 1]; - final var current = vertices[i]; - - final var shouldIncludeFace = - // Don't need to be especially precise with the vision check. - visionBounds.intersects(previous, current) - && requiredOrientation == Orientation.index(origin, previous, current); - - if (shouldIncludeFace) { - // Since we're including this face, the existing segment can be extended. - if (currentSegmentPoints.isEmpty()) { - // Also need the first point. - currentSegmentPoints.add(previous); - } - currentSegmentPoints.add(current); - } else if (!currentSegmentPoints.isEmpty()) { - // Since we're skipping this face, the segment is broken and we must start a new one. - var string = currentSegmentPoints; - if (requiredOrientation != Orientation.COUNTERCLOCKWISE) { - string = string.reversed(); - } - resultConsumer.accept(string); - currentSegmentPoints.clear(); - } - } - - assert currentSegmentPoints.size() == 0 || currentSegmentPoints.size() >= 2; - if (!currentSegmentPoints.isEmpty()) { - var string = currentSegmentPoints; - if (requiredOrientation != Orientation.COUNTERCLOCKWISE) { - string = string.reversed(); - } - resultConsumer.accept(string); - } - } - - public boolean isOcean() { - return isOcean; - } -} diff --git a/src/main/java/net/rptools/maptool/client/ui/zone/vbl/AreaTree.java b/src/main/java/net/rptools/maptool/client/ui/zone/vbl/AreaTree.java deleted file mode 100644 index 93a8ebef87..0000000000 --- a/src/main/java/net/rptools/maptool/client/ui/zone/vbl/AreaTree.java +++ /dev/null @@ -1,166 +0,0 @@ -/* - * This software Copyright by the RPTools.net development team, and - * licensed under the Affero GPL Version 3 or, at your option, any later - * version. - * - * MapTool Source Code is distributed in the hope that it will be - * useful, but WITHOUT ANY WARRANTY; without even the implied warranty - * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * - * You should have received a copy of the GNU Affero General Public - * License * along with this source Code. If not, please visit - * and specifically the Affero license - * text at . - */ -package net.rptools.maptool.client.ui.zone.vbl; - -import com.google.common.collect.Iterables; -import java.awt.geom.Area; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import javax.annotation.Nonnull; -import javax.annotation.Nullable; -import net.rptools.lib.GeometryUtil; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.locationtech.jts.geom.Coordinate; - -/** - * Represents a topology area as a tree of nested polygons. - * - *

Solid areas of topology are called islands, while areas not covered by topology - * are called oceans. Islands and oceans can be arranged in a tree structure where each - * island is a parent to the oceans contained within it, and each ocean is a parent to the islands - * contained within it. At the root of the tree is an infinite ocean with no parent island. - */ -public class AreaTree { - private static final Logger log = LogManager.getLogger(AreaTree.class); - - /** The original area digested. */ - private final @Nonnull Node theOcean; - - /** Create an empty tree. */ - public AreaTree() { - theOcean = new Node(new AreaMeta()); - } - - /** - * Digests a flat {@link java.awt.geom.Area} into a hierarchical {@code AreaTree}. - * - *

The new {@code AreaTree} will represent that same topology as {@code area}, but represented - * as polygonal regions. Holes in the topology will be represented by oceans contained in islands - * - * @param area The area to digest. - */ - public AreaTree(@Nonnull Area area) { - this(); - - final var islands = new ArrayList(); - // Each polygon is an association of a parent polygon with polygonal holes. So we can easily map - // each polygon to a parent island node with child ocean nodes for each hole. - for (final var polygon : GeometryUtil.toJtsPolygons(area)) { - final var island = new Node(new AreaMeta(polygon.getExteriorRing())); - for (int i = 0; i < polygon.getNumInteriorRing(); ++i) { - final var hole = polygon.getInteriorRingN(i); - island.children.add(new Node(new AreaMeta(hole))); - } - islands.add(island); - } - - // Now we need to hook up islands to the hierarchy. By sorting them from large to small, then - // consuming front-to-back, we know that parents will have been added to the hierarchy. - islands.sort(Comparator.comparingDouble(l -> -l.getMeta().getBoundingBoxArea())); - for (var island : islands) { - // This interior point check is only valid because we sorted the islands, ensuring parents are - // added to the tree before any possible children. - final var location = this.locate(island.getMeta().getInteriorPoint()); - if (location.island() != null) { - // This shouldn't happen unless we messed up somewhere. Can't add islands to other islands. - log.warn("Unable to find a parent container for an island. Returning an empty tree"); - this.theOcean.children.clear(); - return; - } - - location.nearestOcean().children.add(island); - } - } - - /** - * Find a point within the topology tree. - * - *

The result contains the nodes most directly associated with {@code point}, namely: - * - *

    - *
  • The deepest ocean containing {@code point}. There will always such an ocean. - *
  • The parent island of the deepest ocean. There will always be such an island unless the - * deepest ocean is the root ocean. - *
  • The child island of the deepest ocean that contains {@code point}. This only exists if - * the point is located directly in an island. - *
- * - * @param point The point to look up. - * @return The location of {@code point} within the tree. - */ - public @Nonnull TreeLocation locate(Coordinate point) { - @Nullable Node parentIsland = null; - @Nonnull Node nearestOcean = theOcean; - @Nullable Node containingIsland = null; - - @Nullable Node nextNodeToCheck = theOcean; - while (nextNodeToCheck != null) { - final var nodeToCheck = nextNodeToCheck; - nextNodeToCheck = null; - - for (final var child : nodeToCheck.getChildren()) { - if (child.getMeta().contains(point)) { - if (!child.getMeta().isOcean()) { - containingIsland = child; - } else { - parentIsland = containingIsland; - nearestOcean = child; - containingIsland = null; - } - nextNodeToCheck = child; - break; - } - } - } - - // No containing child found. - return new TreeLocation(parentIsland, nearestOcean, containingIsland); - } - - /** - * The results of locating a point in the {@code AreaTree}. - * - * @param parentIsland The parent of {@code nearestOcean} if one exists. - * @param nearestOcean The deepest ancestor ocean. If the point is in an ocean, {@code - * nearestOcean} will be that ocean. Otherwise, it will be the parent of {@code island}. - * @param island If the point is in an island, this will be that island. Otherwise {@code null}. - */ - public record TreeLocation( - @Nullable Node parentIsland, @Nonnull Node nearestOcean, @Nullable Node island) {} - - /** - * A node of an {@code AreaTree}. - * - *

A node in the tree references its children and has a boundary ({@code AreaMeta}) as a value. - */ - public static final class Node { - private final @Nonnull AreaMeta meta; - private final List children = new ArrayList<>(); - - private Node(@Nonnull AreaMeta meta) { - this.meta = meta; - } - - public @Nonnull AreaMeta getMeta() { - return meta; - } - - public Iterable getChildren() { - return Iterables.unmodifiableIterable(this.children); - } - } -} diff --git a/src/main/java/net/rptools/maptool/client/ui/zone/vbl/EndpointSet.java b/src/main/java/net/rptools/maptool/client/ui/zone/vbl/EndpointSet.java index 11877d73ee..30ad8e0bfd 100644 --- a/src/main/java/net/rptools/maptool/client/ui/zone/vbl/EndpointSet.java +++ b/src/main/java/net/rptools/maptool/client/ui/zone/vbl/EndpointSet.java @@ -54,7 +54,7 @@ public class EndpointSet { private final int[] bucketSizes; public EndpointSet(Coordinate origin) { - this.origin = origin; + this.origin = new Coordinate(origin); this.envelope = new Envelope(); this.buckets = new VisibilitySweepEndpoint[BUCKET_COUNT][]; @@ -140,21 +140,23 @@ private void deduplicateEndpoints(VisibilitySweepEndpoint[] endpoints, int size) @Nonnull VisibilitySweepEndpoint previous = endpoints[0]; for (var i = 1; i < size; ++i) { final var endpoint = endpoints[i]; - if (!previous.getPoint().equals(endpoint.getPoint())) { + if (endpoint.getStartsWalls().isEmpty() && endpoint.getEndsWalls().isEmpty()) { + // Isolated points contribute nothing to the sweep. + endpoints[i] = null; + } else if (previous.getPoint().equals(endpoint.getPoint())) { + // endpoint is a duplicate of previous, so merge into previous. Don't keep the duplicate. + previous.mergeDuplicate(endpoint); + + // I could also .remove(), but that's expensive for long lists. We're just as well to skip + // this while iterating later. + endpoints[i] = null; + + // TODO Somehow increment the main counter. + // duplicateEndpointCount += 1; + } else { // Haven't seen this endpoint yet. Keep it around. previous = endpoint; - continue; } - - // TODO Somehow increment the main counter. - // duplicateEndpointCount += 1; - - // endpoint is a duplicate of previous, so merge into previous. Don't keep the duplicate. - previous.mergeDuplicate(endpoint); - - // I could also .remove(), but that's expensive for long lists. We're just as well to skip - // this while iterating later. - endpoints[i] = null; } } diff --git a/src/main/java/net/rptools/maptool/client/ui/zone/vbl/VisibilityInspector.java b/src/main/java/net/rptools/maptool/client/ui/zone/vbl/VisibilityInspector.java index 1a4b57824f..7a663e01b8 100644 --- a/src/main/java/net/rptools/maptool/client/ui/zone/vbl/VisibilityInspector.java +++ b/src/main/java/net/rptools/maptool/client/ui/zone/vbl/VisibilityInspector.java @@ -30,13 +30,17 @@ import java.awt.geom.Area; import java.awt.geom.NoninvertibleTransformException; import java.awt.geom.Point2D; +import java.util.ArrayList; import java.util.EnumMap; +import java.util.List; import java.util.Map; import javax.swing.JFrame; import javax.swing.JPanel; import javax.swing.SwingUtilities; import net.rptools.maptool.client.ui.zone.FogUtil; import net.rptools.maptool.model.Zone; +import net.rptools.maptool.model.topology.MaskTopology; +import net.rptools.maptool.model.topology.WallTopology; import net.rptools.maptool.util.GraphicsUtil; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -47,7 +51,7 @@ public class VisibilityInspector extends JPanel { private Map palette = new EnumMap<>(Zone.TopologyType.class); private Map toplogyAreas = new EnumMap<>(Zone.TopologyType.class); - private Map toplogyTrees = new EnumMap<>(Zone.TopologyType.class); + private List masks = new ArrayList<>(); private AffineTransform affineTransform; private Point2D point; private double visionRange; @@ -98,14 +102,14 @@ public void setTopology(Area wallVbl, Area hillVbl, Area pitVbl, Area coverVbl) this.toplogyAreas.put(Zone.TopologyType.PIT_VBL, pitVbl); this.toplogyAreas.put(Zone.TopologyType.COVER_VBL, coverVbl); - this.toplogyTrees.clear(); - var bounds = new Rectangle(); - for (final var entry : this.toplogyAreas.entrySet()) { - var type = entry.getKey(); - var area = entry.getValue(); + this.masks = new ArrayList<>(); + this.masks.addAll(MaskTopology.createFromLegacy(Zone.TopologyType.WALL_VBL, wallVbl)); + this.masks.addAll(MaskTopology.createFromLegacy(Zone.TopologyType.HILL_VBL, hillVbl)); + this.masks.addAll(MaskTopology.createFromLegacy(Zone.TopologyType.PIT_VBL, pitVbl)); + this.masks.addAll(MaskTopology.createFromLegacy(Zone.TopologyType.COVER_VBL, coverVbl)); - var tree = new AreaTree(area); - this.toplogyTrees.put(type, tree); + var bounds = new Rectangle(); + for (final var area : this.toplogyAreas.values()) { bounds.add(area.getBounds()); } @@ -150,15 +154,11 @@ protected void paintComponent(Graphics g) { unobstructedVision.transform(AffineTransform.getTranslateInstance(point.getX(), point.getY())); final var visionBounds = new Area(unobstructedVision.getBounds()); - Area vision; - vision = + Area vision = FogUtil.calculateVisibility( new Point((int) point.getX(), (int) point.getY()), unobstructedVision, - toplogyTrees.get(Zone.TopologyType.WALL_VBL), - toplogyTrees.get(Zone.TopologyType.HILL_VBL), - toplogyTrees.get(Zone.TopologyType.PIT_VBL), - toplogyTrees.get(Zone.TopologyType.COVER_VBL)); + NodedTopology.prepare(new WallTopology(), masks)); final var obstructedVision = new Area(unobstructedVision); diff --git a/src/main/java/net/rptools/maptool/client/ui/zone/vbl/VisibilityProblem.java b/src/main/java/net/rptools/maptool/client/ui/zone/vbl/VisibilityProblem.java index dfa00cf2c0..6f14a117c6 100644 --- a/src/main/java/net/rptools/maptool/client/ui/zone/vbl/VisibilityProblem.java +++ b/src/main/java/net/rptools/maptool/client/ui/zone/vbl/VisibilityProblem.java @@ -19,7 +19,6 @@ import java.util.List; import java.util.TreeSet; import javax.annotation.Nonnull; -import javax.annotation.Nullable; import net.rptools.lib.CodeTimer; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -73,7 +72,7 @@ public class VisibilityProblem { * areas. */ public VisibilityProblem(Coordinate origin, Envelope visionBounds) { - this.origin = origin; + this.origin = new Coordinate(origin); this.endpointSet = new EndpointSet(origin); this.bounds = new Envelope(visionBounds); this.bounds.expandToInclude(origin); @@ -108,15 +107,32 @@ public void add(List string) { final VisibilitySweepEndpoint start; final VisibilitySweepEndpoint end; - if (Orientation.COUNTERCLOCKWISE - == Orientation.index(origin, previous.getPoint(), current.getPoint())) { - // Caller is well-behaved passing us correctly oriented segments. - start = previous; - end = current; - } else { - // Tsk tsk. Caller gave it to us the wrong-way round. - start = current; - end = previous; + var orientation = Orientation.index(origin, previous.getPoint(), current.getPoint()); + switch (orientation) { + case Orientation.COLLINEAR -> { + // Wall straight on with vision react poorly with the algorithm. As much as I'd like to + // support it one day, even just for the visuals, the reality is it make no practical + // difference to the result and isn't worth the effort. So just ignore them. + // It's okay that the endpoints are in the result, even though they might be empty. + previous = current; + continue; + } + case Orientation.COUNTERCLOCKWISE -> { + // Caller is well-behaved passing us correctly oriented segments. + start = previous; + end = current; + } + case Orientation.CLOCKWISE -> { + // Tsk tsk. Caller gave it to us the wrong-way round. + start = current; + end = previous; + } + default -> { + // Uh... this should be possible. But Java complains because it doesn't know that. + log.error("Found invalid orientation: {}", orientation); + previous = current; + continue; + } } start.startsWall(end); @@ -132,6 +148,10 @@ public void add(List string) { } } + public boolean isEmpty() { + return endpointSet.size() == 0; + } + /** * Solve the visibility polygon problem. * @@ -140,17 +160,11 @@ public void add(List string) { * open walls. As the algorithm progresses, open walls are maintained as an ordered set to enable * efficient polling of the closest wall at any given point in time. * - * @return A visibility polygon, represented as a ring of coordinates. A {@code null} result - * indicates that no vision blocking needed to be applied. + * @return A visibility polygon, represented as a ring of coordinates. * @see Efficient Computation of Visibility Polygons, * arXiv:1403.3905 */ - public @Nullable Coordinate[] solve() { - if (endpointSet.size() == 0) { - // No topology, apparently. - return null; - } - + public Coordinate[] solve() { final var timer = CodeTimer.get(); timer.start("add bounds"); diff --git a/src/main/java/net/rptools/maptool/client/ui/zone/vbl/VisibilitySweepEndpoint.java b/src/main/java/net/rptools/maptool/client/ui/zone/vbl/VisibilitySweepEndpoint.java index 7e4a2eec8e..c0c1c10b27 100644 --- a/src/main/java/net/rptools/maptool/client/ui/zone/vbl/VisibilitySweepEndpoint.java +++ b/src/main/java/net/rptools/maptool/client/ui/zone/vbl/VisibilitySweepEndpoint.java @@ -15,6 +15,8 @@ package net.rptools.maptool.client.ui.zone.vbl; import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; import java.util.List; import org.locationtech.jts.geom.Coordinate; @@ -38,12 +40,12 @@ public Coordinate getPoint() { return point; } - public Iterable getStartsWalls() { - return startsWalls; + public Collection getStartsWalls() { + return Collections.unmodifiableCollection(startsWalls); } - public Iterable getEndsWalls() { - return endsWalls; + public Collection getEndsWalls() { + return Collections.unmodifiableCollection(endsWalls); } public void startsWall(VisibilitySweepEndpoint end) { diff --git a/src/main/java/net/rptools/maptool/client/ui/zone/vbl/VisionBlockingAccumulator.java b/src/main/java/net/rptools/maptool/client/ui/zone/vbl/VisionBlockingAccumulator.java deleted file mode 100644 index 594308bf01..0000000000 --- a/src/main/java/net/rptools/maptool/client/ui/zone/vbl/VisionBlockingAccumulator.java +++ /dev/null @@ -1,179 +0,0 @@ -/* - * This software Copyright by the RPTools.net development team, and - * licensed under the Affero GPL Version 3 or, at your option, any later - * version. - * - * MapTool Source Code is distributed in the hope that it will be - * useful, but WITHOUT ANY WARRANTY; without even the implied warranty - * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * - * You should have received a copy of the GNU Affero General Public - * License * along with this source Code. If not, please visit - * and specifically the Affero license - * text at . - */ -package net.rptools.maptool.client.ui.zone.vbl; - -import net.rptools.maptool.model.Zone; -import org.locationtech.jts.geom.Coordinate; -import org.locationtech.jts.geom.Envelope; - -public final class VisionBlockingAccumulator { - private final Coordinate origin; - private final Envelope visionBounds; - private final VisibilityProblem visibilityProblem; - - public VisionBlockingAccumulator( - Coordinate origin, Envelope visionBounds, VisibilityProblem visibilityProblem) { - this.origin = origin; - this.visionBounds = new Envelope(visionBounds); - this.visibilityProblem = visibilityProblem; - } - - private void blockVisionBeyondContainer(AreaTree.Node container) { - final var facing = - container.getMeta().isOcean() - ? Facing.OCEAN_SIDE_FACES_ORIGIN - : Facing.ISLAND_SIDE_FACES_ORIGIN; - - container.getMeta().getFacingSegments(origin, facing, visionBounds, visibilityProblem::add); - for (var child : container.getChildren()) { - child.getMeta().getFacingSegments(origin, facing, visionBounds, visibilityProblem::add); - } - } - - /** - * Finds all topology segments that can take part in blocking vision. - * - *

The exact selection of segments will different depending on the type of the topology. Some - * topology (Wall VBL) is a mask and can completely block vision simply by the origin point being - * located within it. The return value indicates whether this is the case. - * - * @param topology The VBL to apply. - * @return {@code true} if vision is possible, i.e., is not completely blocked by the topology. - */ - public boolean add(Zone.TopologyType type, AreaTree topology) { - return switch (type) { - case WALL_VBL -> addWallBlocking(topology); - case HILL_VBL -> addHillBlocking(topology); - case PIT_VBL -> addPitBlocking(topology); - case COVER_VBL -> addCoverBlocking(topology); - case MBL -> true; - }; - } - - /** - * Finds all wall topology segments that can take part in blocking vision. - * - * @param topology The topology to treat as Wall VBL. - * @return false if the vision has been completely blocked by topology, or true if vision may be - * blocked by particular segments. - */ - private boolean addWallBlocking(AreaTree topology) { - final var location = topology.locate(origin); - - if (location.island() != null) { - // Since we're contained in a wall island, there can be no vision through it. - return false; - } - - blockVisionBeyondContainer(location.nearestOcean()); - return true; - } - - /** - * Finds all hill topology segments that can take part in blocking vision. - * - * @param topology The topology to treat as Hill VBL. - * @return false if the vision has been completely blocked by topology, or true if vision can be - * blocked by particular segments. - */ - private boolean addHillBlocking(AreaTree topology) { - final var location = topology.locate(origin); - - /* - * There are two cases for Hill VBL: - * 1. A token inside hill VBL can see into adjacent oceans, and therefore into other areas of - * Hill VBL in those oceans. - * 2. A token outside hill VBL can see into hill VBL, but not into any oceans adjacent to it. - */ - - if (location.parentIsland() != null) { - blockVisionBeyondContainer(location.parentIsland()); - } - - // Check each contained island. - for (var containedIsland : location.nearestOcean().getChildren()) { - if (containedIsland == location.island()) { - // We don't want to block vision for the hill we're currently in. - // TODO Ideally we could block the second occurence of the current island, but we need - // a way to do that reliably. - continue; - } - - blockVisionBeyondContainer(containedIsland); - } - - if (location.island() != null) { - // Same basics as the nearestOcean logic above, but applied to children of this island - // (grandchildren of nearestOcean). - for (final var childOcean : location.island().getChildren()) { - for (final var containedIsland : childOcean.getChildren()) { - blockVisionBeyondContainer(containedIsland); - } - } - } - - return true; - } - - /** - * Finds all pit topology segments that can take part in blocking vision. - * - * @param topology The topology to treat as Pit VBL. - * @return false if the vision has been completely blocked by topology, or true if vision can be - * blocked by particular segments. - */ - private boolean addPitBlocking(AreaTree topology) { - final var location = topology.locate(origin); - - /* - * There are two cases for Pit VBL: - * 1. A token inside Pit VBL can see only see within the current island, not into any adjacent - * oceans. - * 2. A token outside Pit VBL is unobstructed by the Pit VBL (nothing special to do). - */ - if (location.island() != null) { - blockVisionBeyondContainer(location.island()); - } - - return true; - } - - /** - * Finds all cover topology segments that can take part in blocking vision. - * - * @param topology The topology to treat as Cover VBL. - * @return false if the vision has been completely blocked by topology, or true if vision can be - * blocked by particular segments. - */ - private boolean addCoverBlocking(AreaTree topology) { - final var location = topology.locate(origin); - - /* - * There are two cases for Cover VBL: - * 1. A token inside Cover VBL can see everything, unobstructed by the cover. - * 2. A token outside Cover VBL can see nothing, as if it were wall. - */ - - blockVisionBeyondContainer(location.nearestOcean()); - - if (location.island() != null) { - for (var ocean : location.island().getChildren()) { - blockVisionBeyondContainer(ocean); - } - } - - return true; - } -} From a75d737e1e06896774b8e91fdba40ec8b990a576 Mon Sep 17 00:00:00 2001 From: Kenneth VanderLinde Date: Fri, 1 Nov 2024 09:55:51 -0700 Subject: [PATCH 09/14] Use the new topologies in A* pathfinding This reads the applicable topologies directly from the `Zone`, which means the `ZoneView` no longer has to cache the combination of map and token topologies. There is a slight change in behaviour here: before, the map + token topologies would be merged together, then the key token's topology would be subtracted back out; now the map topology is only merged with token topology other than the key token. The former behaviour would allow the key token to carve out a path using it own topology, but now it can no longer do that. It also opens the door to a different caching option that can be done entirely in the walker (this is not actually done yet). --- .../functions/TokenLocationFunctions.java | 19 +- .../client/functions/TokenMoveFunctions.java | 16 +- .../maptool/client/tool/MeasureTool.java | 3 + .../client/ui/zone/RenderPathWorker.java | 31 +--- .../maptool/client/ui/zone/ZoneView.java | 33 ---- .../client/ui/zone/renderer/SelectionSet.java | 33 ++-- .../client/ui/zone/renderer/ZoneRenderer.java | 6 +- .../client/walker/AbstractZoneWalker.java | 33 +--- .../maptool/client/walker/ZoneWalker.java | 12 +- .../walker/astar/AbstractAStarWalker.java | 172 ++++++++---------- 10 files changed, 137 insertions(+), 221 deletions(-) diff --git a/src/main/java/net/rptools/maptool/client/functions/TokenLocationFunctions.java b/src/main/java/net/rptools/maptool/client/functions/TokenLocationFunctions.java index b6cc8697cc..0deaac6e20 100644 --- a/src/main/java/net/rptools/maptool/client/functions/TokenLocationFunctions.java +++ b/src/main/java/net/rptools/maptool/client/functions/TokenLocationFunctions.java @@ -325,10 +325,12 @@ public double getDistance(Token source, Token target, boolean units, String metr ? new AStarSquareEuclideanWalker(zone, wmetric) : grid.createZoneWalker(); - for (CellPoint scell : sourceCells) { - for (CellPoint tcell : targetCells) { - walker.setWaypoints(scell, tcell); - distance = Math.min(distance, walker.getDistance()); + try (walker) { + for (CellPoint scell : sourceCells) { + for (CellPoint tcell : targetCells) { + walker.setWaypoints(scell, tcell); + distance = Math.min(distance, walker.getDistance()); + } } } if (!units) distance /= zone.getUnitsPerCell(); @@ -381,7 +383,6 @@ public double getDistance( try { WalkerMetric wmetric = WalkerMetric.valueOf(metric); walker = new AStarSquareEuclideanWalker(zone, wmetric); - } catch (IllegalArgumentException e) { throw new ParserException( I18N.getText("macro.function.getDistance.invalidMetric", metric)); @@ -392,9 +393,11 @@ public double getDistance( // Get the distances from each source to target cell and keep the minimum one double distance = Double.MAX_VALUE; - for (CellPoint scell : sourceCells) { - walker.setWaypoints(scell, targetCell); - distance = Math.min(distance, walker.getDistance()); + try (walker) { + for (CellPoint scell : sourceCells) { + walker.setWaypoints(scell, targetCell); + distance = Math.min(distance, walker.getDistance()); + } } if (units) { diff --git a/src/main/java/net/rptools/maptool/client/functions/TokenMoveFunctions.java b/src/main/java/net/rptools/maptool/client/functions/TokenMoveFunctions.java index 35c725b6d8..8de96f8a55 100644 --- a/src/main/java/net/rptools/maptool/client/functions/TokenMoveFunctions.java +++ b/src/main/java/net/rptools/maptool/client/functions/TokenMoveFunctions.java @@ -475,8 +475,6 @@ public static List callForIndividualTokenMoveVetoes( private String getMovement( final Token source, boolean returnFractionOnly, boolean useTerrainModifiers) { - ZoneWalker walker = null; - WalkerMetric metric = MapTool.isPersonalServer() ? AppPreferences.movementMetric.get() @@ -503,12 +501,14 @@ private String getMovement( if (zone.getGrid().getCapabilities().isPathingSupported()) { var firstPoint = cellPath.getFirst(); List cplist = new ArrayList(); - walker = grid.createZoneWalker(); - walker.replaceLastWaypoint(new CellPoint(firstPoint.x, firstPoint.y)); - for (AbstractPoint point : cellPath) { - CellPoint tokenPoint = new CellPoint(point.x, point.y); - walker.replaceLastWaypoint(tokenPoint); - cplist.add(tokenPoint); + + try (ZoneWalker walker = grid.createZoneWalker()) { + walker.replaceLastWaypoint(new CellPoint(firstPoint.x, firstPoint.y)); + for (AbstractPoint point : cellPath) { + CellPoint tokenPoint = new CellPoint(point.x, point.y); + walker.replaceLastWaypoint(tokenPoint); + cplist.add(tokenPoint); + } } double bar = diff --git a/src/main/java/net/rptools/maptool/client/tool/MeasureTool.java b/src/main/java/net/rptools/maptool/client/tool/MeasureTool.java index ea9dfbb67d..f71aa3097e 100644 --- a/src/main/java/net/rptools/maptool/client/tool/MeasureTool.java +++ b/src/main/java/net/rptools/maptool/client/tool/MeasureTool.java @@ -192,6 +192,9 @@ public void mouseReleased(MouseEvent e) { ZoneRenderer renderer = (ZoneRenderer) e.getSource(); if (SwingUtilities.isLeftMouseButton(e)) { + if (walker != null) { + walker.close(); + } walker = null; gridlessPath = null; currentGridlessPoint = null; diff --git a/src/main/java/net/rptools/maptool/client/ui/zone/RenderPathWorker.java b/src/main/java/net/rptools/maptool/client/ui/zone/RenderPathWorker.java index ae99992790..9c90c84e1c 100644 --- a/src/main/java/net/rptools/maptool/client/ui/zone/RenderPathWorker.java +++ b/src/main/java/net/rptools/maptool/client/ui/zone/RenderPathWorker.java @@ -14,12 +14,13 @@ */ package net.rptools.maptool.client.ui.zone; -import java.awt.geom.Area; import java.util.Set; +import javax.annotation.Nonnull; import javax.swing.SwingWorker; import net.rptools.maptool.client.ui.zone.renderer.ZoneRenderer; import net.rptools.maptool.client.walker.ZoneWalker; import net.rptools.maptool.model.CellPoint; +import net.rptools.maptool.model.Token; import net.rptools.maptool.model.Token.TerrainModifierOperation; public class RenderPathWorker extends SwingWorker { @@ -30,46 +31,26 @@ public class RenderPathWorker extends SwingWorker { CellPoint startPoint, endPoint; private final boolean restrictMovement; private final Set terrainModifiersIgnored; - private final Area tokenWallVbl; - private final Area tokenHillVbl; - private final Area tokenPitVbl; - private final Area tokenCoverVbl; - private final Area tokenMbl; + private final @Nonnull Token keyToken; public RenderPathWorker( ZoneWalker walker, CellPoint endPoint, boolean restrictMovement, Set terrainModifiersIgnored, - Area tokenWallVbl, - Area tokenHillVbl, - Area tokenPitVbl, - Area tokenCoverVbl, - Area tokenMbl, + @Nonnull Token keyToken, ZoneRenderer zoneRenderer) { this.walker = walker; this.endPoint = endPoint; this.restrictMovement = restrictMovement; this.zoneRenderer = zoneRenderer; this.terrainModifiersIgnored = terrainModifiersIgnored; - this.tokenWallVbl = tokenWallVbl; - this.tokenHillVbl = tokenHillVbl; - this.tokenPitVbl = tokenPitVbl; - this.tokenCoverVbl = tokenCoverVbl; - this.tokenMbl = tokenMbl; + this.keyToken = keyToken; } @Override protected Void doInBackground() { - walker.replaceLastWaypoint( - endPoint, - restrictMovement, - terrainModifiersIgnored, - tokenWallVbl, - tokenHillVbl, - tokenPitVbl, - tokenCoverVbl, - tokenMbl); + walker.replaceLastWaypoint(endPoint, restrictMovement, terrainModifiersIgnored, keyToken); return null; } diff --git a/src/main/java/net/rptools/maptool/client/ui/zone/ZoneView.java b/src/main/java/net/rptools/maptool/client/ui/zone/ZoneView.java index 11c1018e47..59bce8d918 100644 --- a/src/main/java/net/rptools/maptool/client/ui/zone/ZoneView.java +++ b/src/main/java/net/rptools/maptool/client/ui/zone/ZoneView.java @@ -150,8 +150,6 @@ private void addLightSourceToken(Token token, Set roles) { /** Holds the auras from lightSourceMap after they have been combined. */ private final Map> drawableAuras = new HashMap<>(); - private final Map topologyAreas = new EnumMap<>(Zone.TopologyType.class); - /** * Construct ZoneView from zone. Build lightSourceMap, and add ZoneView to Zone as listener. * @@ -228,34 +226,6 @@ public boolean isUsingVision() { return zone.getVisionType() != Zone.VisionType.OFF; } - /** - * Get the map and token topology of the requested type. - * - *

The topology is cached and should only regenerate when not yet present, which should happen - * on flush calls. - * - * @param topologyType The type of topology to get. - * @return the area of the topology. - */ - public synchronized Area getTopology(Zone.TopologyType topologyType) { - var topology = topologyAreas.get(topologyType); - - if (topology == null) { - log.debug("ZoneView topology area for {} is null, generating...", topologyType.name()); - - topology = new Area(zone.getMaskTopology(topologyType)); - List topologyTokens = - MapTool.getFrame().getCurrentZoneRenderer().getZone().getTokensWithTopology(topologyType); - for (Token topologyToken : topologyTokens) { - topology.add(topologyToken.getTransformedMaskTopology(topologyType)); - } - - topologyAreas.put(topologyType, topology); - } - - return topology; - } - private IlluminationModel getIlluminationModel(IlluminationKey illuminationKey) { final var illuminationModel = illuminationModels.computeIfAbsent(illuminationKey, key -> new IlluminationModel()); @@ -818,7 +788,6 @@ public void flush(Token token) { private void onTopologyChanged() { flush(); - topologyAreas.clear(); } @Subscribe @@ -887,7 +856,6 @@ private void onTokensRemoved(TokensRemoved event) { if (event.tokens().stream().anyMatch(Token::hasAnyTopology)) { flush(); - topologyAreas.clear(); } } @@ -917,7 +885,6 @@ private void processTokenAddChangeEvent(List tokens) { if (tokens.stream().anyMatch(Token::hasAnyTopology)) { flush(); - topologyAreas.clear(); } } diff --git a/src/main/java/net/rptools/maptool/client/ui/zone/renderer/SelectionSet.java b/src/main/java/net/rptools/maptool/client/ui/zone/renderer/SelectionSet.java index d9cc21e5ae..a55c57dfda 100644 --- a/src/main/java/net/rptools/maptool/client/ui/zone/renderer/SelectionSet.java +++ b/src/main/java/net/rptools/maptool/client/ui/zone/renderer/SelectionSet.java @@ -16,6 +16,7 @@ import java.util.HashSet; import java.util.Set; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import javax.annotation.Nonnull; @@ -111,18 +112,31 @@ public boolean contains(Token token) { return selectionSet.contains(token.getId()); } + /** Aborts the movement for this selection. */ + public void cancel() { + walker.close(); + + renderPathTask.cancel(true); + } + // This is called when movement is committed/done. It'll let the last thread either finish or // timeout public void renderFinalPath() { + walker.close(); + if (renderer.zone.getGrid().getCapabilities().isPathingSupported() && token.isSnapToGrid() && renderPathTask != null) { - while (!renderPathTask.isDone()) { - log.trace("Waiting on Path Rendering... "); + + log.trace("Waiting on Path Rendering... "); + while (true) { try { - Thread.sleep(10); + renderPathTask.get(); + break; } catch (InterruptedException e) { - e.printStackTrace(); + } catch (ExecutionException e) { + log.error("Error while waiting for task to finish", e); + break; } } } @@ -149,16 +163,7 @@ public void update(ZonePoint newAnchorPosition) { renderPathTask = new RenderPathWorker( - walker, - point, - restrictMovement, - terrainModifiersIgnored, - token.getTransformedMaskTopology(Zone.TopologyType.WALL_VBL), - token.getTransformedMaskTopology(Zone.TopologyType.HILL_VBL), - token.getTransformedMaskTopology(Zone.TopologyType.PIT_VBL), - token.getTransformedMaskTopology(Zone.TopologyType.COVER_VBL), - token.getTransformedMaskTopology(Zone.TopologyType.MBL), - renderer); + walker, point, restrictMovement, terrainModifiersIgnored, token, renderer); renderPathThreadPool.execute(renderPathTask); } } diff --git a/src/main/java/net/rptools/maptool/client/ui/zone/renderer/ZoneRenderer.java b/src/main/java/net/rptools/maptool/client/ui/zone/renderer/ZoneRenderer.java index 55cf30888b..dd19f373af 100644 --- a/src/main/java/net/rptools/maptool/client/ui/zone/renderer/ZoneRenderer.java +++ b/src/main/java/net/rptools/maptool/client/ui/zone/renderer/ZoneRenderer.java @@ -377,6 +377,7 @@ public void removeMoveSelectionSet(GUID keyToken) { if (set == null) { return; } + set.cancel(); repaintDebouncer.dispatch(); } @@ -387,16 +388,13 @@ public void removeMoveSelectionSet(GUID keyToken) { */ public void commitMoveSelectionSet(GUID keyTokenId) { // TODO: Quick hack to handle updating server state - SelectionSet set = selectionSetMap.get(keyTokenId); - + SelectionSet set = selectionSetMap.remove(keyTokenId); if (set == null) { return; } - // Let the last thread finish rendering the path if A* Pathfinding is on set.renderFinalPath(); - removeMoveSelectionSet(keyTokenId); MapTool.serverCommand().stopTokenMove(getZone().getId(), keyTokenId); Token keyToken = new Token(zone.getToken(keyTokenId), true); diff --git a/src/main/java/net/rptools/maptool/client/walker/AbstractZoneWalker.java b/src/main/java/net/rptools/maptool/client/walker/AbstractZoneWalker.java index d87f6dbfe9..3f6ac5b890 100644 --- a/src/main/java/net/rptools/maptool/client/walker/AbstractZoneWalker.java +++ b/src/main/java/net/rptools/maptool/client/walker/AbstractZoneWalker.java @@ -14,16 +14,17 @@ */ package net.rptools.maptool.client.walker; -import java.awt.geom.Area; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.ListIterator; import java.util.Set; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import net.rptools.maptool.client.ui.zone.RenderPathWorker; import net.rptools.maptool.model.CellPoint; import net.rptools.maptool.model.Path; +import net.rptools.maptool.model.Token; import net.rptools.maptool.model.Token.TerrainModifierOperation; import net.rptools.maptool.model.Zone; @@ -33,11 +34,8 @@ public abstract class AbstractZoneWalker implements ZoneWalker { protected final Zone zone; protected boolean restrictMovement = false; protected Set terrainModifiersIgnored; - protected Area tokenWallVbl; - protected Area tokenHillVbl; - protected Area tokenPitVbl; - protected Area tokenCoverVbl; - protected Area tokenMbl; + // Can be null, e.g., for measurement tools. + protected @Nullable Token keyToken; protected RenderPathWorker renderPathWorker; public AbstractZoneWalker(Zone zone) { @@ -75,15 +73,7 @@ public void addWaypoints(CellPoint... points) { } public void replaceLastWaypoint(CellPoint point) { - replaceLastWaypoint( - point, - false, - Collections.singleton(TerrainModifierOperation.NONE), - null, - null, - null, - null, - null); + replaceLastWaypoint(point, false, Collections.singleton(TerrainModifierOperation.NONE), null); } @Override @@ -91,19 +81,12 @@ public void replaceLastWaypoint( CellPoint point, boolean restrictMovement, Set terrainModifiersIgnored, - Area tokenWallVbl, - Area tokenHillVbl, - Area tokenPitVbl, - Area tokenCoverVbl, - Area tokenMbl) { + @Nullable Token keyToken) { this.restrictMovement = restrictMovement; this.terrainModifiersIgnored = terrainModifiersIgnored; - this.tokenWallVbl = tokenWallVbl; - this.tokenHillVbl = tokenHillVbl; - this.tokenPitVbl = tokenPitVbl; - this.tokenCoverVbl = tokenCoverVbl; - this.tokenMbl = tokenMbl; + + this.keyToken = keyToken; if (partialPaths.isEmpty()) { return; diff --git a/src/main/java/net/rptools/maptool/client/walker/ZoneWalker.java b/src/main/java/net/rptools/maptool/client/walker/ZoneWalker.java index 4d1868d97a..394c7b929e 100644 --- a/src/main/java/net/rptools/maptool/client/walker/ZoneWalker.java +++ b/src/main/java/net/rptools/maptool/client/walker/ZoneWalker.java @@ -14,17 +14,19 @@ */ package net.rptools.maptool.client.walker; -import java.awt.geom.Area; import java.util.Map; import java.util.Set; import javax.annotation.Nonnull; import net.rptools.maptool.client.ui.zone.RenderPathWorker; import net.rptools.maptool.model.CellPoint; import net.rptools.maptool.model.Path; +import net.rptools.maptool.model.Token; import net.rptools.maptool.model.Token.TerrainModifierOperation; import net.rptools.maptool.model.TokenFootprint; -public interface ZoneWalker { +public interface ZoneWalker extends AutoCloseable { + @Override + void close(); public void setWaypoints(CellPoint... points); @@ -36,11 +38,7 @@ public void replaceLastWaypoint( CellPoint point, boolean restrictMovement, Set terrainModifiersIgnored, - Area tokenWallVbl, - Area tokenHillVbl, - Area tokenPitVbl, - Area tokenCoverVbl, - Area tokenMbl); + Token keyToken); public boolean isWaypoint(CellPoint point); diff --git a/src/main/java/net/rptools/maptool/client/walker/astar/AbstractAStarWalker.java b/src/main/java/net/rptools/maptool/client/walker/astar/AbstractAStarWalker.java index 3aebbbc04d..84caf95414 100644 --- a/src/main/java/net/rptools/maptool/client/walker/astar/AbstractAStarWalker.java +++ b/src/main/java/net/rptools/maptool/client/walker/astar/AbstractAStarWalker.java @@ -14,14 +14,17 @@ */ package net.rptools.maptool.client.walker.astar; +import com.google.common.eventbus.Subscribe; import java.awt.Color; import java.awt.EventQueue; import java.awt.Rectangle; import java.awt.geom.Area; +import java.awt.geom.Rectangle2D; import java.text.DecimalFormat; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; +import java.util.EnumSet; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedList; @@ -32,17 +35,23 @@ import java.util.Queue; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; +import javax.annotation.Nonnull; import net.rptools.lib.GeometryUtil; import net.rptools.maptool.client.DeveloperOptions; import net.rptools.maptool.client.MapTool; +import net.rptools.maptool.client.ui.zone.vbl.MovementBlockingTopology; import net.rptools.maptool.client.walker.AbstractZoneWalker; +import net.rptools.maptool.events.MapToolEventBus; import net.rptools.maptool.model.CellPoint; import net.rptools.maptool.model.GUID; import net.rptools.maptool.model.Label; import net.rptools.maptool.model.Token; import net.rptools.maptool.model.TokenFootprint; import net.rptools.maptool.model.Zone; +import net.rptools.maptool.model.zones.MaskTopologyChanged; +import net.rptools.maptool.model.zones.WallTopologyChanged; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.locationtech.jts.algorithm.ConvexHull; @@ -61,21 +70,16 @@ private static boolean isInteger(double d) { } private static final Logger log = LogManager.getLogger(AbstractAStarWalker.class); - // Manually set this in order to view H, G & F costs as rendered labels private final GeometryFactory geometryFactory = new GeometryFactory(); protected int crossX = 0; protected int crossY = 0; - private Area vbl = new Area(); private Area fowExposedArea = new Area(); private double cell_cost = zone.getUnitsPerCell(); private double distance = -1; - private PreparedGeometry vblGeometry = null; + private final AtomicBoolean invalidatedTopology = new AtomicBoolean(true); + private @Nonnull MovementBlockingTopology preparedTopology = new MovementBlockingTopology(); private PreparedGeometry fowExposedAreaGeometry = null; - // private long avgRetrieveTime; - // private long avgTestTime; - // private long retrievalCount; - // private long testCount; private TokenFootprint footprint = new TokenFootprint(); private Map> vblBlockedMovesByGoal = new ConcurrentHashMap<>(); private Map> fowBlockedMovesByGoal = new ConcurrentHashMap<>(); @@ -103,6 +107,34 @@ public AbstractAStarWalker(Zone zone) { token.getTerrainModifierOperation(), token.getTerrainModifier())); } } + + new MapToolEventBus().getMainEventBus().register(this); + } + + @Override + public void close() { + new MapToolEventBus().getMainEventBus().unregister(this); + } + + private void onTopologyChanged() { + // This event is not called on the walker thread, so needs synchronization. + invalidatedTopology.set(true); + } + + @Subscribe + private void onTopologyChanged(WallTopologyChanged event) { + if (event.zone() != zone) { + return; + } + onTopologyChanged(); + } + + @Subscribe + private void onTopologyChanged(MaskTopologyChanged event) { + if (event.zone() != zone) { + return; + } + onTopologyChanged(); } /** @@ -187,45 +219,23 @@ protected List calculatePath(CellPoint start, CellPoint goal) { // Using JTS because AWT Area can only intersect with Area and we want to use simple lines here. // Render VBL to Geometry class once and store. // Note: zoneRenderer will be null if map is not visible to players. - Area newVbl = new Area(); Area newFowExposedArea = new Area(); final var zoneRenderer = MapTool.getFrame().getZoneRenderer(zone); if (zoneRenderer != null) { final var zoneView = zoneRenderer.getZoneView(); - var mbl = zoneView.getTopology(Zone.TopologyType.MBL); - if (tokenMbl != null) { - mbl = new Area(mbl); - mbl.subtract(tokenMbl); - } - - if (MapTool.getServerPolicy().getVblBlocksMove()) { - var wallVbl = zoneView.getTopology(Zone.TopologyType.WALL_VBL); - var hillVbl = zoneView.getTopology(Zone.TopologyType.HILL_VBL); - var pitVbl = zoneView.getTopology(Zone.TopologyType.PIT_VBL); - - // A token's topology should not be used to block itself! - if (tokenWallVbl != null) { - wallVbl = new Area(wallVbl); - wallVbl.subtract(tokenWallVbl); - } - if (tokenHillVbl != null) { - hillVbl = new Area(hillVbl); - hillVbl.subtract(tokenHillVbl); - } - if (tokenPitVbl != null) { - pitVbl = new Area(pitVbl); - pitVbl.subtract(tokenPitVbl); - } - - newVbl.add(wallVbl); - newVbl.add(hillVbl); - newVbl.add(pitVbl); - - // Finally, add the Move Blocking Layer! - newVbl.add(mbl); - } else { - newVbl = mbl; + if (invalidatedTopology.compareAndSet(true, false)) { + // The move cache may no longer accurately reflect the VBL limitations. + this.vblBlockedMovesByGoal.clear(); + + var topologyTypes = + MapTool.getServerPolicy().getVblBlocksMove() + ? EnumSet.allOf(Zone.TopologyType.class) + : EnumSet.of(Zone.TopologyType.MBL); + this.preparedTopology = + new MovementBlockingTopology( + zone.getWalls(), + zone.getMasks(topologyTypes, keyToken == null ? null : keyToken.getId())); } var view = zoneRenderer.getPlayerView(); @@ -233,24 +243,6 @@ protected List calculatePath(CellPoint start, CellPoint goal) { zone.hasFog() && !view.isGMView() ? zoneView.getExposedArea(view) : new Area(); } - if (!newVbl.equals(vbl)) { - // The move cache may no longer accurately reflect the VBL limitations. - this.vblBlockedMovesByGoal.clear(); - - vbl = newVbl; - // VBL has changed. Let's update the JTS geometry to match. - if (vbl.isEmpty()) { - this.vblGeometry = null; - } else { - try { - this.vblGeometry = PreparedGeometryFactory.prepare(GeometryUtil.toJts(vbl)); - } catch (Exception e) { - log.info("vblGeometry oh oh: ", e); - } - } - - // log.info("vblGeometry bounds: " + vblGeometry.toString()); - } if (!Objects.equals(newFowExposedArea, fowExposedArea)) { // The move cache may no longer accurately reflect the FOW limitations. this.fowBlockedMovesByGoal.clear(); @@ -285,7 +277,7 @@ protected List calculatePath(CellPoint start, CellPoint goal) { // log.info("A* Path timeout estimate: " + estimatedTimeoutNeeded); - Rectangle pathfindingBounds = this.getPathfindingBounds(start, goal); + Rectangle2D pathfindingBounds = this.getPathfindingBounds(start, goal); log.debug("Starting pathfinding"); log.debug("Pathfinding bounds are {}", pathfindingBounds); @@ -373,11 +365,6 @@ protected List calculatePath(CellPoint start, CellPoint goal) { log.debug("Time to calculate A* path warning: " + timeOut + "ms"); } - // if (retrievalCount > 0) - // log.info("avgRetrieveTime: " + Math.floor(avgRetrieveTime / retrievalCount)/1000 + " micro"); - // if (testCount > 0) - // log.info("avgTestTime: " + Math.floor(avgTestTime / testCount)/1000 + " micro"); - return returnedCellPointList; } @@ -399,27 +386,40 @@ protected List calculatePath(CellPoint start, CellPoint goal) { * @param goal * @return A bounding box suitable for constraining the A* search space. */ - protected Rectangle getPathfindingBounds(CellPoint start, CellPoint goal) { + protected Rectangle2D getPathfindingBounds(CellPoint start, CellPoint goal) { // Bounding box must contain all VBL/MBL ... - Rectangle pathfindingBounds = vbl.getBounds(); - pathfindingBounds = pathfindingBounds.union(fowExposedArea.getBounds()); + var vblEnvelope = preparedTopology.getEnvelope(); + + Rectangle2D pathfindingBounds = + new Rectangle2D.Double( + vblEnvelope.getMinX(), + vblEnvelope.getMinY(), + vblEnvelope.getWidth(), + vblEnvelope.getHeight()); + + pathfindingBounds = pathfindingBounds.createUnion(fowExposedArea.getBounds()); // ... and the footprints of all terrain tokens ... for (var cellPoint : terrainCells.keySet()) { - pathfindingBounds = pathfindingBounds.union(zone.getGrid().getBounds(cellPoint)); + pathfindingBounds = pathfindingBounds.createUnion(zone.getGrid().getBounds(cellPoint)); } // ... and the original token position ... - pathfindingBounds = pathfindingBounds.union(zone.getGrid().getBounds(start)); + pathfindingBounds = pathfindingBounds.createUnion(zone.getGrid().getBounds(start)); // ... and the target token position ... - pathfindingBounds = pathfindingBounds.union(zone.getGrid().getBounds(goal)); + pathfindingBounds = pathfindingBounds.createUnion(zone.getGrid().getBounds(goal)); // ... and have ample room for the token to go anywhere around the outside if necessary. var tokenBounds = footprint.getBounds(zone.getGrid()); - pathfindingBounds.grow(2 * tokenBounds.width, 2 * tokenBounds.height); + // Expand by twice the token size to ensure plenty of room. + pathfindingBounds.setRect( + pathfindingBounds.getMinX() - 2 * tokenBounds.width, + pathfindingBounds.getMinY() - 2 * tokenBounds.height, + pathfindingBounds.getWidth() + 4 * tokenBounds.width, + pathfindingBounds.getHeight() + 4 * tokenBounds.height); return pathfindingBounds; } protected List getNeighbors( - AStarCellPoint node, Set closedSet, Rectangle pathfindingBounds) { + AStarCellPoint node, Set closedSet, Rectangle2D pathfindingBounds) { List neighbors = new ArrayList<>(); int[][] neighborMap = getNeighborMap(node.position.x, node.position.y); @@ -556,10 +556,6 @@ protected List getNeighbors( } private boolean tokenFootprintIntersectsVBL(CellPoint position) { - if (vblGeometry == null) { - return false; - } - var points = footprint.getOccupiedCells(position).stream() .map( @@ -570,24 +566,16 @@ private boolean tokenFootprintIntersectsVBL(CellPoint position) { .toArray(Coordinate[]::new); Geometry footprintGeometry = new ConvexHull(points, geometryFactory).getConvexHull(); - return vblGeometry.intersects(footprintGeometry); + return preparedTopology.intersects(footprintGeometry); } private boolean vblBlocksMovement(CellPoint start, CellPoint goal) { - if (vblGeometry == null) { - return false; - } - - // Stopwatch stopwatch = Stopwatch.createStarted(); Map blockedMoves = vblBlockedMovesByGoal.computeIfAbsent(goal, pos -> new HashMap<>()); Boolean test = blockedMoves.get(start); // if it's null then the test for that direction hasn't been set yet otherwise just return the // previous result if (test != null) { - // log.info("Time to retrieve: " + stopwatch.elapsed(TimeUnit.NANOSECONDS)); - // avgRetrieveTime += stopwatch.elapsed(TimeUnit.NANOSECONDS); - // retrievalCount++; return test; } @@ -617,15 +605,12 @@ private boolean vblBlocksMovement(CellPoint start, CellPoint goal) { boolean blocksMovement; try { - blocksMovement = vblGeometry.intersects(centerRay); + blocksMovement = preparedTopology.intersects(centerRay); } catch (Exception e) { log.info("clipped.intersects oh oh: ", e); return true; } - // avgTestTime += stopwatch.elapsed(TimeUnit.NANOSECONDS); - // testCount++; - blockedMoves.put(start, blocksMovement); return blocksMovement; @@ -636,16 +621,12 @@ private boolean fowBlocksMovement(CellPoint start, CellPoint goal) { return false; } - // Stopwatch stopwatch = Stopwatch.createStarted(); Map blockedMoves = fowBlockedMovesByGoal.computeIfAbsent(goal, pos -> new HashMap<>()); Boolean test = blockedMoves.get(start); // if it's null then the test for that direction hasn't been set yet otherwise just return the // previous result if (test != null) { - // log.info("Time to retrieve: " + stopwatch.elapsed(TimeUnit.NANOSECONDS)); - // avgRetrieveTime += stopwatch.elapsed(TimeUnit.NANOSECONDS); - // retrievalCount++; return test; } @@ -673,9 +654,6 @@ private boolean fowBlocksMovement(CellPoint start, CellPoint goal) { return true; } - // avgTestTime += stopwatch.elapsed(TimeUnit.NANOSECONDS); - // testCount++; - blockedMoves.put(start, blocksMovement); return blocksMovement; From 62ee3248a45c6edb60946889cbfb73306e9d5401 Mon Sep 17 00:00:00 2001 From: Kenneth VanderLinde Date: Sat, 16 Nov 2024 12:38:11 -0800 Subject: [PATCH 10/14] Remove wall arrow until we introduce proper orientation --- .../maptool/client/tool/WallTopologyTool.java | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/src/main/java/net/rptools/maptool/client/tool/WallTopologyTool.java b/src/main/java/net/rptools/maptool/client/tool/WallTopologyTool.java index e242ad42e6..293e567e7b 100644 --- a/src/main/java/net/rptools/maptool/client/tool/WallTopologyTool.java +++ b/src/main/java/net/rptools/maptool/client/tool/WallTopologyTool.java @@ -344,25 +344,6 @@ public void paint(Graphics2D g2) { shape.moveTo(from.getX(), from.getY()); shape.lineTo(to.getX(), to.getY()); - if (from.distance(to) > 4 * handleRadius) { - // Draw a fun little arrow indicating the wall direection. - // theta increases clockwise. - var theta = Math.atan2(to.getY() - from.getY(), to.getX() - from.getX()); - var arrowSize = handleRadius; - var vertex = - new Point2D.Double( - to.getX() - 4 * handleRadius * Math.cos(theta), - to.getY() - 4 * handleRadius * Math.sin(theta)); - shape.moveTo(vertex.getX(), vertex.getY()); - shape.lineTo( - vertex.getX() - arrowSize * Math.cos(theta - Math.PI / 4), - vertex.getY() - arrowSize * Math.sin(theta - Math.PI / 4)); - shape.moveTo(vertex.getX(), vertex.getY()); - shape.lineTo( - vertex.getX() - arrowSize * Math.cos(theta + Math.PI / 4), - vertex.getY() - arrowSize * Math.sin(theta + Math.PI / 4)); - } - g2.setStroke(wallOutlineStroke); g2.setPaint(wallOutlineColor); g2.draw(shape); From 9f937f75e6c5a756cdae2eaf487290f5c2a21bdb Mon Sep 17 00:00:00 2001 From: Kenneth VanderLinde Date: Mon, 18 Nov 2024 14:03:01 -0800 Subject: [PATCH 11/14] Do not invalidate NodedTopology when only MBL changes True for map MBL and token MBL. --- .../ui/token/dialog/edit/EditTokenDialog.java | 5 +++- .../maptool/client/ui/zone/ZoneView.java | 6 ++--- .../client/ui/zone/renderer/ZoneRenderer.java | 10 ++++---- .../java/net/rptools/maptool/model/Token.java | 23 +++++++++---------- .../java/net/rptools/maptool/model/Zone.java | 18 +++++++++------ 5 files changed, 33 insertions(+), 29 deletions(-) diff --git a/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/EditTokenDialog.java b/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/EditTokenDialog.java index 5addfdccee..e5ffed9a83 100644 --- a/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/EditTokenDialog.java +++ b/src/main/java/net/rptools/maptool/client/ui/token/dialog/edit/EditTokenDialog.java @@ -940,7 +940,10 @@ public boolean commit() { MapTool.getFrame().resetTokenPanels(); // Jamz: TODO check if topology changed on token first - MapTool.getFrame().getCurrentZoneRenderer().getZone().tokenMaskTopologyChanged(); + MapTool.getFrame() + .getCurrentZoneRenderer() + .getZone() + .tokenMaskTopologyChanged(token.getMaskTopologyTypes()); return true; } diff --git a/src/main/java/net/rptools/maptool/client/ui/zone/ZoneView.java b/src/main/java/net/rptools/maptool/client/ui/zone/ZoneView.java index 59bce8d918..a9367d202c 100644 --- a/src/main/java/net/rptools/maptool/client/ui/zone/ZoneView.java +++ b/src/main/java/net/rptools/maptool/client/ui/zone/ZoneView.java @@ -818,7 +818,7 @@ private void onZoneLightingChanged(ZoneLightingChanged event) { private boolean flushExistingTokens(List tokens) { boolean tokenChangedTopology = false; for (Token token : tokens) { - if (token.hasAnyTopology()) tokenChangedTopology = true; + if (token.hasAnyMaskTopology()) tokenChangedTopology = true; flush(token); } // Ug, stupid hack here, can't find a bug where if a NPC token is moved before lights are @@ -854,7 +854,7 @@ private void onTokensRemoved(TokensRemoved event) { flushLights(); } - if (event.tokens().stream().anyMatch(Token::hasAnyTopology)) { + if (event.tokens().stream().anyMatch(Token::hasAnyMaskTopology)) { flush(); } } @@ -883,7 +883,7 @@ private void processTokenAddChangeEvent(List tokens) { visibleAreaMap.clear(); } - if (tokens.stream().anyMatch(Token::hasAnyTopology)) { + if (tokens.stream().anyMatch(Token::hasAnyMaskTopology)) { flush(); } } diff --git a/src/main/java/net/rptools/maptool/client/ui/zone/renderer/ZoneRenderer.java b/src/main/java/net/rptools/maptool/client/ui/zone/renderer/ZoneRenderer.java index dd19f373af..380057c1e7 100644 --- a/src/main/java/net/rptools/maptool/client/ui/zone/renderer/ZoneRenderer.java +++ b/src/main/java/net/rptools/maptool/client/ui/zone/renderer/ZoneRenderer.java @@ -421,7 +421,7 @@ public void commitMoveSelectionSet(GUID keyTokenId) { moveTimer.start("setup"); - boolean topologyTokenMoved = false; // If any token has topology we need to reset FoW + var changedMaskTopologyTypes = EnumSet.noneOf(Zone.TopologyType.class); Path path = set.getWalker() != null ? set.getWalker().getPath() : set.getGridlessPath(); @@ -461,9 +461,7 @@ public void commitMoveSelectionSet(GUID keyTokenId) { filteredTokens.add(tokenGUID); } - if (token.hasAnyTopology()) { - topologyTokenMoved = true; - } + changedMaskTopologyTypes.addAll(token.getMaskTopologyTypes()); } moveTimer.stop("eachtoken"); @@ -502,8 +500,8 @@ public void commitMoveSelectionSet(GUID keyTokenId) { MapTool.getFrame().updateTokenTree(); moveTimer.stop("updateTokenTree"); - if (topologyTokenMoved) { - zone.tokenMaskTopologyChanged(); + if (!changedMaskTopologyTypes.isEmpty()) { + zone.tokenMaskTopologyChanged(changedMaskTopologyTypes); } }); } else { diff --git a/src/main/java/net/rptools/maptool/model/Token.java b/src/main/java/net/rptools/maptool/model/Token.java index 0e760af7b4..530610473a 100644 --- a/src/main/java/net/rptools/maptool/model/Token.java +++ b/src/main/java/net/rptools/maptool/model/Token.java @@ -1439,19 +1439,18 @@ public void setMaskTopology(Zone.TopologyType topologyType, @Nullable Area topol case MBL -> mbl = topology; } - if (!hasAnyTopology()) { + if (!hasAnyMaskTopology()) { vblColorSensitivity = -1; } } /** - * Return the existence of the requested type of topology. - * - * @param topologyType The type of topology to check for. - * @return true if the token has the given type of topology. + * @return All types of mask topology attached to the token. */ - public boolean hasTopology(Zone.TopologyType topologyType) { - return getMaskTopology(topologyType) != null; + public Collection getMaskTopologyTypes() { + var result = EnumSet.allOf(Zone.TopologyType.class); + result.removeIf(type -> getMaskTopology(type) == null); + return result; } /** @@ -1459,7 +1458,7 @@ public boolean hasTopology(Zone.TopologyType topologyType) { * * @return true if the token has any kind of topology. */ - public boolean hasAnyTopology() { + public boolean hasAnyMaskTopology() { return Arrays.stream(Zone.TopologyType.values()) .map(this::getMaskTopology) .anyMatch(Objects::nonNull); @@ -2688,7 +2687,7 @@ public void updateProperty(Zone zone, Update update, List boolean lightChanged = false; boolean macroChanged = false; boolean panelLookChanged = false; // appearance of token in a panel changed - boolean topologyChanged = false; + Zone.TopologyType topologyChangeType = null; switch (update) { case setState: var state = parameters.get(0).getStringValue(); @@ -2855,7 +2854,7 @@ public void updateProperty(Zone zone, Update update, List { final var topologyType = Zone.TopologyType.valueOf(parameters.get(0).getTopologyType()); setMaskTopology(topologyType, Mapper.map(parameters.get(1).getArea())); - topologyChanged = true; + topologyChangeType = topologyType; break; } case setImageAsset: @@ -2945,8 +2944,8 @@ public void updateProperty(Zone zone, Update update, List if (panelLookChanged) { zone.tokenPanelChanged(this); } - if (topologyChanged) { - zone.tokenMaskTopologyChanged(); + if (topologyChangeType != null) { + zone.tokenMaskTopologyChanged(EnumSet.of(topologyChangeType)); } zone.tokenChanged(this); // fire Event.TOKEN_CHANGED, which updates topology if token has VBL } diff --git a/src/main/java/net/rptools/maptool/model/Zone.java b/src/main/java/net/rptools/maptool/model/Zone.java index 1741e2e5e0..487195b401 100644 --- a/src/main/java/net/rptools/maptool/model/Zone.java +++ b/src/main/java/net/rptools/maptool/model/Zone.java @@ -1043,13 +1043,21 @@ public void updateMaskTopology(Area area, boolean erase, TopologyType topologyTy topology.add(area); } - nodedTopology = null; + // MBL doesn't affect vision, so no need to invalidate the noding. + if (topologyType != TopologyType.MBL) { + nodedTopology = null; + } new MapToolEventBus().getMainEventBus().post(new MaskTopologyChanged(this)); } /** Fire the event {@link MaskTopologyChanged}. */ - public void tokenMaskTopologyChanged() { - nodedTopology = null; + public void tokenMaskTopologyChanged(Collection types) { + if (types.contains(TopologyType.WALL_VBL) + || types.contains(TopologyType.HILL_VBL) + || types.contains(TopologyType.PIT_VBL) + || types.contains(TopologyType.COVER_VBL)) { + nodedTopology = null; + } new MapToolEventBus().getMainEventBus().post(new MaskTopologyChanged(this)); } @@ -1779,10 +1787,6 @@ public List getTokensAlwaysVisible() { return getTokensFiltered(Token::isAlwaysVisible); } - public List getTokensWithTopology(TopologyType topologyType) { - return getTokensFiltered(token -> token.hasTopology(topologyType)); - } - public List getTokensWithTerrainModifiers() { return getTokensFiltered( t -> !t.getTerrainModifierOperation().equals(TerrainModifierOperation.NONE)); From d23a9bee90b576a6a5710701cb3f8388c4023b6d Mon Sep 17 00:00:00 2001 From: Kenneth VanderLinde Date: Tue, 19 Nov 2024 21:54:00 -0800 Subject: [PATCH 12/14] Rendering and UX improvements for WallTopologyTool - Minimum size for handles and edges - Selection distances are responsive to zoom level - No more magic numbers for stroke thickenss - Change in colours to make thing look a bit sharper --- .../net/rptools/maptool/client/AppStyle.java | 6 +- .../maptool/client/tool/WallTopologyTool.java | 58 +++++++++++++------ .../client/tool/rig/WallTopologyRig.java | 16 ++--- 3 files changed, 51 insertions(+), 29 deletions(-) diff --git a/src/main/java/net/rptools/maptool/client/AppStyle.java b/src/main/java/net/rptools/maptool/client/AppStyle.java index add625516e..f04d2e103f 100644 --- a/src/main/java/net/rptools/maptool/client/AppStyle.java +++ b/src/main/java/net/rptools/maptool/client/AppStyle.java @@ -37,9 +37,9 @@ public class AppStyle { public static Color selectionBoxFill = Color.blue; public static Color resizeBoxOutline = Color.red; public static Color resizeBoxFill = Color.yellow; - public static Color wallTopologyColor = new Color(200, 128, 0, 255); - public static Color wallTopologyOutlineColor = new Color(140, 88, 0, 128); - public static Color selectedWallTopologyColor = new Color(255, 182, 0, 255); + public static Color wallTopologyColor = new Color(255, 182, 0, 255); + public static Color wallTopologyOutlineColor = Color.black; + public static Color selectedWallTopologyColor = new Color(255, 136, 0, 255); public static Color topologyColor = new Color(0, 0, 255, 128); public static Color topologyAddColor = new Color(255, 0, 0, 128); public static Color topologyRemoveColor = new Color(255, 255, 255, 128); diff --git a/src/main/java/net/rptools/maptool/client/tool/WallTopologyTool.java b/src/main/java/net/rptools/maptool/client/tool/WallTopologyTool.java index 293e567e7b..f4e334e431 100644 --- a/src/main/java/net/rptools/maptool/client/tool/WallTopologyTool.java +++ b/src/main/java/net/rptools/maptool/client/tool/WallTopologyTool.java @@ -52,22 +52,34 @@ public class WallTopologyTool extends DefaultTool implements ZoneOverlay { private final TopologyTool.MaskOverlay maskOverlay = new TopologyTool.MaskOverlay(); private double getHandleRadius() { - return 4.f; + var radius = 4.; + + var scale = renderer.getScale(); + if (scale < 1) { + radius /= scale; + } + + return radius; } - private double getHandleSelectDistance() { - var handleSelectDistance = getHandleRadius(); + private double getWallHalfWidth() { + var width = 1.5; + var scale = renderer.getScale(); if (scale < 1) { - return handleSelectDistance / renderer.getScale(); + width /= scale; } - return handleSelectDistance; + + return width; + } + + private double getHandleSelectDistance() { + // Include a bit of leniency for the user. + return getHandleRadius() * 1.125; // Perhaps 1.125 to match outline stroke? } private double getWallSelectDistance() { - // Wall select distance doesn't have to be identical to the handle select distance. We can tweak - // this for better UX if it helps. - return getHandleSelectDistance(); + return getWallHalfWidth() * 1.5; } @Override @@ -89,7 +101,7 @@ public boolean isAvailable() { protected void attachTo(ZoneRenderer renderer) { super.attachTo(renderer); currentPosition = new Point2D.Double(Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY); - var rig = new WallTopologyRig(getHandleSelectDistance(), getWallSelectDistance()); + var rig = new WallTopologyRig(this::getHandleSelectDistance, this::getWallSelectDistance); rig.setWalls(getZone().getWalls()); changeToolMode(new BasicToolMode(this, rig)); @@ -110,10 +122,10 @@ public void paintOverlay(ZoneRenderer renderer, Graphics2D g) { maskOverlay.paintOverlay(renderer, g); Graphics2D g2 = (Graphics2D) g.create(); - SwingUtil.useAntiAliasing(g2); - g2.setComposite(AlphaComposite.SrcAtop); g2.translate(renderer.getViewOffsetX(), renderer.getViewOffsetY()); g2.scale(renderer.getScale(), renderer.getScale()); + SwingUtil.useAntiAliasing(g2); + g2.setComposite(AlphaComposite.SrcOver); mode.paint(g2); } @@ -179,7 +191,7 @@ private Snap getSnapMode(MouseEvent e) { @Subscribe private void onTopologyChanged(WallTopologyChanged event) { - var rig = new WallTopologyRig(getHandleSelectDistance(), getWallSelectDistance()); + var rig = new WallTopologyRig(this::getHandleSelectDistance, this::getWallSelectDistance); rig.setWalls(getZone().getWalls()); changeToolMode(new BasicToolMode(this, rig)); } @@ -287,17 +299,15 @@ protected Paint getHandleFill(Handle handle) { return Color.white; } - protected BasicStroke getHandleStroke() { - return new BasicStroke(1f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND); - } - protected Paint getWallFill(Movable wall) { return AppStyle.wallTopologyColor; } protected void paintHandle(Graphics2D g2, Point2D point, Paint fill) { var handleRadius = tool.getHandleRadius(); - var handleOutlineStroke = getHandleStroke(); + var handleOutlineStroke = + new BasicStroke( + (float) (handleRadius / 4.), BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND); var handleOutlineColor = Color.black; var shape = @@ -328,11 +338,15 @@ public void paint(Graphics2D g2) { bounds.getWidth() + 2 * padding, bounds.getHeight() + 2 * padding); - var wallStroke = new BasicStroke(2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND); + var wallStroke = + new BasicStroke( + (float) (2 * tool.getWallHalfWidth()), BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND); var wallOutlineColor = AppStyle.wallTopologyOutlineColor; var wallOutlineStroke = new BasicStroke( - wallStroke.getLineWidth() + 2f, wallStroke.getEndCap(), wallStroke.getLineJoin()); + (float) (wallStroke.getLineWidth() * 1.5), + wallStroke.getEndCap(), + wallStroke.getLineJoin()); var walls = rig.getWallsWithin(bounds); for (var wall : walls) { var wallFill = getWallFill(wall); @@ -340,6 +354,12 @@ public void paint(Graphics2D g2) { // `Stroke.createStokedShape()` when there are many walls. var from = wall.getSource().from().getPosition(); var to = wall.getSource().to().getPosition(); + + if (from.distanceSq(to) <= handleRadius * handleRadius) { + // No point rendering something so small. Could even be laxer, but this will suffice. + continue; + } + var shape = new Path2D.Double(); shape.moveTo(from.getX(), from.getY()); shape.lineTo(to.getX(), to.getY()); diff --git a/src/main/java/net/rptools/maptool/client/tool/rig/WallTopologyRig.java b/src/main/java/net/rptools/maptool/client/tool/rig/WallTopologyRig.java index 0804d56efc..403de57958 100644 --- a/src/main/java/net/rptools/maptool/client/tool/rig/WallTopologyRig.java +++ b/src/main/java/net/rptools/maptool/client/tool/rig/WallTopologyRig.java @@ -21,6 +21,7 @@ import java.util.List; import java.util.Optional; import java.util.function.Predicate; +import java.util.function.Supplier; import net.rptools.lib.GeometryUtil; import net.rptools.maptool.model.topology.WallTopology; import net.rptools.maptool.model.topology.WallTopology.Vertex; @@ -33,13 +34,14 @@ public sealed interface Element extends Movable permits WallTopologyRig.MovableVertex, WallTopologyRig.MovableWall {} private WallTopology walls; - private final double vertexSelectDistance; - private final double wallSelectDistance; + private final Supplier vertexSelectDistanceSupplier; + private final Supplier wallSelectDistanceSupplier; - public WallTopologyRig(double vertexSelectDistance, double wallSelectDistance) { + public WallTopologyRig( + Supplier vertexSelectDistanceSupplier, Supplier wallSelectDistanceSupplier) { this.walls = new WallTopology(); - this.vertexSelectDistance = vertexSelectDistance; - this.wallSelectDistance = wallSelectDistance; + this.vertexSelectDistanceSupplier = vertexSelectDistanceSupplier; + this.wallSelectDistanceSupplier = wallSelectDistanceSupplier; updateShape(); } @@ -111,7 +113,7 @@ public Optional> getNearbyElement( @Override public Optional getNearbyHandle( Point2D point, double extraSpace, Predicate> filter) { - var selectDistance = vertexSelectDistance + extraSpace; + var selectDistance = vertexSelectDistanceSupplier.get() + extraSpace; var candidatesVertices = getHandlesWithin( new Rectangle2D.Double( @@ -134,7 +136,7 @@ public Optional getNearbyWall( Point2D point, double extraSpace, Predicate> filter) { var coordinate = GeometryUtil.point2DToCoordinate(point); - var selectDistance = wallSelectDistance + extraSpace; + var selectDistance = wallSelectDistanceSupplier.get() + extraSpace; var bounds = new Rectangle2D.Double( point.getX() - selectDistance, From 9d5efe5eab229538855018edfe804d98df8957d1 Mon Sep 17 00:00:00 2001 From: Kenneth VanderLinde Date: Tue, 19 Nov 2024 23:45:46 -0800 Subject: [PATCH 13/14] Bring vertices and walls in front of other upon dragging --- .../maptool/client/tool/WallTopologyTool.java | 12 ++++++++++++ .../rptools/maptool/model/topology/WallTopology.java | 10 +++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/rptools/maptool/client/tool/WallTopologyTool.java b/src/main/java/net/rptools/maptool/client/tool/WallTopologyTool.java index f4e334e431..fa61a7a048 100644 --- a/src/main/java/net/rptools/maptool/client/tool/WallTopologyTool.java +++ b/src/main/java/net/rptools/maptool/client/tool/WallTopologyTool.java @@ -550,6 +550,13 @@ public DragWallToolMode( super(tool, rig, wall, originalMousePoint); } + @Override + public void activate() { + var source = this.movable.getSource(); + source.from().bringToFront(); + source.to().bringToFront(); + } + @Override public boolean shouldAllowMapDrag(MouseEvent e) { return true; @@ -596,6 +603,11 @@ public DragVertexToolMode( this.nonTrivialChange = !cancelIfTrivial; } + @Override + public void activate() { + this.movable.getSource().bringToFront(); + } + private void findConnectToHandle(MouseEvent event) { connectTo = null; diff --git a/src/main/java/net/rptools/maptool/model/topology/WallTopology.java b/src/main/java/net/rptools/maptool/model/topology/WallTopology.java index d6528efedf..7fcb99dd45 100644 --- a/src/main/java/net/rptools/maptool/model/topology/WallTopology.java +++ b/src/main/java/net/rptools/maptool/model/topology/WallTopology.java @@ -96,6 +96,10 @@ public void setPosition(double x, double y) { public void setPosition(Point2D position) { setPosition(position.getX(), position.getY()); } + + public void bringToFront() { + internal.zIndex(++nextZIndex); + } } /** @@ -188,6 +192,10 @@ public Point2D position() { public int zIndex() { return zIndex; } + + public void zIndex(int zIndex) { + this.zIndex = zIndex; + } } /** @@ -322,7 +330,7 @@ private VertexInternal createVertexImpl(GUID id) throws GraphException { // order. We also include a z-index so that other components can put vertices in other data // structures and sort them again by z-order. var data = new VertexInternal(id, new Point2D.Double()); - data.zIndex = ++nextZIndex; + data.zIndex(++nextZIndex); var previous = vertexInternalById.put(id, data); assert previous == null : "Invariant not held"; From 09d101573174b499aae09a205aa1580616c238c5 Mon Sep 17 00:00:00 2001 From: Kenneth VanderLinde Date: Wed, 20 Nov 2024 22:51:37 -0800 Subject: [PATCH 14/14] Replace Wall Topology tool icon --- .../client/ui/theme/RessourceManager.java | 2 +- .../icons/rod_takehara/ribbon/Draw Wall.svg | 16 ------- .../rod_takehara/ribbon/Wall Topology.svg | 45 +++++++++++++++++++ 3 files changed, 46 insertions(+), 17 deletions(-) delete mode 100644 src/main/resources/net/rptools/maptool/client/icons/rod_takehara/ribbon/Draw Wall.svg create mode 100644 src/main/resources/net/rptools/maptool/client/icons/rod_takehara/ribbon/Wall Topology.svg diff --git a/src/main/java/net/rptools/maptool/client/ui/theme/RessourceManager.java b/src/main/java/net/rptools/maptool/client/ui/theme/RessourceManager.java index d58ad67531..1d9a301113 100644 --- a/src/main/java/net/rptools/maptool/client/ui/theme/RessourceManager.java +++ b/src/main/java/net/rptools/maptool/client/ui/theme/RessourceManager.java @@ -413,7 +413,7 @@ public class RessourceManager { put(Icons.TOOLBAR_TOKENSELECTION_NPC_ON, ROD_ICONS + "ribbon/NPC.svg"); put(Icons.TOOLBAR_TOKENSELECTION_PC_OFF, ROD_ICONS + "ribbon/PC.svg"); put(Icons.TOOLBAR_TOKENSELECTION_PC_ON, ROD_ICONS + "ribbon/PC.svg"); - put(Icons.TOOLBAR_TOPOLOGY_WALL, ROD_ICONS + "ribbon/Draw Wall.svg"); + put(Icons.TOOLBAR_TOPOLOGY_WALL, ROD_ICONS + "ribbon/Wall Topology.svg"); put(Icons.TOOLBAR_TOPOLOGY_BOX, ROD_ICONS + "ribbon/Draw Rectangle.svg"); put(Icons.TOOLBAR_TOPOLOGY_BOX_HOLLOW, ROD_ICONS + "ribbon/Draw Hollow Rectangle.svg"); put(Icons.TOOLBAR_TOPOLOGY_CROSS, ROD_ICONS + "ribbon/Draw Cross.svg"); diff --git a/src/main/resources/net/rptools/maptool/client/icons/rod_takehara/ribbon/Draw Wall.svg b/src/main/resources/net/rptools/maptool/client/icons/rod_takehara/ribbon/Draw Wall.svg deleted file mode 100644 index 10aad99ed0..0000000000 --- a/src/main/resources/net/rptools/maptool/client/icons/rod_takehara/ribbon/Draw Wall.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/main/resources/net/rptools/maptool/client/icons/rod_takehara/ribbon/Wall Topology.svg b/src/main/resources/net/rptools/maptool/client/icons/rod_takehara/ribbon/Wall Topology.svg new file mode 100644 index 0000000000..803f99134a --- /dev/null +++ b/src/main/resources/net/rptools/maptool/client/icons/rod_takehara/ribbon/Wall Topology.svg @@ -0,0 +1,45 @@ + + + + Original path + + + Node cutouts + + Handle 1 + + + Handle 2 + + + Handle 3 + + + Handle 5 + + + Handle 7 + + + + Cut path + + + Nodes + + Node 1 + + + Node 2 + + + Node 3 + + + Node 5 + + + Node 7 + + + \ No newline at end of file