Empty a deleted tile's cells on every tilemap (#268)

Deleting a tile left the cells painted with it in the tilemaps. They
drew nothing, blocked nothing and did not show in the delete dialog,
but still read as a tile for "Tile at" and stopped a fill.

- DeleteAssetCommand empties the cells of a deleted tile on every
  layer of every tilemap in the same step. Undo puts the tile and its
  cells back together.
- The delete dialog counts them, for example "3 cell(s) on 1
  tilemap(s) are painted with it and become empty." (cellsPainted).
- presentTile treats a cell naming a missing tile as empty. "Tile at"
  (World.tileAt) and floodFill use it, so cells already in existing
  files read and fill as empty. There is no migration.
- The brush, the rectangle and the fill check the tile in hand. A
  tile deleted after it was picked no longer paints new invisible
  cells, the editor says "Choose a tile to paint with." instead.
- Unchanged on purpose: the picker still reports such a cell as
  naming a tile that no longer exists, and erasing removes it.

Tests:
- ./check.sh: 195 files, 2823 tests, CHECK EXIT 0 (+6).
- GM_STEPS=39,40: ALL PASSED, 100 checks (4 new in 40t).
- Full e2e sum is now 1590 (1586 confirmed by CI on round 8, plus 4).
- Counter-checks, all fail as expected: 5 in unit tests (delete
  without emptying, undo without cells, fill and "Tile at" without
  presentTile, wrong map count) and 3 in the browser (dialog without
  count, delete without emptying, brush without the check).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-29 19:00:22 +02:00
1 parent 14972d057f
commit 9b5eeaab57
11 files changed
+310 -38

No files matched your search

+21 -12
View File
@@ -28,7 +28,13 @@ import { findAsset, findGraphAsset, type ObjectHolder, type Project } from '../c
import type { SceneObject } from '../core/model/SceneObject';
import { pixelArtRefIds } from '../core/model/pixelart';
import { textureRefIds } from '../core/model/texture';
import { animationsUsing, tilesUsing, findPropertyUsages, findUsages } from '../core/model/usages';
import {
animationsUsing,
cellsPainted,
tilesUsing,
findPropertyUsages,
findUsages,
} from '../core/model/usages';
import { Dialog } from '../ui';
import { type EditorState, objectSelection } from './state';
@@ -189,20 +195,23 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void {
const typed =
asset.type === 'struct' ? structTypeUsages(state.project, assetId) : { places: 0, lists: 0, nodes: 0 };
const lists = typed.lists > 0 ? `, ${typed.lists} list(s)` : '';
const painted = asset.type === 'tile' ? cellsPainted(state.project, assetId) : { tilemaps: 0, cells: 0 };
const detail =
asset.type === 'scene'
? `The scene and its ${asset.objects.length} object(s) are removed.`
: eased.keys > 0 || eased.nodes > 0
? `${eased.keys} animation key(s) and ${eased.nodes} Lerp node(s) use it and blend linearly instead.`
: typed.places > 0 || typed.lists > 0 || typed.nodes > 0
? `${typed.places} struct(s)${lists} and ${typed.nodes} node(s) use it and keep its fields as their own.`
: references > 0 || animations > 0 || tiles > 0
? wornDetail(references, animations, tiles, asset.type)
: usages === 0
? 'This cannot be undone with Ctrl+Z after reload.'
: asset.type === 'container'
? `${usages} placed container(s) are removed together with their content.`
: `${usages} placed instance(s) are removed as well (scenes and container templates).`;
: painted.cells > 0
? `${painted.cells} cell(s) on ${painted.tilemaps} tilemap(s) are painted with it and become empty.`
: eased.keys > 0 || eased.nodes > 0
? `${eased.keys} animation key(s) and ${eased.nodes} Lerp node(s) use it and blend linearly instead.`
: typed.places > 0 || typed.lists > 0 || typed.nodes > 0
? `${typed.places} struct(s)${lists} and ${typed.nodes} node(s) use it and keep its fields as their own.`
: references > 0 || animations > 0 || tiles > 0
? wornDetail(references, animations, tiles, asset.type)
: usages === 0
? 'This cannot be undone with Ctrl+Z after reload.'
: asset.type === 'container'
? `${usages} placed container(s) are removed together with their content.`
: `${usages} placed instance(s) are removed as well (scenes and container templates).`;
new Dialog(`Delete "${asset.name}"?`, detail, [
{ label: 'Cancel' },
{ label: 'Delete', danger: true, onClick: () => state.execute(new DeleteAssetCommand(assetId)) },
+6 -3
View File
@@ -14,6 +14,7 @@ import {
findLayer,
floodFill,
inGrid,
presentTile,
tilemapColumns,
tilemapRows,
tilemapTileSize,
@@ -140,12 +141,12 @@ export class TilemapPainter {
/** Fills the connected cells of one tile in a single command, so one click is one undo step. */
private fillFrom(target: PaintTarget, layer: TilemapLayer, at: Vec2): void {
const state = this.host.state;
const tileId = state.tilemapPaint.tileId;
const tileId = presentTile(state.project, state.tilemapPaint.tileId);
if (!tileId) {
state.setNotice('Choose a tile to paint with.');
return;
}
const cells = floodFill(target.item.props, layer, at, tileId);
const cells = floodFill(state.project, target.item.props, layer, at, tileId);
const command = paintTiles(
layer,
target.asset.id,
@@ -157,7 +158,9 @@ export class TilemapPainter {
private startStroke(target: PaintTarget, global: Vec2): boolean {
const paint = this.host.state.tilemapPaint;
const tileId = paint.tool === 'erase' ? '' : paint.tileId;
// The tile in hand may have been deleted since it was picked; painting it would leave cells that
// draw nothing (#268).
const tileId = paint.tool === 'erase' ? '' : presentTile(this.host.state.project, paint.tileId);
if (paint.tool !== 'erase' && !tileId) {
this.host.state.setNotice('Choose a tile to paint with.');
return false;