diff --git a/src/main/java/net/rptools/maptool/client/tool/drawing/TriangleTemplateTool.java b/src/main/java/net/rptools/maptool/client/tool/drawing/TriangleTemplateTool.java new file mode 100644 index 0000000000..b6e1b5e999 --- /dev/null +++ b/src/main/java/net/rptools/maptool/client/tool/drawing/TriangleTemplateTool.java @@ -0,0 +1,456 @@ +/* + * 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.drawing; + +import java.awt.*; +import java.awt.event.MouseEvent; +import java.awt.event.MouseMotionListener; +import java.awt.geom.AffineTransform; +import javax.swing.*; +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.Tool; +import net.rptools.maptool.client.tool.ToolHelper; +import net.rptools.maptool.client.ui.zone.renderer.ZoneRenderer; +import net.rptools.maptool.model.Grid; +import net.rptools.maptool.model.ZonePoint; +import net.rptools.maptool.model.drawing.*; + +/** + * Draw a template for a right angle cone with a flat end. This currently only implements cones + * where the base width = height. + * + *

Based on implementation of RadiusTemplateTool, but with different features... + * + *

The template shows the squares that are effected. The player chooses the starting vertex which + * will be the point of the cone, then moves the mouse to set the ending vertex which becomes the + * midpoint of the base of the cone. [Actually implemented in abstract drawing] Holding CTRL while + * moving the mouse allows the user to move the entire template after the initial vertex has been + * set but before the radius has been set. This allows users to move the AOE around to know where + * they are casting a spell. + * + *

This class primarily handles the state machine of: + * + *

ToolSelectedAndInactive PlacingInitialVertex MovingVertexUsingCtrl AdjustingRadiusUsingMouse + * -> Might change this? SendingDrawableToServerForRender... + */ +public class TriangleTemplateTool extends AbstractDrawingTool implements MouseMotionListener { + /*--------------------------------------------------------------------------------------------- + * Instance Variables + *-------------------------------------------------------------------------------------------*/ + + protected AbstractTemplate template = createBaseTemplate(); + + /** This flag controls the painting of the template. */ + protected boolean painting; + + /** + * Has the anchoring point been set? When false, the anchor point is being placed. When true, the + * area of effect is being drawn on the display. + */ + protected boolean anchorSet; + + /** + * The offset used to move the vertex when the control key is pressed. If this value is null + * then this would be the first time that the control key had been reported in the mouse + * event. + */ + protected ZonePoint controlOffset; + + /*--------------------------------------------------------------------------------------------- + * Class Variables + *-------------------------------------------------------------------------------------------*/ + + /** + * The width of the cursor. Since the cursor is a cross, this is the width of the horizontal bar + * and the height of the vertical bar. Always make it an odd number to keep it aligned on the grid + * properly. + */ + public static final int CURSOR_WIDTH = 25; + + /*--------------------------------------------------------------------------------------------- + * Constructor + *-------------------------------------------------------------------------------------------*/ + + public TriangleTemplateTool() {} + + /*--------------------------------------------------------------------------------------------- + * Instance Methods + *-------------------------------------------------------------------------------------------*/ + + /** + * Create the base template for the tool. + * + * @return The right angle cone template that is to be drawn. + */ + protected AbstractTemplate createBaseTemplate() { + return new TriangleTemplate(); + } + + /** + * Calculate the cell at the mouse point. If it is different from the current point, make it the + * current point and repaint. + * + * @param e The event to be checked. + * @param point The current point. + * @return Flag indicating that the value changed. + */ + protected boolean setCellAtMouse(MouseEvent e, ZonePoint point) { + ZonePoint working = getCellAtMouse(e); + if (!working.equals(point)) { + point.x = working.x; + point.y = working.y; + renderer.repaint(); + return true; + } // endif + return false; + } + + /** + * Calculate the cell closest to a mouse point. + * + * @param e The event to be checked. + * @return The cell at the mouse point in screen coordinates. + */ + protected ZonePoint getCellAtMouse(MouseEvent e) { + // Find the cell that the mouse is in. + ZonePoint mouse = new ScreenPoint(e.getX(), e.getY()).convertToZone(renderer); + + // Typically here we would snap this to the grid... + // For this template, since we are reducing the shape + // to an aoe, there is no need to start from a specific + // cell, so this will not be snapped to the grid. + // This also helps this template specifically, because + // users will want to make minor adjustments to this template + // and won't want to be stuck on a given corner as a starting + // point. + return mouse; + } + + /** + * Calculate the radius between two cells based on a mouse event. + * + * @param e Mouse event being checked + * @return The radius between the current mouse location and the vertex location. + */ + protected int getRadiusAtMouse(MouseEvent e) { + // To keep the re-sizing a bit smoother, this takes the distance between + // ZonePoints instead of CellPoints. Basically we look at the starting + // mouse and ending mouse point and calculate the distance based on a + // "snapped" distance instead of snapping the starting and ending point + // to the grid and calculating the distance from that. The end result + // should be a much smoother and more predictable template sizing. + ZonePoint workingZonePoint = getCellAtMouse(e); + ZonePoint currentVertex = template.getVertex(); + + int x = Math.abs(workingZonePoint.x - currentVertex.x); + int y = Math.abs(workingZonePoint.y - currentVertex.y); + + int distance = template.getDistance(x, y); + Grid grid = MapTool.getCampaign().getZone(template.getZoneId()).getGrid(); + float gridScaledDistance = (float) distance / (grid.getSize()); + int radius = Math.round(gridScaledDistance); + return radius; + } + + /** + * Paint a cursor + * + * @param g Where to paint. + * @param paint Data to draw the cursor + * @param thickness The thickness of the cursor. + * @param vertex The vertex holding the cursor. + */ + protected void paintCursor(Graphics2D g, Paint paint, float thickness, ZonePoint vertex) { + int halfCursor = CURSOR_WIDTH / 2; + g.setPaint(paint); + g.setStroke(new BasicStroke(thickness)); + g.drawLine(vertex.x - halfCursor, vertex.y, vertex.x + halfCursor, vertex.y); + g.drawLine(vertex.x, vertex.y - halfCursor, vertex.x, vertex.y + halfCursor); + } + + /** + * Get the pen set up to paint the overlay. + * + * @return The pen used to paint the overlay. + */ + protected Pen getPenForOverlay() { + // Get the pen and modify to only show a cursor and the boundary + Pen pen = getPen(); // new copy of pen, OK to modify + pen.setBackgroundMode(Pen.MODE_SOLID); + pen.setForegroundMode(Pen.MODE_SOLID); + pen.setThickness(3); + if (pen.isEraser()) { + pen.setEraser(false); + pen.setPaint(new DrawableColorPaint(Color.WHITE)); + } // endif + return pen; + } + + /** + * Paint the radius value in feet. + * + * @param g Where to paint. + * @param p Vertex where radius is painted. + */ + protected void paintRadius(Graphics2D g, ZonePoint p) { + if (template.getRadius() > 0 && anchorSet) { + ScreenPoint centerText = ScreenPoint.fromZonePoint(renderer, p); + centerText.translate(CURSOR_WIDTH, -CURSOR_WIDTH); + ToolHelper.drawMeasurement( + g, + template.getRadius() * renderer.getZone().getUnitsPerCell(), + (int) centerText.x, + (int) centerText.y); + } // endif + } + + /** + * New instance of the template, at the passed vertex + * + * @param vertex The starting vertex for the new template or null if we should use + * the current template's vertex. + */ + protected void resetTool(ZonePoint vertex) { + anchorSet = false; + if (vertex == null) { + vertex = template.getVertex(); + vertex = new ZonePoint(vertex.x, vertex.y); // Must create copy! + } // endif + template = createBaseTemplate(); + template.setVertex(vertex); + template.setZoneId(renderer.getZone().getId()); + controlOffset = null; + renderer.repaint(); + } + + /** + * Handles setting the vertex when the control key is pressed during mouse movement. A change in + * the passed vertex causes the template to repaint the zone. + * + * @param e The mouse movement event. + * @param vertex The vertex being modified. + */ + protected void handleControlOffset(MouseEvent e, ZonePoint vertex) { + ZonePoint working = getCellAtMouse(e); + if (controlOffset == null) { + controlOffset = working; + controlOffset.x = working.x - vertex.x; + controlOffset.y = working.y - vertex.y; + } else { + working.x = working.x - controlOffset.x; + working.y = working.y - controlOffset.y; + if (!working.equals(vertex)) { + if (vertex == template.getVertex()) { + template.setVertex(working); + } else { + vertex.x = working.x; + vertex.y = working.y; + } // endif + renderer.repaint(); + } // endif + } // endif + } + + /** + * Set the radius on a mouse move after the anchor has been set. + * + * @param e Current mouse locations + */ + protected void setRadiusFromAnchor(MouseEvent e) { + template.setRadius(getRadiusAtMouse(e)); + } + + /** + * Handle mouse movement. Done here so that subclasses can still benefit from the code in + * DefaultTool w/o rewriting it. + * + * @param e Current mouse location + */ + protected void handleMouseMovement(MouseEvent e) { + // Set the anchor + ZonePoint vertex = template.getVertex(); + if (!anchorSet) { + setCellAtMouse(e, vertex); + controlOffset = null; + + // Move the anchor if control pressed. + } else if (SwingUtil.isControlDown(e)) { + handleControlOffset(e, vertex); + + // Set the radius and repaint + } else { + setRadiusFromAnchor(e); + ZonePoint ep = getCellAtMouse(e); + TriangleTemplate t = (TriangleTemplate) template; + t.calculateTheta(e, renderer); + renderer.repaint(); + controlOffset = null; + } // endif + } + + /*--------------------------------------------------------------------------------------------- + * DefaultTool Interface Methods + *-------------------------------------------------------------------------------------------*/ + + /** + * @see net.rptools.maptool.client.tool.DefaultTool#mouseMoved(MouseEvent) + */ + @Override + public void mouseMoved(MouseEvent e) { + super.mouseMoved(e); + handleMouseMovement(e); + } + + /*--------------------------------------------------------------------------------------------- + * Overridden AbstractDrawingTool Methods + *-------------------------------------------------------------------------------------------*/ + + /** + * @see net.rptools.maptool.client.ui.zone.ZoneOverlay#paintOverlay(ZoneRenderer, Graphics2D) + */ + @Override + public void paintOverlay(ZoneRenderer renderer, Graphics2D g) { + if (painting && renderer != null) { + Pen pen = getPenForOverlay(); + AffineTransform old = g.getTransform(); + AffineTransform newTransform = g.getTransform(); + newTransform.concatenate(getPaintTransform(renderer)); + g.setTransform(newTransform); + template.draw(g, pen); + Paint paint = pen.getPaint() != null ? pen.getPaint().getPaint() : null; + paintCursor(g, paint, pen.getThickness(), template.getVertex()); + g.setTransform(old); + paintRadius(g, template.getVertex()); + } // endif + } + + /** + * New instance of the template, at the current vertex + * + * @see Tool#resetTool() + */ + @Override + protected void resetTool() { + if (!anchorSet) { + super.resetTool(); + return; + } + resetTool(null); + } + + /** + * It is OK to modify the pen returned by this method + * + * @see AbstractDrawingTool#getPen() + */ + @Override + protected Pen getPen() { + // Just paint the foreground + Pen pen = super.getPen(); + pen.setBackgroundMode(Pen.MODE_SOLID); + return pen; + } + + /** + * @see Tool#detachFrom(ZoneRenderer) + */ + @Override + protected void detachFrom(ZoneRenderer renderer) { + super.detachFrom(renderer); + template.setZoneId(null); + renderer.repaint(); + } + + /** + * @see Tool#attachTo(ZoneRenderer) + */ + @Override + protected void attachTo(ZoneRenderer renderer) { + template.setZoneId(renderer.getZone().getId()); + renderer.repaint(); + super.attachTo(renderer); + } + + /** + * @see Tool#getTooltip() + */ + @Override + public String getTooltip() { + return "tool.triangletemplate.tooltip"; + } + + /** + * @see Tool#getInstructions() + */ + @Override + public String getInstructions() { + return "tool.triangletemplate.instructions"; + } + + /*--------------------------------------------------------------------------------------------- + * MouseListener Interface Methods + *-------------------------------------------------------------------------------------------*/ + + /** + * @see java.awt.event.MouseListener#mousePressed(MouseEvent) + */ + @Override + public void mousePressed(MouseEvent e) { + super.mousePressed(e); + if (!painting) return; + + if (SwingUtilities.isLeftMouseButton(e)) { + controlOffset = null; + if (!anchorSet) { + anchorSet = true; + return; + } // endif + + if (template.getRadius() < AbstractTemplate.MIN_RADIUS) return; + TriangleTemplate t = (TriangleTemplate) template; + + setIsEraser(isEraser(e)); + template.setRadius(getRadiusAtMouse(e)); + + ZonePoint vertex = template.getVertex(); + ZonePoint newPoint = new ZonePoint(vertex.x, vertex.y); + completeDrawable(renderer.getZone().getId(), getPen(), template); + setIsEraser(false); + resetTool(newPoint); + } + } + + /** + * @see java.awt.event.MouseListener#mouseEntered(MouseEvent) + */ + @Override + public void mouseEntered(MouseEvent e) { + super.mouseEntered(e); + painting = true; + renderer.repaint(); + } + + /** + * @see java.awt.event.MouseListener#mouseExited(MouseEvent) + */ + @Override + public void mouseExited(MouseEvent e) { + super.mouseExited(e); + painting = false; + renderer.repaint(); + } +} 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 4916c28680..7cd94b45b6 100644 --- a/src/main/java/net/rptools/maptool/client/ui/ToolbarPanel.java +++ b/src/main/java/net/rptools/maptool/client/ui/ToolbarPanel.java @@ -299,6 +299,9 @@ private OptionPanel createTemplatePanel() { panel .add(WallTemplateTool.class) .setIcon(RessourceManager.getBigIcon(Icons.TOOLBAR_TEMPLATE_WALL)); + panel + .add(TriangleTemplateTool.class) + .setIcon(RessourceManager.getBigIcon(Icons.TOOLBAR_TEMPLATE_TRIANGLE)); 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..199bd085d2 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 @@ -150,6 +150,7 @@ public enum Icons { TOOLBAR_TEMPLATE_RADIUS, TOOLBAR_TEMPLATE_RADIUS_CELL, TOOLBAR_TEMPLATE_WALL, + TOOLBAR_TEMPLATE_TRIANGLE, TOOLBAR_TOKENSELECTION_ALL_OFF, TOOLBAR_TOKENSELECTION_ALL_ON, TOOLBAR_TOKENSELECTION_ME_OFF, 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 bf6a15e6c8..3f5db560ec 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 @@ -398,13 +398,14 @@ public class RessourceManager { put( Icons.TOOLBAR_TEMPLATE_LINE_CELL, ROD_ICONS + "ribbon/Line Template Centered on Grid.svg"); - put(Icons.TOOLBAR_TEMPLATE_OFF, ROD_ICONS + "ribbon/Cone Template.svg"); - put(Icons.TOOLBAR_TEMPLATE_ON, ROD_ICONS + "ribbon/Cone Template.svg"); + put(Icons.TOOLBAR_TEMPLATE_OFF, ROD_ICONS + "ribbon/Triangle Template.svg"); + put(Icons.TOOLBAR_TEMPLATE_ON, ROD_ICONS + "ribbon/Triangle Template.svg"); put(Icons.TOOLBAR_TEMPLATE_RADIUS, ROD_ICONS + "ribbon/Radius Template.svg"); put( Icons.TOOLBAR_TEMPLATE_RADIUS_CELL, ROD_ICONS + "ribbon/Radius Template Centered on Grid.svg"); put(Icons.TOOLBAR_TEMPLATE_WALL, ROD_ICONS + "ribbon/Wall Line Template.svg"); + put(Icons.TOOLBAR_TEMPLATE_TRIANGLE, ROD_ICONS + "ribbon/Triangle Template.svg"); put(Icons.TOOLBAR_TOKENSELECTION_ALL_OFF, ROD_ICONS + "ribbon/All.svg"); put(Icons.TOOLBAR_TOKENSELECTION_ALL_ON, ROD_ICONS + "ribbon/All.svg"); put(Icons.TOOLBAR_TOKENSELECTION_ME_OFF, ROD_ICONS + "ribbon/Me.svg"); diff --git a/src/main/java/net/rptools/maptool/model/drawing/Drawable.java b/src/main/java/net/rptools/maptool/model/drawing/Drawable.java index 2b10791b15..3c9f91334a 100644 --- a/src/main/java/net/rptools/maptool/model/drawing/Drawable.java +++ b/src/main/java/net/rptools/maptool/model/drawing/Drawable.java @@ -165,6 +165,20 @@ static Drawable fromDto(DrawableDto drawableDto) { drawable.setLayer(Zone.Layer.valueOf(dto.getLayer())); return drawable; } + case TRIANGLE_TEMPLATE -> { + var dto = drawableDto.getTriangleTemplate(); + var id = GUID.valueOf(dto.getId()); + var drawable = new TriangleTemplate(id); + drawable.setZoneId(GUID.valueOf(dto.getZoneId())); + drawable.setRadius(dto.getRadius()); + var vertex = dto.getVertex(); + drawable.setVertex(new ZonePoint(vertex.getX(), vertex.getY())); + if (dto.hasName()) drawable.setName(dto.getName().getValue()); + drawable.setLayer(Zone.Layer.valueOf(dto.getLayer())); + drawable.setTheta(dto.getTheta()); + drawable.setSensitivity(dto.getSensitivity()); + return drawable; + } case BURST_TEMPLATE -> { var dto = drawableDto.getBurstTemplate(); var id = GUID.valueOf(dto.getId()); diff --git a/src/main/java/net/rptools/maptool/model/drawing/TriangleTemplate.java b/src/main/java/net/rptools/maptool/model/drawing/TriangleTemplate.java new file mode 100644 index 0000000000..8d5571d340 --- /dev/null +++ b/src/main/java/net/rptools/maptool/model/drawing/TriangleTemplate.java @@ -0,0 +1,425 @@ +/* + * 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.drawing; + +import com.google.protobuf.StringValue; +import java.awt.*; +import java.awt.Rectangle; +import java.awt.event.MouseEvent; +import java.awt.geom.*; +import java.util.Comparator; +import java.util.PriorityQueue; +import net.rptools.maptool.client.MapTool; +import net.rptools.maptool.client.ui.zone.renderer.ZoneRenderer; +import net.rptools.maptool.model.*; +import net.rptools.maptool.server.proto.drawing.DrawableDto; +import net.rptools.maptool.server.proto.drawing.TriangleTemplateDto; + +public class TriangleTemplate extends AbstractTemplate { + // The definition of the cone is it is as wide as it is + // long, so the cone angle is tan inverse of 1/2, since + // if the length of the cone is 1, there is half it's + // length from the midpoint to the left and right edge + // of the base of the cone respectively... + private static final double CONE_ANGLE = Math.atan2(0.5, 1.0); + + // This is the ratio of the cone's side length to the + // length from the point of the cone to the midpoint of the + // base of the cone... + private static final double CONE_SIDE_LENGTH_RATIO = 1 / Math.cos(CONE_ANGLE); + + private static Path2D.Double stencilDefinitionBuilder() { + // Calculate the position of the vertices of the cone based on + // the angle of the cone. + double coneSideLength = CONE_SIDE_LENGTH_RATIO; + double vertex1X = coneSideLength * Math.cos(CONE_ANGLE); + double vertex1Y = coneSideLength * Math.sin(CONE_ANGLE); + + double vertex2X = coneSideLength * Math.cos(-CONE_ANGLE); + double vertex2Y = coneSideLength * Math.sin(-CONE_ANGLE); + + Path2D.Double path = new Path2D.Double(); + path.moveTo(0, 0); + path.lineTo(vertex1X, vertex1Y); + path.lineTo(vertex2X, vertex2Y); + path.lineTo(0, 0); + return path; + } + + private static final Path2D.Double STENCIL_DEFINITION = stencilDefinitionBuilder(); + + public TriangleTemplate() { + this.showAOEOverlay = true; // While "building" it should show the overlay. + } + + public TriangleTemplate(GUID id) { + super(id); + this.showAOEOverlay = false; + } + + // The angle from the center of the cone to the horizontal. + // This angle lets you adjust where the cone is pointed. + private double theta = 0.0; + + public double getTheta() { + return theta; + } + + public void setTheta(double v) { + theta = v; + } + + // How much of a grid cell must be covered by the "stencil" + // for the grid cell to be considered part of the AoE. + private double sensitivity = 0.0; + + public double getSensitivity() { + return sensitivity; + } + + public void setSensitivity(double sensitivity) { + this.sensitivity = sensitivity; + } + + private boolean showAOEOverlay = false; + + public void calculateTheta(MouseEvent e, ZoneRenderer renderer) { + if (getRadius() == 0) return; + + // Copy logic for off-setting to match what is in + // ScreenPoint.convertToZone... + // Without this, our theta calculations stops working + // when we zoom or pan the view. + double scale = renderer.getScale(); + double zX = e.getX(); + double zY = e.getY(); + + // Translate + zX -= renderer.getViewOffsetX(); + zY -= renderer.getViewOffsetY(); + + // Scale + zX = Math.floor(zX / scale); + zY = Math.floor(zY / scale); + + ZonePoint sp = getVertex(); + double adjacent = zX - sp.x; + double opposite = zY - sp.y; + setTheta(Math.atan2(opposite, adjacent)); + } + + /*--------------------------------------------------------------------------------------------- + * Overridden AbstractTemplate Methods + *-------------------------------------------------------------------------------------------*/ + + /** + * @see AbstractTemplate#paintBorder(Graphics2D, int, int, int, int, int, int) + */ + @Override + protected void paintBorder( + Graphics2D g, int x, int y, int xOff, int yOff, int gridSize, int distance) { + // NO-OP, not used. I overrode AbstractTemplate#paint in this class and I don't call paintBorder + // and paintArea separately anymore. + } + + @Override + protected void paintArea( + Graphics2D g, int x, int y, int xOff, int yOff, int gridSize, int distance) { + // NO-OP, not used. I overrode AbstractTemplate#paint in this class and I don't call paintBorder + // and paintArea separately anymore. + } + + private void splitAndQueueVertically(Rectangle r, PriorityQueue q, int gridSize) { + // Midpoint is actually "distance to midpoint from x". + // split rectangle horizontally: + int midpoint = r.height / 2; + int midpointSnappedToGrid = midpoint - midpoint % gridSize; + Rectangle top = new Rectangle(r.x, r.y, r.width, midpointSnappedToGrid); + // The bottom split starts at midpoint and has a height of the original height - midpoint + int bottomY = r.y + midpointSnappedToGrid; + int bottomH = r.height - midpointSnappedToGrid; + Rectangle bottom = new Rectangle(r.x, bottomY, r.width, bottomH); + + q.add(top); + q.add(bottom); + } + + private void splitAndQueueHorizontally(Rectangle r, PriorityQueue q, int gridSize) { + // Midpoint is actually "distance to midpoint from x". + // split rectangle horizontally: + int midpoint = r.width / 2; + int midpointSnappedToGrid = midpoint - midpoint % gridSize; + Rectangle left = new Rectangle(r.x, r.y, midpointSnappedToGrid, r.height); + // The right split starts at midpoint and has a width of the original width - midpoint + int rightX = r.x + midpointSnappedToGrid; + int rightW = r.width - midpointSnappedToGrid; + Rectangle right = new Rectangle(rightX, r.y, rightW, r.height); + + if (r.height > gridSize) { + splitAndQueueVertically(left, q, gridSize); + splitAndQueueVertically(right, q, gridSize); + } else { + q.add(left); + q.add(right); + } + } + + @Override + protected void paint(Graphics2D g, boolean border, boolean area) { + boolean debug = true; + Path2D.Double path = getConePath(); + if (debug && border) { + Rectangle boundingBox = this.getBoundingBox(path); + Grid grid = MapTool.getCampaign().getZone(getZoneId()).getGrid(); + int gridSize = grid.getSize(); + Rectangle gridSnappedBoundingBox = this.getGridSnappedBoundingBox(boundingBox, gridSize); + Color normal = g.getColor(); + g.setColor(new Color(0, 0, 255)); + g.draw(boundingBox); + g.setColor(new Color(255, 0, 0)); + g.draw(gridSnappedBoundingBox); + g.setColor(normal); + } + + Area aoe = this.getArea(); + + // Paint what is needed. + if (area) { + g.fill(aoe); + } + if (border) { + if (this.showAOEOverlay) { // While drawing, it's helpful to see the cone overlay. + g.draw(path); + } + g.draw(aoe); + } + // endif + } + + /*--------------------------------------------------------------------------------------------- + * Drawable Interface Methods + *-------------------------------------------------------------------------------------------*/ + + /** + * @see Drawable#getBounds() + */ + public Rectangle getBounds() { + if (getZoneId() == null) { + // This avoids a NPE when loading up a campaign + return new Rectangle(); + } + Zone zone = MapTool.getCampaign().getZone(getZoneId()); + if (zone == null) { + return new Rectangle(); + } + int gridSize = zone.getGrid().getSize(); + int quadrantSize = getRadius() * gridSize + BOUNDS_PADDING; + ZonePoint vertex = getVertex(); + return new Rectangle( + vertex.x - quadrantSize, vertex.y - quadrantSize, quadrantSize * 2, quadrantSize * 2); + } + + protected static Path2D.Double getConePath(ZonePoint sp, int radius, int gridSize, double theta) { + if (radius == 0) return new Path2D.Double(); + + // Calculate the position of the vertices of the cone based on + // the angle of the cone. + double coneSideLength = CONE_SIDE_LENGTH_RATIO * gridSize * radius; + double coneAimAngle = theta; + double vertex1X = coneSideLength * Math.cos(coneAimAngle + CONE_ANGLE) + sp.x; + double vertex1Y = coneSideLength * Math.sin(coneAimAngle + CONE_ANGLE) + sp.y; + + double vertex2X = coneSideLength * Math.cos(coneAimAngle - CONE_ANGLE) + sp.x; + double vertex2Y = coneSideLength * Math.sin(coneAimAngle - CONE_ANGLE) + sp.y; + + Path2D.Double path = new Path2D.Double(); + path.moveTo(sp.x, sp.y); + path.lineTo(vertex1X, vertex1Y); + path.lineTo(vertex2X, vertex2Y); + path.lineTo(sp.x, sp.y); + return path; + } + + private Path2D.Double getConePath() { + ZonePoint sp = getVertex(); + + // Only paint if the start and endpoints are not equal and the + // radius is non-zero. + int radius = getRadius(); + Grid grid = MapTool.getCampaign().getZone(getZoneId()).getGrid(); + int gridSize = grid.getSize(); + + double theta = getTheta(); + + return getConePath(sp, radius, gridSize, theta); + } + + private Rectangle getBoundingBox(Path2D.Double path) { + return path.getBounds(); + } + + private Rectangle getGridSnappedBoundingBox(Rectangle boundingBox, int gridSize) { + // bounding box is not scaled to the grid. + // We want to take the gridSize and snap the bounding box onto the grid. + // In the top left position, we want to snap to the top left corner, + // in the bottom right position we want to snap to the bottom right corner. + double xLeftUpperGridScale = (double) boundingBox.x / gridSize; + double xLeftUpperGridSnapped = Math.floor(xLeftUpperGridScale); + double xLeftUpperZoneScale = gridSize * xLeftUpperGridSnapped; + int xLeftUpper = (int) xLeftUpperZoneScale; + + double yLeftUpperGridScale = (double) boundingBox.y / gridSize; + double yLeftUpperGridSnapped = Math.floor(yLeftUpperGridScale); + double yLeftUpperZoneScale = gridSize * yLeftUpperGridSnapped; + int yLeftUpper = (int) yLeftUpperZoneScale; + + ZonePoint leftUpper = new ZonePoint(xLeftUpper, yLeftUpper); + + float bottomRightX = boundingBox.x + boundingBox.width; + double xRightBottomGridScale = (double) bottomRightX / gridSize; + double xRightBottomGridSnapped = Math.ceil(xRightBottomGridScale); + double xRightBottomZoneScale = gridSize * xRightBottomGridSnapped; + int xRightBottom = (int) xRightBottomZoneScale; + + float bottomRightY = boundingBox.y + boundingBox.height; + double yRightBottomGridScale = (double) bottomRightY / gridSize; + double yRightBottomGridSnapped = Math.ceil(yRightBottomGridScale); + double yRightBottomZoneScale = gridSize * yRightBottomGridSnapped; + int yRightBottom = (int) yRightBottomZoneScale; + + ZonePoint bottomRight = new ZonePoint(xRightBottom, yRightBottom); + Rectangle gridSnappedBoundingBox = + new Rectangle( + leftUpper.x, leftUpper.y, bottomRight.x - leftUpper.x, bottomRight.y - leftUpper.y); + + return gridSnappedBoundingBox; + } + + @Override + public Area getArea() { + if (MapTool.getCampaign().getZone(getZoneId()) == null) { + return new Area(); + } + Grid grid = MapTool.getCampaign().getZone(getZoneId()).getGrid(); + int gridSize = grid.getSize(); + + Path2D.Double path = getConePath(); + + // boundingBox is the minimal bounding box of the cone. + // griddedBoundingBox is the bounding box of all game squares + // that the boundingBox intersects. + Rectangle boundingBox = getBoundingBox(path); + Rectangle gridSnappedBoundingBox = getGridSnappedBoundingBox(boundingBox, gridSize); + Area cone = new Area(path); + Area aoe = new Area(); // Empty rectangle that we will update. + /** */ + PriorityQueue queue = + new PriorityQueue( + new Comparator() { + @Override + /** We prioritize smaller rectangles so the Queue stays smaller. */ + public int compare(Rectangle o1, Rectangle o2) { + return o1.height * o1.width - o2.height * o2.width; + } + }); + queue.add(gridSnappedBoundingBox); + while (queue.size() > 0) { + // if fully contained, then add it to the shape + // if empty, + Rectangle candidateRectForAoe = queue.poll(); + // cast to Area to allow subtracting cone. + Area candidateAreaForAoe = new Area(candidateRectForAoe); + candidateAreaForAoe.subtract(cone); + if (candidateAreaForAoe.isEmpty()) { + // If the subtraction leaves the grid square empty, then + // the grid square is fully enclosed by the aoe! + // Add it to the aoe. + aoe.add(new Area(candidateRectForAoe)); + } + // If the gridArea is not equal to the candidateRectForAoe, then + // it means there is some amount of intersection between + // the grid square and the aoe... + else if (!candidateAreaForAoe.equals(new Area(candidateRectForAoe))) { + if (candidateRectForAoe.width > gridSize) { + splitAndQueueHorizontally(candidateRectForAoe, queue, gridSize); + } else if (candidateRectForAoe.height > gridSize) { + splitAndQueueVertically(candidateRectForAoe, queue, gridSize); + } else { + // Otherwise, it's already a gridsquare in size, so let's check + // whether the overlapping area is enough for it to be considered + // in the aoe! + int totalArea = gridSize * gridSize; + // How much of the grid square needs to be covered to be part of aoe. + double requiredPercent = getSensitivity(); + double thresholdArea = totalArea * (100 - requiredPercent) / 100; + + // Application of the Shoelace formula, using a "flattened" + // path iterator... You can find other examples online and this + // code will look very familiar... but basically + // this is a way of calculating the area under any polygon in o(n) + // where n is the number of sides... + PathIterator it = candidateAreaForAoe.getPathIterator(null, 0.1); + double a = 0.0; + double startingX = 0.0; + double startingY = 0.0; + double previousX = 0.0; + double previousY = 0.0; + double[] coords = new double[6]; + while (!it.isDone()) { + int segmentType = it.currentSegment(coords); + if (segmentType == PathIterator.SEG_MOVETO) { + // set the starting coordinates.. + startingX = coords[0]; + startingY = coords[1]; + } else if (segmentType == PathIterator.SEG_LINETO) { + a += (coords[0] - previousX) * (coords[1] + previousY) / 2.0; + } else if (segmentType == PathIterator.SEG_CLOSE) { + a += (startingX - previousX) * (startingY + previousY) / 2.0; + } + previousX = coords[0]; + previousY = coords[1]; + it.next(); + } + + // Since a is the "subtraction of aoe" from the total area, we're + // actually deciding if the remaining area is less than the threshold + // area... This is all a bit counter-intuitive but could pretty easily + // be reworked. + if (a < thresholdArea) { + aoe.add(new Area(candidateRectForAoe)); + } + } + } // else there is no overlap, so there is nothing to do. + } + + return aoe; + } + + @Override + public DrawableDto toDto() { + var dto = TriangleTemplateDto.newBuilder(); + dto.setId(getId().toString()) + .setLayer(getLayer().name()) + .setZoneId(getZoneId().toString()) + .setRadius(getRadius()) + .setVertex(getVertex().toDto()) + .setTheta(getTheta()) + .setSensitivity(getSensitivity()); + + if (getName() != null) dto.setName(StringValue.of(getName())); + + return DrawableDto.newBuilder().setTriangleTemplate(dto).build(); + } +} diff --git a/src/main/proto/drawing_dto.proto b/src/main/proto/drawing_dto.proto index 98e45786bd..98b5d93390 100644 --- a/src/main/proto/drawing_dto.proto +++ b/src/main/proto/drawing_dto.proto @@ -50,6 +50,7 @@ message DrawableDto { BlastTemplateDto blast_template = 13; LineTemplateDto line_template = 14; WallTemplateDto wall_template = 15; + TriangleTemplateDto triangle_template = 16; } } @@ -119,6 +120,17 @@ message RadiusTemplateDto { int32 radius = 6; } +message TriangleTemplateDto { + string id = 1; + string layer = 2; + google.protobuf.StringValue name = 3; + string zoneId = 4; + IntPointDto vertex = 5; + int32 radius = 6; + double theta = 7; + double sensitivity = 8; +} + message LineCellTemplateDto { string id = 1; string layer = 2; diff --git a/src/main/resources/net/rptools/maptool/client/icons/rod_takehara/ribbon/Cone Template.svg b/src/main/resources/net/rptools/maptool/client/icons/rod_takehara/ribbon/Cone Template.svg index cc3dcd3be5..d623a8dcc1 100644 --- a/src/main/resources/net/rptools/maptool/client/icons/rod_takehara/ribbon/Cone Template.svg +++ b/src/main/resources/net/rptools/maptool/client/icons/rod_takehara/ribbon/Cone Template.svg @@ -1,6 +1,6 @@ - + @@ -12,7 +12,7 @@ - + diff --git a/src/main/resources/net/rptools/maptool/client/icons/rod_takehara/ribbon/Triangle Template.svg b/src/main/resources/net/rptools/maptool/client/icons/rod_takehara/ribbon/Triangle Template.svg new file mode 100644 index 0000000000..cc3dcd3be5 --- /dev/null +++ b/src/main/resources/net/rptools/maptool/client/icons/rod_takehara/ribbon/Triangle Template.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/net/rptools/maptool/language/i18n.properties b/src/main/resources/net/rptools/maptool/language/i18n.properties index a7218b0f0d..04ffae705c 100644 --- a/src/main/resources/net/rptools/maptool/language/i18n.properties +++ b/src/main/resources/net/rptools/maptool/language/i18n.properties @@ -2888,6 +2888,8 @@ tool.polytopo.tooltip = Draw a closed polygon for vision blocking. tool.radiusCellTemplate.tooltip = Draw a radius template centered on a grid cell. tool.radiustemplate.instructions = LClick: set initial point, second LClick sets radius; Ctrl: move origin point tool.radiustemplate.tooltip = Draw a radius template. +tool.triangletemplate.instructions = LClick: set initial point, second LClick sets end point; Ctrl: move origin point +tool.triangletemplate.tooltip = Draw a triangle template. tool.rect.instructions = LClick: set initial/final point, Shift+LClick: erase rectangle; Ctrl: snap-to-grid, Alt: origin is centerpoint tool.rect.tooltip = Draw a rectangle. tool.rectexpose.instructions = LClick: set initial/final point to expose; Shift+LClick: hide rectangle; Alt: origin is centerpoint diff --git a/src/test/java/net/rptools/maptool/model/drawing/TriangleTemplatePerformanceTest.java b/src/test/java/net/rptools/maptool/model/drawing/TriangleTemplatePerformanceTest.java new file mode 100644 index 0000000000..bf8bd44a51 --- /dev/null +++ b/src/test/java/net/rptools/maptool/model/drawing/TriangleTemplatePerformanceTest.java @@ -0,0 +1,68 @@ +/* + * 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.drawing; + +import net.rptools.maptool.model.ZonePoint; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.awt.geom.Path2D; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +public class TriangleTemplatePerformanceTest { + @Test + @DisplayName("Test performance of getConePath") + void testRDrawingArea() throws Exception { + ZonePoint[] startPoints = + new ZonePoint[] { + new ZonePoint(100, -50), + new ZonePoint(50, 0), + new ZonePoint(100, 0), + new ZonePoint(150, 0), + new ZonePoint(50, 50), + new ZonePoint(100, 50), + new ZonePoint(150, 50), + new ZonePoint(100, 100), + }; + int thetaSplits = 200; + double [] testThetas = new double[thetaSplits]; + for (int i = 0; i < thetaSplits; i++) { + testThetas[i] = Math.PI / (double) i; + } + + int numberOfRadiuses = 200; + int [] testRadiuses = new int[numberOfRadiuses]; + for (int i = 1; i <= numberOfRadiuses; i++) { + testRadiuses[i-1] = i * 5; + } + + long trianglesCalculated = 0; + long startTime = System.currentTimeMillis(); + for (ZonePoint startPoint : startPoints) { + for (double theta : testThetas) { + for (int radius : testRadiuses) { + Path2D.Double conePath = TriangleTemplate.getConePath(startPoint, radius, 100, theta); + trianglesCalculated++; + assertNotNull(conePath); + } + } + } + long endTime = System.currentTimeMillis(); + System.out.print(trianglesCalculated); + System.out.print(" Triangles Calculated in Duration Using Transformed Stencil: "); + System.out.println(endTime - startTime); + } +}