Files
feuerwerk/tests/core/tilemap.test.ts
T
adminandClaude Opus 5.5 aef1eba917
game-maker / gate (push) Successful in 2m55s
game-maker / suite (push) Successful in 9m21s
Stop thin lines between tiles of a nearest tilemap at odd zooms
At zoom levels that do not land quarter edges on whole screen pixels,
a tilemap whose texture uses Nearest showed one-pixel lines in the
wrong colour. The float uv of a pixel centre right at a quad edge
could round onto the first texel of the neighbouring quarter in the
sheet, which is usually a different tile. Terrain sheets showed it
most, because every tile is four quads there.

- Nearest now pulls the uvs 0.02 texel inside each quarter. That is
  far below half a texel, so every picked texel stays the same and
  pixel art keeps its size.
- Linear keeps its half-texel inset.
- The first draft used 0.01. The user raised it to 0.02 after
  checking the result in the browser.

Tests:
- ./check.sh: 156 files, 2299 tests, CHECK EXIT 0 (before 2298).
- New unit test: every nearest uv sits strictly inside its quarter,
  by less than 0.05 texel. Two older tests now expect the inset.
- Counter-check: with no inset for nearest the new test and the
  inset test fail.
- No e2e check. The user checked the result in the browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 07:26:46 +02:00

567 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import {
CreateAssetCommand,
CreateTextureCommand,
SetAssetPropertyCommand,
} from '../../src/core/commands/assetCommands';
import { AddTilePictureCommand } from '../../src/core/commands/tileCommands';
import {
AddTilemapLayerCommand,
DuplicateTilemapLayerCommand,
MoveTilemapLayerCommand,
PaintTilesCommand,
paintTiles,
RemoveTilemapLayerCommand,
RenameTilemapLayerCommand,
SetTilemapLayerHiddenCommand,
SetTilemapLayerZCommand,
} from '../../src/core/commands/tilemapCommands';
import type { TilemapAsset } from '../../src/core/model/Asset';
import { isPlaceableType } from '../../src/core/model/AssetTypes';
import { TILE_KIND_KEY, type TileKind } from '../../src/core/model/tile';
import { findAsset, type Project } from '../../src/core/model/Project';
import {
cellAt,
cellAtPoint,
cellKey,
cellOrigin,
cellsAlong,
cellsInRect,
floodFill,
inGrid,
layerTiles,
neighboursOf,
TILEMAP_COLUMNS_KEY,
TILEMAP_ROWS_KEY,
TILEMAP_TILE_KEY,
tilemapTileIds,
tilemapSize,
} from '../../src/core/model/tilemap';
import { tilemapsUsing } from '../../src/core/model/usages';
import { DIMMED_LAYER_ALPHA, layerAlpha } from '../../src/viewport/tilemap';
import { groupByTile, tileMesh } from '../../src/viewport/tilemapMesh';
import { makeProject, roundTrip } from '../helpers';
const FILE = `${'a1b2c3d4'.repeat(8)}.png`;
/** A tilemap with a floor autotile to paint with, both sized like the RPG-Maker sheets. */
function makeTilemap(kind: TileKind = 'floor') {
const fixture = makeProject();
const texture = new CreateTextureCommand('Outside', {
file: FILE,
width: 96,
height: kind === 'floor' ? 144 : 96,
});
fixture.stack.execute(texture);
const tile = new CreateAssetCommand('tile', 'Grass');
fixture.stack.execute(tile);
fixture.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_KIND_KEY, kind));
fixture.stack.execute(new AddTilePictureCommand(tile.asset.id, texture.asset.id));
const tilemap = new CreateAssetCommand('tilemap', 'Ground');
fixture.stack.execute(tilemap);
const assetOf = () => findAsset(fixture.project, tilemap.asset.id) as TilemapAsset;
return {
...fixture,
tileId: tile.asset.id,
tilemapId: tilemap.asset.id,
assetOf,
layerId: () => assetOf().layers[0]!.id,
paint: (cells: { x: number; y: number }[], tileId = tile.asset.id) =>
fixture.stack.execute(
new PaintTilesCommand(
tilemap.asset.id,
assetOf().layers[0]!.id,
cells.map((at) => ({ ...at, tileId })),
),
),
};
}
function cellsOf(project: Project, assetId: string): Record<string, string> {
const asset = findAsset(project, assetId) as TilemapAsset;
return asset.layers[0]!.cells;
}
describe('the tilemap asset', () => {
it('is placed, unlike the tiles it draws with', () => {
expect(isPlaceableType('tilemap')).toBe(true);
expect(isPlaceableType('tile')).toBe(false);
});
it('starts with one layer and no cells', () => {
const f = makeTilemap();
expect(f.assetOf().layers).toHaveLength(1);
expect(f.assetOf().layers[0]!.cells).toEqual({});
});
it('is as big as its grid says, centred on its position', () => {
const f = makeTilemap();
const props = f.assetOf().props;
expect(tilemapSize(props)).toEqual({ w: 20 * 48, h: 15 * 48 });
expect(cellOrigin(props, 0, 0)).toEqual({ x: -480, y: -360 });
// The centre of the map is the corner of the four middle cells.
expect(cellAtPoint(props, 0, 0)).toEqual({ x: 10, y: 7 });
expect(cellAtPoint(props, -480, -360)).toEqual({ x: 0, y: 0 });
expect(cellAtPoint(props, -1, -1)).toEqual({ x: 9, y: 7 });
});
it('follows the grid and the cell size when they change', () => {
const f = makeTilemap();
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_COLUMNS_KEY, 4));
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_ROWS_KEY, 3));
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_TILE_KEY, 10));
expect(tilemapSize(f.assetOf().props)).toEqual({ w: 40, h: 30 });
expect(inGrid(f.assetOf().props, 3, 2)).toBe(true);
expect(inGrid(f.assetOf().props, 4, 2)).toBe(false);
expect(inGrid(f.assetOf().props, -1, 0)).toBe(false);
});
});
describe('painting a tilemap', () => {
it('paints, erases and takes both back as one step', () => {
const f = makeTilemap();
f.paint([
{ x: 1, y: 1 },
{ x: 2, y: 1 },
]);
expect(cellsOf(f.project, f.tilemapId)).toEqual({ '1,1': f.tileId, '2,1': f.tileId });
f.stack.execute(new PaintTilesCommand(f.tilemapId, f.layerId(), [{ x: 1, y: 1, tileId: '' }]));
expect(cellsOf(f.project, f.tilemapId)).toEqual({ '2,1': f.tileId });
f.stack.undo();
expect(cellsOf(f.project, f.tilemapId)).toEqual({ '1,1': f.tileId, '2,1': f.tileId });
f.stack.undo();
expect(cellsOf(f.project, f.tilemapId)).toEqual({});
});
it('puts back what a stroke painted over, not merely empty', () => {
const f = makeTilemap();
const other = new CreateAssetCommand('tile', 'Sand');
f.stack.execute(other);
f.paint([{ x: 3, y: 3 }]);
f.paint([{ x: 3, y: 3 }], other.asset.id);
expect(cellAt(f.assetOf().layers[0]!, 3, 3)).toBe(other.asset.id);
f.stack.undo();
expect(cellAt(f.assetOf().layers[0]!, 3, 3)).toBe(f.tileId);
});
it('knows when a stroke would change nothing', () => {
const f = makeTilemap();
f.paint([{ x: 0, y: 0 }]);
const layer = f.assetOf().layers[0]!;
expect(PaintTilesCommand.changes(layer, [{ x: 0, y: 0, tileId: f.tileId }])).toBe(false);
expect(PaintTilesCommand.changes(layer, [{ x: 0, y: 0, tileId: '' }])).toBe(true);
expect(PaintTilesCommand.changes(layer, [{ x: 5, y: 5, tileId: f.tileId }])).toBe(true);
});
it('makes no undo step for a rectangle over cells that already hold its tile', () => {
const f = makeTilemap();
const box = cellsInRect(f.assetOf().props, { x: 0, y: 0 }, { x: 2, y: 2 });
f.paint(box);
const again = box.map((at) => ({ ...at, tileId: f.tileId }));
expect(paintTiles(f.assetOf().layers[0]!, f.tilemapId, again)).toBeNull();
// The one undo left takes back the first rectangle, not an empty second one.
f.stack.undo();
expect(cellsOf(f.project, f.tilemapId)).toEqual({});
});
it('still makes one when a single cell of the rectangle changes', () => {
const f = makeTilemap();
f.paint(cellsInRect(f.assetOf().props, { x: 0, y: 0 }, { x: 1, y: 1 }));
const wider = cellsInRect(f.assetOf().props, { x: 0, y: 0 }, { x: 2, y: 1 });
const command = paintTiles(
f.assetOf().layers[0]!,
f.tilemapId,
wider.map((at) => ({ ...at, tileId: f.tileId })),
);
expect(command).not.toBeNull();
f.stack.execute(command!);
expect(Object.keys(cellsOf(f.project, f.tilemapId))).toHaveLength(6);
f.stack.undo();
expect(Object.keys(cellsOf(f.project, f.tilemapId)).sort()).toEqual(['0,0', '0,1', '1,0', '1,1']);
});
it('makes no undo step for a fill that finds nothing to fill', () => {
const f = makeTilemap();
f.paint([{ x: 0, y: 0 }]);
const layer = f.assetOf().layers[0]!;
expect(floodFill(f.assetOf().props, layer, { x: 0, y: 0 }, f.tileId)).toEqual([]);
expect(paintTiles(layer, f.tilemapId, [])).toBeNull();
});
it('covers the cells between two pointer events', () => {
expect(cellsAlong({ x: 0, y: 0 }, { x: 3, y: 0 })).toEqual([
{ x: 1, y: 0 },
{ x: 2, y: 0 },
{ x: 3, y: 0 },
]);
expect(cellsAlong({ x: 0, y: 0 }, { x: 2, y: 2 })).toEqual([
{ x: 1, y: 1 },
{ x: 2, y: 2 },
]);
expect(cellsAlong({ x: 4, y: 2 }, { x: 4, y: 2 })).toEqual([{ x: 4, y: 2 }]);
});
it('clips a rectangle to the grid', () => {
const f = makeTilemap();
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_COLUMNS_KEY, 3));
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_ROWS_KEY, 2));
const cells = cellsInRect(f.assetOf().props, { x: -2, y: 1 }, { x: 9, y: 9 });
expect(cells).toEqual([
{ x: 0, y: 1 },
{ x: 1, y: 1 },
{ x: 2, y: 1 },
]);
});
});
describe('what a painted cell shows', () => {
it('joins only cells holding the same tile', () => {
const f = makeTilemap();
const other = new CreateAssetCommand('tile', 'Sand');
f.stack.execute(other);
f.paint([
{ x: 5, y: 5 },
{ x: 6, y: 5 },
]);
f.paint([{ x: 5, y: 4 }], other.asset.id);
const layer = f.assetOf().layers[0]!;
const props = f.assetOf().props;
const around = neighboursOf(props, layer, 5, 5, f.tileId);
expect(around.e).toBe(true);
// The cell above holds a *different* tile, so this one closes against it.
expect(around.n).toBe(false);
expect(around.w).toBe(false);
});
it('counts anything outside the grid as empty', () => {
const f = makeTilemap();
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_COLUMNS_KEY, 2));
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_ROWS_KEY, 2));
f.paint([
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 0, y: 1 },
{ x: 1, y: 1 },
]);
const asset = f.assetOf();
const around = neighboursOf(asset.props, asset.layers[0]!, 1, 1, f.tileId);
expect(around.n).toBe(true);
expect(around.w).toBe(true);
expect(around.e).toBe(false);
expect(around.s).toBe(false);
});
it('gives a lone cell the four convex corners and a joined one the inside', () => {
const f = makeTilemap();
f.paint([{ x: 2, y: 2 }]);
const asset = f.assetOf();
const lone = layerTiles(f.project, asset.props, asset.layers[0]!)[0]!;
// The block's convex corners, as `FLOOR_CORNERS` names them.
expect(lone.quadrants).toEqual([
{ x: 0, y: 2 },
{ x: 3, y: 2 },
{ x: 0, y: 5 },
{ x: 3, y: 5 },
]);
f.paint([
{ x: 1, y: 1 },
{ x: 2, y: 1 },
{ x: 3, y: 1 },
{ x: 1, y: 2 },
{ x: 3, y: 2 },
{ x: 1, y: 3 },
{ x: 2, y: 3 },
{ x: 3, y: 3 },
]);
const after = layerTiles(f.project, f.assetOf().props, f.assetOf().layers[0]!);
const middle = after.find((tile) => tile.x === 2 && tile.y === 2)!;
expect(middle.quadrants).toEqual([
{ x: 2, y: 4 },
{ x: 1, y: 4 },
{ x: 2, y: 3 },
{ x: 1, y: 3 },
]);
});
it('draws a plain tile the same in every cell, whatever lies around it', () => {
const f = makeTilemap('none');
f.paint([
{ x: 1, y: 1 },
{ x: 2, y: 1 },
{ x: 1, y: 2 },
{ x: 5, y: 5 },
]);
const asset = f.assetOf();
const tiles = layerTiles(f.project, asset.props, asset.layers[0]!);
expect(tiles).toHaveLength(4);
for (const tile of tiles)
expect(tile.quadrants).toEqual([
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 0, y: 1 },
{ x: 1, y: 1 },
]);
});
it('leaves out cells outside the grid and cells naming nothing', () => {
const f = makeTilemap();
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_COLUMNS_KEY, 2));
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_ROWS_KEY, 2));
f.paint([
{ x: 0, y: 0 },
{ x: 5, y: 0 },
]);
f.stack.execute(new PaintTilesCommand(f.tilemapId, f.layerId(), [{ x: 1, y: 1, tileId: 'gone' }]));
const asset = f.assetOf();
const tiles = layerTiles(f.project, asset.props, asset.layers[0]!);
expect(tiles.map((t) => `${t.x},${t.y}`)).toEqual(['0,0']);
// The cell naming a deleted tile is kept in the model – an undo may bring it back.
expect(asset.layers[0]!.cells[cellKey(1, 1)]).toBe('gone');
});
it('reads the same tiles twice, in reading order', () => {
const f = makeTilemap();
f.paint([
{ x: 4, y: 2 },
{ x: 1, y: 1 },
{ x: 2, y: 1 },
]);
const asset = f.assetOf();
const order = layerTiles(f.project, asset.props, asset.layers[0]!).map((t) => `${t.x},${t.y}`);
expect(order).toEqual(['1,1', '2,1', '4,2']);
});
});
describe('the layers of a tilemap', () => {
it('adds, renames, reorders, hides and removes, each with an undo', () => {
const f = makeTilemap();
const add = new AddTilemapLayerCommand(f.tilemapId);
f.stack.execute(add);
expect(f.assetOf().layers.map((l) => l.name)).toEqual(['Layer 1', 'Layer 2']);
f.stack.execute(new RenameTilemapLayerCommand(f.tilemapId, add.layerId, 'Roofs'));
expect(f.assetOf().layers[1]!.name).toBe('Roofs');
f.stack.execute(new MoveTilemapLayerCommand(f.tilemapId, 1, 0));
expect(f.assetOf().layers.map((l) => l.name)).toEqual(['Roofs', 'Layer 1']);
f.stack.undo();
expect(f.assetOf().layers.map((l) => l.name)).toEqual(['Layer 1', 'Roofs']);
f.stack.execute(new SetTilemapLayerHiddenCommand(f.tilemapId, add.layerId, true));
expect(f.assetOf().layers[1]!.hidden).toBe(true);
f.stack.undo();
expect(f.assetOf().layers[1]!.hidden).toBeUndefined();
f.stack.execute(new RemoveTilemapLayerCommand(f.tilemapId, add.layerId));
expect(f.assetOf().layers).toHaveLength(1);
f.stack.undo();
expect(f.assetOf().layers.map((l) => l.name)).toEqual(['Layer 1', 'Roofs']);
});
it('duplicates a layer right above it, with its cells, its z and a name of its own', () => {
const f = makeTilemap();
f.paint([{ x: 1, y: 1 }]);
f.stack.execute(new SetTilemapLayerZCommand(f.tilemapId, f.layerId(), 3));
f.stack.execute(new AddTilemapLayerCommand(f.tilemapId));
const copy = new DuplicateTilemapLayerCommand(f.tilemapId, f.layerId());
f.stack.execute(copy);
const layers = f.assetOf().layers;
expect(layers.map((l) => l.name)).toEqual(['Layer 1', 'Layer 1 copy', 'Layer 2']);
expect(layers[1]).toMatchObject({ id: copy.layerId, z: 3, cells: layers[0]!.cells });
// Cells of its own: painting on the copy leaves the original alone.
f.stack.execute(new PaintTilesCommand(f.tilemapId, copy.layerId, [{ x: 1, y: 1, tileId: '' }]));
expect(f.assetOf().layers[0]!.cells[cellKey(1, 1)]).toBeTruthy();
f.stack.undo();
f.stack.undo();
expect(f.assetOf().layers.map((l) => l.name)).toEqual(['Layer 1', 'Layer 2']);
});
it('renames a layer without the spaces around it, refuses a blank name and undoes to the old one', () => {
const f = makeTilemap();
f.stack.execute(new RenameTilemapLayerCommand(f.tilemapId, f.layerId(), ' Ground '));
expect(f.assetOf().layers[0]!.name).toBe('Ground');
expect(() => f.stack.execute(new RenameTilemapLayerCommand(f.tilemapId, f.layerId(), ' '))).toThrow(
'A layer needs a name',
);
expect(f.assetOf().layers[0]!.name).toBe('Ground');
f.stack.undo();
expect(f.assetOf().layers[0]!.name).toBe('Layer 1');
});
it('keeps the last layer, because there has to be one to paint on', () => {
const f = makeTilemap();
expect(() => f.stack.execute(new RemoveTilemapLayerCommand(f.tilemapId, f.layerId()))).toThrow(
'A tilemap keeps at least one layer',
);
expect(f.assetOf().layers).toHaveLength(1);
});
it('takes the cells of a removed layer with it and brings them back', () => {
const f = makeTilemap();
const add = new AddTilemapLayerCommand(f.tilemapId);
f.stack.execute(add);
f.stack.execute(new PaintTilesCommand(f.tilemapId, add.layerId, [{ x: 2, y: 2, tileId: f.tileId }]));
f.stack.execute(new RemoveTilemapLayerCommand(f.tilemapId, add.layerId));
expect(f.assetOf().layers).toHaveLength(1);
f.stack.undo();
expect(f.assetOf().layers[1]!.cells).toEqual({ '2,2': f.tileId });
});
});
describe('how the scene view shows the layers next to the painted one', () => {
it('draws all of them whole, fades the others or leaves them out', () => {
expect(layerAlpha('a', null)).toBe(1);
expect(layerAlpha('b', { layerId: 'a', mode: 'all' })).toBe(1);
expect(layerAlpha('a', { layerId: 'a', mode: 'dim' })).toBe(1);
expect(layerAlpha('b', { layerId: 'a', mode: 'dim' })).toBe(DIMMED_LAYER_ALPHA);
expect(layerAlpha('a', { layerId: 'a', mode: 'solo' })).toBe(1);
expect(layerAlpha('b', { layerId: 'a', mode: 'solo' })).toBe(0);
});
});
describe('a tilemap in the document', () => {
it('survives a round trip with its layers and cells', () => {
const f = makeTilemap();
const add = new AddTilemapLayerCommand(f.tilemapId);
f.stack.execute(add);
f.stack.execute(new SetTilemapLayerHiddenCommand(f.tilemapId, add.layerId, true));
f.paint([{ x: 1, y: 2 }]);
const back = roundTrip(f.project);
const asset = findAsset(back, f.tilemapId) as TilemapAsset;
expect(asset.layers).toHaveLength(2);
expect(asset.layers[0]!.cells).toEqual({ '1,2': f.tileId });
expect(asset.layers[1]!.hidden).toBe(true);
});
it('drops cells that are not a pair of whole numbers, and doubled layers', () => {
const f = makeTilemap();
const asset = f.assetOf();
asset.layers[0]!.cells['nonsense'] = f.tileId;
asset.layers[0]!.cells['1.5,2'] = f.tileId;
asset.layers[0]!.cells['3,3'] = f.tileId;
asset.layers.push({ ...asset.layers[0]!, name: 'Twin' });
const back = findAsset(roundTrip(f.project), f.tilemapId) as TilemapAsset;
expect(back.layers).toHaveLength(1);
expect(back.layers[0]!.cells).toEqual({ '3,3': f.tileId });
});
it('counts as a use of the tiles painted on it', () => {
const f = makeTilemap();
expect(tilemapsUsing(f.project, [f.tileId])).toHaveLength(0);
f.paint([{ x: 0, y: 0 }]);
expect(tilemapsUsing(f.project, [f.tileId]).map((a) => a.id)).toEqual([f.tilemapId]);
expect(tilemapTileIds(f.assetOf())).toEqual([f.tileId]);
});
});
describe('the mesh a layer becomes', () => {
const sheet = { quad: 24, block: { x: 0, y: 0, w: 96, h: 144 }, textureWidth: 96, textureHeight: 144 };
it('is one mesh per tile, not one per cell', () => {
const f = makeTilemap();
const other = new CreateAssetCommand('tile', 'Sand');
f.stack.execute(other);
f.paint([
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 2, y: 0 },
]);
f.paint([{ x: 4, y: 0 }], other.asset.id);
const asset = f.assetOf();
const groups = groupByTile(layerTiles(f.project, asset.props, asset.layers[0]!));
expect(groups.size).toBe(2);
expect(groups.get(f.tileId)).toHaveLength(3);
});
it('builds four quads per cell, at the cell it belongs to', () => {
const tiles = [
{
x: 1,
y: 0,
tileId: 'a',
quadrants: [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 0, y: 1 },
{ x: 1, y: 1 },
] as [
{ x: number; y: number },
{ x: number; y: number },
{ x: number; y: number },
{ x: number; y: number },
],
},
];
const { positions, uvs, indices } = tileMesh(tiles, 48, { x: 0, y: 0 }, { ...sheet, smooth: false });
expect(positions).toHaveLength(4 * 4 * 2);
expect(indices).toHaveLength(4 * 6);
// The cell starts at x = 48, and its top-left quarter is 24 wide.
expect([positions[0], positions[1]]).toEqual([48, 0]);
expect([positions[2], positions[3]]).toEqual([72, 0]);
// Quadrant (0, 0) of a 96 × 144 sheet cut into 24 px quarters is the first quarter of the uvs.
expect(uvs[0]).toBeCloseTo(0, 3);
expect(uvs[1]).toBeCloseTo(0, 3);
expect(uvs[2]).toBeCloseTo(24 / 96, 3);
expect(uvs[3]).toBeCloseTo(0, 3);
});
it('pulls the uvs half a texel in when the picture is sampled smoothly', () => {
const tiles = [
{
x: 0,
y: 0,
tileId: 'a',
quadrants: [
{ x: 0, y: 0 },
{ x: 0, y: 0 },
{ x: 0, y: 0 },
{ x: 0, y: 0 },
] as [
{ x: number; y: number },
{ x: number; y: number },
{ x: number; y: number },
{ x: number; y: number },
],
},
];
const hard = tileMesh(tiles, 48, { x: 0, y: 0 }, { ...sheet, smooth: false });
const soft = tileMesh(tiles, 48, { x: 0, y: 0 }, { ...sheet, smooth: true });
expect(hard.uvs[0]).toBeCloseTo(0.02 / 96, 6);
expect(soft.uvs[0]).toBeCloseTo(0.5 / 96, 6);
// The positions are untouched: only what is sampled moves, never where it lands.
expect([...soft.positions]).toEqual([...hard.positions]);
});
it('keeps nearest uvs a sliver inside the quarter so odd zooms never pick the neighbour', () => {
const tiles = [
{
x: 0,
y: 0,
tileId: 'a',
quadrants: [
{ x: 1, y: 1 },
{ x: 1, y: 1 },
{ x: 1, y: 1 },
{ x: 1, y: 1 },
] as [
{ x: number; y: number },
{ x: number; y: number },
{ x: number; y: number },
{ x: number; y: number },
],
},
];
const { uvs } = tileMesh(tiles, 48, { x: 0, y: 0 }, { ...sheet, smooth: false });
// Quarter (1, 1) spans texels 24..48 on both axes. Every corner sits strictly inside it,
// but by far less than half a texel, so the texel picked at any pixel centre stays the same.
const us = [uvs[0]!, uvs[2]!, uvs[4]!, uvs[6]!].map((u) => u * 96);
const vs = [uvs[1]!, uvs[3]!, uvs[5]!, uvs[7]!].map((v) => v * 144);
for (const t of [...us, ...vs]) {
expect(t).toBeGreaterThan(24);
expect(t).toBeLessThan(48);
expect(Math.min(t - 24, 48 - t)).toBeLessThan(0.05);
}
});
});