From 558c9d9f5e15a88bea5cb88d3dd490e93ccf02e3 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 13 Jun 2026 03:48:43 +0100 Subject: [PATCH] refactor(style): move ShapeOutline ring math into ShapeRings (file under 500 LOC) - star / regularPolygon / directional vertex generation + the toPoints/clamp helpers move to the existing package-private ShapeRings; the public ShapeOutline factories become thin wrappers - behaviour-preserving: the 22 ShapeOutlineTest vertex pins stay green - ShapeOutline 560 -> 490 LOC; ShapeRings gains 4 direct unit tests (star, regularPolygon, directional, toPoints) Follow-up to the Path-clipper PR which pushed the file over the 500 line. --- .../compose/document/style/ShapeOutline.java | 84 ++------------ .../compose/document/style/ShapeRings.java | 103 ++++++++++++++++++ .../document/style/ShapeRingsTest.java | 50 +++++++++ 3 files changed, 160 insertions(+), 77 deletions(-) diff --git a/src/main/java/com/demcha/compose/document/style/ShapeOutline.java b/src/main/java/com/demcha/compose/document/style/ShapeOutline.java index eb1449090..3bb478b0f 100644 --- a/src/main/java/com/demcha/compose/document/style/ShapeOutline.java +++ b/src/main/java/com/demcha/compose/document/style/ShapeOutline.java @@ -1,6 +1,5 @@ package com.demcha.compose.document.style; -import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -331,25 +330,7 @@ static Polygon star(double width, double height, int points) { if (points < 3) { throw new IllegalArgumentException("star needs at least 3 points: " + points); } - double outerRadius = 0.5; - // Inner/outer ratio of a true star polygon (the inner ring sits on the - // chords between outer points); it tends to 1 as the point count grows - // and equals the classic 0.382 at five points. Below five points the - // formula degenerates, so fall back to a fixed spiky ratio. - double innerRatio = points >= 5 - ? Math.cos(2 * Math.PI / points) / Math.cos(Math.PI / points) - : 0.38; - double innerRadius = 0.5 * innerRatio; - double start = Math.PI / 2.0; // first outer vertex faces up - List vertices = new ArrayList<>(points * 2); - for (int i = 0; i < points * 2; i++) { - double radius = (i % 2 == 0) ? outerRadius : innerRadius; - double angle = start + i * Math.PI / points; - double x = clampUnit(0.5 + radius * Math.cos(angle)); - double y = clampUnit(0.5 + radius * Math.sin(angle)); - vertices.add(new ShapePoint(x, y)); - } - return new Polygon(width, height, vertices); + return new Polygon(width, height, ShapeRings.toPoints(ShapeRings.star(points))); } /** @@ -389,7 +370,7 @@ static Polygon arrow(double width, double height, Direction direction, ArrowStyl {0.00, 0.00}, {1.00, 0.50}, {0.00, 1.00} }; }; - return new Polygon(width, height, directional(base, direction)); + return new Polygon(width, height, ShapeRings.toPoints(ShapeRings.directional(base, direction))); } /** @@ -430,7 +411,7 @@ static Polygon chevron(double width, double height, Direction direction) { {0.00, 1.00}, {1.00, 0.50}, {0.00, 0.00}, {thickness, 0.00}, {1.00 - thickness, 0.50}, {thickness, 1.00} }; - return new Polygon(width, height, directional(base, direction)); + return new Polygon(width, height, ShapeRings.toPoints(ShapeRings.directional(base, direction))); } /** @@ -458,11 +439,11 @@ static Polygon checkmark(double width, double height) { static Polygon checkmark(double width, double height, CheckmarkStyle style) { Objects.requireNonNull(style, "style"); List ring = switch (style) { - case CLASSIC -> toPoints(new double[][]{ + case CLASSIC -> ShapeRings.toPoints(new double[][]{ {0.45, 0.00}, {1.00, 0.72}, {0.86, 0.92}, {0.42, 0.34}, {0.16, 0.58}, {0.04, 0.44} }); - case HEAVY -> toPoints(ShapeRings.checkmarkBand(0.13)); + case HEAVY -> ShapeRings.toPoints(ShapeRings.checkmarkBand(0.13)); }; return new Polygon(width, height, ring); } @@ -482,7 +463,7 @@ static Polygon plus(double width, double height) { {1.00, high}, {high, high}, {high, 1.00}, {low, 1.00}, {low, high}, {0.00, high}, {0.00, low}, {low, low} }; - return new Polygon(width, height, toPoints(points)); + return new Polygon(width, height, ShapeRings.toPoints(points)); } /** @@ -498,58 +479,7 @@ static Polygon regularPolygon(double width, double height, int sides) { if (sides < 3) { throw new IllegalArgumentException("regular polygon needs at least 3 sides: " + sides); } - double start = Math.PI / 2.0; - List vertices = new ArrayList<>(sides); - for (int i = 0; i < sides; i++) { - double angle = start + i * 2.0 * Math.PI / sides; - vertices.add(new ShapePoint( - clampUnit(0.5 + 0.5 * Math.cos(angle)), - clampUnit(0.5 + 0.5 * Math.sin(angle)))); - } - return new Polygon(width, height, vertices); - } - - private static List directional(double[][] base, Direction direction) { - Direction resolved = direction == null ? Direction.RIGHT : direction; - List points = new ArrayList<>(base.length); - for (double[] vertex : base) { - double x = vertex[0]; - double y = vertex[1]; - double tx; - double ty; - switch (resolved) { - case LEFT -> { - tx = 1.0 - x; - ty = y; - } - case UP -> { - tx = y; - ty = x; - } - case DOWN -> { - tx = y; - ty = 1.0 - x; - } - default -> { - tx = x; - ty = y; - } - } - points.add(new ShapePoint(clampUnit(tx), clampUnit(ty))); - } - return points; - } - - private static List toPoints(double[][] raw) { - List points = new ArrayList<>(raw.length); - for (double[] vertex : raw) { - points.add(new ShapePoint(clampUnit(vertex[0]), clampUnit(vertex[1]))); - } - return points; - } - - private static double clampUnit(double value) { - return value < 0.0 ? 0.0 : (value > 1.0 ? 1.0 : value); + return new Polygon(width, height, ShapeRings.toPoints(ShapeRings.regularPolygon(sides))); } private static void requirePositive(String label, double value) { diff --git a/src/main/java/com/demcha/compose/document/style/ShapeRings.java b/src/main/java/com/demcha/compose/document/style/ShapeRings.java index 2131ad52c..e356f9214 100644 --- a/src/main/java/com/demcha/compose/document/style/ShapeRings.java +++ b/src/main/java/com/demcha/compose/document/style/ShapeRings.java @@ -1,5 +1,8 @@ package com.demcha.compose.document.style; +import java.util.ArrayList; +import java.util.List; + /** * Package-private geometry helpers that compute raw vertex rings for the more * involved {@link ShapeOutline} figures, keeping the vector math out of the @@ -15,6 +18,106 @@ final class ShapeRings { private ShapeRings() { } + /** + * Wraps a raw vertex ring into {@link ShapePoint}s, clamping each + * coordinate into the unit box. + * + * @param raw vertex ring in unit-box coordinates (may stray slightly out) + * @return the clamped points in the same order + */ + static List toPoints(double[][] raw) { + List points = new ArrayList<>(raw.length); + for (double[] vertex : raw) { + points.add(new ShapePoint(clampUnit(vertex[0]), clampUnit(vertex[1]))); + } + return points; + } + + private static double clampUnit(double value) { + return value < 0.0 ? 0.0 : (value > 1.0 ? 1.0 : value); + } + + /** + * Builds the vertex ring of an {@code n}-pointed star inscribed in the unit + * box, first outer point facing up. Outer points sit on radius 0.5; the + * inner ring uses the true-star ratio (the inner vertices land on the + * chords between outer points) for five-plus points, falling back to a + * fixed spiky ratio below five where that formula degenerates. + * + * @param points number of outer points (caller validates {@code >= 3}) + * @return {@code 2 * points} ring vertices in draw order + */ + static double[][] star(int points) { + double innerRatio = points >= 5 + ? Math.cos(2 * Math.PI / points) / Math.cos(Math.PI / points) + : 0.38; + double innerRadius = 0.5 * innerRatio; + double start = Math.PI / 2.0; // first outer vertex faces up + double[][] ring = new double[points * 2][2]; + for (int i = 0; i < points * 2; i++) { + double radius = (i % 2 == 0) ? 0.5 : innerRadius; + double angle = start + i * Math.PI / points; + ring[i][0] = 0.5 + radius * Math.cos(angle); + ring[i][1] = 0.5 + radius * Math.sin(angle); + } + return ring; + } + + /** + * Builds the vertex ring of a regular {@code sides}-gon inscribed in the + * unit box, first vertex facing up. + * + * @param sides number of sides (caller validates {@code >= 3}) + * @return {@code sides} ring vertices in draw order + */ + static double[][] regularPolygon(int sides) { + double start = Math.PI / 2.0; + double[][] ring = new double[sides][2]; + for (int i = 0; i < sides; i++) { + double angle = start + i * 2.0 * Math.PI / sides; + ring[i][0] = 0.5 + 0.5 * Math.cos(angle); + ring[i][1] = 0.5 + 0.5 * Math.sin(angle); + } + return ring; + } + + /** + * Reorients a right-pointing base ring toward {@code direction} by the + * axis-aligned remap SVG figures use (mirror for LEFT, transpose for + * UP/DOWN). Coordinates stay in the unit box. + * + * @param base right-pointing vertex ring + * @param direction target direction; {@code null} keeps RIGHT + * @return the reoriented ring + */ + static double[][] directional(double[][] base, ShapeOutline.Direction direction) { + ShapeOutline.Direction resolved = direction == null ? ShapeOutline.Direction.RIGHT : direction; + double[][] ring = new double[base.length][2]; + for (int i = 0; i < base.length; i++) { + double x = base[i][0]; + double y = base[i][1]; + switch (resolved) { + case LEFT -> { + ring[i][0] = 1.0 - x; + ring[i][1] = y; + } + case UP -> { + ring[i][0] = y; + ring[i][1] = x; + } + case DOWN -> { + ring[i][0] = y; + ring[i][1] = 1.0 - x; + } + default -> { + ring[i][0] = x; + ring[i][1] = y; + } + } + } + return ring; + } + /** * Builds a constant-width checkmark band of perpendicular half-thickness * {@code half} around a fixed left-tip → elbow → right-tip centreline, with a diff --git a/src/test/java/com/demcha/compose/document/style/ShapeRingsTest.java b/src/test/java/com/demcha/compose/document/style/ShapeRingsTest.java index 24b0b3f39..5d7b78f4a 100644 --- a/src/test/java/com/demcha/compose/document/style/ShapeRingsTest.java +++ b/src/test/java/com/demcha/compose/document/style/ShapeRingsTest.java @@ -3,6 +3,7 @@ import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; class ShapeRingsTest { @@ -25,4 +26,53 @@ void thickerBandPushesTheOuterElbowLower() { assertThat(thick[0][1]).isLessThan(thin[0][1]); } + + @Test + void starHasTwoVerticesPerPointWithFirstFacingUp() { + double[][] ring = ShapeRings.star(5); + + assertThat(ring.length).isEqualTo(10); + // First outer vertex faces up: centred x, top y. + assertThat(ring[0][0]).isCloseTo(0.5, within(1e-9)); + assertThat(ring[0][1]).isCloseTo(1.0, within(1e-9)); + // Outer vertices (even indices) sit farther from centre than inner ones. + double outer = Math.hypot(ring[0][0] - 0.5, ring[0][1] - 0.5); + double inner = Math.hypot(ring[1][0] - 0.5, ring[1][1] - 0.5); + assertThat(outer).isGreaterThan(inner); + } + + @Test + void regularPolygonInscribesNVerticesFirstFacingUp() { + double[][] hex = ShapeRings.regularPolygon(6); + + assertThat(hex.length).isEqualTo(6); + assertThat(hex[0][0]).isCloseTo(0.5, within(1e-9)); + assertThat(hex[0][1]).isCloseTo(1.0, within(1e-9)); + for (double[] v : hex) { + assertThat(Math.hypot(v[0] - 0.5, v[1] - 0.5)).isCloseTo(0.5, within(1e-9)); + } + } + + @Test + void directionalMirrorsForLeftAndTransposesForUp() { + double[][] base = {{0.0, 0.35}, {1.0, 0.5}, {0.0, 0.65}}; + + double[][] left = ShapeRings.directional(base, ShapeOutline.Direction.LEFT); + assertThat(left[1][0]).isCloseTo(0.0, within(1e-9)); // tip mirrored to the left edge + + double[][] up = ShapeRings.directional(base, ShapeOutline.Direction.UP); + assertThat(up[1][1]).isCloseTo(1.0, within(1e-9)); // tip transposed to the top edge + + // null defaults to RIGHT (identity). + assertThat(ShapeRings.directional(base, null)[1][0]).isCloseTo(1.0, within(1e-9)); + } + + @Test + void toPointsClampsOutOfBoxCoordinates() { + var points = ShapeRings.toPoints(new double[][]{{-0.2, 0.5}, {1.3, 0.5}, {0.5, 0.5}}); + + assertThat(points.get(0).x()).isCloseTo(0.0, within(1e-9)); + assertThat(points.get(1).x()).isCloseTo(1.0, within(1e-9)); + assertThat(points.get(2).x()).isCloseTo(0.5, within(1e-9)); + } }