diff --git a/src/main/java/com/maxello1/whamagic/WitchHatMod.java b/src/main/java/com/maxello1/whamagic/WitchHatMod.java index 35626cb..ad71255 100644 --- a/src/main/java/com/maxello1/whamagic/WitchHatMod.java +++ b/src/main/java/com/maxello1/whamagic/WitchHatMod.java @@ -101,7 +101,6 @@ public void onInitialize() { com.maxello1.whamagic.parser.SpellParser.ParseResult result = com.maxello1.whamagic.parser.SpellParser.parse(payload.strokes()); net.minecraft.world.item.ItemStack newStack = stack.copy(); applyParseResultToStack(newStack, result, payload.strokes()); - newStack.set(STROKES_COMPONENT, payload.strokes()); context.player().setItemInHand(usedHand, newStack); context.player().getInventory().setChanged(); if (context.player() instanceof net.minecraft.server.level.ServerPlayer serverPlayer) { @@ -118,18 +117,15 @@ public void onInitialize() { } /** - * Apply a parse result to an item stack by setting or removing the StoredSpell component. - * Extracted so both the network handler and tests exercise the same production code path. + * Apply a parse result to an item stack by setting or removing the StoredSpell component + * and always storing the raw strokes. This represents the complete item-update operation + * so both the network handler and tests exercise the same production code path. */ public static void applyParseResultToStack( net.minecraft.world.item.ItemStack stack, com.maxello1.whamagic.parser.SpellParser.ParseResult result, java.util.List> strokes) { - if (result.isValidSpell()) { - stack.set(STORED_SPELL_COMPONENT, - com.maxello1.whamagic.magic.StoredSpell.fromIr(result.ir, strokes)); - } else { - stack.remove(STORED_SPELL_COMPONENT); - } + com.maxello1.whamagic.magic.SpellStackUpdater.applyParseResultToStack( + stack, result, strokes, STORED_SPELL_COMPONENT, STROKES_COMPONENT); } } diff --git a/src/main/java/com/maxello1/whamagic/magic/RingDetector.java b/src/main/java/com/maxello1/whamagic/magic/RingDetector.java index 8455f65..6490bc3 100644 --- a/src/main/java/com/maxello1/whamagic/magic/RingDetector.java +++ b/src/main/java/com/maxello1/whamagic/magic/RingDetector.java @@ -13,6 +13,8 @@ package com.maxello1.whamagic.magic; import com.maxello1.whamagic.parser.Point; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.HashSet; @@ -20,39 +22,75 @@ import java.util.Set; public class RingDetector { - public record RingGlyph(Point center, double radius, double completeness, boolean isClosed, double gapAngleDeg, double rmse) {} + private static final Logger LOGGER = LoggerFactory.getLogger(RingDetector.class); + + public record RingGlyph(Point center, double radius, double completeness, boolean isClosed, + double gapAngleDeg, double rmse, + double normalizedRmse, double maxNormalizedResidual, + double residualStdDev, double medianTangentAlignment, + double p90TangentAlignment, double circularity) {} public record RingDetection(RingGlyph glyph, java.util.Set ringStrokeIndices) {} + /** + * Validation thresholds for circle-vs-polygon rejection. + * Tuned against the ring_shapes fixture suite. + */ + public record RingValidationThresholds( + double maxAbsoluteRmse, + double maxNormalizedRmse, + double maxNormalizedResidual, + double maxResidualStdDev, + double maxMedianTangentAlignment, + double maxP90TangentAlignment, + double minCircularity + ) { + public static final RingValidationThresholds DEFAULTS = new RingValidationThresholds( + 0.05, // maxAbsoluteRmse — absolute RMSE threshold (existing) + 0.08, // maxNormalizedRmse — rmse / radius + 0.05, // maxNormalizedResidual — max |dist - R| / R (rough circle ≈ 0.037, octagon ≈ 0.052) + 0.10, // maxResidualStdDev — std(dist - R) / R + 0.25, // maxMedianTangentAlignment — median |dot(tangent, radius)| (octagon ≈ 0.17) + 0.25, // maxP90TangentAlignment — 90th pctl tangent (rough circle ≈ 0.14, octagon ≈ 0.30) + 0.96 // minCircularity — 4π·area/perimeter² (rough circle ≈ 0.989, octagon ≈ 0.958) + ); + } + public static RingDetection detectRing(List> strokes) { + return detectRing(strokes, RingValidationThresholds.DEFAULTS); + } + + public static RingDetection detectRing(List> strokes, RingValidationThresholds thresholds) { Set bestStrokes = null; RingGlyph bestGlyph = null; double bestScore = -1; - + int n = strokes.size(); - // Test combinations up to 4 strokes to form a ring int maxK = Math.min(n, 4); - + // Pre-resample to save time List> resampledStrokes = new ArrayList<>(); for (int i = 0; i < n; i++) { resampledStrokes.add(resample(strokes.get(i), 0.025)); } - + for (int k = 1; k <= maxK; k++) { List> combos = new ArrayList<>(); generateCombinations(n, k, 0, new ArrayList<>(), combos); - + for (List combo : combos) { - List combined = new ArrayList<>(); + // Collect per-stroke point lists (preserve stroke boundaries for tangent analysis) + List> candidateStrokes = new ArrayList<>(); + int totalPoints = 0; for (int idx : combo) { - combined.addAll(resampledStrokes.get(idx)); + List s = resampledStrokes.get(idx); + candidateStrokes.add(s); + totalPoints += s.size(); } - - if (combined.size() < 10) continue; - - RingGlyph glyph = fitCircle(combined, 45, 0.05); + + if (totalPoints < 10) continue; + + RingGlyph glyph = fitCircle(candidateStrokes, 45, thresholds); if (glyph != null && glyph.radius > 0.08 && glyph.radius < 0.8 && glyph.completeness > 0.75) { - // Favor completeness, heavily penalize RMSE, penalize using extra strokes slightly double score = glyph.completeness - (glyph.rmse * 5.0) - (k * 0.01); if (bestGlyph == null || score > bestScore) { bestGlyph = glyph; @@ -62,11 +100,25 @@ public static RingDetection detectRing(List> strokes) { } } } - - if (bestStrokes == null || bestGlyph == null) return null; + + if (bestStrokes == null || bestGlyph == null) { + LOGGER.debug("Ring detection: no ring found"); + return null; + } + LOGGER.debug("Ring detection: found ring (r={}, completeness={}, rmse={}, normRmse={}, " + + "maxResid={}, residStd={}, medTangent={}, p90Tangent={}, circ={})", + String.format("%.3f", bestGlyph.radius), + String.format("%.3f", bestGlyph.completeness), + String.format("%.4f", bestGlyph.rmse), + String.format("%.4f", bestGlyph.normalizedRmse), + String.format("%.4f", bestGlyph.maxNormalizedResidual), + String.format("%.4f", bestGlyph.residualStdDev), + String.format("%.4f", bestGlyph.medianTangentAlignment), + String.format("%.4f", bestGlyph.p90TangentAlignment), + String.format("%.4f", bestGlyph.circularity)); return new RingDetection(bestGlyph, bestStrokes); } - + private static void generateCombinations(int n, int k, int start, List current, List> result) { if (current.size() == k) { result.add(new ArrayList<>(current)); @@ -78,16 +130,35 @@ private static void generateCombinations(int n, int k, int start, List current.remove(current.size() - 1); } } - - private static RingGlyph fitCircle(List pts, double maxGapDeg, double maxRmse) { + + /** + * Fit a circle to a set of candidate strokes with comprehensive circularity validation. + * + *

The algebraic Kåsa fit operates on all points combined. Local tangent-alignment + * measurements are computed independently within each source stroke to avoid + * creating false tangent vectors across stroke boundaries.

+ * + * @param candidateStrokes per-stroke point lists (stroke boundaries preserved) + * @param maxGapDeg maximum angular gap (degrees) for closed classification + * @param thresholds validation thresholds for polygon rejection + * @return RingGlyph if the candidate passes all checks, null otherwise + */ + private static RingGlyph fitCircle(List> candidateStrokes, + double maxGapDeg, + RingValidationThresholds thresholds) { + // Flatten for global fit + List pts = new ArrayList<>(); + for (List stroke : candidateStrokes) { + pts.addAll(stroke); + } int n = pts.size(); if (n < 3) return null; - - // Least squares algebraic circle fit (Kåsa method) + + // ---- Least squares algebraic circle fit (Kåsa method) ---- double Mx = 0, My = 0; for (Point p : pts) { Mx += p.x; My += p.y; } Mx /= n; My /= n; - + double Cxx = 0, Cyy = 0, Cxy = 0, Cxz = 0, Cyz = 0, Mz = 0; for (Point p : pts) { double x = p.x - Mx; @@ -97,10 +168,10 @@ private static RingGlyph fitCircle(List pts, double maxGapDeg, double max Cxz += x * z; Cyz += y * z; Mz += z; } Cxx /= n; Cyy /= n; Cxy /= n; Cxz /= n; Cyz /= n; Mz /= n; - + double D = Cxx * Cyy - Cxy * Cxy; if (Math.abs(D) < 1e-10) return null; // Collinear - + double A = (Cxz * Cyy - Cyz * Cxy) / D; double B = (Cxx * Cyz - Cxy * Cxz) / D; double cx = A / 2.0 + Mx; @@ -108,31 +179,130 @@ private static RingGlyph fitCircle(List pts, double maxGapDeg, double max double rSq = (A / 2.0) * (A / 2.0) + (B / 2.0) * (B / 2.0) + Mz; if (rSq <= 0) return null; double R = Math.sqrt(rSq); - - // Root Mean Square Error calculation - double rmse = 0; + Point center = new Point(cx, cy); + + // ---- Radial residual analysis ---- + double sumSqErr = 0; + double maxResidual = 0; + double sumResidual = 0; + double sumSqResidual = 0; for (Point p : pts) { - double err = distance(p, new Point(cx, cy)) - R; - rmse += err * err; + double dist = distance(p, center); + double err = dist - R; + sumSqErr += err * err; + double absNormResidual = Math.abs(err) / R; + maxResidual = Math.max(maxResidual, absNormResidual); + sumResidual += err / R; + sumSqResidual += (err / R) * (err / R); + } + double rmse = Math.sqrt(sumSqErr / n); + double normalizedRmse = rmse / R; + double meanResidual = sumResidual / n; + double residualVariance = Math.max(0.0, sumSqResidual / n - meanResidual * meanResidual); + double residualStdDev = Math.sqrt(residualVariance); + + // ---- Gate 1: Absolute RMSE (existing check) ---- + if (rmse > thresholds.maxAbsoluteRmse) return null; + + // ---- Gate 2: Normalized RMSE ---- + if (normalizedRmse > thresholds.maxNormalizedRmse) return null; + + // ---- Gate 3: Maximum normalized radial residual ---- + if (maxResidual > thresholds.maxNormalizedResidual) return null; + + // ---- Gate 4: Radial residual standard deviation ---- + if (residualStdDev > thresholds.maxResidualStdDev) return null; + + // ---- Tangent-to-radius alignment (per-stroke, skip endpoints) ---- + List alignments = new ArrayList<>(); + for (List stroke : candidateStrokes) { + if (stroke.size() < 3) continue; + // Skip first and last point of each stroke + for (int i = 1; i < stroke.size() - 1; i++) { + Point prev = stroke.get(i - 1); + Point curr = stroke.get(i); + Point next = stroke.get(i + 1); + + // Local tangent vector (central difference) + double tx = next.x - prev.x; + double ty = next.y - prev.y; + double tLen = Math.sqrt(tx * tx + ty * ty); + if (tLen < 1e-10) continue; + tx /= tLen; + ty /= tLen; + + // Radius vector from center to current point + double rx = curr.x - cx; + double ry = curr.y - cy; + double rLen = Math.sqrt(rx * rx + ry * ry); + if (rLen < 1e-10) continue; + rx /= rLen; + ry /= rLen; + + // Absolute dot product: 0 = perpendicular (circle), 1 = parallel (polygon edge) + double alignment = Math.abs(tx * rx + ty * ry); + alignments.add(alignment); + } + } + + double medianTangentAlignment = 0; + double p90TangentAlignment = 0; + if (!alignments.isEmpty()) { + double[] sorted = alignments.stream().mapToDouble(Double::doubleValue).sorted().toArray(); + medianTangentAlignment = sorted[sorted.length / 2]; + int p90Index = (int) (sorted.length * 0.90); + p90TangentAlignment = sorted[Math.min(p90Index, sorted.length - 1)]; + } + + // ---- Gate 5: Tangent alignment ---- + if (medianTangentAlignment > thresholds.maxMedianTangentAlignment) return null; + if (p90TangentAlignment > thresholds.maxP90TangentAlignment) return null; + + // ---- Circularity for closed single-stroke candidates ---- + double circularity = 0; + if (candidateStrokes.size() == 1) { + List stroke = candidateStrokes.get(0); + if (stroke.size() >= 4) { + double perimeter = 0; + for (int i = 0; i < stroke.size() - 1; i++) { + perimeter += distance(stroke.get(i), stroke.get(i + 1)); + } + Point first = stroke.get(0); + Point last = stroke.get(stroke.size() - 1); + perimeter += distance(last, first); + + // Shoelace formula for area + double area = 0; + for (int i = 0; i < stroke.size() - 1; i++) { + area += stroke.get(i).x * stroke.get(i + 1).y; + area -= stroke.get(i + 1).x * stroke.get(i).y; + } + area += last.x * first.y; + area -= first.x * last.y; + area = Math.abs(area) / 2.0; + + if (perimeter > 1e-10) { + circularity = (4.0 * Math.PI * area) / (perimeter * perimeter); + } + + // ---- Gate 6: Circularity (single-stroke only) ---- + if (circularity < thresholds.minCircularity) return null; + } } - rmse = Math.sqrt(rmse / n); - - if (rmse > maxRmse) return null; - - // Calculate completeness and gap + + // ---- Angular completeness and gap ---- boolean[] bins = new boolean[360]; for (Point p : pts) { double angle = Math.toDegrees(Math.atan2(p.y - cy, p.x - cx)); if (angle < 0) angle += 360; int bin = (int) Math.round(angle) % 360; - // Pad slightly for tolerance bins[bin] = true; bins[(bin + 1) % 360] = true; bins[(bin + 359) % 360] = true; bins[(bin + 2) % 360] = true; bins[(bin + 358) % 360] = true; } - + int filled = 0; int maxGap = 0; int currentGap = 0; @@ -145,32 +315,34 @@ private static RingGlyph fitCircle(List pts, double maxGapDeg, double max currentGap++; } } - + double completeness = filled / 360.0; boolean isClosed = completeness > 0.85 && maxGap < maxGapDeg; - - return new RingGlyph(new Point(cx, cy), R, completeness, isClosed, maxGap, rmse); + + return new RingGlyph(center, R, completeness, isClosed, maxGap, rmse, + normalizedRmse, maxResidual, residualStdDev, + medianTangentAlignment, p90TangentAlignment, circularity); } - + private static List resample(List pts, double interval) { if (pts.isEmpty()) return pts; List resampled = new ArrayList<>(); resampled.add(pts.get(0)); - double D = 0; + double dAccum = 0; int i = 1; Point current = pts.get(0); while (i < pts.size()) { Point next = pts.get(i); double d = distance(current, next); - if (D + d >= interval) { - double tx = current.x + ((interval - D) / d) * (next.x - current.x); - double ty = current.y + ((interval - D) / d) * (next.y - current.y); + if (dAccum + d >= interval) { + double tx = current.x + ((interval - dAccum) / d) * (next.x - current.x); + double ty = current.y + ((interval - dAccum) / d) * (next.y - current.y); Point q = new Point(tx, ty); resampled.add(q); current = q; - D = 0; + dAccum = 0; } else { - D += d; + dAccum += d; current = next; i++; } diff --git a/src/main/java/com/maxello1/whamagic/magic/SpellStackUpdater.java b/src/main/java/com/maxello1/whamagic/magic/SpellStackUpdater.java new file mode 100644 index 0000000..d1b8572 --- /dev/null +++ b/src/main/java/com/maxello1/whamagic/magic/SpellStackUpdater.java @@ -0,0 +1,27 @@ +package com.maxello1.whamagic.magic; + +import com.maxello1.whamagic.parser.Point; +import com.maxello1.whamagic.parser.SpellParser; +import net.minecraft.core.component.DataComponentType; +import net.minecraft.world.item.ItemStack; + +import java.util.List; + +/** Applies a server parse result and its source strokes to spell paper data. */ +public final class SpellStackUpdater { + private SpellStackUpdater() {} + + public static void applyParseResultToStack( + ItemStack stack, + SpellParser.ParseResult result, + List> strokes, + DataComponentType storedSpellComponent, + DataComponentType>> strokesComponent) { + stack.set(strokesComponent, strokes); + if (result.isValidSpell()) { + stack.set(storedSpellComponent, StoredSpell.fromIr(result.ir, strokes)); + } else { + stack.remove(storedSpellComponent); + } + } +} diff --git a/src/main/java/com/maxello1/whamagic/parser/CandidateGenerator.java b/src/main/java/com/maxello1/whamagic/parser/CandidateGenerator.java index b6afa09..ad131f9 100644 --- a/src/main/java/com/maxello1/whamagic/parser/CandidateGenerator.java +++ b/src/main/java/com/maxello1/whamagic/parser/CandidateGenerator.java @@ -191,7 +191,9 @@ private static List groupPrimitives(List> stro int n = strokes.size(); double refSize = ring != null ? ring.radius() * 2 : 1.0; - double directThresh = 0.02; // direct endpoint proximity + // Detached rays and accents belonging to one symbol may not touch exactly. + // A 0.06 gap groups the current Wind geometry while keeping its nearest rim sign separate. + double directThresh = 0.06; double directThreshSq = directThresh * directThresh; double maxGroupSpan = refSize * settings.maxInternalGapRatio() * 2; // max span before splitting @@ -446,7 +448,9 @@ private static boolean isValidCandidate(SymbolCandidate cand, RingDetector.RingG if (ring != null) { if (cand.bounds().width() > ring.radius() * 2 * settings.maxCandidateWidthRatio()) return false; if (cand.bounds().height() > ring.radius() * 2 * settings.maxCandidateHeightRatio()) return false; - if (cand.angularSpan() > settings.maxAngularSpanDeg()) return false; + // Ring-relative angles are unstable for central sigils and only + // constrain outward candidates where they represent layout span. + if (cand.radialPosition() > 0.25 && cand.angularSpan() > settings.maxAngularSpanDeg()) return false; } return true; } diff --git a/src/main/java/com/maxello1/whamagic/parser/PointCloudRecognizer.java b/src/main/java/com/maxello1/whamagic/parser/PointCloudRecognizer.java index b032161..4b245e4 100644 --- a/src/main/java/com/maxello1/whamagic/parser/PointCloudRecognizer.java +++ b/src/main/java/com/maxello1/whamagic/parser/PointCloudRecognizer.java @@ -59,6 +59,12 @@ public class PointCloudRecognizer implements SymbolRecognizer { /** Weight of turning angle in $P+ distance calculation. */ private static final double ANGLE_WEIGHT = 0.15; + /** Closed templates above this value are meaningfully asymmetric. */ + private static final double ASYMMETRIC_TEMPLATE_MIN = 0.06; + + /** Reject a closed candidate when it is far more symmetric than its template. */ + private static final double MIN_ASYMMETRY_RATIO = 0.45; + // ---- Data Structures ---- /** @@ -81,13 +87,17 @@ public static class PointCloudTemplate { public final com.maxello1.whamagic.magic.SymbolKind kind; public final String element; public final CloudPoint[] points; // always length N after normalization + public final int strokeCount; // original number of strokes in the template + public final boolean closedSingleStroke; + public final double centralSymmetryError; public final com.maxello1.whamagic.magic.SigilSemantic sigilSemantic; public final com.maxello1.whamagic.magic.SignSemantic signSemantic; public final com.maxello1.whamagic.magic.SymbolRecognitionRules recognitionRules; public PointCloudTemplate(String id, String displayName, com.maxello1.whamagic.magic.SymbolKind kind, String element, - CloudPoint[] points, + CloudPoint[] points, int strokeCount, boolean closedSingleStroke, + double centralSymmetryError, com.maxello1.whamagic.magic.SigilSemantic sigilSemantic, com.maxello1.whamagic.magic.SignSemantic signSemantic, com.maxello1.whamagic.magic.SymbolRecognitionRules recognitionRules) { @@ -96,6 +106,9 @@ public PointCloudTemplate(String id, String displayName, this.kind = kind; this.element = element; this.points = points; + this.strokeCount = strokeCount; + this.closedSingleStroke = closedSingleStroke; + this.centralSymmetryError = centralSymmetryError; this.sigilSemantic = sigilSemantic; this.signSemantic = signSemantic; this.recognitionRules = recognitionRules; @@ -146,7 +159,10 @@ public static void registerTemplateStatic(String id, String displayName, com.maxello1.whamagic.magic.SymbolRecognitionRules recognitionRules) { CloudPoint[] cloud = strokesToCloud(strokes); CloudPoint[] normalized = normalize(cloud, N); - templates.add(new PointCloudTemplate(id, displayName, kind, element, normalized, + boolean closedSingleStroke = isClosedSingleStroke(strokes); + double symmetryError = closedSingleStroke ? centralSymmetryError(normalized) : 0.0; + templates.add(new PointCloudTemplate(id, displayName, kind, element, normalized, strokes.size(), + closedSingleStroke, symmetryError, sigilSemantic, signSemantic, recognitionRules)); LOGGER.debug("Registered $P template '{}' ({} strokes -> {} points)", id, strokes.size(), N); } @@ -179,6 +195,8 @@ public static RasterRecognizer.RecognitionResult recognizeStatic(List scored = new ArrayList<>(); @@ -186,6 +204,29 @@ public static RasterRecognizer.RecognitionResult recognizeStatic(List= ASYMMETRIC_TEMPLATE_MIN + && candidateSymmetryError < tmpl.centralSymmetryError * MIN_ASYMMETRY_RATIO) { + score = 0.0; + } + // Stroke-count completeness check: + // 1. Missing strokes: reject strictly. Prevents incomplete symbols (like Light missing a square) from matching. + // 2. Too many extra strokes: reject if more than double. Prevents complex drawings from matching simple 1-stroke shapes. + // 3. Moderate extra strokes: allow with mild penalty. This is CRITICAL for multi-symbol spells where + // CandidateGenerator might group multiple symbols into a single super-candidate (e.g. 7 wind + 2 column = 9 strokes). + int candidateStrokeCount = strokes.size(); + int templateStrokeCount = tmpl.strokeCount; + if (candidateStrokeCount < templateStrokeCount) { + // Missing strokes: strictly reject + score = 0; + } else if (candidateStrokeCount > templateStrokeCount * 2) { + // More than double the strokes: reject (e.g. 5-stroke candidate vs 1-stroke template) + score = 0; + } else if (candidateStrokeCount > templateStrokeCount) { + // Moderate extra strokes: mild proportional penalty + double ratio = (double) templateStrokeCount / candidateStrokeCount; + score *= Math.pow(ratio, 0.5); + } scored.add(new TemplateScore(tmpl, score, distance)); } @@ -617,6 +658,61 @@ private static void translateToOrigin(CloudPoint[] points) { // ---- Utilities ---- + private static boolean isClosedSingleStroke(List> strokes) { + if (strokes.size() != 1 || strokes.get(0).size() < 4) return false; + List stroke = strokes.get(0); + Point first = stroke.get(0); + Point last = stroke.get(stroke.size() - 1); + + double minX = Double.MAX_VALUE, minY = Double.MAX_VALUE; + double maxX = -Double.MAX_VALUE, maxY = -Double.MAX_VALUE; + for (Point point : stroke) { + minX = Math.min(minX, point.x); + minY = Math.min(minY, point.y); + maxX = Math.max(maxX, point.x); + maxY = Math.max(maxY, point.y); + } + double diagonal = Math.hypot(maxX - minX, maxY - minY); + double closureGap = Math.hypot(first.x - last.x, first.y - last.y); + return diagonal > 1e-10 && closureGap <= diagonal * 0.08; + } + + /** + * Mean distance from each point's reflection through the centroid to the + * nearest cloud point, normalized by the cloud diagonal. + */ + private static double centralSymmetryError(CloudPoint[] points) { + if (points.length == 0) return 0.0; + double cx = 0, cy = 0; + double minX = Double.MAX_VALUE, minY = Double.MAX_VALUE; + double maxX = -Double.MAX_VALUE, maxY = -Double.MAX_VALUE; + for (CloudPoint point : points) { + cx += point.x(); + cy += point.y(); + minX = Math.min(minX, point.x()); + minY = Math.min(minY, point.y()); + maxX = Math.max(maxX, point.x()); + maxY = Math.max(maxY, point.y()); + } + cx /= points.length; + cy /= points.length; + double diagonal = Math.hypot(maxX - minX, maxY - minY); + if (diagonal < 1e-10) return 0.0; + + double error = 0.0; + for (CloudPoint point : points) { + double reflectedX = 2.0 * cx - point.x(); + double reflectedY = 2.0 * cy - point.y(); + double nearest = Double.MAX_VALUE; + for (CloudPoint other : points) { + nearest = Math.min(nearest, + Math.hypot(other.x() - reflectedX, other.y() - reflectedY)); + } + error += nearest; + } + return error / points.length / diagonal; + } + /** * $P+ distance: combines spatial distance with angular difference. * Spatial distance measures position similarity. diff --git a/src/test/java/com/maxello1/whamagic/SpellStackUpdateTest.java b/src/test/java/com/maxello1/whamagic/SpellStackUpdateTest.java new file mode 100644 index 0000000..d47b43e --- /dev/null +++ b/src/test/java/com/maxello1/whamagic/SpellStackUpdateTest.java @@ -0,0 +1,79 @@ +package com.maxello1.whamagic; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.maxello1.whamagic.parser.Point; +import com.maxello1.whamagic.parser.SpellDictionary; +import com.maxello1.whamagic.parser.SpellParser; +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.item.ItemStack; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.FileReader; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class SpellStackUpdateTest { + private static final Gson GSON = new Gson(); + private static net.minecraft.core.component.DataComponentType storedSpellComponent; + private static net.minecraft.core.component.DataComponentType>> rawStrokesComponent; + + @BeforeAll + static void loadDictionary() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + net.minecraft.world.item.Items.PAPER.builtInRegistryHolder() + .bindComponents(net.minecraft.core.component.DataComponentMap.EMPTY); + storedSpellComponent = new net.minecraft.core.component.DataComponentType.Builder() + .persistent(com.maxello1.whamagic.magic.StoredSpell.CODEC).build(); + rawStrokesComponent = new net.minecraft.core.component.DataComponentType.Builder>>() + .persistent(Point.STROKES_CODEC).build(); + SpellDictionary.ensureLoaded(); + } + + @Test + void invalidOverwriteRemovesStoredSpellAndKeepsLatestRawStrokes() throws Exception { + List> validStrokes = loadStrokes( + "src/test/resources/fixtures/canonical/multi/spell_light_complete.json"); + List> invalidStrokes = loadStrokes( + "src/test/resources/fixtures/canonical/negative/neg_light_incomplete.json"); + + SpellParser.ParseResult validResult = SpellParser.parse(validStrokes); + SpellParser.ParseResult invalidResult = SpellParser.parse(invalidStrokes); + assertTrue(validResult.isValidSpell(), "Complete Light fixture must produce a valid spell"); + assertFalse(invalidResult.isValidSpell(), "Incomplete Light fixture must remain invalid"); + + ItemStack stack = new ItemStack(net.minecraft.world.item.Items.PAPER); + com.maxello1.whamagic.magic.SpellStackUpdater.applyParseResultToStack( + stack, validResult, validStrokes, storedSpellComponent, rawStrokesComponent); + assertNotNull(stack.get(storedSpellComponent)); + assertSame(validStrokes, stack.get(rawStrokesComponent)); + + com.maxello1.whamagic.magic.SpellStackUpdater.applyParseResultToStack( + stack, invalidResult, invalidStrokes, storedSpellComponent, rawStrokesComponent); + assertNull(stack.get(storedSpellComponent), + "Invalid overwrite must remove the previous compiled spell"); + assertSame(invalidStrokes, stack.get(rawStrokesComponent), + "Raw strokes must reflect the latest submitted drawing"); + } + + private static List> loadStrokes(String path) throws Exception { + JsonObject fixture = GSON.fromJson(new FileReader(new File(path)), JsonObject.class); + List> strokes = new ArrayList<>(); + for (JsonElement strokeElement : fixture.getAsJsonArray("strokes")) { + List stroke = new ArrayList<>(); + for (JsonElement pointElement : strokeElement.getAsJsonArray()) { + JsonObject point = pointElement.getAsJsonObject(); + stroke.add(new Point(point.get("x").getAsDouble(), point.get("y").getAsDouble())); + } + strokes.add(stroke); + } + return strokes; + } +} diff --git a/src/test/java/com/maxello1/whamagic/parser/RecognitionMetricsTest.java b/src/test/java/com/maxello1/whamagic/parser/RecognitionMetricsTest.java index b263526..e782359 100644 --- a/src/test/java/com/maxello1/whamagic/parser/RecognitionMetricsTest.java +++ b/src/test/java/com/maxello1/whamagic/parser/RecognitionMetricsTest.java @@ -49,6 +49,7 @@ public void produceRecognitionMetrics() throws Exception { TreeMap canonicalNegative = discoverFixtures(new File(fixturesRoot, "canonical/negative")); TreeMap canonicalMulti = discoverFixtures(new File(fixturesRoot, "canonical/multi")); TreeMap canonicalInvariance = discoverFixtures(new File(fixturesRoot, "canonical/invariance")); + TreeMap canonicalRingShapes = discoverFixtures(new File(fixturesRoot, "canonical/ring_shapes")); TreeMap holdoutPositive = discoverFixtures(new File(fixturesRoot, "holdout/positive")); TreeMap holdoutNegative = discoverFixtures(new File(fixturesRoot, "holdout/negative")); @@ -111,8 +112,11 @@ public void produceRecognitionMetrics() throws Exception { // Log ring detection for debugging if (result.ast != null && result.ast.ring() != null) { var ring = result.ast.ring(); - detailReport.append(String.format(" [RING] %s: ring detected (r=%.3f, completeness=%.3f, rmse=%.4f)\n", - file.getName(), ring.radius(), ring.completeness(), ring.rmse())); + detailReport.append(String.format(" [RING] %s: ring detected (r=%.3f, completeness=%.3f, rmse=%.4f, " + + "normRmse=%.4f, maxResid=%.4f, residStd=%.4f, medTangent=%.4f, p90Tangent=%.4f, circ=%.4f)\n", + file.getName(), ring.radius(), ring.completeness(), ring.rmse(), + ring.normalizedRmse(), ring.maxNormalizedResidual(), ring.residualStdDev(), + ring.medianTangentAlignment(), ring.p90TangentAlignment(), ring.circularity())); } // Track limits @@ -240,6 +244,7 @@ public void produceRecognitionMetrics() throws Exception { falsePositives++; falsePositiveDetails.add(file.getName() + ": got=" + String.join(", ", recognizedIds)); canonicalNegativeFailures.add(file.getName() + ": got=" + String.join(", ", recognizedIds)); + appendSelectionDiagnostics(detailReport, file.getName(), result); } } @@ -255,7 +260,9 @@ public void produceRecognitionMetrics() throws Exception { List expectedSigns = getExpectedSigns(fixture); List> strokes = parseStrokes(fixture); + long parseStartNanos = System.nanoTime(); SpellParser.ParseResult result = SpellParser.parse(strokes); + double parseDurationMs = (System.nanoTime() - parseStartNanos) / 1_000_000.0; // Determinism check SpellParser.ParseResult result2 = SpellParser.parse(strokes); @@ -282,14 +289,18 @@ public void produceRecognitionMetrics() throws Exception { String expectedLabel = formatExpected(expectedSigils, expectedSigns); List recognizedIds = getRecognizedIds(result); - report.append(String.format("%-35s expected=%-20s recognized=%-30s\n", + int recognitionCalls = result.debugResult != null ? result.debugResult.recognitionCalls() : 0; + int candidateCount = result.debugResult != null ? result.debugResult.candidateCount() : 0; + report.append(String.format("%-35s expected=%-20s recognized=%-30s calls=%-4d candidates=%-4d parseMs=%.3f\n", file.getName(), expectedLabel.isEmpty() ? "(none)" : expectedLabel, - recognizedIds.isEmpty() ? "(none)" : String.join(", ", recognizedIds))); + recognizedIds.isEmpty() ? "(none)" : String.join(", ", recognizedIds), + recognitionCalls, candidateCount, parseDurationMs)); if (!sigilMatch || !signMatch) { canonicalMultiFailures.add(file.getName() + ": expected_sigils=" + expectedSigils + " expected_signs=" + expectedSigns + " got_sigils=" + recognizedSigils + " got_signs=" + recognizedSigns); + appendSelectionDiagnostics(detailReport, file.getName(), result); } else { totalPositive++; top1Correct++; @@ -500,6 +511,16 @@ public void produceRecognitionMetrics() throws Exception { "Canonical negative fixtures must not be empty"); assertFalse(canonicalMulti.isEmpty(), "Canonical multi-symbol fixtures must not be empty"); + assertFalse(canonicalInvariance.isEmpty(), + "Canonical invariance fixtures must not be empty"); + assertFalse(canonicalRingShapes.isEmpty(), + "Canonical ring-shape fixtures must not be empty"); + assertTrue(canonicalMulti.containsKey("spell_earth_levitation_x2.json"), + "Earth + Levitation x2 fixture must exist"); + assertTrue(canonicalMulti.containsKey("spell_earth_levitation_x3.json"), + "Earth + Levitation x3 fixture must exist"); + assertTrue(canonicalMulti.containsKey("spell_messy_multi.json"), + "Messy multi-symbol fixture must exist"); // 1. Determinism: all results must be identical across runs assertTrue(deterministicFailures.isEmpty(), @@ -763,6 +784,43 @@ private String formatExpected(List sigils, List signs) { private record FixtureEntry(File file, JsonObject fixture) {} + private void appendSelectionDiagnostics(StringBuilder out, String fileName, SpellParser.ParseResult result) { + out.append(" [SELECTION DETAIL] ").append(fileName).append(':').append('\n'); + if (result.debugResult == null) { + out.append(" (no debug result)\n\n"); + return; + } + out.append(" Primitive groups: "); + for (com.maxello1.whamagic.magic.PrimitiveStrokeGroup group : result.debugResult.primitiveGroups()) { + out.append('#').append(group.id()).append(group.sourceStrokeIndices()).append(' '); + } + out.append('\n'); + out.append(" Selected candidates: "); + for (com.maxello1.whamagic.magic.SymbolCandidate candidate : result.debugResult.selectedCandidates()) { + out.append('#').append(candidate.id()) + .append(candidate.sourceStrokeIndices()) + .append(candidate.isSuperCandidate() ? "(super) " : " "); + } + out.append('\n'); + List evaluated = result.debugResult.allEvaluated(); + int limit = evaluated.size(); + for (int i = 0; i < limit; i++) { + EvaluatedCandidate eval = evaluated.get(i); + out.append(String.format( + " #%d strokes=%s super=%s sigil=%s sign=%s role=(%.3f,%.3f)%n", + eval.cand.id(), eval.cand.sourceStrokeIndices(), eval.cand.isSuperCandidate(), + recognitionSummary(eval.sigilRes), recognitionSummary(eval.signRes), + eval.sigilRoleScore, eval.signRoleScore)); + } + out.append('\n'); + } + + private String recognitionSummary(RasterRecognizer.RecognitionResult result) { + if (result == null) return "none"; + return String.format("%s/%.3f/%s/%s", result.id, result.score, + result.recognized, result.rejectionReason); + } + /** * Append detailed diagnostic information for a single positive fixture. * Finds the best alternative across all evaluated candidates and prints diff --git a/src/test/java/com/maxello1/whamagic/parser/RingDetectorTest.java b/src/test/java/com/maxello1/whamagic/parser/RingDetectorTest.java new file mode 100644 index 0000000..30af685 --- /dev/null +++ b/src/test/java/com/maxello1/whamagic/parser/RingDetectorTest.java @@ -0,0 +1,146 @@ +package com.maxello1.whamagic.parser; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.maxello1.whamagic.magic.RingDetector; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.FileReader; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Dedicated unit test for RingDetector circle-vs-polygon rejection. + * + *

Tests the detector directly — "Was this geometry detected as a ring?" — + * separately from RecognitionMetricsTest which answers "Was the entire spell + * interpreted correctly?"

+ */ +public class RingDetectorTest { + + private static final Gson GSON = new Gson(); + + @Test + public void testRingShapeFixtures() throws Exception { + File ringShapesDir = new File("src/test/resources/fixtures/canonical/ring_shapes"); + assertTrue(ringShapesDir.exists() && ringShapesDir.isDirectory(), + "Ring shapes fixture directory must exist: " + ringShapesDir.getAbsolutePath()); + + File[] fixtures = ringShapesDir.listFiles((dir, name) -> name.endsWith(".json")); + assertNotNull(fixtures); + assertFalse(fixtures.length == 0, "Ring shape fixtures must not be empty"); + + List failures = new ArrayList<>(); + StringBuilder report = new StringBuilder(); + report.append("=== RingDetector Shape Test Report ===\n\n"); + + Arrays.sort(fixtures, Comparator.comparing(File::getName)); + + for (File f : fixtures) { + JsonObject json = GSON.fromJson(new FileReader(f), JsonObject.class); + boolean expectRing = json.has("expectRing") && json.get("expectRing").getAsBoolean(); + List> strokes = parseStrokes(json.getAsJsonArray("strokes")); + + RingDetector.RingDetection detection = RingDetector.detectRing(strokes); + boolean ringDetected = detection != null; + + report.append(String.format("%-35s expect=%-5s detected=%-5s", + f.getName(), expectRing, ringDetected)); + + if (detection != null) { + var g = detection.glyph(); + report.append(String.format(" r=%.3f compl=%.3f rmse=%.4f normRmse=%.4f " + + "maxResid=%.4f residStd=%.4f medTang=%.4f p90Tang=%.4f circ=%.4f strokes=%s", + g.radius(), g.completeness(), g.rmse(), + g.normalizedRmse(), g.maxNormalizedResidual(), g.residualStdDev(), + g.medianTangentAlignment(), g.p90TangentAlignment(), g.circularity(), + detection.ringStrokeIndices())); + } + report.append("\n"); + + if (expectRing != ringDetected) { + failures.add(f.getName() + ": expectRing=" + expectRing + " detected=" + ringDetected); + } + } + + // Write report + new File("build/reports").mkdirs(); + try (var writer = new java.io.FileWriter("build/reports/ring-detector-test.txt")) { + writer.write(report.toString()); + } + + assertTrue(failures.isEmpty(), + "Ring detection mismatches:\n " + String.join("\n ", failures)); + } + + /** + * Test that spell_light_complete.json detects the actual circular stroke (index 0) + * as the ring, not Light's outer square. + */ + @Test + public void testLightCompleteRingStrokeIndex() throws Exception { + File f = new File("src/test/resources/fixtures/canonical/multi/spell_light_complete.json"); + if (!f.exists()) { + fail("spell_light_complete.json must exist"); + } + + JsonObject json = GSON.fromJson(new FileReader(f), JsonObject.class); + List> strokes = parseStrokes(json.getAsJsonArray("strokes")); + + // The fixture should have expectedRingStrokeIndices = [0] + JsonArray expectedIndices = json.has("expectedRingStrokeIndices") + ? json.getAsJsonArray("expectedRingStrokeIndices") : null; + assertNotNull(expectedIndices, "spell_light_complete.json must have expectedRingStrokeIndices"); + + Set expected = new HashSet<>(); + for (JsonElement e : expectedIndices) { + expected.add(e.getAsInt()); + } + + RingDetector.RingDetection detection = RingDetector.detectRing(strokes); + assertNotNull(detection, "Ring must be detected in spell_light_complete.json"); + + assertEquals(expected, detection.ringStrokeIndices(), + "Ring must be detected on the circular stroke (index 0), not Light's outer square. " + + "Detected ring strokes: " + detection.ringStrokeIndices()); + } + + /** + * Test that the standalone Light sigil (with outer square but no circle) + * does NOT have a ring detected. + */ + @Test + public void testStandaloneLightNoRing() throws Exception { + File f = new File("src/test/resources/fixtures/canonical/positive/sigil_light.json"); + if (!f.exists()) { + fail("sigil_light.json must exist"); + } + + JsonObject json = GSON.fromJson(new FileReader(f), JsonObject.class); + List> strokes = parseStrokes(json.getAsJsonArray("strokes")); + + RingDetector.RingDetection detection = RingDetector.detectRing(strokes); + assertNull(detection, + "Standalone Light's outer square must NOT be detected as a ring. " + + (detection != null ? "Detected ring: r=" + + String.format("%.3f", detection.glyph().radius()) + + " strokes=" + detection.ringStrokeIndices() : "")); + } + + private static List> parseStrokes(JsonArray strokesArr) { + List> strokes = new ArrayList<>(); + for (JsonElement s : strokesArr) { + List stroke = new ArrayList<>(); + for (JsonElement p : s.getAsJsonArray()) { + JsonObject pt = p.getAsJsonObject(); + stroke.add(new Point(pt.get("x").getAsDouble(), pt.get("y").getAsDouble())); + } + strokes.add(stroke); + } + return strokes; + } +} diff --git a/src/test/resources/fixtures/canonical/invariance/inv_light_reorder.json b/src/test/resources/fixtures/canonical/invariance/inv_light_reorder.json index 9d3cc6a..6fbac7b 100644 --- a/src/test/resources/fixtures/canonical/invariance/inv_light_reorder.json +++ b/src/test/resources/fixtures/canonical/invariance/inv_light_reorder.json @@ -1 +1,8 @@ -{"formatVersion": 2, "name": "inv_light_reorder", "sampleRole": "canonical_invariance", "expectedIntent": {"sigils": ["light"], "signs": []}, "expectRing": true, "expectValidSpell": true, "notes": "Invariance: reversed stroke order", "strokes": [[{"x": 0.8, "y": 0.5}, {"x": 0.82, "y": 0.5}, {"x": 0.84, "y": 0.5}, {"x": 0.86, "y": 0.5}], [{"x": 0.2, "y": 0.5}, {"x": 0.18, "y": 0.5}, {"x": 0.16, "y": 0.5}, {"x": 0.14, "y": 0.5}], [{"x": 0.5, "y": 0.8}, {"x": 0.5, "y": 0.82}, {"x": 0.5, "y": 0.84}, {"x": 0.5, "y": 0.86}], [{"x": 0.5, "y": 0.2}, {"x": 0.5, "y": 0.18}, {"x": 0.5, "y": 0.16}, {"x": 0.5, "y": 0.14}], [{"x": 0.5, "y": 0.21}, {"x": 0.65, "y": 0.36}, {"x": 0.79, "y": 0.5}, {"x": 0.65, "y": 0.64}, {"x": 0.5, "y": 0.79}, {"x": 0.35, "y": 0.64}, {"x": 0.21, "y": 0.5}, {"x": 0.35, "y": 0.36}, {"x": 0.5, "y": 0.21}], [{"x": 0.21, "y": 0.21}, {"x": 0.35, "y": 0.2}, {"x": 0.5, "y": 0.2}, {"x": 0.65, "y": 0.2}, {"x": 0.79, "y": 0.21}, {"x": 0.8, "y": 0.35}, {"x": 0.8, "y": 0.5}, {"x": 0.8, "y": 0.65}, {"x": 0.79, "y": 0.79}, {"x": 0.65, "y": 0.8}, {"x": 0.5, "y": 0.8}, {"x": 0.35, "y": 0.8}, {"x": 0.21, "y": 0.79}, {"x": 0.2, "y": 0.65}, {"x": 0.2, "y": 0.5}, {"x": 0.2, "y": 0.35}, {"x": 0.21, "y": 0.21}]]} +{"formatVersion": 2, "name": "inv_light_reorder", "sampleRole": "canonical_invariance", "expectedIntent": {"sigils": ["light"], "signs": []}, "expectRing": false, "expectValidSpell": false, "notes": "Invariance: reversed stroke order of full Light geometry", "strokes": [ + [{"x":0.800,"y":0.500},{"x":0.850,"y":0.500},{"x":0.900,"y":0.500},{"x":0.950,"y":0.500},{"x":0.980,"y":0.500}], + [{"x":0.200,"y":0.500},{"x":0.150,"y":0.500},{"x":0.100,"y":0.500},{"x":0.050,"y":0.500},{"x":0.020,"y":0.500}], + [{"x":0.500,"y":0.800},{"x":0.500,"y":0.850},{"x":0.500,"y":0.900},{"x":0.500,"y":0.950},{"x":0.500,"y":0.980}], + [{"x":0.500,"y":0.200},{"x":0.500,"y":0.150},{"x":0.500,"y":0.100},{"x":0.500,"y":0.050},{"x":0.500,"y":0.020}], + [{"x":0.500,"y":0.200},{"x":0.650,"y":0.350},{"x":0.800,"y":0.500},{"x":0.650,"y":0.650},{"x":0.500,"y":0.800},{"x":0.350,"y":0.650},{"x":0.200,"y":0.500},{"x":0.350,"y":0.350},{"x":0.500,"y":0.200}], + [{"x":0.200,"y":0.200},{"x":0.350,"y":0.200},{"x":0.500,"y":0.200},{"x":0.650,"y":0.200},{"x":0.800,"y":0.200},{"x":0.800,"y":0.350},{"x":0.800,"y":0.500},{"x":0.800,"y":0.650},{"x":0.800,"y":0.800},{"x":0.650,"y":0.800},{"x":0.500,"y":0.800},{"x":0.350,"y":0.800},{"x":0.200,"y":0.800},{"x":0.200,"y":0.650},{"x":0.200,"y":0.500},{"x":0.200,"y":0.350},{"x":0.200,"y":0.200}] +]} diff --git a/src/test/resources/fixtures/canonical/multi/spell_earth_levitation_x3.json b/src/test/resources/fixtures/canonical/multi/spell_earth_levitation_x3.json new file mode 100644 index 0000000..8e59242 --- /dev/null +++ b/src/test/resources/fixtures/canonical/multi/spell_earth_levitation_x3.json @@ -0,0 +1,19 @@ +{ + "formatVersion": 2, + "name": "spell_earth_levitation_x3", + "sampleRole": "canonical_integration_synthetic", + "expectedIntent": {"sigils": ["earth"], "signs": ["levitation", "levitation", "levitation"]}, + "expectRing": true, + "expectValidSpell": true, + "notes": "Synthetic: Earth with three independently drawn Levitation signs around a circular ring.", + "strokes": [ + [{"x":0.900,"y":0.500},{"x":0.870,"y":0.653},{"x":0.783,"y":0.783},{"x":0.653,"y":0.870},{"x":0.500,"y":0.900},{"x":0.347,"y":0.870},{"x":0.217,"y":0.783},{"x":0.130,"y":0.653},{"x":0.100,"y":0.500},{"x":0.130,"y":0.347},{"x":0.217,"y":0.217},{"x":0.347,"y":0.130},{"x":0.500,"y":0.100},{"x":0.653,"y":0.130},{"x":0.783,"y":0.217},{"x":0.870,"y":0.347},{"x":0.900,"y":0.500}], + [{"x":0.371,"y":0.443},{"x":0.431,"y":0.452},{"x":0.446,"y":0.455},{"x":0.506,"y":0.455},{"x":0.551,"y":0.452},{"x":0.581,"y":0.449},{"x":0.629,"y":0.443},{"x":0.629,"y":0.470}], + [{"x":0.371,"y":0.557},{"x":0.431,"y":0.554},{"x":0.452,"y":0.551},{"x":0.506,"y":0.548},{"x":0.551,"y":0.554},{"x":0.581,"y":0.557},{"x":0.629,"y":0.560},{"x":0.629,"y":0.530}], + [{"x":0.443,"y":0.371},{"x":0.449,"y":0.446},{"x":0.452,"y":0.506},{"x":0.452,"y":0.551},{"x":0.452,"y":0.581},{"x":0.449,"y":0.626},{"x":0.464,"y":0.629}], + [{"x":0.557,"y":0.371},{"x":0.551,"y":0.446},{"x":0.548,"y":0.506},{"x":0.551,"y":0.551},{"x":0.554,"y":0.581},{"x":0.554,"y":0.626},{"x":0.536,"y":0.629}], + [{"x":0.500,"y":0.879},{"x":0.500,"y":0.909},{"x":0.500,"y":0.939},{"x":0.500,"y":0.981}], [{"x":0.452,"y":0.981},{"x":0.500,"y":0.981},{"x":0.548,"y":0.981}], [{"x":0.461,"y":0.918},{"x":0.480,"y":0.899},{"x":0.500,"y":0.879}], [{"x":0.500,"y":0.879},{"x":0.519,"y":0.899},{"x":0.539,"y":0.918}], + [{"x":0.500,"y":0.121},{"x":0.500,"y":0.079},{"x":0.500,"y":0.049},{"x":0.500,"y":0.019}], [{"x":0.548,"y":0.121},{"x":0.500,"y":0.121},{"x":0.452,"y":0.121}], [{"x":0.500,"y":0.019},{"x":0.480,"y":0.039},{"x":0.461,"y":0.058}], [{"x":0.539,"y":0.058},{"x":0.519,"y":0.039},{"x":0.500,"y":0.019}], + [{"x":0.879,"y":0.500},{"x":0.909,"y":0.500},{"x":0.939,"y":0.500},{"x":0.981,"y":0.500}], [{"x":0.981,"y":0.548},{"x":0.981,"y":0.500},{"x":0.981,"y":0.452}], [{"x":0.918,"y":0.539},{"x":0.899,"y":0.520},{"x":0.879,"y":0.500}], [{"x":0.879,"y":0.500},{"x":0.899,"y":0.481},{"x":0.918,"y":0.461}] + ] +} diff --git a/src/test/resources/fixtures/canonical/multi/spell_light_complete.json b/src/test/resources/fixtures/canonical/multi/spell_light_complete.json new file mode 100644 index 0000000..60d60ca --- /dev/null +++ b/src/test/resources/fixtures/canonical/multi/spell_light_complete.json @@ -0,0 +1,9 @@ +{"formatVersion": 2, "name": "spell_light_complete", "sampleRole": "canonical_integration_synthetic", "expectedIntent": {"sigils": ["light"], "signs": []}, "expectRing": true, "expectValidSpell": true, "expectedRingStrokeIndices": [0], "notes": "Synthetic: circular ring + full Light sigil. Ring must be detected on stroke 0 (circle), not on Light's outer square.", "strokes": [ + [{"x":0.950,"y":0.500},{"x":0.935,"y":0.616},{"x":0.890,"y":0.725},{"x":0.818,"y":0.818},{"x":0.725,"y":0.890},{"x":0.616,"y":0.935},{"x":0.500,"y":0.950},{"x":0.384,"y":0.935},{"x":0.275,"y":0.890},{"x":0.182,"y":0.818},{"x":0.110,"y":0.725},{"x":0.065,"y":0.616},{"x":0.050,"y":0.500},{"x":0.065,"y":0.384},{"x":0.110,"y":0.275},{"x":0.182,"y":0.182},{"x":0.275,"y":0.110},{"x":0.384,"y":0.065},{"x":0.500,"y":0.050},{"x":0.616,"y":0.065},{"x":0.725,"y":0.110},{"x":0.818,"y":0.182},{"x":0.890,"y":0.275},{"x":0.935,"y":0.384},{"x":0.950,"y":0.500}], + [{"x":0.290,"y":0.290},{"x":0.395,"y":0.290},{"x":0.500,"y":0.290},{"x":0.605,"y":0.290},{"x":0.710,"y":0.290},{"x":0.710,"y":0.395},{"x":0.710,"y":0.500},{"x":0.710,"y":0.605},{"x":0.710,"y":0.710},{"x":0.605,"y":0.710},{"x":0.500,"y":0.710},{"x":0.395,"y":0.710},{"x":0.290,"y":0.710},{"x":0.290,"y":0.605},{"x":0.290,"y":0.500},{"x":0.290,"y":0.395},{"x":0.290,"y":0.290}], + [{"x":0.500,"y":0.290},{"x":0.605,"y":0.395},{"x":0.710,"y":0.500},{"x":0.605,"y":0.605},{"x":0.500,"y":0.710},{"x":0.395,"y":0.605},{"x":0.290,"y":0.500},{"x":0.395,"y":0.395},{"x":0.500,"y":0.290}], + [{"x":0.500,"y":0.290},{"x":0.500,"y":0.255},{"x":0.500,"y":0.220},{"x":0.500,"y":0.185},{"x":0.500,"y":0.164}], + [{"x":0.500,"y":0.710},{"x":0.500,"y":0.745},{"x":0.500,"y":0.780},{"x":0.500,"y":0.815},{"x":0.500,"y":0.836}], + [{"x":0.290,"y":0.500},{"x":0.255,"y":0.500},{"x":0.220,"y":0.500},{"x":0.185,"y":0.500},{"x":0.164,"y":0.500}], + [{"x":0.710,"y":0.500},{"x":0.745,"y":0.500},{"x":0.780,"y":0.500},{"x":0.815,"y":0.500},{"x":0.836,"y":0.500}] +]} diff --git a/src/test/resources/fixtures/canonical/multi/spell_messy_multi.json b/src/test/resources/fixtures/canonical/multi/spell_messy_multi.json new file mode 100644 index 0000000..3df9cb2 --- /dev/null +++ b/src/test/resources/fixtures/canonical/multi/spell_messy_multi.json @@ -0,0 +1,21 @@ +{ + "formatVersion": 2, + "name": "spell_messy_multi", + "sampleRole": "canonical_integration_synthetic", + "expectedIntent": {"sigils": ["earth"], "signs": ["levitation", "levitation"]}, + "expectRing": true, + "expectValidSpell": true, + "notes": "Deterministic synthetic stress case with jitter, reversed and interleaved strokes, a rough ring, and discardable noise.", + "strokes": [ + [{"x":0.904,"y":0.500},{"x":0.871,"y":0.650},{"x":0.779,"y":0.779},{"x":0.650,"y":0.871},{"x":0.500,"y":0.904},{"x":0.350,"y":0.871},{"x":0.221,"y":0.779},{"x":0.129,"y":0.650},{"x":0.096,"y":0.500},{"x":0.129,"y":0.350},{"x":0.221,"y":0.221},{"x":0.350,"y":0.129},{"x":0.500,"y":0.096},{"x":0.650,"y":0.129},{"x":0.779,"y":0.221},{"x":0.871,"y":0.350},{"x":0.904,"y":0.500}], + [{"x":0.537,"y":0.630},{"x":0.553,"y":0.624},{"x":0.550,"y":0.560},{"x":0.547,"y":0.500},{"x":0.552,"y":0.451},{"x":0.552,"y":0.430},{"x":0.558,"y":0.370}], + [{"x":0.500,"y":0.881},{"x":0.518,"y":0.900},{"x":0.540,"y":0.920}], [{"x":0.549,"y":0.983},{"x":0.501,"y":0.982},{"x":0.451,"y":0.980}], + [{"x":0.630,"y":0.531},{"x":0.629,"y":0.561},{"x":0.578,"y":0.557},{"x":0.550,"y":0.553},{"x":0.505,"y":0.549},{"x":0.452,"y":0.552},{"x":0.430,"y":0.555},{"x":0.370,"y":0.558}], + [{"x":0.500,"y":0.018},{"x":0.480,"y":0.038},{"x":0.459,"y":0.059}], [{"x":0.500,"y":0.122},{"x":0.500,"y":0.080},{"x":0.499,"y":0.048},{"x":0.500,"y":0.018}], + [{"x":0.463,"y":0.630},{"x":0.449,"y":0.626},{"x":0.453,"y":0.560},{"x":0.451,"y":0.500},{"x":0.449,"y":0.450},{"x":0.447,"y":0.430},{"x":0.442,"y":0.370}], + [{"x":0.540,"y":0.920},{"x":0.520,"y":0.900},{"x":0.500,"y":0.881}], [{"x":0.500,"y":0.982},{"x":0.500,"y":0.940},{"x":0.501,"y":0.910},{"x":0.500,"y":0.881}], + [{"x":0.630,"y":0.469},{"x":0.630,"y":0.442},{"x":0.580,"y":0.448},{"x":0.551,"y":0.451},{"x":0.505,"y":0.454},{"x":0.449,"y":0.450},{"x":0.430,"y":0.451},{"x":0.370,"y":0.442}], + [{"x":0.539,"y":0.059},{"x":0.519,"y":0.039},{"x":0.500,"y":0.018}], [{"x":0.451,"y":0.121},{"x":0.500,"y":0.122},{"x":0.549,"y":0.120}], + [{"x":0.724,"y":0.716},{"x":0.728,"y":0.719}] + ] +} diff --git a/src/test/resources/fixtures/canonical/negative/neg_light_incomplete.json b/src/test/resources/fixtures/canonical/negative/neg_light_incomplete.json new file mode 100644 index 0000000..952b463 --- /dev/null +++ b/src/test/resources/fixtures/canonical/negative/neg_light_incomplete.json @@ -0,0 +1,7 @@ +{"formatVersion": 2, "name": "neg_light_incomplete", "sampleRole": "canonical", "expectedIntent": {"sigils": [], "signs": []}, "expectRing": false, "expectValidSpell": false, "notes": "Incomplete Light: inner diamond + stems only, missing outer square. Must not be recognized as Light.", "strokes": [ + [{"x":0.500,"y":0.200},{"x":0.650,"y":0.350},{"x":0.800,"y":0.500},{"x":0.650,"y":0.650},{"x":0.500,"y":0.800},{"x":0.350,"y":0.650},{"x":0.200,"y":0.500},{"x":0.350,"y":0.350},{"x":0.500,"y":0.200}], + [{"x":0.500,"y":0.200},{"x":0.500,"y":0.150},{"x":0.500,"y":0.100},{"x":0.500,"y":0.050},{"x":0.500,"y":0.020}], + [{"x":0.500,"y":0.800},{"x":0.500,"y":0.850},{"x":0.500,"y":0.900},{"x":0.500,"y":0.950},{"x":0.500,"y":0.980}], + [{"x":0.200,"y":0.500},{"x":0.150,"y":0.500},{"x":0.100,"y":0.500},{"x":0.050,"y":0.500},{"x":0.020,"y":0.500}], + [{"x":0.800,"y":0.500},{"x":0.850,"y":0.500},{"x":0.900,"y":0.500},{"x":0.950,"y":0.500},{"x":0.980,"y":0.500}] +]} diff --git a/src/test/resources/fixtures/canonical/positive/sigil_light.json b/src/test/resources/fixtures/canonical/positive/sigil_light.json index 6c4e374..2ca0200 100644 --- a/src/test/resources/fixtures/canonical/positive/sigil_light.json +++ b/src/test/resources/fixtures/canonical/positive/sigil_light.json @@ -1,8 +1,8 @@ -{"formatVersion": 2, "name": "sigil_light", "sampleRole": "canonical", "expectedIntent": {"sigils": ["light"], "signs": []}, "expectRing": true, "expectValidSpell": true, "notes": "Outer square detected as ring; stems shortened to stay within ring diameter bounds", "strokes": [ - [{"x":0.21,"y":0.21},{"x":0.35,"y":0.20},{"x":0.50,"y":0.20},{"x":0.65,"y":0.20},{"x":0.79,"y":0.21},{"x":0.80,"y":0.35},{"x":0.80,"y":0.50},{"x":0.80,"y":0.65},{"x":0.79,"y":0.79},{"x":0.65,"y":0.80},{"x":0.50,"y":0.80},{"x":0.35,"y":0.80},{"x":0.21,"y":0.79},{"x":0.20,"y":0.65},{"x":0.20,"y":0.50},{"x":0.20,"y":0.35},{"x":0.21,"y":0.21}], - [{"x":0.50,"y":0.21},{"x":0.65,"y":0.36},{"x":0.79,"y":0.50},{"x":0.65,"y":0.64},{"x":0.50,"y":0.79},{"x":0.35,"y":0.64},{"x":0.21,"y":0.50},{"x":0.35,"y":0.36},{"x":0.50,"y":0.21}], - [{"x":0.50,"y":0.20},{"x":0.50,"y":0.18},{"x":0.50,"y":0.16},{"x":0.50,"y":0.14}], - [{"x":0.50,"y":0.80},{"x":0.50,"y":0.82},{"x":0.50,"y":0.84},{"x":0.50,"y":0.86}], - [{"x":0.20,"y":0.50},{"x":0.18,"y":0.50},{"x":0.16,"y":0.50},{"x":0.14,"y":0.50}], - [{"x":0.80,"y":0.50},{"x":0.82,"y":0.50},{"x":0.84,"y":0.50},{"x":0.86,"y":0.50}] +{"formatVersion": 2, "name": "sigil_light", "sampleRole": "canonical", "expectedIntent": {"sigils": ["light"], "signs": []}, "expectRing": false, "expectValidSpell": false, "notes": "Full Light sigil with outer square, inner diamond, and four cardinal stems. No spell ring present.", "strokes": [ + [{"x":0.200,"y":0.200},{"x":0.350,"y":0.200},{"x":0.500,"y":0.200},{"x":0.650,"y":0.200},{"x":0.800,"y":0.200},{"x":0.800,"y":0.350},{"x":0.800,"y":0.500},{"x":0.800,"y":0.650},{"x":0.800,"y":0.800},{"x":0.650,"y":0.800},{"x":0.500,"y":0.800},{"x":0.350,"y":0.800},{"x":0.200,"y":0.800},{"x":0.200,"y":0.650},{"x":0.200,"y":0.500},{"x":0.200,"y":0.350},{"x":0.200,"y":0.200}], + [{"x":0.500,"y":0.200},{"x":0.650,"y":0.350},{"x":0.800,"y":0.500},{"x":0.650,"y":0.650},{"x":0.500,"y":0.800},{"x":0.350,"y":0.650},{"x":0.200,"y":0.500},{"x":0.350,"y":0.350},{"x":0.500,"y":0.200}], + [{"x":0.500,"y":0.200},{"x":0.500,"y":0.150},{"x":0.500,"y":0.100},{"x":0.500,"y":0.050},{"x":0.500,"y":0.020}], + [{"x":0.500,"y":0.800},{"x":0.500,"y":0.850},{"x":0.500,"y":0.900},{"x":0.500,"y":0.950},{"x":0.500,"y":0.980}], + [{"x":0.200,"y":0.500},{"x":0.150,"y":0.500},{"x":0.100,"y":0.500},{"x":0.050,"y":0.500},{"x":0.020,"y":0.500}], + [{"x":0.800,"y":0.500},{"x":0.850,"y":0.500},{"x":0.900,"y":0.500},{"x":0.950,"y":0.500},{"x":0.980,"y":0.500}] ]} diff --git a/src/test/resources/fixtures/canonical/ring_shapes/ring_clean_circle.json b/src/test/resources/fixtures/canonical/ring_shapes/ring_clean_circle.json new file mode 100644 index 0000000..81d3092 --- /dev/null +++ b/src/test/resources/fixtures/canonical/ring_shapes/ring_clean_circle.json @@ -0,0 +1,3 @@ +{"formatVersion": 2, "name": "ring_clean_circle", "sampleRole": "canonical", "expectedIntent": {"sigils": [], "signs": []}, "expectRing": true, "expectValidSpell": false, "notes": "Single stroke, 25-point circle centered at (0.50, 0.50) with radius 0.40", "strokes": [ + [{"x":0.900,"y":0.500},{"x":0.886,"y":0.604},{"x":0.846,"y":0.700},{"x":0.783,"y":0.783},{"x":0.700,"y":0.846},{"x":0.604,"y":0.886},{"x":0.500,"y":0.900},{"x":0.396,"y":0.886},{"x":0.300,"y":0.846},{"x":0.217,"y":0.783},{"x":0.154,"y":0.700},{"x":0.114,"y":0.604},{"x":0.100,"y":0.500},{"x":0.114,"y":0.396},{"x":0.154,"y":0.300},{"x":0.217,"y":0.217},{"x":0.300,"y":0.154},{"x":0.396,"y":0.114},{"x":0.500,"y":0.100},{"x":0.604,"y":0.114},{"x":0.700,"y":0.154},{"x":0.783,"y":0.217},{"x":0.846,"y":0.300},{"x":0.886,"y":0.396},{"x":0.900,"y":0.500}] +]} diff --git a/src/test/resources/fixtures/canonical/ring_shapes/ring_diamond.json b/src/test/resources/fixtures/canonical/ring_shapes/ring_diamond.json new file mode 100644 index 0000000..c39e452 --- /dev/null +++ b/src/test/resources/fixtures/canonical/ring_shapes/ring_diamond.json @@ -0,0 +1,3 @@ +{"formatVersion": 2, "name": "ring_diamond", "sampleRole": "canonical", "expectedIntent": {"sigils": [], "signs": []}, "expectRing": false, "expectValidSpell": false, "notes": "45-degree rotated square (diamond), must be rejected as ring", "strokes": [ + [{"x":0.500,"y":0.100},{"x":0.600,"y":0.200},{"x":0.700,"y":0.300},{"x":0.800,"y":0.400},{"x":0.900,"y":0.500},{"x":0.800,"y":0.600},{"x":0.700,"y":0.700},{"x":0.600,"y":0.800},{"x":0.500,"y":0.900},{"x":0.400,"y":0.800},{"x":0.300,"y":0.700},{"x":0.200,"y":0.600},{"x":0.100,"y":0.500},{"x":0.200,"y":0.400},{"x":0.300,"y":0.300},{"x":0.400,"y":0.200},{"x":0.500,"y":0.100}] +]} diff --git a/src/test/resources/fixtures/canonical/ring_shapes/ring_incomplete_arc.json b/src/test/resources/fixtures/canonical/ring_shapes/ring_incomplete_arc.json new file mode 100644 index 0000000..6bab3aa --- /dev/null +++ b/src/test/resources/fixtures/canonical/ring_shapes/ring_incomplete_arc.json @@ -0,0 +1,3 @@ +{"formatVersion": 2, "name": "ring_incomplete_arc", "sampleRole": "canonical", "expectedIntent": {"sigils": [], "signs": []}, "expectRing": false, "expectValidSpell": false, "notes": "180-degree arc, incomplete circle", "strokes": [ + [{"x":0.900,"y":0.500},{"x":0.886,"y":0.604},{"x":0.846,"y":0.700},{"x":0.783,"y":0.783},{"x":0.700,"y":0.846},{"x":0.604,"y":0.886},{"x":0.500,"y":0.900},{"x":0.396,"y":0.886},{"x":0.300,"y":0.846},{"x":0.217,"y":0.783},{"x":0.154,"y":0.700},{"x":0.114,"y":0.604},{"x":0.100,"y":0.500}] +]} diff --git a/src/test/resources/fixtures/canonical/ring_shapes/ring_octagon.json b/src/test/resources/fixtures/canonical/ring_shapes/ring_octagon.json new file mode 100644 index 0000000..64d1db5 --- /dev/null +++ b/src/test/resources/fixtures/canonical/ring_shapes/ring_octagon.json @@ -0,0 +1,3 @@ +{"formatVersion": 2, "name": "ring_octagon", "sampleRole": "canonical", "expectedIntent": {"sigils": [], "signs": []}, "expectRing": false, "expectValidSpell": false, "notes": "Regular octagon, must be rejected as ring", "strokes": [ + [{"x":0.900,"y":0.500},{"x":0.861,"y":0.594},{"x":0.822,"y":0.689},{"x":0.783,"y":0.783},{"x":0.689,"y":0.822},{"x":0.594,"y":0.861},{"x":0.500,"y":0.900},{"x":0.406,"y":0.861},{"x":0.311,"y":0.822},{"x":0.217,"y":0.783},{"x":0.178,"y":0.689},{"x":0.139,"y":0.594},{"x":0.100,"y":0.500},{"x":0.139,"y":0.406},{"x":0.178,"y":0.311},{"x":0.217,"y":0.217},{"x":0.311,"y":0.178},{"x":0.406,"y":0.139},{"x":0.500,"y":0.100},{"x":0.594,"y":0.139},{"x":0.689,"y":0.178},{"x":0.783,"y":0.217},{"x":0.822,"y":0.311},{"x":0.861,"y":0.406},{"x":0.900,"y":0.500}] +]} diff --git a/src/test/resources/fixtures/canonical/ring_shapes/ring_rough_circle.json b/src/test/resources/fixtures/canonical/ring_shapes/ring_rough_circle.json new file mode 100644 index 0000000..3806d48 --- /dev/null +++ b/src/test/resources/fixtures/canonical/ring_shapes/ring_rough_circle.json @@ -0,0 +1,3 @@ +{"formatVersion": 2, "name": "ring_rough_circle", "sampleRole": "canonical", "expectedIntent": {"sigils": [], "signs": []}, "expectRing": true, "expectValidSpell": false, "notes": "Circle with deterministic hand-drawn-like radial noise (±0.01)", "strokes": [ + [{"x":0.908,"y":0.500},{"x":0.893,"y":0.601},{"x":0.846,"y":0.693},{"x":0.776,"y":0.768},{"x":0.691,"y":0.841},{"x":0.598,"y":0.886},{"x":0.500,"y":0.910},{"x":0.403,"y":0.893},{"x":0.310,"y":0.846},{"x":0.231,"y":0.778},{"x":0.160,"y":0.694},{"x":0.115,"y":0.601},{"x":0.092,"y":0.500},{"x":0.108,"y":0.399},{"x":0.155,"y":0.307},{"x":0.226,"y":0.226},{"x":0.312,"y":0.158},{"x":0.401,"y":0.112},{"x":0.500,"y":0.092},{"x":0.599,"y":0.110},{"x":0.693,"y":0.154},{"x":0.773,"y":0.224},{"x":0.846,"y":0.308},{"x":0.893,"y":0.400},{"x":0.908,"y":0.500}] +]} diff --git a/src/test/resources/fixtures/canonical/ring_shapes/ring_rounded_square.json b/src/test/resources/fixtures/canonical/ring_shapes/ring_rounded_square.json new file mode 100644 index 0000000..e39993f --- /dev/null +++ b/src/test/resources/fixtures/canonical/ring_shapes/ring_rounded_square.json @@ -0,0 +1,3 @@ +{"formatVersion": 2, "name": "ring_rounded_square", "sampleRole": "canonical", "expectedIntent": {"sigils": [], "signs": []}, "expectRing": false, "expectValidSpell": false, "notes": "Square with rounded corners, must be rejected as ring", "strokes": [ + [{"x":0.230,"y":0.200},{"x":0.350,"y":0.200},{"x":0.500,"y":0.200},{"x":0.650,"y":0.200},{"x":0.770,"y":0.200},{"x":0.800,"y":0.230},{"x":0.800,"y":0.350},{"x":0.800,"y":0.500},{"x":0.800,"y":0.650},{"x":0.800,"y":0.770},{"x":0.770,"y":0.800},{"x":0.650,"y":0.800},{"x":0.500,"y":0.800},{"x":0.350,"y":0.800},{"x":0.230,"y":0.800},{"x":0.200,"y":0.770},{"x":0.200,"y":0.650},{"x":0.200,"y":0.500},{"x":0.200,"y":0.350},{"x":0.200,"y":0.230},{"x":0.230,"y":0.200}] +]} diff --git a/src/test/resources/fixtures/canonical/ring_shapes/ring_square.json b/src/test/resources/fixtures/canonical/ring_shapes/ring_square.json new file mode 100644 index 0000000..97110a5 --- /dev/null +++ b/src/test/resources/fixtures/canonical/ring_shapes/ring_square.json @@ -0,0 +1,3 @@ +{"formatVersion": 2, "name": "ring_square", "sampleRole": "canonical", "expectedIntent": {"sigils": [], "signs": []}, "expectRing": false, "expectValidSpell": false, "notes": "Axis-aligned square, must be rejected as ring", "strokes": [ + [{"x":0.200,"y":0.200},{"x":0.350,"y":0.200},{"x":0.500,"y":0.200},{"x":0.650,"y":0.200},{"x":0.800,"y":0.200},{"x":0.800,"y":0.350},{"x":0.800,"y":0.500},{"x":0.800,"y":0.650},{"x":0.800,"y":0.800},{"x":0.650,"y":0.800},{"x":0.500,"y":0.800},{"x":0.350,"y":0.800},{"x":0.200,"y":0.800},{"x":0.200,"y":0.650},{"x":0.200,"y":0.500},{"x":0.200,"y":0.350},{"x":0.200,"y":0.200}] +]}