DramaticShapeVoxelMod archival mirror · read-only
dcf078c2e014a1ae43491aaa0c65c2c96b74ef09

1.0.4: flower/bush/statue/trash-can billboards, in-place mesh refresh

DramaticShape · 2026-07-27 · parent fdcd5f1fce

1.0.4: flower/bush/statue/trash-can billboards, in-place mesh refresh

Flowers stand as 1px animated cutouts on their own pulled mesh (own-cell
flowers hide behind the walker, the cell south still overdraws feet).
Cuttable bushes, gym statues (single-block plinth) and Lt. Surge's trash
cans stand as 5-voxel prop cutouts; prop_ground names the tile painted
beneath. Block edits refresh meshes in place instead of blinking to 2D.

Changed files

CHANGELOG.md                  | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++
 data/voxel_heights.lua        |  39 +++++++++++++++++++
 lib/ChunkMesher.lua           | 129 ++++++++++++++++++++++++++++++++++++++++++++++++++++----------
 lib/Structures.lua            |  41 ++++++++++++++++++--
 lib/VoxelScene.lua            |  25 ++++++++++++
 main.lua                      |   8 +++-
 manifest.json                 |   2 +-
 tests/dramatic_shape_test.lua |  13 +++++--
 tests/voxel_anim_probe.lua    |   3 ++
 9 files changed, 334 insertions(+), 30 deletions(-)

Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 53b4294..7acff95 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,109 @@
 # Changelog
 
+## 1.0.4
+
+### Added
+
+- **Flowers stand up, and keep swaying.** The animated meadow tile
+  ($03, the one tile the overworld animates by frame rewrite) now
+  renders as a billboard one voxel deep: the drawing's darkest tones
+  plus everything they enclose are cut out per pixel, and the ground
+  beneath is synthesized from the commonest flat neighbour, exactly
+  like the ground under a detected prop.
+
+  The interesting part is that the cutout still animates. A mesh is
+  static, so the geometry spans the UNION of the mask over the base art
+  and all three animation frames, and the animation lives entirely in
+  the texture: TerrainAtlas already rewrites the flower's slot in the
+  private animated atlas each step, and for this tile it now writes
+  only the current frame's mask opaque with everything else keyed to
+  alpha 0 -- which the voxel shader discards, and the shadow pass with
+  it. The standing silhouette trims itself frame by frame in texture
+  space, off the same engine clock as the flat path, without a vertex
+  moving. The class is derived, not authored: any frames-animated tile
+  resolves to the new `flower` class with no profile entry, the same
+  way tall grass derives from `grassTile` (hand-authoring still wins).
+
+- **The cuttable bush is a standing cutout.** The four tiles Cut
+  deletes ($2D/$2E/$3D/$3E -- across the whole tileset they appear only
+  in the five cut-tree blocks) are pinned to the thin `prop` pool: a
+  per-pixel standee 5 voxels deep, black-outline segmented with its
+  enclosed pixels kept, the drawn grass dither flooding away. It
+  stands on plain grass ($2C) -- the very tile Cut leaves behind per
+  field.cutTreeSwaps -- via the profile's new `prop_ground` key, which
+  names the tile painted under a pinned prop instead of whatever flat
+  tile its neighbours vote in.
+
+- **Gym statues: a solid plinth, a standing bird.** The statue pair
+  flanking every badge gym's aisle (and Bruno's room) is one cell of
+  figure over one cell of plinth. The plinth ($22/$23/$32/$33) is
+  pinned `wall`: a solid 16px block. The figure ($02/$38/$12/$13) is
+  pinned `prop`: a 5-voxel cutout that stands ON the plinth through the
+  authored-box support rule, its checkered background flooded away and
+  the pixels its outline encloses kept.
+
+  The whole statue keeps ONE cell of footprint. The support rule used
+  to extend the box under the claimed cell (the monitor-on-desk path),
+  which marched the plinth a second block backwards; a figure whose
+  support is a FULL-HEIGHT block now collapses instead -- the drawn
+  figure cell becomes synthesized floor, since the block below already
+  carries the whole base. Furniture supports keep the extension: their
+  drawn cell is the furniture's own upper rows, and floor there would
+  amputate the desk. The round boulder drawn beside some statues is
+  deliberately NOT pinned -- it also tiles wall-to-wall as Pewter's
+  rock rows, which are scenery for the detector.
+
+- **Lt. Surge's trash cans stand up.** The can ($0B/$0C/$1B/$1C, the
+  lone graphic of blocks 38/39) takes the same treatment as the
+  cuttable bush: a 5-voxel `prop` cutout, black-outline segmented with
+  its enclosed pixels kept, standing on the gyms' main floor tile
+  ($11) via `prop_ground`.
+
+### Fixed
+
+- **Cut trees now vanish in voxel mode -- and grow back.** The
+  engine's Cut path swapped the block with a raw `setBlock` + renderer
+  rebuild, never emitting `world.block_replaced` -- so this mod's
+  listener (which rebuilds the map's mesh exactly for this) never
+  heard about it, and the diorama kept showing the tree. The engine
+  now routes Cut through `replaceBlock`
+  (src/world/OverworldController.lua), whose whole purpose -- per its
+  own comment, "Victory Road barriers, Cut trees" -- is that same swap
+  plus the event. The regrowth path had the same hole one door away:
+  cut trees are restored block by block when the map is re-entered,
+  and the card-key doors are stamped closed on floor load, both
+  through the same silent `setBlock` -- with the mesh cache staying
+  warm across a round trip (that is what prevLive is for), the world
+  kept showing the stump you left. Both paths now announce each block.
+
+- **A block edit no longer blinks the world down to 2D.** The
+  listener used to drop the edited map's mesh outright, and mesh
+  builds are asynchronous -- so cutting a tree (or stepping out of a
+  door onto a map whose trees just regrew) flashed the flat 2D world
+  for the frames the rebuild took. `ChunkMesher.refresh` rebuilds in
+  place instead: the stale mesh keeps drawing, the replacement cooks
+  in the background, and each slot swaps as its build lands -- the
+  tree pops out (or back in) with the scene never leaving 3D.
+
+- **Flowers cull the player correctly from every angle.** The flower
+  billboards were baked into the terrain mesh, which draws without
+  the characters' camera-ward pull -- so a walker standing among
+  flowers won the depth test against ALL of them, including the
+  flower south of their feet that should overdraw them. The flower
+  quads now ride their own mesh, drawn after the characters with
+  exactly the characters' pull (the tall-grass trick): the flower in
+  front of a walker occludes their feet, the one behind them hides,
+  at every camera angle. Unlike grass the flower mesh still casts
+  shadows -- it is a handful of cutouts per meadow, not thousands of
+  tufts.
+
+- The `voxel_anim_probe` driver crashed on engine builds without the
+  optional `TileRenderer.animFrame` seam, and again on tilesets whose
+  animation list carries a "toggle" entry (spinner rooms), which claims
+  a tile LIST rather than one slot. It now reads the clock through the
+  mod's own fallback chain and skips toggle entries in the placement
+  census.
+
 ## 1.0.3
 
 ### Added
diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua
index 2757fa9..5fe4b67 100644
--- a/data/voxel_heights.lua
+++ b/data/voxel_heights.lua
@@ -65,6 +65,11 @@
 --                           monitor on its desk).  prop is a second pool
 --                           so two touching cutouts stay separate
 --
+-- A tileset entry may also carry `prop_ground` (not a class): a map of
+-- prop tile id -> ground tile id naming the tile painted under that
+-- pinned prop, overriding the flat-neighbour vote -- the cuttable bush
+-- stands on the grass Cut leaves behind, whatever borders it.
+--
 -- Whole BUILDINGS are not tile pins -- one drawing packs a roof seen from
 -- above, a facade seen face-on and sloped ends as diagonal silhouettes,
 -- and no single class covers that.  They live in the `buildings` list at
@@ -124,6 +129,40 @@ return {
       -- fence-textured tower. `post` extracts each cell alone, so these
       -- render as the same thin posts, marching north
       post = { 14, 85 },
+      -- the cuttable bush ($2D/$2E/$3D/$3E, the four tiles Cut deletes
+      -- -- across the whole tileset they appear only in the five
+      -- cut-tree blocks): a standing per-pixel cutout 5 voxels deep,
+      -- black-outline segmented with the pixels the outline encloses
+      -- kept, its drawn grass dither flooding away as background
+      prop = { 45, 46, 61, 62 },
+      -- the ground painted under those pinned props, by the prop tile's
+      -- own id: the bush stands on plain grass ($2C) -- the very tile
+      -- Cut leaves behind (field.cutTreeSwaps' after-blocks) -- rather
+      -- than whatever flat tile its neighbours vote
+      prop_ground = { [45] = 44, [46] = 44, [61] = 44, [62] = 44 },
+    },
+
+    -- The badge gyms and Bruno's room (one tileset): the bird statues
+    -- that flank every gym's aisles, and Lt. Surge's trash cans.  A
+    -- statue is one cell of figure ($02/$38/$12/$13) drawn over one
+    -- cell of plinth ($22/$23/$32/$33); the trash can is one cell
+    -- ($0B/$0C/$1B/$1C, blocks 38/39 only).  Across the whole blockset
+    -- none of these tiles appears anywhere else.  The plinth stays a
+    -- SOLID 16px block; the figure stands ON it as a per-pixel cutout
+    -- 5 voxels deep (the thin `prop` pool), collapsing to the plinth's
+    -- single cell of footprint (Structures' wall-support rule); the
+    -- can is the same 5-voxel cutout at floor level.  Both are
+    -- black-outline segmented -- backgrounds flood away, the pixels
+    -- the outline encloses stay.  Everything stands on the gyms' main
+    -- floor tile ($11), which is also Bruno's floor.
+    -- (The round boulder drawn beside some statues, $07/$08/$17/$18,
+    -- is NOT pinned: it also tiles wall-to-wall as Pewter's rock rows,
+    -- which are scenery for the detector, not a lone prop.)
+    GYM = {
+      wall = { 34, 35, 50, 51 },
+      prop = { 2, 56, 18, 19, 11, 12, 27, 28 },
+      prop_ground = { [2] = 17, [56] = 17, [18] = 17, [19] = 17,
+                      [11] = 17, [12] = 17, [27] = 17, [28] = 17 },
     },
 
     -- ledge pairs from data.field.tilePairs
diff --git a/lib/ChunkMesher.lua b/lib/ChunkMesher.lua
index 77c2d4b..f742a43 100644
--- a/lib/ChunkMesher.lua
+++ b/lib/ChunkMesher.lua
@@ -704,15 +704,10 @@ function ChunkMesher.build(map, bodyOnly, masks)
   return sink.finish()
 end
 
--- The tall-grass rows as their own mesh: VoxelScene draws it AFTER the
--- characters so the southern row of a grass cell still overdraws a
--- walker's feet (characters stamp over terrain, Gen 1 style, so ordinary
--- terrain could never do this).
-local function buildGrassMesh(map)
-  local S = Structures.forMap(map)
-  if #S.grassQuads == 0 then return nil end
+local function quadsMesh(quads)
+  if #quads == 0 then return nil end
   local verts, indices, n = {}, {}, 0
-  for _, q in ipairs(S.grassQuads) do
+  for _, q in ipairs(quads) do
     for i = 1, 4 do
       local c = q[i]
       local uv = q.uv and q.uv[i] or { q.u, q.v }
@@ -724,6 +719,33 @@ local function buildGrassMesh(map)
   return Voxel3D.newMesh(verts, indices)
 end
 
+-- The tall-grass rows as their own mesh: VoxelScene draws it AFTER the
+-- characters so the southern row of a grass cell still overdraws a
+-- walker's feet (characters stamp over terrain, Gen 1 style, so ordinary
+-- terrain could never do this).
+local function buildGrassMesh(map)
+  return quadsMesh(Structures.forMap(map).grassQuads)
+end
+
+-- The flower billboards as their own mesh, for the same reason as the
+-- grass one: it draws AFTER the characters WITH the same camera-ward
+-- pull, so a flower south of a walker occludes their feet and one north
+-- of them hides behind them. Baked into the terrain mesh they lost that
+-- depth fight against the pulled character card whenever the player
+-- stood among flowers. Unlike grass this mesh still CASTS shadows (the
+-- sun pass draws it): a handful of flowers per meadow, not thousands of
+-- tufts.
+local function buildFlowerMesh(map)
+  return quadsMesh(Structures.forMap(map).flowerQuads)
+end
+
+-- Replace a cached slot, releasing whatever mesh it held.
+local function swapSlot(c, slot, mesh)
+  local old = c[slot]
+  if old and old ~= mesh and old.release then pcall(old.release, old) end
+  c[slot] = mesh
+end
+
 -- ------------------------------------------------------------- the cache
 
 local function entry(id)
@@ -736,11 +758,12 @@ local function entry(id)
 end
 
 local function releaseEntry(c)
-  for _, slot in ipairs({ "full", "body", "grass" }) do
+  for _, slot in ipairs({ "full", "body", "grass", "flowers" }) do
     local mesh = c[slot]
     if mesh and mesh.release then pcall(mesh.release, mesh) end
     c[slot] = nil
   end
+  c.stale = nil
 end
 
 -- ---------------------------------------------------------- async builds
@@ -778,26 +801,48 @@ end
 local function runJob(job)
   local map = job.map
   local c = entry(job.id)
-  if c.grass == nil then
+  if c.grass == nil or c.flowers == nil or (c.stale and c.stale.aux) then
     local okG, grass = pcall(buildGrassMesh, map)
-    if (gen[job.id] or 0) ~= job.gen then return end
-    c.grass = (okG and grass) or false
+    local okF, flowers = pcall(buildFlowerMesh, map)
+    if (gen[job.id] or 0) ~= job.gen then
+      if okG and grass and grass.release then pcall(grass.release, grass) end
+      if okF and flowers and flowers.release then
+        pcall(flowers.release, flowers)
+      end
+      return
+    end
+    swapSlot(c, "grass", (okG and grass) or false)
+    swapSlot(c, "flowers", (okF and flowers) or false)
+    if c.stale then c.stale.aux = nil end
   end
   local sink = newSink()
   runGeometry(map, job.slot == "body", job.masks, sink)
   local mesh = sink.finish()
-  if (gen[job.id] or 0) ~= job.gen then return end
-  c[job.slot] = mesh or false
+  if (gen[job.id] or 0) ~= job.gen then
+    if mesh and mesh.release then pcall(mesh.release, mesh) end
+    return
+  end
+  swapSlot(c, job.slot, mesh or false)
+  if c.stale then
+    c.stale[job.slot] = nil
+    if not (c.stale.full or c.stale.body or c.stale.aux) then
+      c.stale = nil
+    end
+  end
 end
 
 -- Queue a build unless the slot is already cached or queued. Returns the
 -- cached mesh when there is one (false-cached misses return nil).
 -- `urgent` marks the current map's meshes: pump() gives those a bigger
--- slice and runs them before neighbour jobs.
+-- slice and runs them before neighbour jobs. A slot refresh() marked
+-- stale queues its rebuild AND keeps handing back the old mesh, so a
+-- one-block edit never drops the scene to the flat 2D path while the
+-- replacement cooks.
 function ChunkMesher.request(map, bodyOnly, masks, urgent)
   local slot = bodyOnly and "body" or "full"
   local c = cache[map.id]
-  if c and c[slot] ~= nil then return c[slot] or nil end
+  local stale = c and c.stale and (c.stale[slot] or c.stale.aux)
+  if c and c[slot] ~= nil and not stale then return c[slot] or nil end
   local key = jobKey(map.id, slot)
   local job = jobIndex[key]
   if not job then
@@ -808,7 +853,7 @@ function ChunkMesher.request(map, bodyOnly, masks, urgent)
   elseif urgent then
     job.urgent = true
   end
-  return nil
+  return (c and c[slot]) or nil
 end
 
 function ChunkMesher.pending()
@@ -871,17 +916,26 @@ end
 function ChunkMesher.get(map, bodyOnly, masks)
   local slot = bodyOnly and "body" or "full"
   local c = entry(map.id)
-  if c.grass == nil then
+  if c.grass == nil or c.flowers == nil or (c.stale and c.stale.aux) then
     local okG, grass = pcall(buildGrassMesh, map)
-    c.grass = (okG and grass) or false
+    local okF, flowers = pcall(buildFlowerMesh, map)
+    swapSlot(c, "grass", (okG and grass) or false)
+    swapSlot(c, "flowers", (okF and flowers) or false)
+    if c.stale then c.stale.aux = nil end
   end
-  if c[slot] == nil then
+  if c[slot] == nil or (c.stale and c.stale[slot]) then
     local ok, mesh = pcall(ChunkMesher.build, map, bodyOnly, masks)
     if not ok then
       print("[warn] voxel mesh build failed for " .. tostring(map.id)
             .. ": " .. tostring(mesh))
     end
-    c[slot] = (ok and mesh) or false
+    swapSlot(c, slot, (ok and mesh) or false)
+    if c.stale then
+      c.stale[slot] = nil
+      if not (c.stale.full or c.stale.body or c.stale.aux) then
+        c.stale = nil
+      end
+    end
     local key = jobKey(map.id, slot)
     local job = jobIndex[key]
     if job then finishJob(job, true) end
@@ -901,6 +955,39 @@ function ChunkMesher.grass(map)
   return c and c.grass or nil
 end
 
+function ChunkMesher.flowers(map)
+  local c = cache[map.id]
+  return c and c.flowers or nil
+end
+
+-- Rebuild a map's meshes IN PLACE: the stale meshes keep drawing while
+-- replacements cook, and each slot swaps as its build lands. This is
+-- the block-edit path (a cut tree, a door stamp) -- invalidate() drops
+-- the mesh outright, and until the async rebuild landed the scene fell
+-- to the flat 2D path, a whole-world blink for a one-block edit.
+function ChunkMesher.refresh(mapId)
+  if not mapId then return ChunkMesher.invalidate() end
+  local c = cache[mapId]
+  -- nothing drawable cached: the plain drop costs nothing visible
+  if not (c and (c.full or c.body)) then
+    return ChunkMesher.invalidate(mapId)
+  end
+  Structures.invalidate(mapId)
+  gen[mapId] = (gen[mapId] or 0) + 1
+  for i = #jobs, 1, -1 do
+    local job = jobs[i]
+    if job.id == mapId then
+      jobIndex[jobKey(job.id, job.slot)] = nil
+      table.remove(jobs, i)
+    end
+  end
+  -- false-cached slots count as stale too: a retry after a failed build
+  -- is exactly a rebuild
+  c.stale = { aux = true,
+              full = (c.full ~= nil) or nil,
+              body = (c.body ~= nil) or nil }
+end
+
 -- Evict everything outside `live` (a set of map ids): far maps' meshes
 -- are released -- GPU buffer and LOVE's CPU copy both -- and their
 -- Structures analysis dropped. The live set is the current map plus its
diff --git a/lib/Structures.lua b/lib/Structures.lua
index c262841..127d3c0 100644
--- a/lib/Structures.lua
+++ b/lib/Structures.lua
@@ -193,7 +193,7 @@ function Structures.forMap(map)
   -- terrain.)
   S = { shapeAt = shapeAt, tileAt = tileAt, outdoor = Map.isOutdoor(def),
         runs = {}, skip = {}, ground = {}, objectQuads = {},
-        grassQuads = {}, roundStamps = {} }
+        grassQuads = {}, flowerQuads = {}, roundStamps = {} }
   Buildings.build(S, map, pixels(tileset), perRow)
 
   -- Fold doors into their buildings. A door cell is WALKABLE (the player
@@ -418,6 +418,27 @@ function Structures.forMap(map)
     Structures.buildFlowers(S, map, tw, th, x0, x1, y0, y1, data)
   end
 
+  -- ---- authored ground under pinned props ----
+  -- The profile can name the tile a pinned prop stands on (a tileset
+  -- entry's prop_ground: prop tile id -> ground tile id), overriding
+  -- the neighbour vote. The cuttable bush stands on the plain grass
+  -- Cut itself leaves behind, not on whatever path its neighbours
+  -- happen to vote in.
+  do
+    local okP, prof = pcall(V.data, "voxel_heights")
+    local entry = okP and type(prof) == "table" and prof.tilesets
+                  and prof.tilesets[tileset.id]
+    local pg = entry and entry.prop_ground
+    if type(pg) == "table" then
+      for k, skipped in pairs(S.skip) do
+        if skipped then
+          local g = pg[S.tileAt[k]]
+          if g then S.ground[k] = g end
+        end
+      end
+    end
+  end
+
   -- unresolved claimed ground (a hull with no art match, headless
   -- cylinders): no flat neighbour to vote with, so fall back to the
   -- map's commonest ground tile
@@ -1847,7 +1868,17 @@ function Structures.buildObject(S, map, region, cluster,
   end
   for _, c in ipairs(cluster.tiles) do
     local k = keyOf(c[1], c[2])
-    if support then
+    if support and support.class == "wall" then
+      -- a figure drawn above a FULL-HEIGHT block (the gym statue on its
+      -- plinth) is a statue on a pillar with ONE cell of footprint: the
+      -- block below already carries the whole base, so the drawn cell
+      -- becomes synthesized floor rather than a second block marching
+      -- the base backwards. Furniture supports (a monitor on its desk)
+      -- keep the box-extension below -- their drawn cell is the
+      -- furniture's own upper rows, and floor there would amputate it.
+      S.skip[k] = true
+      S.ground[k] = best
+    elseif support then
       -- the claimed tile keeps rendering as the box the prop stands on,
       -- wearing the art its own ROW would have without the drawing (the
       -- trim row stays trim); only when the whole row is the prop does
@@ -2130,7 +2161,11 @@ end
 
 function Structures.buildFlowers(S, map, tw, th, x0, x1, y0, y1, data)
   local templates = {}
-  local quads = S.objectQuads
+  -- flowerQuads, not objectQuads: flowers sit on WALKABLE cells, so
+  -- their mesh draws after the characters with the character pull
+  -- (ChunkMesher's flower mesh) -- terrain-baked they lose the depth
+  -- fight against the pulled card whenever the player stands among them
+  local quads = S.flowerQuads
   for ty = y0, y1 do
     for tx = x0, x1 do
       Budget.tick()
diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua
index c4d0742..d8b584b 100644
--- a/lib/VoxelScene.lua
+++ b/lib/VoxelScene.lua
@@ -460,6 +460,14 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
     ShadowMap.draw(nbMesh[i], atlasFor(nb.map),
                    Mat4.translate(nb.ox, 0, nb.oy))
   end
+  -- flower billboards live outside the terrain mesh (they draw after the
+  -- characters, pulled -- see render), but the sun still sees them: a
+  -- handful of cutouts per meadow, unlike the grass left out below
+  ShadowMap.draw(ChunkMesher.flowers(state.map), atlasFor(state.map), nil)
+  for _, nb in ipairs(state.neighbors or {}) do
+    ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
+                   Mat4.translate(nb.ox, 0, nb.oy))
+  end
   for _, p in ipairs(posed) do
     local def = p.sprite.def
     local frame, mirror = frameFor(def, p.facing, p.phase, p.flip)
@@ -559,6 +567,23 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
     Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map),
                  Mat4.translate(nb.ox, 0, nb.oy), pull)
   end
+  -- flower billboards: pulled like the characters and the grass, MINUS
+  -- the depth of 8 world pixels along the view (8 sin a -- the camera
+  -- looks along (0, -cos a, -sin a), so that is exactly one tile row of
+  -- northness). A pure depth handicap with zero screen drift: every
+  -- flower is judged as if it stood one tile row further north. The
+  -- character card's feet plane sits at its cell's MIDDLE (py + 8), so
+  -- a flower on the walker's own cell (z +4 or +12 across the cell)
+  -- lands behind the card and the player obscures the patch they stand
+  -- ON, while the nearest flower of the cell south (+20) stays in front
+  -- and keeps overdrawing their feet.
+  local fpull = math.max(0, pull - 8 * math.sin(math.max(Voxel.angle, 0.05)))
+  Voxel3D.draw(ChunkMesher.flowers(state.map), atlasFor(state.map), nil,
+               fpull)
+  for _, nb in ipairs(state.neighbors or {}) do
+    Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
+                 Mat4.translate(nb.ox, 0, nb.oy), fpull)
+  end
 
   return Voxel3D.endScene()
 end
diff --git a/main.lua b/main.lua
index 94f7fd8..9b01be9 100644
--- a/main.lua
+++ b/main.lua
@@ -344,9 +344,13 @@ end)
 -- tree that is no longer there.  The 2D tile renderer invalidates its own
 -- caches off the same edit.
 
+-- refresh, not invalidate: the stale mesh keeps drawing while the
+-- replacement builds in the background, so a one-block edit (Cut, a
+-- door stamp, the tree regrowing on re-entry) repopulates in place
+-- instead of blinking the whole scene down to the flat 2D path
 mod.events:on("world.block_replaced", function(payload)
   local mapId = payload and (payload.mapId or (payload.map and payload.map.id))
-  if mapId then ChunkMesher.invalidate(mapId) end
+  if mapId then ChunkMesher.refresh(mapId) end
 end)
 
 -- A reloaded map is rebuilt from scratch (warps that re-enter the same map,
@@ -372,7 +376,7 @@ mod.events:on("map.reloaded", function(payload)
   if mapId then ChunkMesher.invalidate(mapId) end
 end)
 
-mod.exports.version = "1.0.3"
+mod.exports.version = "1.0.4"
 -- exposed so a companion mod can pin its own tiles' shapes or read the
 -- camera without reaching into this mod's file layout
 mod.exports.lib = V
diff --git a/manifest.json b/manifest.json
index be6dca3..fa53bbb 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,7 +1,7 @@
 {
   "id": "DRAMATIC_SHAPE",
   "name": "Dramatic Shape Voxel Mod",
-  "version": "1.0.3",
+  "version": "1.0.4",
   "api": 2,
   "entry": "main.lua",
   "profile": "content",
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index 5b2320b..5447507 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -535,8 +535,10 @@ local ChunkMesher = run.loader.exports.DRAMATIC_SHAPE.lib.require("ChunkMesher")
 local Runtime = require("src.mods.Runtime")
 
 local realInvalidate = ChunkMesher.invalidate
-local dropped = {}
+local realRefresh = ChunkMesher.refresh
+local dropped, refreshed = {}, {}
 ChunkMesher.invalidate = function(id) dropped[#dropped + 1] = id or "<every map>" end
+ChunkMesher.refresh = function(id) refreshed[#refreshed + 1] = id or "<every map>" end
 
 Runtime.emit("map.reloaded", { mapId = "PALLET_TOWN", reason = "colors" })
 T.eq(#dropped, 0,
@@ -551,11 +553,16 @@ T.eq(dropped[1], "PALLET_TOWN", "and drops exactly the map that reloaded")
 Runtime.emit("map.reloaded", { mapId = "VIRIDIAN_CITY" })
 T.eq(#dropped, 2, "a reload with no stated reason is treated as a real one")
 
--- and the edits that genuinely change geometry are untouched by any of this
+-- a block edit (Cut, a door stamp, the tree regrowing on re-entry) is a
+-- REFRESH, not a drop: the stale mesh keeps drawing while the rebuild
+-- cooks, so the scene never blinks down to the flat 2D path
 Runtime.emit("world.block_replaced", { mapId = "PALLET_TOWN" })
-T.eq(#dropped, 3, "a replaced block still drops the mesh it changed")
+T.eq(#dropped, 2, "a replaced block does not drop the mesh outright")
+T.eq(#refreshed, 1, "it refreshes the mesh in place instead")
+T.eq(refreshed[1], "PALLET_TOWN", "and refreshes exactly the edited map")
 
 ChunkMesher.invalidate = realInvalidate
+ChunkMesher.refresh = realRefresh
 
 -- ------- the non-colour palette modes must not come through as SGB
 --
diff --git a/tests/voxel_anim_probe.lua b/tests/voxel_anim_probe.lua
index fd926d7..3ff4475 100644
--- a/tests/voxel_anim_probe.lua
+++ b/tests/voxel_anim_probe.lua
@@ -94,6 +94,8 @@ return function(game)
     if S then
       local def = ow.map.def
       for _, spec in ipairs(specs) do
+        if not spec.tile then goto continue end   -- "toggle" specs claim
+                                                  -- tile LISTS, not one slot
         local flat, prop, run, first = 0, 0, 0, nil
         for ty = 0, def.height * 4 - 1 do
           for tx = 0, def.width * 4 - 1 do
@@ -112,6 +114,7 @@ return function(game)
                .. " %d inside a prop%s"):format(spec.tile, flat, run, prop,
           first and (" -- first at cell " .. math.floor(first[1] / 2)
                      .. "," .. math.floor(first[2] / 2)) or ""))
+        ::continue::
       end
     end
   end