diff --git a/src/main/java/com/mythlane/gravityflip/viz/RegionVisualizer.java b/src/main/java/com/mythlane/gravityflip/viz/RegionVisualizer.java
new file mode 100644
index 0000000..41af469
--- /dev/null
+++ b/src/main/java/com/mythlane/gravityflip/viz/RegionVisualizer.java
@@ -0,0 +1,214 @@
+package com.mythlane.gravityflip.viz;
+
+import com.hypixel.hytale.math.matrix.Matrix4d;
+import com.hypixel.hytale.math.shape.Box;
+import com.hypixel.hytale.math.vector.Vector3d;
+import com.hypixel.hytale.math.vector.Vector3f;
+import com.hypixel.hytale.protocol.DebugShape;
+import com.hypixel.hytale.server.core.modules.debug.DebugUtils;
+import com.hypixel.hytale.server.core.universe.world.World;
+import com.mythlane.gravityflip.region.GravityFlipRegion;
+import com.mythlane.gravityflip.region.RegionSnapshot;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Consumer;
+import java.util.function.LongSupplier;
+
+/**
+ * Plan 03-05 : service qui émet des cubes debug (DisplayDebug / ClearDebugShapes) pour
+ * matérialiser chaque région de gravity-flip côté clients. Ne crée aucun scheduler ;
+ * {@link #visualize(World, RegionSnapshot)} est appelé à chaque tick par
+ * {@code RegionTickLoop}, avec throttling par région via {@code VisualRefreshMs}.
+ *
+ *
Mapping VisualMode → flags (cf. {@link DebugUtils}) :
+ *
+ * - {@code "Outline"} → {@link DebugUtils#FLAG_NO_SOLID} (wireframe uniquement).
+ * - {@code "Faces"} → {@link DebugUtils#FLAG_NO_WIREFRAME} (faces uniquement).
+ * - {@code "Both"} → {@link DebugUtils#FLAG_NONE} (wire + solide).
+ * - {@code "None"} → skip (aucune émission).
+ * - autre / null → fallback {@code "Outline"}.
+ *
+ *
+ * Opacity caveat : {@link DebugUtils#addCube} hardcode {@code opacity=0.8} ;
+ * on passe donc par le low-level {@link DebugUtils#add(World, DebugShape, Matrix4d, Vector3f, float, float, int)}
+ * qui accepte l'opacity custom. TTL = {@code refreshMs * 1.2 / 1000} (overlap 20 %
+ * pour éviter le flicker entre deux émissions).
+ *
+ *
Threading : {@code lastEmitMs} est un {@link ConcurrentHashMap} car
+ * {@code RegionTickLoop} tourne sur un thread daemon séparé. Chaque appel
+ * {@link DebugUtils} est wrappé dans {@code world.execute(...)} pour satisfaire
+ * l'assert WorldThread (pattern identique à {@code RegionRegistry.refreshFor}).
+ *
+ *
Anti-DoS : {@code VisualRefreshMs} est clampé à un plancher pour
+ * empêcher une valeur pathologique (0 ou très petite) de saturer les clients
+ * avec des paquets {@code DisplayDebug}.
+ */
+public final class RegionVisualizer {
+
+ /** Plancher anti-DoS sur {@code VisualRefreshMs} (threat T-03-05-03). */
+ static final int MIN_REFRESH_MS = 100;
+
+ /** Testability: emitter injectable (prod = DebugUtils.add wrapper). */
+ @FunctionalInterface
+ public interface DebugEmitter {
+ void emit(World world, DebugShape shape, Matrix4d matrix,
+ Vector3f color, float opacity, float time, int flags);
+ }
+
+ /** Testability: executor injectable (prod = {@code world::execute}). */
+ @FunctionalInterface
+ public interface WorldExecutor {
+ void execute(World world, Runnable r);
+ }
+
+ private static final DebugEmitter DEFAULT_EMITTER =
+ (world, shape, matrix, color, opacity, time, flags) ->
+ DebugUtils.add(world, shape, matrix, color, opacity, time, flags);
+
+ private static final WorldExecutor DEFAULT_EXECUTOR =
+ (world, r) -> world.execute(r);
+
+ private final Consumer errorHandler;
+ private final DebugEmitter emitter;
+ private final WorldExecutor executor;
+ private final LongSupplier clock;
+ private final Map lastEmitMs = new ConcurrentHashMap<>();
+
+ public RegionVisualizer(Consumer errorHandler) {
+ this(errorHandler, DEFAULT_EMITTER, DEFAULT_EXECUTOR, System::currentTimeMillis);
+ }
+
+ /** Package-private ctor pour tests (emitter, executor, clock injectés). */
+ RegionVisualizer(Consumer errorHandler,
+ DebugEmitter emitter,
+ WorldExecutor executor,
+ LongSupplier clock) {
+ this.errorHandler = errorHandler == null ? t -> {} : errorHandler;
+ this.emitter = emitter;
+ this.executor = executor;
+ this.clock = clock;
+ }
+
+ /**
+ * Émet un cube debug pour chaque région éligible du {@code snapshot}, en respectant
+ * mode / couleur / opacity / throttling. Ne propage aucune exception : tout throw
+ * est routé vers l'errorHandler (le tick ne doit pas mourir).
+ */
+ public void visualize(World world, RegionSnapshot snapshot) {
+ if (snapshot == null) return;
+ try {
+ Map byRegion = snapshot.byRegion();
+ if (byRegion == null) return;
+ for (GravityFlipRegion r : byRegion.keySet()) {
+ emitOne(world, r);
+ }
+ } catch (Throwable th) {
+ errorHandler.accept(th);
+ }
+ }
+
+ private void emitOne(World world, GravityFlipRegion r) {
+ if (r == null || !r.isEnabled()) return;
+ String mode = normalizeMode(r.getVisualMode());
+ if ("None".equals(mode)) return;
+ int flags = flagsForMode(mode);
+
+ int refreshMs = Math.max(MIN_REFRESH_MS, r.getVisualRefreshMs());
+ long now = clock.getAsLong();
+ Long last = lastEmitMs.get(r.getName());
+ if (last != null && (now - last) < refreshMs) return;
+
+ Vector3f color = parseColor(r.getVisualColor());
+ Matrix4d matrix = matrixFromBox(r.getBox());
+ float ttlSeconds = refreshMs * 1.2f / 1000f;
+ float opacity = (float) clamp(r.getVisualOpacity(), 0.0, 1.0);
+
+ lastEmitMs.put(r.getName(), now);
+
+ try {
+ executor.execute(world, () -> {
+ try {
+ emitter.emit(world, DebugShape.Cube, matrix, color, opacity, ttlSeconds, flags);
+ } catch (Throwable th) {
+ errorHandler.accept(th);
+ }
+ });
+ } catch (Throwable th) {
+ errorHandler.accept(th);
+ }
+ }
+
+ /** Clear immédiat de toutes les shapes debug côté clients (appelé au shutdown). */
+ public void clearAll(World world) {
+ if (world == null) return;
+ try {
+ executor.execute(world, () -> {
+ try {
+ DebugUtils.clear(world);
+ } catch (Throwable th) {
+ errorHandler.accept(th);
+ }
+ });
+ } catch (Throwable th) {
+ errorHandler.accept(th);
+ }
+ }
+
+ // ---------- helpers (package-private pour tests) ----------
+
+ /** Parse {@code #RRGGBB} → Vector3f(r/255, g/255, b/255) ; fallback COLOR_CYAN sur toute erreur. */
+ static Vector3f parseColor(String hex) {
+ if (hex == null || hex.length() != 7 || hex.charAt(0) != '#') {
+ return new Vector3f(DebugUtils.COLOR_CYAN);
+ }
+ try {
+ int r = Integer.parseInt(hex.substring(1, 3), 16);
+ int g = Integer.parseInt(hex.substring(3, 5), 16);
+ int b = Integer.parseInt(hex.substring(5, 7), 16);
+ return new Vector3f(r / 255.0f, g / 255.0f, b / 255.0f);
+ } catch (NumberFormatException e) {
+ return new Vector3f(DebugUtils.COLOR_CYAN);
+ }
+ }
+
+ /** Normalise le mode : un mode inconnu → "Outline" ; null → "Outline". */
+ static String normalizeMode(String mode) {
+ if ("Outline".equals(mode) || "Faces".equals(mode) || "Both".equals(mode) || "None".equals(mode)) {
+ return mode;
+ }
+ return "Outline";
+ }
+
+ /** Mapping VisualMode → flags DebugUtils. "None" n'est jamais passé ici (skip en amont). */
+ static int flagsForMode(String mode) {
+ switch (normalizeMode(mode)) {
+ case "Faces": return DebugUtils.FLAG_NO_WIREFRAME;
+ case "Both": return DebugUtils.FLAG_NONE;
+ case "None": return DebugUtils.FLAG_NONE; // sentinel — caller skip avant.
+ case "Outline":
+ default: return DebugUtils.FLAG_NO_SOLID;
+ }
+ }
+
+ /** Identity.translate(center).scale(sizeX, sizeY, sizeZ) pour une Box non-cubique. */
+ static Matrix4d matrixFromBox(Box box) {
+ Vector3d min = box.min;
+ Vector3d max = box.max;
+ double cx = (min.x + max.x) * 0.5;
+ double cy = (min.y + max.y) * 0.5;
+ double cz = (min.z + max.z) * 0.5;
+ double sx = max.x - min.x;
+ double sy = max.y - min.y;
+ double sz = max.z - min.z;
+ Matrix4d m = new Matrix4d();
+ m.identity();
+ m.translate(cx, cy, cz);
+ m.scale(sx, sy, sz);
+ return m;
+ }
+
+ static double clamp(double v, double lo, double hi) {
+ return v < lo ? lo : (v > hi ? hi : v);
+ }
+}
diff --git a/src/test/java/com/mythlane/gravityflip/viz/RegionVisualizerTest.java b/src/test/java/com/mythlane/gravityflip/viz/RegionVisualizerTest.java
new file mode 100644
index 0000000..0b4460b
--- /dev/null
+++ b/src/test/java/com/mythlane/gravityflip/viz/RegionVisualizerTest.java
@@ -0,0 +1,222 @@
+package com.mythlane.gravityflip.viz;
+
+import com.hypixel.hytale.component.Ref;
+import com.hypixel.hytale.math.matrix.Matrix4d;
+import com.hypixel.hytale.math.shape.Box;
+import com.hypixel.hytale.math.vector.Vector3d;
+import com.hypixel.hytale.math.vector.Vector3f;
+import com.hypixel.hytale.protocol.DebugShape;
+import com.hypixel.hytale.server.core.modules.debug.DebugUtils;
+import com.hypixel.hytale.server.core.universe.world.World;
+import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
+import com.mythlane.gravityflip.region.GravityFlipRegion;
+import com.mythlane.gravityflip.region.RegionSnapshot;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicLong;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests unitaires pour {@link RegionVisualizer}. Le {@code World} n'est jamais touché
+ * (pas mockable sous JDK 25) : le {@code WorldExecutor} injecté exécute la lambda
+ * inline sans ré-entrer dans World, et le {@code DebugEmitter} pousse dans une liste.
+ */
+class RegionVisualizerTest {
+
+ private static final class Call {
+ final DebugShape shape; final Matrix4d matrix; final Vector3f color;
+ final float opacity; final float time; final int flags;
+ Call(DebugShape s, Matrix4d m, Vector3f c, float o, float t, int f) {
+ shape = s; matrix = m; color = c; opacity = o; time = t; flags = f;
+ }
+ }
+
+ // ---------- parseColor ----------
+
+ @Test
+ void parseColor_validHex() {
+ Vector3f c = RegionVisualizer.parseColor("#FF8800");
+ assertEquals(1.0f, c.x, 0.01f);
+ assertEquals(0.533f, c.y, 0.01f);
+ assertEquals(0.0f, c.z, 0.01f);
+ }
+
+ @Test
+ void parseColor_invalidHex_fallsBackToCyan() {
+ Vector3f cyan = DebugUtils.COLOR_CYAN;
+ for (String bad : new String[]{null, "", "not-a-hex", "#ZZZZZZ", "#12345", "FF8800"}) {
+ Vector3f c = RegionVisualizer.parseColor(bad);
+ assertEquals(cyan.x, c.x, 0.0001f, "input=" + bad);
+ assertEquals(cyan.y, c.y, 0.0001f, "input=" + bad);
+ assertEquals(cyan.z, c.z, 0.0001f, "input=" + bad);
+ }
+ }
+
+ // ---------- normalizeMode / flagsForMode ----------
+
+ @Test
+ void parseMode_unknown_fallsBackToOutline() {
+ assertEquals("Outline", RegionVisualizer.normalizeMode("Blah"));
+ assertEquals("Outline", RegionVisualizer.normalizeMode(null));
+ assertEquals("Faces", RegionVisualizer.normalizeMode("Faces"));
+ assertEquals("Both", RegionVisualizer.normalizeMode("Both"));
+ assertEquals("None", RegionVisualizer.normalizeMode("None"));
+ }
+
+ @Test
+ void flagsForMode_mapping() {
+ assertEquals(DebugUtils.FLAG_NO_SOLID, RegionVisualizer.flagsForMode("Outline"));
+ assertEquals(DebugUtils.FLAG_NO_WIREFRAME, RegionVisualizer.flagsForMode("Faces"));
+ assertEquals(DebugUtils.FLAG_NONE, RegionVisualizer.flagsForMode("Both"));
+ // unknown → Outline
+ assertEquals(DebugUtils.FLAG_NO_SOLID, RegionVisualizer.flagsForMode("xxx"));
+ }
+
+ // ---------- matrixFromBox ----------
+
+ @Test
+ void matrix_boxNonCubic() {
+ Box b = new Box(new Vector3d(0, 0, 0), new Vector3d(2, 4, 6));
+ Matrix4d m = RegionVisualizer.matrixFromBox(b);
+ double[] d = m.getData();
+ // column-major : scale en diag [0][5][10], translation en [12][13][14]
+ assertEquals(2.0, d[0], 1e-9);
+ assertEquals(4.0, d[5], 1e-9);
+ assertEquals(6.0, d[10], 1e-9);
+ assertEquals(1.0, d[12], 1e-9); // center x = 1
+ assertEquals(2.0, d[13], 1e-9); // center y = 2
+ assertEquals(3.0, d[14], 1e-9); // center z = 3
+ }
+
+ // ---------- visualize : throttling / modes / skip ----------
+
+ @Test
+ void visualize_throttlingSkipsSecondCallWithinWindow() {
+ List calls = new ArrayList<>();
+ AtomicLong clock = new AtomicLong(1_000L);
+ RegionVisualizer viz = new RegionVisualizer(
+ t -> fail("errorHandler unexpectedly called: " + t),
+ (w, shape, m, c, o, t, f) -> calls.add(new Call(shape, m, c, o, t, f)),
+ (w, r) -> r.run(),
+ clock::get);
+
+ GravityFlipRegion r = region("z1", "#FF8800", "Outline", 1000, 0.5);
+ RegionSnapshot snap = snapshotOf(r);
+
+ viz.visualize(null, snap);
+ assertEquals(1, calls.size(), "premier tick émet");
+
+ clock.set(1_500L); // +500ms < 1000 refreshMs
+ viz.visualize(null, snap);
+ assertEquals(1, calls.size(), "deuxième tick throttle");
+
+ clock.set(2_100L); // +1100ms >= 1000
+ viz.visualize(null, snap);
+ assertEquals(2, calls.size(), "troisième tick ré-émet après refreshMs");
+ }
+
+ @Test
+ void visualize_skipsWhenModeNone() {
+ List calls = new ArrayList<>();
+ RegionVisualizer viz = newViz(calls, 0L);
+ GravityFlipRegion r = region("z1", "#FF0000", "None", 1000, 0.5);
+ viz.visualize(null, snapshotOf(r));
+ assertTrue(calls.isEmpty());
+ }
+
+ @Test
+ void visualize_skipsWhenDisabled() {
+ List calls = new ArrayList<>();
+ RegionVisualizer viz = newViz(calls, 0L);
+ GravityFlipRegion r = region("z1", "#FF0000", "Outline", 1000, 0.5);
+ r.setEnabled(false);
+ viz.visualize(null, snapshotOf(r));
+ assertTrue(calls.isEmpty());
+ }
+
+ @Test
+ void visualize_usesCorrectFlagsAndOpacityForFaces() {
+ List calls = new ArrayList<>();
+ RegionVisualizer viz = newViz(calls, 0L);
+ GravityFlipRegion r = region("z1", "#00FF00", "Faces", 1000, 0.75);
+ viz.visualize(null, snapshotOf(r));
+ assertEquals(1, calls.size());
+ Call c = calls.get(0);
+ assertEquals(DebugShape.Cube, c.shape);
+ assertEquals(DebugUtils.FLAG_NO_WIREFRAME, c.flags);
+ assertEquals(0.75f, c.opacity, 1e-6);
+ // TTL = 1000 * 1.2 / 1000 = 1.2s
+ assertEquals(1.2f, c.time, 1e-3);
+ }
+
+ @Test
+ void visualize_clampsOpacityOutOfRange() {
+ List calls = new ArrayList<>();
+ RegionVisualizer viz = newViz(calls, 0L);
+ GravityFlipRegion r = region("z1", "#00FF00", "Both", 1000, 2.5); // > 1 → clamp à 1
+ viz.visualize(null, snapshotOf(r));
+ assertEquals(1.0f, calls.get(0).opacity, 1e-6);
+ }
+
+ @Test
+ void visualize_clampsRefreshFloorBelowMin() {
+ // refreshMs = 10 < MIN_REFRESH_MS (100) → effectif = 100ms
+ List calls = new ArrayList<>();
+ AtomicLong clock = new AtomicLong(0L);
+ RegionVisualizer viz = new RegionVisualizer(
+ t -> {},
+ (w, shape, m, c, o, t, f) -> calls.add(new Call(shape, m, c, o, t, f)),
+ (w, r) -> r.run(),
+ clock::get);
+ GravityFlipRegion r = region("z1", "#00FF00", "Outline", 10, 0.5);
+ viz.visualize(null, snapshotOf(r));
+ clock.set(50L); // 50ms < 100 plancher
+ viz.visualize(null, snapshotOf(r));
+ assertEquals(1, calls.size(), "plancher 100ms protège contre flood");
+ clock.set(150L);
+ viz.visualize(null, snapshotOf(r));
+ assertEquals(2, calls.size());
+ }
+
+ // ---------- helpers ----------
+
+ private static RegionVisualizer newViz(List calls, long now) {
+ AtomicLong clock = new AtomicLong(now);
+ return new RegionVisualizer(
+ t -> {},
+ (w, shape, m, c, o, t, f) -> calls.add(new Call(shape, m, c, o, t, f)),
+ (w, r) -> r.run(),
+ clock::get);
+ }
+
+ private static GravityFlipRegion region(String name, String color, String mode,
+ int refreshMs, double opacity) {
+ GravityFlipRegion r = new GravityFlipRegion(
+ name,
+ new Box(new Vector3d(0, 0, 0), new Vector3d(1, 1, 1)),
+ true);
+ r.setVisualColor(color);
+ r.setVisualMode(mode);
+ r.setVisualRefreshMs(refreshMs);
+ r.setVisualOpacity(opacity);
+ return r;
+ }
+
+ private static RegionSnapshot snapshotOf(GravityFlipRegion r) {
+ Map>> by = new LinkedHashMap<>();
+ by.put(r, Collections.emptyList());
+ Map>> frozen = Collections.unmodifiableMap(by);
+ return new RegionSnapshot() {
+ @Override public Map>> byRegion() { return frozen; }
+ @Override public long tickId() { return 1L; }
+ @Override public World world() { return null; }
+ };
+ }
+}