feat(03-06): add Particles visual mode with per-region id/density (Task 3)
This commit is contained in:
@@ -88,6 +88,11 @@ public final class GravityFlipRegion {
|
|||||||
(r, v) -> r.visualRefreshMs = v, r -> r.visualRefreshMs).add()
|
(r, v) -> r.visualRefreshMs = v, r -> r.visualRefreshMs).add()
|
||||||
.append(new KeyedCodec<>("VisualOpacity", Codec.DOUBLE),
|
.append(new KeyedCodec<>("VisualOpacity", Codec.DOUBLE),
|
||||||
(r, v) -> r.visualOpacity = v, r -> r.visualOpacity).add()
|
(r, v) -> r.visualOpacity = v, r -> r.visualOpacity).add()
|
||||||
|
// --- Plan 03-06 : 2 optional particle-mode fields ---
|
||||||
|
.append(new KeyedCodec<>("VisualParticleId", Codec.STRING),
|
||||||
|
(r, v) -> r.visualParticleId = v, r -> r.visualParticleId).add()
|
||||||
|
.append(new KeyedCodec<>("VisualParticleDensity", Codec.DOUBLE),
|
||||||
|
(r, v) -> r.visualParticleDensity = v, r -> r.visualParticleDensity).add()
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// Package-private mutable fields written directly by the codec setters.
|
// Package-private mutable fields written directly by the codec setters.
|
||||||
@@ -109,6 +114,11 @@ public final class GravityFlipRegion {
|
|||||||
int visualRefreshMs = 1000;
|
int visualRefreshMs = 1000;
|
||||||
double visualOpacity = 0.5;
|
double visualOpacity = 0.5;
|
||||||
|
|
||||||
|
// Plan 03-06 : particle-mode fields — defaults chosen after Task 1 dump
|
||||||
|
// (Dust_Sparkles_Fine, density 1.0 particle/m on each of the 12 AABB edges).
|
||||||
|
String visualParticleId = "Dust_Sparkles_Fine";
|
||||||
|
double visualParticleDensity = 1.0;
|
||||||
|
|
||||||
public GravityFlipRegion() {}
|
public GravityFlipRegion() {}
|
||||||
|
|
||||||
public GravityFlipRegion(String name, Box box, boolean enabled) {
|
public GravityFlipRegion(String name, Box box, boolean enabled) {
|
||||||
@@ -155,6 +165,14 @@ public final class GravityFlipRegion {
|
|||||||
public void setVisualRefreshMs(int v) { this.visualRefreshMs = v; }
|
public void setVisualRefreshMs(int v) { this.visualRefreshMs = v; }
|
||||||
public void setVisualOpacity(double v) { this.visualOpacity = v; }
|
public void setVisualOpacity(double v) { this.visualOpacity = v; }
|
||||||
|
|
||||||
|
// --- Plan 03-06 getters / setters ---
|
||||||
|
|
||||||
|
public String getVisualParticleId() { return visualParticleId; }
|
||||||
|
public double getVisualParticleDensity() { return visualParticleDensity; }
|
||||||
|
|
||||||
|
public void setVisualParticleId(String v) { this.visualParticleId = v; }
|
||||||
|
public void setVisualParticleDensity(double v) { this.visualParticleDensity = v; }
|
||||||
|
|
||||||
/** Convenience accessor for tick-loop / physics consumers in Phase 02-02. */
|
/** Convenience accessor for tick-loop / physics consumers in Phase 02-02. */
|
||||||
public Box asBox() { return box; }
|
public Box asBox() { return box; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,95 +5,158 @@ import com.hypixel.hytale.math.shape.Box;
|
|||||||
import com.hypixel.hytale.math.vector.Vector3d;
|
import com.hypixel.hytale.math.vector.Vector3d;
|
||||||
import com.hypixel.hytale.math.vector.Vector3f;
|
import com.hypixel.hytale.math.vector.Vector3f;
|
||||||
import com.hypixel.hytale.protocol.DebugShape;
|
import com.hypixel.hytale.protocol.DebugShape;
|
||||||
|
import com.hypixel.hytale.protocol.Position;
|
||||||
|
import com.hypixel.hytale.protocol.ToClientPacket;
|
||||||
|
import com.hypixel.hytale.protocol.packets.world.SpawnParticleSystem;
|
||||||
|
import com.hypixel.hytale.server.core.asset.type.particle.config.ParticleSystem;
|
||||||
import com.hypixel.hytale.server.core.modules.debug.DebugUtils;
|
import com.hypixel.hytale.server.core.modules.debug.DebugUtils;
|
||||||
|
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||||
import com.hypixel.hytale.server.core.universe.world.World;
|
import com.hypixel.hytale.server.core.universe.world.World;
|
||||||
import com.mythlane.gravityflip.region.GravityFlipRegion;
|
import com.mythlane.gravityflip.region.GravityFlipRegion;
|
||||||
import com.mythlane.gravityflip.region.RegionSnapshot;
|
import com.mythlane.gravityflip.region.RegionSnapshot;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap.KeySetView;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
import java.util.function.LongSupplier;
|
import java.util.function.LongSupplier;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plan 03-05 : service qui émet des cubes debug (DisplayDebug / ClearDebugShapes) pour
|
* Plan 03-05 + 03-06 : service qui émet soit des cubes {@link DebugUtils} (modes
|
||||||
* matérialiser chaque région de gravity-flip côté clients. Ne crée aucun scheduler ;
|
* Outline/Faces/Both) soit des particules le long des 12 arêtes de l'AABB
|
||||||
* {@link #visualize(World, RegionSnapshot)} est appelé à chaque tick par
|
* (mode Particles) pour matérialiser chaque région de gravity-flip côté clients.
|
||||||
* {@code RegionTickLoop}, avec throttling par région via {@code VisualRefreshMs}.
|
* Ne crée aucun scheduler ; {@link #visualize(World, RegionSnapshot)} est appelé
|
||||||
|
* à chaque tick par {@code RegionTickLoop}, avec throttling par région via
|
||||||
|
* {@code VisualRefreshMs}.
|
||||||
*
|
*
|
||||||
* <p><b>Mapping VisualMode → flags</b> (cf. {@link DebugUtils}) :
|
* <p><b>Mapping VisualMode → render path</b> :
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>{@code "Outline"} → {@link DebugUtils#FLAG_NO_SOLID} (wireframe uniquement).</li>
|
* <li>{@code "Outline"} → {@link DebugUtils} cube avec {@link DebugUtils#FLAG_NO_SOLID}.</li>
|
||||||
* <li>{@code "Faces"} → {@link DebugUtils#FLAG_NO_WIREFRAME} (faces uniquement).</li>
|
* <li>{@code "Faces"} → {@link DebugUtils} cube avec {@link DebugUtils#FLAG_NO_WIREFRAME}.</li>
|
||||||
* <li>{@code "Both"} → {@link DebugUtils#FLAG_NONE} (wire + solide).</li>
|
* <li>{@code "Both"} → {@link DebugUtils} cube avec {@link DebugUtils#FLAG_NONE}.</li>
|
||||||
|
* <li>{@code "Particles"} → particules le long des 12 arêtes AABB (Plan 03-06).</li>
|
||||||
* <li>{@code "None"} → skip (aucune émission).</li>
|
* <li>{@code "None"} → skip (aucune émission).</li>
|
||||||
* <li>autre / null → fallback {@code "Outline"}.</li>
|
* <li>autre / null → fallback {@code "Outline"}.</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* <p><b>Opacity caveat</b> : {@link DebugUtils#addCube} hardcode {@code opacity=0.8} ;
|
* <p><b>Particles render path (03-06)</b> : on n'utilise pas
|
||||||
* on passe donc par le low-level {@link DebugUtils#add(World, DebugShape, Matrix4d, Vector3f, float, float, int)}
|
* {@code ParticleUtil.spawnParticleEffect} directement car sa signature exige un
|
||||||
* qui accepte l'opacity custom. TTL = {@code refreshMs * 1.2 / 1000} (overlap 20 %
|
* {@code ComponentAccessor<EntityStore>} non trivial à résoudre depuis un
|
||||||
* pour éviter le flicker entre deux émissions).
|
* {@code world.execute(...)} lambda côté plugin. À la place, on émet le packet
|
||||||
|
* {@link SpawnParticleSystem} directement vers {@code world.getPlayerRefs()}
|
||||||
|
* (exact même pattern que {@link DebugUtils#add} — cf. lignes 59-61 du
|
||||||
|
* décompilé). L'accessor n'est utilisé par ParticleUtil que pour résoudre les
|
||||||
|
* {@code PlayerRef} via {@code SpatialResource} ; puisque {@code World} expose
|
||||||
|
* déjà les {@code PlayerRef} directement, ce raccourci est sémantiquement
|
||||||
|
* équivalent pour un broadcast world-wide.
|
||||||
|
*
|
||||||
|
* <p><b>Runtime id validation</b> : avant la première émission pour un
|
||||||
|
* {@code VisualParticleId} donné, on vérifie que l'id existe dans
|
||||||
|
* {@code ParticleSystem.getAssetMap()} (pattern précédent :
|
||||||
|
* {@code ParticleSystemExistsValidator}). Id invalide → warning loggé une fois
|
||||||
|
* via {@code errorHandler}, fallback sur {@value #DEFAULT_PARTICLE_ID}.
|
||||||
*
|
*
|
||||||
* <p><b>Threading</b> : {@code lastEmitMs} est un {@link ConcurrentHashMap} car
|
* <p><b>Threading</b> : {@code lastEmitMs} est un {@link ConcurrentHashMap} car
|
||||||
* {@code RegionTickLoop} tourne sur un thread daemon séparé. Chaque appel
|
* {@code RegionTickLoop} tourne sur un thread daemon séparé. Les émissions
|
||||||
* {@link DebugUtils} est wrappé dans {@code world.execute(...)} pour satisfaire
|
* packet sont wrappées dans {@code world.execute(...)} pour respecter l'assert
|
||||||
* l'assert WorldThread (pattern identique à {@code RegionRegistry.refreshFor}).
|
* WorldThread (pattern identique à {@code RegionRegistry.refreshFor}).
|
||||||
*
|
*
|
||||||
* <p><b>Anti-DoS</b> : {@code VisualRefreshMs} est clampé à un plancher pour
|
* <p><b>Anti-DoS</b> : {@code VisualRefreshMs} est clampé à un plancher pour
|
||||||
* empêcher une valeur pathologique (0 ou très petite) de saturer les clients
|
* empêcher une valeur pathologique de saturer les clients. {@code density} est
|
||||||
* avec des paquets {@code DisplayDebug}.
|
* clampé côté {@link ParticleEdgeEmitter} à {@code [0.1, 10.0]}.
|
||||||
*/
|
*/
|
||||||
public final class RegionVisualizer {
|
public final class RegionVisualizer {
|
||||||
|
|
||||||
/** Plancher anti-DoS sur {@code VisualRefreshMs} (threat T-03-05-03). */
|
/** Plancher anti-DoS sur {@code VisualRefreshMs} (threat T-03-05-03). */
|
||||||
static final int MIN_REFRESH_MS = 100;
|
static final int MIN_REFRESH_MS = 100;
|
||||||
|
|
||||||
/** Testability: emitter injectable (prod = DebugUtils.add wrapper). */
|
/** Fallback asset-id lorsqu'une VisualParticleId invalide est détectée. */
|
||||||
|
public static final String DEFAULT_PARTICLE_ID = "Dust_Sparkles_Fine";
|
||||||
|
|
||||||
|
/** Testability: DebugUtils emitter injectable (prod = {@link DebugUtils#add} wrapper). */
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
public interface DebugEmitter {
|
public interface DebugEmitter {
|
||||||
void emit(World world, DebugShape shape, Matrix4d matrix,
|
void emit(World world, DebugShape shape, Matrix4d matrix,
|
||||||
Vector3f color, float opacity, float time, int flags);
|
Vector3f color, float opacity, float time, int flags);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Testability: particle emitter injectable (prod = direct-packet broadcast). */
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface ParticleEmitter {
|
||||||
|
void emit(World world, String id, Vector3d pos);
|
||||||
|
}
|
||||||
|
|
||||||
/** Testability: executor injectable (prod = {@code world::execute}). */
|
/** Testability: executor injectable (prod = {@code world::execute}). */
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
public interface WorldExecutor {
|
public interface WorldExecutor {
|
||||||
void execute(World world, Runnable r);
|
void execute(World world, Runnable r);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final DebugEmitter DEFAULT_EMITTER =
|
private static final DebugEmitter DEFAULT_DEBUG_EMITTER =
|
||||||
(world, shape, matrix, color, opacity, time, flags) ->
|
(world, shape, matrix, color, opacity, time, flags) ->
|
||||||
DebugUtils.add(world, shape, matrix, color, opacity, time, flags);
|
DebugUtils.add(world, shape, matrix, color, opacity, time, flags);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prod particle emitter : broadcast direct de {@link SpawnParticleSystem} à
|
||||||
|
* {@code world.getPlayerRefs()} (sans ComponentAccessor — voir javadoc de classe).
|
||||||
|
*/
|
||||||
|
private static final ParticleEmitter DEFAULT_PARTICLE_EMITTER =
|
||||||
|
(world, id, pos) -> {
|
||||||
|
SpawnParticleSystem packet = new SpawnParticleSystem(
|
||||||
|
id,
|
||||||
|
new Position(pos.x, pos.y, pos.z),
|
||||||
|
null, // no rotation
|
||||||
|
1.0f, // default scale
|
||||||
|
null); // no color override
|
||||||
|
for (PlayerRef playerRef : world.getPlayerRefs()) {
|
||||||
|
playerRef.getPacketHandler().write((ToClientPacket) packet);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
private static final WorldExecutor DEFAULT_EXECUTOR =
|
private static final WorldExecutor DEFAULT_EXECUTOR =
|
||||||
(world, r) -> world.execute(r);
|
(world, r) -> world.execute(r);
|
||||||
|
|
||||||
private final Consumer<Throwable> errorHandler;
|
private final Consumer<Throwable> errorHandler;
|
||||||
private final DebugEmitter emitter;
|
private final DebugEmitter emitter;
|
||||||
|
private final ParticleEmitter particleEmitter;
|
||||||
private final WorldExecutor executor;
|
private final WorldExecutor executor;
|
||||||
private final LongSupplier clock;
|
private final LongSupplier clock;
|
||||||
private final Map<String, Long> lastEmitMs = new ConcurrentHashMap<>();
|
private final Map<String, Long> lastEmitMs = new ConcurrentHashMap<>();
|
||||||
|
/** Ids déjà warned comme invalides — évite le log-spam par tick. */
|
||||||
|
private final KeySetView<String, Boolean> warnedInvalidIds = ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
public RegionVisualizer(Consumer<Throwable> errorHandler) {
|
public RegionVisualizer(Consumer<Throwable> errorHandler) {
|
||||||
this(errorHandler, DEFAULT_EMITTER, DEFAULT_EXECUTOR, System::currentTimeMillis);
|
this(errorHandler, DEFAULT_DEBUG_EMITTER, DEFAULT_PARTICLE_EMITTER,
|
||||||
|
DEFAULT_EXECUTOR, System::currentTimeMillis);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Package-private ctor pour tests (emitter, executor, clock injectés). */
|
/** Package-private ctor pour tests (emitter DebugUtils + executor + clock injectés). */
|
||||||
RegionVisualizer(Consumer<Throwable> errorHandler,
|
RegionVisualizer(Consumer<Throwable> errorHandler,
|
||||||
DebugEmitter emitter,
|
DebugEmitter emitter,
|
||||||
WorldExecutor executor,
|
WorldExecutor executor,
|
||||||
LongSupplier clock) {
|
LongSupplier clock) {
|
||||||
|
this(errorHandler, emitter, DEFAULT_PARTICLE_EMITTER, executor, clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Package-private ctor pour tests (tous les emitters + executor + clock injectés). */
|
||||||
|
RegionVisualizer(Consumer<Throwable> errorHandler,
|
||||||
|
DebugEmitter emitter,
|
||||||
|
ParticleEmitter particleEmitter,
|
||||||
|
WorldExecutor executor,
|
||||||
|
LongSupplier clock) {
|
||||||
this.errorHandler = errorHandler == null ? t -> {} : errorHandler;
|
this.errorHandler = errorHandler == null ? t -> {} : errorHandler;
|
||||||
this.emitter = emitter;
|
this.emitter = emitter;
|
||||||
|
this.particleEmitter = particleEmitter;
|
||||||
this.executor = executor;
|
this.executor = executor;
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Émet un cube debug pour chaque région éligible du {@code snapshot}, en respectant
|
* Émet la visualisation debug pour chaque région éligible du {@code snapshot},
|
||||||
* mode / couleur / opacity / throttling. Ne propage aucune exception : tout throw
|
* en respectant mode / couleur / opacity / throttling. Ne propage aucune
|
||||||
* est routé vers l'errorHandler (le tick ne doit pas mourir).
|
* exception : tout throw est routé vers l'errorHandler (le tick ne doit pas mourir).
|
||||||
*/
|
*/
|
||||||
public void visualize(World world, RegionSnapshot snapshot) {
|
public void visualize(World world, RegionSnapshot snapshot) {
|
||||||
if (snapshot == null) return;
|
if (snapshot == null) return;
|
||||||
@@ -112,20 +175,27 @@ public final class RegionVisualizer {
|
|||||||
if (r == null || !r.isEnabled()) return;
|
if (r == null || !r.isEnabled()) return;
|
||||||
String mode = normalizeMode(r.getVisualMode());
|
String mode = normalizeMode(r.getVisualMode());
|
||||||
if ("None".equals(mode)) return;
|
if ("None".equals(mode)) return;
|
||||||
int flags = flagsForMode(mode);
|
|
||||||
|
|
||||||
int refreshMs = Math.max(MIN_REFRESH_MS, r.getVisualRefreshMs());
|
int refreshMs = Math.max(MIN_REFRESH_MS, r.getVisualRefreshMs());
|
||||||
long now = clock.getAsLong();
|
long now = clock.getAsLong();
|
||||||
Long last = lastEmitMs.get(r.getName());
|
Long last = lastEmitMs.get(r.getName());
|
||||||
if (last != null && (now - last) < refreshMs) return;
|
if (last != null && (now - last) < refreshMs) return;
|
||||||
|
lastEmitMs.put(r.getName(), now);
|
||||||
|
|
||||||
|
if ("Particles".equals(mode)) {
|
||||||
|
emitParticles(world, r);
|
||||||
|
} else {
|
||||||
|
emitDebugCube(world, r, mode, refreshMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void emitDebugCube(World world, GravityFlipRegion r, String mode, int refreshMs) {
|
||||||
|
int flags = flagsForMode(mode);
|
||||||
Vector3f color = parseColor(r.getVisualColor());
|
Vector3f color = parseColor(r.getVisualColor());
|
||||||
Matrix4d matrix = matrixFromBox(r.getBox());
|
Matrix4d matrix = matrixFromBox(r.getBox());
|
||||||
float ttlSeconds = refreshMs * 1.2f / 1000f;
|
float ttlSeconds = refreshMs * 1.2f / 1000f;
|
||||||
float opacity = (float) clamp(r.getVisualOpacity(), 0.0, 1.0);
|
float opacity = (float) clamp(r.getVisualOpacity(), 0.0, 1.0);
|
||||||
|
|
||||||
lastEmitMs.put(r.getName(), now);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
executor.execute(world, () -> {
|
executor.execute(world, () -> {
|
||||||
try {
|
try {
|
||||||
@@ -139,6 +209,53 @@ public final class RegionVisualizer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void emitParticles(World world, GravityFlipRegion r) {
|
||||||
|
List<Vector3d> points = ParticleEdgeEmitter.edgePoints(r.getBox(), r.getVisualParticleDensity());
|
||||||
|
String requested = r.getVisualParticleId();
|
||||||
|
String particleId = resolveParticleId(requested);
|
||||||
|
|
||||||
|
try {
|
||||||
|
executor.execute(world, () -> {
|
||||||
|
for (Vector3d p : points) {
|
||||||
|
try {
|
||||||
|
particleEmitter.emit(world, particleId, p);
|
||||||
|
} catch (Throwable th) {
|
||||||
|
errorHandler.accept(th);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Throwable th) {
|
||||||
|
errorHandler.accept(th);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime validation de {@code VisualParticleId}. Id valide → retourne tel quel.
|
||||||
|
* Id inconnu → warn-once (dédup via {@link #warnedInvalidIds}) et fallback
|
||||||
|
* sur {@link #DEFAULT_PARTICLE_ID}. Si la validation elle-même throw (ex :
|
||||||
|
* AssetMap indisponible hors contexte serveur), on laisse passer l'id tel quel
|
||||||
|
* (fail-open — le serveur loggera l'erreur par packet si vraiment cassé).
|
||||||
|
*/
|
||||||
|
String resolveParticleId(String requested) {
|
||||||
|
if (requested == null || requested.isEmpty()) {
|
||||||
|
return DEFAULT_PARTICLE_ID;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (ParticleSystem.getAssetMap().getAsset(requested) != null) {
|
||||||
|
return requested;
|
||||||
|
}
|
||||||
|
} catch (Throwable th) {
|
||||||
|
// AssetMap indisponible (tests hors serveur) — fail-open.
|
||||||
|
return requested;
|
||||||
|
}
|
||||||
|
if (warnedInvalidIds.add(requested)) {
|
||||||
|
errorHandler.accept(new IllegalArgumentException(
|
||||||
|
"[RegionVisualizer] Unknown VisualParticleId '" + requested
|
||||||
|
+ "' — falling back to '" + DEFAULT_PARTICLE_ID + "'"));
|
||||||
|
}
|
||||||
|
return DEFAULT_PARTICLE_ID;
|
||||||
|
}
|
||||||
|
|
||||||
/** Clear immédiat de toutes les shapes debug côté clients (appelé au shutdown). */
|
/** Clear immédiat de toutes les shapes debug côté clients (appelé au shutdown). */
|
||||||
public void clearAll(World world) {
|
public void clearAll(World world) {
|
||||||
if (world == null) return;
|
if (world == null) return;
|
||||||
@@ -172,20 +289,22 @@ public final class RegionVisualizer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Normalise le mode : un mode inconnu → "Outline" ; null → "Outline". */
|
/** Normalise le mode : inconnu/null → "Outline". */
|
||||||
static String normalizeMode(String mode) {
|
static String normalizeMode(String mode) {
|
||||||
if ("Outline".equals(mode) || "Faces".equals(mode) || "Both".equals(mode) || "None".equals(mode)) {
|
if ("Outline".equals(mode) || "Faces".equals(mode) || "Both".equals(mode)
|
||||||
|
|| "None".equals(mode) || "Particles".equals(mode)) {
|
||||||
return mode;
|
return mode;
|
||||||
}
|
}
|
||||||
return "Outline";
|
return "Outline";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mapping VisualMode → flags DebugUtils. "None" n'est jamais passé ici (skip en amont). */
|
/** Mapping VisualMode → flags DebugUtils. "None"/"Particles" n'utilisent pas les flags. */
|
||||||
static int flagsForMode(String mode) {
|
static int flagsForMode(String mode) {
|
||||||
switch (normalizeMode(mode)) {
|
switch (normalizeMode(mode)) {
|
||||||
case "Faces": return DebugUtils.FLAG_NO_WIREFRAME;
|
case "Faces": return DebugUtils.FLAG_NO_WIREFRAME;
|
||||||
case "Both": return DebugUtils.FLAG_NONE;
|
case "Both": return DebugUtils.FLAG_NONE;
|
||||||
case "None": return DebugUtils.FLAG_NONE; // sentinel — caller skip avant.
|
case "None": return DebugUtils.FLAG_NONE; // sentinel — caller skip avant.
|
||||||
|
case "Particles": return DebugUtils.FLAG_NONE; // unused — particles ne passent pas par DebugUtils.
|
||||||
case "Outline":
|
case "Outline":
|
||||||
default: return DebugUtils.FLAG_NO_SOLID;
|
default: return DebugUtils.FLAG_NO_SOLID;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,10 +75,51 @@ class RegionVisualizerTest {
|
|||||||
assertEquals(DebugUtils.FLAG_NO_SOLID, RegionVisualizer.flagsForMode("Outline"));
|
assertEquals(DebugUtils.FLAG_NO_SOLID, RegionVisualizer.flagsForMode("Outline"));
|
||||||
assertEquals(DebugUtils.FLAG_NO_WIREFRAME, RegionVisualizer.flagsForMode("Faces"));
|
assertEquals(DebugUtils.FLAG_NO_WIREFRAME, RegionVisualizer.flagsForMode("Faces"));
|
||||||
assertEquals(DebugUtils.FLAG_NONE, RegionVisualizer.flagsForMode("Both"));
|
assertEquals(DebugUtils.FLAG_NONE, RegionVisualizer.flagsForMode("Both"));
|
||||||
|
assertEquals(DebugUtils.FLAG_NONE, RegionVisualizer.flagsForMode("Particles"));
|
||||||
// unknown → Outline
|
// unknown → Outline
|
||||||
assertEquals(DebugUtils.FLAG_NO_SOLID, RegionVisualizer.flagsForMode("xxx"));
|
assertEquals(DebugUtils.FLAG_NO_SOLID, RegionVisualizer.flagsForMode("xxx"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseMode_particlesAccepted() {
|
||||||
|
assertEquals("Particles", RegionVisualizer.normalizeMode("Particles"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Particles branch ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void visualize_particlesMode_callsParticleEmitterOncePerEdgePoint() {
|
||||||
|
List<String> particleCalls = new ArrayList<>();
|
||||||
|
RegionVisualizer.ParticleEmitter pe = (w, id, pos) -> particleCalls.add(id + "@" + pos.x + "," + pos.y + "," + pos.z);
|
||||||
|
RegionVisualizer viz = new RegionVisualizer(
|
||||||
|
t -> fail("errorHandler unexpectedly called: " + t),
|
||||||
|
(w, shape, m, c, o, t, f) -> fail("DebugEmitter should not fire in Particles mode"),
|
||||||
|
pe,
|
||||||
|
(w, r) -> r.run(),
|
||||||
|
() -> 0L);
|
||||||
|
|
||||||
|
// unit box at density=1 → 8 corner points emitted.
|
||||||
|
GravityFlipRegion r = region("pz", "#FFFFFF", "Particles", 1000, 0.5);
|
||||||
|
r.setVisualParticleId("Dust_Sparkles_Fine");
|
||||||
|
r.setVisualParticleDensity(1.0);
|
||||||
|
|
||||||
|
viz.visualize(null, snapshotOf(r));
|
||||||
|
assertEquals(8, particleCalls.size(), "unit box + density=1 → 8 corner emissions");
|
||||||
|
// All calls use the requested id (validation falls open in test context).
|
||||||
|
for (String call : particleCalls) {
|
||||||
|
assertTrue(call.startsWith("Dust_Sparkles_Fine@"), "unexpected call: " + call);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void particleDefaults_absentInConstructedRegion() {
|
||||||
|
// Defaults must match the codec's documented defaults (03-06).
|
||||||
|
GravityFlipRegion r = new GravityFlipRegion("x",
|
||||||
|
new Box(new Vector3d(0, 0, 0), new Vector3d(1, 1, 1)), true);
|
||||||
|
assertEquals("Dust_Sparkles_Fine", r.getVisualParticleId());
|
||||||
|
assertEquals(1.0, r.getVisualParticleDensity(), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- matrixFromBox ----------
|
// ---------- matrixFromBox ----------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user