fix(ui-map): move fog overlay to a viewport-level layer below the copies
Two regressions surfaced once visible-hyperspace toggled on a real
dev-deploy map:
1. On the zero-turn map the bg holes painted ON TOP of the planet
glyphs — every LOCAL planet looked like a hollow circle of
background colour instead of the planet pixel inside an
unfogged area.
2. On a legacy report with a drive tech that pushes the visibility
radius well past the world dimensions the bg circles overlapped
to cover the entire viewport. Combined with the wrong z-order
the result was a uniformly black canvas with every primitive
hidden.
The per-copy implementation added the fog container via
`copy.addChildAt(container, 0)` and trusted Pixi v8 to insert the
container at the start of the copy's children. Whether by a Pixi
quirk or by some interaction with how `populatePrimitives` orders
its `c.addChild(g)` calls, the fog ended up rendering after every
primitive in practice — the symptoms above are a perfect match for
that ordering.
Restructured the fog rendering so the z-order is structural
rather than relying on `addChildAt`:
- A single `fogLayer: Container` is added to the viewport BEFORE
the nine torus copies. Pixi renders viewport children in order,
so the layer is guaranteed to paint first; every copy renders
on top.
- `fogPaintOps` now emits world-space coordinates with wrap
offsets baked in (9 fog rects + 9 bg circles per visibility
entry in torus mode, 1 + N in no-wrap mode). The renderer
populates `fogLayer` with one `Graphics` per op — no per-copy
iteration on the fog side.
- The previous `fogGraphics: Container[]` closure state is gone.
Each `setVisibilityFog` flip drops every child of `fogLayer`
and rebuilds it. The dispose path drops the children
eagerly before `app.destroy({children: true})` walks the tree.
The fog-paint-ops test exercises the new contract: the no-wrap
path keeps one rect + N circles, the torus path expands to nine
rects + nine wrapped circles per entry (including the seam-fix
case at x = 950).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -243,29 +243,26 @@ export type FogPaintOp =
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* fogPaintOps returns the ordered sequence of paint operations that
|
* fogPaintOps returns the ordered sequence of paint operations that
|
||||||
* draw the Phase 29 visible-hyperspace overlay on a single torus
|
* draw the Phase 29 visible-hyperspace overlay. The renderer
|
||||||
* copy. The first op is the fog-coloured rectangle covering the
|
* dispatches each op onto its own Pixi `Graphics` inside a single
|
||||||
* full world; subsequent ops are background-coloured circles, one
|
* `fogLayer` that sits below every primitive copy, so the natural
|
||||||
* per visibility circle, painted on top of the fog rectangle. The
|
* rendering order paints fog underneath the world.
|
||||||
* natural rendering order unions overlapping circles for free —
|
*
|
||||||
* earlier iterations relied on Pixi v8's `Graphics.cut()` to
|
* Coordinates are in world space (the `fogLayer` has no transform),
|
||||||
* subtract holes, but `cut()` produced incorrect unions for
|
* which means the wrap offsets are baked directly into the
|
||||||
* overlapping circles (the symptom was a handful of disconnected
|
* positions — there is no per-tile dispatch on the renderer side.
|
||||||
* arc segments instead of a clean union).
|
|
||||||
*
|
*
|
||||||
* `mode` controls the torus-wrap behaviour:
|
* `mode` controls the torus-wrap behaviour:
|
||||||
*
|
*
|
||||||
* - `"torus"`: every visibility circle is also drawn at the eight
|
* - `"torus"`: every fog rect AND every visibility circle is
|
||||||
* wrapped positions (±width, ±height) so the circle remains
|
* emitted at the nine offsets (`(dx, dy) ∈ {-1, 0, 1}²`), so
|
||||||
* visually continuous when its painted area extends past the
|
* the fog covers all nine torus tiles and a planet near a seam
|
||||||
* world rectangle into a neighbouring tile — without the wraps
|
* keeps a continuous visibility hole across it.
|
||||||
* the next tile's fog rectangle overpaints the bleed, producing
|
* - `"no-wrap"`: only the central tile is emitted. The user can
|
||||||
* a "sector" artifact at the seam.
|
* never pan past the boundary in no-wrap mode, so the
|
||||||
* - `"no-wrap"`: only the planet's own position is drawn. The
|
* additional wraps would just be wasted paint — worse, a
|
||||||
* wrapped positions would create extra holes inside the world
|
* wrapped circle from a planet near an edge would leak into
|
||||||
* rectangle when a planet sits near an edge (the user can never
|
* the visible world rectangle as an unwanted hole.
|
||||||
* pan past the boundary in no-wrap mode, but the wrapped circle
|
|
||||||
* could still leak into the visible area).
|
|
||||||
*
|
*
|
||||||
* Empty `circles` returns an empty list — the caller skips fog
|
* Empty `circles` returns an empty list — the caller skips fog
|
||||||
* rendering entirely. Width/height ≤ 0 also returns empty so a
|
* rendering entirely. Width/height ≤ 0 also returns empty so a
|
||||||
@@ -282,17 +279,18 @@ export function fogPaintOps(
|
|||||||
if (world.width <= 0 || world.height <= 0) return [];
|
if (world.width <= 0 || world.height <= 0) return [];
|
||||||
const offsets: ReadonlyArray<readonly [number, number]> =
|
const offsets: ReadonlyArray<readonly [number, number]> =
|
||||||
mode === "torus" ? TORUS_OFFSETS : ORIGIN_ONLY_OFFSET;
|
mode === "torus" ? TORUS_OFFSETS : ORIGIN_ONLY_OFFSET;
|
||||||
const ops: FogPaintOp[] = [
|
const ops: FogPaintOp[] = [];
|
||||||
{
|
for (const [dx, dy] of offsets) {
|
||||||
|
ops.push({
|
||||||
kind: "fillRect",
|
kind: "fillRect",
|
||||||
x: 0,
|
x: dx * world.width,
|
||||||
y: 0,
|
y: dy * world.height,
|
||||||
width: world.width,
|
width: world.width,
|
||||||
height: world.height,
|
height: world.height,
|
||||||
color: fogColor,
|
color: fogColor,
|
||||||
alpha: 1,
|
alpha: 1,
|
||||||
},
|
});
|
||||||
];
|
}
|
||||||
for (const c of circles) {
|
for (const c of circles) {
|
||||||
for (const [dx, dy] of offsets) {
|
for (const [dx, dy] of offsets) {
|
||||||
ops.push({
|
ops.push({
|
||||||
@@ -343,6 +341,17 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
|
|
||||||
app.stage.addChild(viewport);
|
app.stage.addChild(viewport);
|
||||||
|
|
||||||
|
// Phase 29 fog layer: a single Container sharing the viewport's
|
||||||
|
// coordinate space, populated by `setVisibilityFog`. Added to
|
||||||
|
// the viewport BEFORE the nine torus copies so the layered
|
||||||
|
// repaint (fog rectangles + background-coloured circles) always
|
||||||
|
// renders underneath every primitive. An earlier per-copy
|
||||||
|
// approach with `copy.addChildAt(fog, 0)` ended up with fog on
|
||||||
|
// top in practice — moving the fog to a sibling of the copies
|
||||||
|
// avoids any reorder ambiguity.
|
||||||
|
const fogLayer = new Container();
|
||||||
|
viewport.addChild(fogLayer);
|
||||||
|
|
||||||
// Create nine torus copies, each holding its own primitive
|
// Create nine torus copies, each holding its own primitive
|
||||||
// graphics. Origin copy is always visible; the other eight
|
// graphics. Origin copy is always visible; the other eight
|
||||||
// follow the active wrap mode.
|
// follow the active wrap mode.
|
||||||
@@ -368,17 +377,10 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
// renderer-internal hit-test sites (pointer-move, clicked) and the
|
// renderer-internal hit-test sites (pointer-move, clicked) and the
|
||||||
// external `handle.hitAt` thread it through `hitTest`.
|
// external `handle.hitAt` thread it through `hitTest`.
|
||||||
let hiddenIds: ReadonlySet<PrimitiveID> = EMPTY_HIDDEN_IDS;
|
let hiddenIds: ReadonlySet<PrimitiveID> = EMPTY_HIDDEN_IDS;
|
||||||
// Per-copy fog Containers for the Phase 29 visibility fog
|
// `fogLayer` (declared above) is repopulated every time
|
||||||
// overlay. Each container holds one `Graphics` per
|
// `setVisibilityFog` runs. We track the dispatched ops only
|
||||||
// `FogPaintOp` (fog rect + one bg-coloured circle per
|
// implicitly via the layer's children; on every flip we drop
|
||||||
// visibility circle × wrap position), inserted at index 0 of
|
// the previous children and rebuild from the new op list.
|
||||||
// the torus copy so primitives paint on top. Created lazily
|
|
||||||
// when `setVisibilityFog` first receives a non-empty list and
|
|
||||||
// destroyed wholesale on every subsequent call — Pixi v8's
|
|
||||||
// multi-shape Graphics is supported in theory, but stacking
|
|
||||||
// each fill on its own Graphics removes any risk of an
|
|
||||||
// internal-state regression dropping a layer.
|
|
||||||
let fogGraphics: Container[] = [];
|
|
||||||
const applyHiddenStateTo = (id: PrimitiveID, list: Graphics[]): void => {
|
const applyHiddenStateTo = (id: PrimitiveID, list: Graphics[]): void => {
|
||||||
const visible = !hiddenIds.has(id);
|
const visible = !hiddenIds.has(id);
|
||||||
for (const g of list) g.visible = visible;
|
for (const g of list) g.visible = visible;
|
||||||
@@ -804,16 +806,13 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
},
|
},
|
||||||
isPrimitiveHidden: (id) => hiddenIds.has(id),
|
isPrimitiveHidden: (id) => hiddenIds.has(id),
|
||||||
setVisibilityFog: (circles) => {
|
setVisibilityFog: (circles) => {
|
||||||
// Drop the old fog Containers first — every flip rebuilds
|
// Drop the previous fog children — every flip rebuilds
|
||||||
// from scratch instead of mutating in place, so the
|
// from scratch instead of mutating in place. Pixi v8's
|
||||||
// implementation stays simple and Pixi-v8-residue-free.
|
// `Container.removeChildren()` returns the detached
|
||||||
// `destroy({children: true})` propagates to every owned
|
// children so we can destroy each one explicitly.
|
||||||
// Graphics inside the Container.
|
for (const old of fogLayer.removeChildren()) {
|
||||||
for (const c of fogGraphics) {
|
old.destroy({ children: true });
|
||||||
c.parent?.removeChild(c);
|
|
||||||
c.destroy({ children: true });
|
|
||||||
}
|
}
|
||||||
fogGraphics = [];
|
|
||||||
const ops = fogPaintOps(
|
const ops = fogPaintOps(
|
||||||
opts.world,
|
opts.world,
|
||||||
circles,
|
circles,
|
||||||
@@ -822,29 +821,21 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
mode,
|
mode,
|
||||||
);
|
);
|
||||||
if (ops.length === 0) return;
|
if (ops.length === 0) return;
|
||||||
for (const copy of copies) {
|
// Each op gets its own Graphics so any multi-shape Pixi
|
||||||
const container = new Container();
|
// quirks cannot drop a layer (an earlier all-in-one
|
||||||
// One Graphics per op — the fog rect first, then every
|
// implementation surfaced exactly that symptom in DEV —
|
||||||
// background-coloured circle on top. Per-shape Graphics
|
// only the last planet's glyph stayed visible inside the
|
||||||
// removes any risk of multi-shape Pixi quirks dropping a
|
// bg holes). The ops carry world-space positions; the
|
||||||
// layer (the previous all-in-one Graphics implementation
|
// `fogLayer` has no transform.
|
||||||
// surfaced exactly that symptom in DEV — only the last
|
for (const op of ops) {
|
||||||
// planet's glyph stayed visible inside the bg holes).
|
const g = new Graphics();
|
||||||
for (const op of ops) {
|
if (op.kind === "fillRect") {
|
||||||
const g = new Graphics();
|
g.rect(op.x, op.y, op.width, op.height);
|
||||||
if (op.kind === "fillRect") {
|
} else {
|
||||||
g.rect(op.x, op.y, op.width, op.height);
|
g.circle(op.x, op.y, op.radius);
|
||||||
} else {
|
|
||||||
g.circle(op.x, op.y, op.radius);
|
|
||||||
}
|
|
||||||
g.fill({ color: op.color, alpha: op.alpha });
|
|
||||||
container.addChild(g);
|
|
||||||
}
|
}
|
||||||
// Fog sits below every primitive on the same copy so
|
g.fill({ color: op.color, alpha: op.alpha });
|
||||||
// planet glyphs paint on top. `addChildAt(g, 0)` keeps
|
fogLayer.addChild(g);
|
||||||
// the rest of the children's order intact.
|
|
||||||
copy.addChildAt(container, 0);
|
|
||||||
fogGraphics.push(container);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
resize: (w, h) => {
|
resize: (w, h) => {
|
||||||
@@ -869,15 +860,15 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
teardownPickMode();
|
teardownPickMode();
|
||||||
previous?.onPick(null);
|
previous?.onPick(null);
|
||||||
}
|
}
|
||||||
// `app.destroy({...children: true})` below would also walk
|
// `app.destroy({...children: true})` below recursively
|
||||||
// fog containers, but we drop them eagerly so the closure
|
// destroys every container in the scene graph, fogLayer
|
||||||
// reference clears even if a future caller queries the
|
// included. The explicit removeChildren()/destroy here
|
||||||
// renderer mid-dispose.
|
// drops the fog children eagerly so a future caller
|
||||||
for (const c of fogGraphics) {
|
// querying the renderer mid-dispose does not see stale
|
||||||
c.parent?.removeChild(c);
|
// fog instances still parented under the layer.
|
||||||
c.destroy({ children: true });
|
for (const old of fogLayer.removeChildren()) {
|
||||||
|
old.destroy({ children: true });
|
||||||
}
|
}
|
||||||
fogGraphics = [];
|
|
||||||
viewport.off("moved", enforceCentreWhenLarger);
|
viewport.off("moved", enforceCentreWhenLarger);
|
||||||
viewport.off("moved", wrapTorusCamera);
|
viewport.off("moved", wrapTorusCamera);
|
||||||
viewport.off("clicked", handleViewportClicked);
|
viewport.off("clicked", handleViewportClicked);
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
// Phase 29 unit coverage for the Phase 29 fog overlay's layered
|
// Phase 29 unit coverage for the visible-hyperspace overlay's
|
||||||
// overpaint logic. `fogPaintOps` lives in `src/map/render.ts` next
|
// layered overpaint logic. `fogPaintOps` lives in `src/map/render.ts`
|
||||||
// to its sole consumer (`RendererHandle.setVisibilityFog`) — the
|
// next to its sole consumer (`RendererHandle.setVisibilityFog`) —
|
||||||
// renderer dispatches each op straight onto its own Pixi `Graphics`
|
// the renderer dispatches each op onto its own Pixi `Graphics`
|
||||||
// (one per shape) inside a per-copy `Container`, so the unit test
|
// inside a `fogLayer` container that sits below every primitive
|
||||||
// exercises the public ordering contract: a single fog-coloured
|
// copy. The natural rendering order paints fog underneath the
|
||||||
// rectangle followed by one background-coloured circle per
|
// world, replacing the earlier `cut()` implementation that
|
||||||
// visibility entry (multiplied by the torus wrap offsets when the
|
// produced disconnected arc segments.
|
||||||
// renderer is in torus mode). The natural rendering order unions
|
//
|
||||||
// overlapping circles for free, replacing the earlier `cut()`
|
// Coordinates returned by `fogPaintOps` are in world space because
|
||||||
// implementation that produced disconnected arc segments.
|
// `fogLayer` has no transform — wraps for torus mode are baked
|
||||||
|
// into the ops directly.
|
||||||
|
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ describe("fogPaintOps — no-wrap mode", () => {
|
|||||||
expect(fogPaintOps(WORLD, [], FOG_COLOR, BG_COLOR, "no-wrap")).toEqual([]);
|
expect(fogPaintOps(WORLD, [], FOG_COLOR, BG_COLOR, "no-wrap")).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("single circle emits fog rect + one bg circle in that order", () => {
|
test("single circle emits a single fog rect + one bg circle", () => {
|
||||||
const ops = fogPaintOps(
|
const ops = fogPaintOps(
|
||||||
WORLD,
|
WORLD,
|
||||||
[{ x: 100, y: 200, radius: 50 }],
|
[{ x: 100, y: 200, radius: 50 }],
|
||||||
@@ -98,7 +99,7 @@ describe("fogPaintOps — no-wrap mode", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("fogPaintOps — torus mode", () => {
|
describe("fogPaintOps — torus mode", () => {
|
||||||
test("each circle is emitted at nine wrapped positions", () => {
|
test("single circle expands to 9 fog rects + 9 bg circles in world space", () => {
|
||||||
const ops = fogPaintOps(
|
const ops = fogPaintOps(
|
||||||
WORLD,
|
WORLD,
|
||||||
[{ x: 100, y: 200, radius: 50 }],
|
[{ x: 100, y: 200, radius: 50 }],
|
||||||
@@ -106,25 +107,43 @@ describe("fogPaintOps — torus mode", () => {
|
|||||||
BG_COLOR,
|
BG_COLOR,
|
||||||
"torus",
|
"torus",
|
||||||
);
|
);
|
||||||
// 1 fog rect + 9 wrapped circles.
|
// 9 fog rects + 9 wrapped circles.
|
||||||
expect(ops.length).toBe(10);
|
expect(ops.length).toBe(18);
|
||||||
expect(ops[0].kind).toBe("fillRect");
|
// The first 9 ops are fog rects, one per neighbour tile.
|
||||||
const positions = ops
|
const rectPositions = ops
|
||||||
.slice(1)
|
.slice(0, 9)
|
||||||
.map((op) => (op.kind === "fillCircle" ? `${op.x},${op.y}` : ""))
|
.map((op) =>
|
||||||
|
op.kind === "fillRect" ? `${op.x},${op.y}` : "non-rect",
|
||||||
|
)
|
||||||
.sort();
|
.sort();
|
||||||
// Every neighbour offset is emitted with width=1000 / height=800.
|
const expectedRectPositions: string[] = [];
|
||||||
const expected: string[] = [];
|
|
||||||
for (const dx of [-1, 0, 1]) {
|
for (const dx of [-1, 0, 1]) {
|
||||||
for (const dy of [-1, 0, 1]) {
|
for (const dy of [-1, 0, 1]) {
|
||||||
expected.push(`${100 + dx * 1000},${200 + dy * 800}`);
|
expectedRectPositions.push(`${dx * 1000},${dy * 800}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
expected.sort();
|
expectedRectPositions.sort();
|
||||||
expect(positions).toEqual(expected);
|
expect(rectPositions).toEqual(expectedRectPositions);
|
||||||
|
// The next 9 ops are bg circles at every wrapped planet position.
|
||||||
|
const circlePositions = ops
|
||||||
|
.slice(9)
|
||||||
|
.map((op) =>
|
||||||
|
op.kind === "fillCircle" ? `${op.x},${op.y}` : "non-circle",
|
||||||
|
)
|
||||||
|
.sort();
|
||||||
|
const expectedCirclePositions: string[] = [];
|
||||||
|
for (const dx of [-1, 0, 1]) {
|
||||||
|
for (const dy of [-1, 0, 1]) {
|
||||||
|
expectedCirclePositions.push(
|
||||||
|
`${100 + dx * 1000},${200 + dy * 800}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expectedCirclePositions.sort();
|
||||||
|
expect(circlePositions).toEqual(expectedCirclePositions);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("multiple circles produce 9 × N wrapped circles after the fog rect", () => {
|
test("multiple circles produce 9 fog rects + 9N bg circles", () => {
|
||||||
const ops = fogPaintOps(
|
const ops = fogPaintOps(
|
||||||
WORLD,
|
WORLD,
|
||||||
[
|
[
|
||||||
@@ -135,14 +154,17 @@ describe("fogPaintOps — torus mode", () => {
|
|||||||
BG_COLOR,
|
BG_COLOR,
|
||||||
"torus",
|
"torus",
|
||||||
);
|
);
|
||||||
// 1 fog rect + (9 wraps × 2 circles) = 19 ops.
|
// 9 fog rects + (9 wraps × 2 circles) = 27 ops.
|
||||||
expect(ops.length).toBe(19);
|
expect(ops.length).toBe(27);
|
||||||
expect(ops[0].kind).toBe("fillRect");
|
expect(
|
||||||
// Each circle keeps its own radius across every wrap.
|
ops.slice(0, 9).every((op) => op.kind === "fillRect"),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
ops.slice(9).every((op) => op.kind === "fillCircle"),
|
||||||
|
).toBe(true);
|
||||||
const radii = ops
|
const radii = ops
|
||||||
.slice(1)
|
.slice(9)
|
||||||
.map((op) => (op.kind === "fillCircle" ? op.radius : 0))
|
.map((op) => (op.kind === "fillCircle" ? op.radius : 0));
|
||||||
.filter((r) => r > 0);
|
|
||||||
expect(radii.filter((r) => r === 50).length).toBe(9);
|
expect(radii.filter((r) => r === 50).length).toBe(9);
|
||||||
expect(radii.filter((r) => r === 30).length).toBe(9);
|
expect(radii.filter((r) => r === 30).length).toBe(9);
|
||||||
});
|
});
|
||||||
@@ -150,10 +172,10 @@ describe("fogPaintOps — torus mode", () => {
|
|||||||
test("a circle near the right edge produces a wrapped copy past the seam", () => {
|
test("a circle near the right edge produces a wrapped copy past the seam", () => {
|
||||||
// Planet at (950, 400) with radius 300 — the painted area
|
// Planet at (950, 400) with radius 300 — the painted area
|
||||||
// extends to x = 1250 in the central tile. In torus mode the
|
// extends to x = 1250 in the central tile. In torus mode the
|
||||||
// renderer also draws a wrapped circle at (950 - 1000, 400) =
|
// renderer also draws wrapped circles at (-50, 400) and
|
||||||
// (-50, 400) so the next tile (with its own fog rect) keeps a
|
// (1950, 400) so the circle stays continuous across the seam
|
||||||
// matching unfogged hole at the seam — this is the fix for
|
// instead of appearing as a sector clipped by the neighbour
|
||||||
// the "sector" artifact at the wrap boundary.
|
// tile's fog rectangle.
|
||||||
const ops = fogPaintOps(
|
const ops = fogPaintOps(
|
||||||
WORLD,
|
WORLD,
|
||||||
[{ x: 950, y: 400, radius: 300 }],
|
[{ x: 950, y: 400, radius: 300 }],
|
||||||
@@ -161,12 +183,12 @@ describe("fogPaintOps — torus mode", () => {
|
|||||||
BG_COLOR,
|
BG_COLOR,
|
||||||
"torus",
|
"torus",
|
||||||
);
|
);
|
||||||
const xs = ops
|
const circleXs = ops
|
||||||
.slice(1)
|
.filter((op) => op.kind === "fillCircle")
|
||||||
.map((op) => (op.kind === "fillCircle" ? op.x : 0));
|
.map((op) => (op.kind === "fillCircle" ? op.x : 0));
|
||||||
expect(xs).toContain(-50);
|
expect(circleXs).toContain(-50);
|
||||||
expect(xs).toContain(950);
|
expect(circleXs).toContain(950);
|
||||||
expect(xs).toContain(1950);
|
expect(circleXs).toContain(1950);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("empty input still returns no ops in torus mode", () => {
|
test("empty input still returns no ops in torus mode", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user