Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 5 additions & 9 deletions src/main/java/com/maxello1/whamagic/WitchHatMod.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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<java.util.List<com.maxello1.whamagic.parser.Point>> 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);
}
}
262 changes: 217 additions & 45 deletions src/main/java/com/maxello1/whamagic/magic/RingDetector.java

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions src/main/java/com/maxello1/whamagic/magic/SpellStackUpdater.java
Original file line number Diff line number Diff line change
@@ -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<List<Point>> strokes,
DataComponentType<StoredSpell> storedSpellComponent,
DataComponentType<List<List<Point>>> strokesComponent) {
stack.set(strokesComponent, strokes);
if (result.isValidSpell()) {
stack.set(storedSpellComponent, StoredSpell.fromIr(result.ir, strokes));
} else {
stack.remove(storedSpellComponent);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,9 @@ private static List<PrimitiveStrokeGroup> groupPrimitives(List<List<Point>> 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

Expand Down Expand Up @@ -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;
}
Expand Down
100 changes: 98 additions & 2 deletions src/main/java/com/maxello1/whamagic/parser/PointCloudRecognizer.java
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@
/** 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 ----

/**
Expand All @@ -81,13 +87,17 @@
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) {
Expand All @@ -96,6 +106,9 @@
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;
Expand Down Expand Up @@ -130,7 +143,7 @@
private static final List<PointCloudTemplate> templates = new ArrayList<>();

/** @deprecated Use instance methods via SymbolRecognizer interface instead. */
public static void clearTemplatesStatic() {

Check warning on line 146 in src/main/java/com/maxello1/whamagic/parser/PointCloudRecognizer.java

View workflow job for this annotation

GitHub Actions / build

[dep-ann] deprecated item is not annotated with @deprecated

Check warning on line 146 in src/main/java/com/maxello1/whamagic/parser/PointCloudRecognizer.java

View workflow job for this annotation

GitHub Actions / build

[dep-ann] deprecated item is not annotated with @deprecated
templates.clear();
}

Expand All @@ -146,7 +159,10 @@
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);
}
Expand Down Expand Up @@ -179,13 +195,38 @@
null, null, com.maxello1.whamagic.magic.RecognitionRejectionReason.NO_STROKES);
}
CloudPoint[] candidate = normalize(candidateCloud, N);
boolean candidateClosedSingleStroke = isClosedSingleStroke(strokes);
double candidateSymmetryError = candidateClosedSingleStroke ? centralSymmetryError(candidate) : 0.0;

// Match against each template of the expected kind
List<TemplateScore> scored = new ArrayList<>();
for (PointCloudTemplate tmpl : templates) {
if (tmpl.kind != expectedKind) continue;
double distance = greedyCloudMatch(candidate, tmpl.points, N);
double score = Math.max((2.0 - distance) / 2.0, 0.0);
if (candidateClosedSingleStroke && tmpl.closedSingleStroke
&& tmpl.centralSymmetryError >= 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;
Comment thread
Maxello1 marked this conversation as resolved.
} 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));
}

Expand Down Expand Up @@ -617,6 +658,61 @@

// ---- Utilities ----

private static boolean isClosedSingleStroke(List<List<Point>> strokes) {
if (strokes.size() != 1 || strokes.get(0).size() < 4) return false;
List<Point> 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.
Expand Down
79 changes: 79 additions & 0 deletions src/test/java/com/maxello1/whamagic/SpellStackUpdateTest.java
Original file line number Diff line number Diff line change
@@ -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<com.maxello1.whamagic.magic.StoredSpell> storedSpellComponent;
private static net.minecraft.core.component.DataComponentType<List<List<Point>>> 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<com.maxello1.whamagic.magic.StoredSpell>()
.persistent(com.maxello1.whamagic.magic.StoredSpell.CODEC).build();
rawStrokesComponent = new net.minecraft.core.component.DataComponentType.Builder<List<List<Point>>>()
.persistent(Point.STROKES_CODEC).build();
SpellDictionary.ensureLoaded();
}

@Test
void invalidOverwriteRemovesStoredSpellAndKeepsLatestRawStrokes() throws Exception {
List<List<Point>> validStrokes = loadStrokes(
"src/test/resources/fixtures/canonical/multi/spell_light_complete.json");
List<List<Point>> 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<List<Point>> loadStrokes(String path) throws Exception {
JsonObject fixture = GSON.fromJson(new FileReader(new File(path)), JsonObject.class);
List<List<Point>> strokes = new ArrayList<>();
for (JsonElement strokeElement : fixture.getAsJsonArray("strokes")) {
List<Point> 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;
}
}
Loading