Nine-slice and the sort line hung on the texture frame alone. Drawing
and sorting already read both off the TextureRef every picture hands
out, but the generated pictures always said "no slice, no line". So
the feature moves from the frame to the picture, without a second
copy of any rule.
- PictureCut (border, fill, sortLine) is one type. TextureFrame
extends it, PixelArtAsset and VectorArtAsset carry it as a whole.
The texture file format does not change, and no migration is needed.
- The rules in texture.ts take a cut and a size: cutSortLine,
setCutSlice, rawCutSlice and the rest replace the frame* versions.
serialize.ts reads all three through one readCut.
- cutTarget.ts resolves a target to cut and size: a texture frame, a
pixel frame's size or the vector canvas. It lives apart from
texture.ts because vectorart.ts reaches texture.ts itself.
- SetSliceCommand and SetSortLineCommand take that target instead of
(assetId, frameId). They refuse an asset that draws no picture.
- generatedRef fills slice and sortLine from the drawing. The whole
strip of a pixel art asset with several frames gets none, it is no
picture of that size. With one frame asset and frame are the same.
- Nine-slice scale is one shared schema row, now also on pixel art and
vector art.
- cutSection.ts holds the NINE-SLICE and SORTING rows once. The frame
inspector and the drawing inspector both build them.
- SheetCanvas draws a drawing's lines that are set, dashed as in the
Texture tab. They are not dragged there, the painting tools own the
canvas (as proposed in the issue).
- drawOrder falls back to the picture's height when a ref has no
rect, or a sort line on vector art would have been ignored.
Tests:
- Gate: 232 files, 3542 tests (+8: drawing-cut.test.ts, and vector
art in sort-by-y.test.ts). Three counter-proofs bite: no cut on a
drawing, a cut on the whole strip, no height fallback.
- e2e 48: 44 checks (+1, sum 1732). 48l-ii reads red and blue where
12-pixel borders at scale 2 put them. Without borders both spots
read green.
- Looked at: the Pixel art tab with the lines, and the sliced sprite.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The performance panel showed Physics and Editor as the large parts of
a frame in a scene of 222 entities. Nearly all of it came from a
tilemap that only stands there.
- Whoever writes cells in place now says so with cellsChanged. Only
then does cellsVersion compare the content. The comparison ran about
60 times per frame and was the largest single item at 14 percent.
- The four writers call it: the painter during and at the end of a
stroke, PaintTilesCommand on execute and on undo. A new writer that
forgets it stays invisible.
- The game view is drawn once per frame in play. With an animated tile
in view it was drawn twice, once by the run and once by animate().
animate() now gets the redraw count from the start of the tick.
- TilemapField builds the geometry of a mesh only when its source
changes: the cells, the tile size, the origin or the image. For that
groupByTile and layerRows return the same objects for the same list.
- PhysicsSystem skips a tilemap whose source and switch are unchanged.
The switch is kept because a map that starts switched off would
otherwise have dead colliders once it is switched on.
- World.syncToModel looks entities and assets up in a map per pass
instead of a linear search per object.
Measured on stein2 with a copy of the reference project, ms per frame:
Physics 2.64 to 0.97, Drawing 6.89 to 2.56, Editor 1.95 to 1.31.
Left open: syncToModel still mirrors every entity every frame. That
is its own issue.
Tests:
- Gate: 198 files, 2887 tests (2883 + 4), exit 0
- e2e 1-17,28,33,39-43,59: 810 checks passed, 40 has +3
- Smokes: 24 / 19 / 21, exit 0
- Three unit tests wrote cells directly and now call cellsChanged
- Eleven counter-probes fail. Two did not at first: the stroke needed
a check of its own, the physics test has to start with the map
switched off. That last one was repeated on stein2 only.
- e2e total 1613 is computed, not measured in a full run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The CI run for 30de4d3 failed on e2e 37b-ii: dragging the sort knob of
a nine-slice frame resized the frame instead (h 18, lifted null). The
knob's middle sits exactly 8 px left of the frame, which is also the
edge of the corner handle's 8 px tolerance. Until now only the rounding
of the pointer decided which of the two won. The slimmer header bars of
#245 moved that rounding to the corner handle's side. The bug itself
has been there since #228.
- sortLineAt bounds the knob where it is drawn, from a gap to the reach
left of the frame, instead of by the handle tolerance.
- TextureCanvas passes that gap in image pixels, scaled by the zoom.
- The corner handle stays reachable at the corner itself and to the
right of and below it.
Tests:
- ./check.sh: 178 files, 2576 tests, CHECK EXIT 0
- GM_STEPS=1,37: ALL PASSED, 26 checks (37b-ii: lifted 6, h 24)
- New unit test: the knob's middle belongs to the knob even inside
the corner handle's reach. It fails with the old bound.
- Full e2e run not repeated. 37b-ii was the only failure in CI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A sprite sorted by Y stood on the bottom of its box. With a shadow or
empty rows under the feet that is too low, so a figure slipped in
front of a tree it should stand behind. The line belongs to the frame,
not the texture, because one texture can hold several sprite sheets.
- New field `sortLine` on a frame: whole image pixels up from the
frame's bottom edge, 0 to the frame height. 0 is stored as nothing,
so old projects stay the same. Shrinking a frame keeps the line
inside it.
- `feetOf` in drawOrder.ts lifts a sprite by the same share of its
box. Scene view, game view, play and picking all use it. In an
animation the line of the frame shown right now counts.
- The texture tab draws the selected frame's line dashed in yellow
with a knob left of the frame. At 0 the line shares the bottom edge
with the nine-slice border and a resize handle, so the knob is
tested first. A line inside the frame can be dragged directly,
tested after the nine-slice lines.
- The inspector has a new "Sorting" section with a "Sort line"
field. Dragging or typing is one undo step each.
Limit: frames of one walk cycle need the same value, set by hand.
Otherwise the figure changes its place in the order while walking.
Tests:
- ./check.sh: 160 files, 2356 tests, CHECK EXIT 0 (before 2350).
- 6 new unit tests in tests/viewport/sort-by-y.test.ts.
- e2e 37 and 39-43: ALL PASSED, 162 checks. New check 37b-ii drags
the knob up 6 px and undoes it, plus the inspector row.
- Counter checks: if feetOf ignores the line, exactly the 3 order
tests fail. If the knob cannot be grabbed, 37b-ii fails.
- The e2e sum in the README is 1380, counted, not yet seen in CI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A tilemap layer was one piece in the draw order, so a figure stood
either in front of a whole layer of trees or behind all of it. For a
top-down game the trees in front of the figure's feet have to cover
it and the ones behind must not.
- A new "Sort by Y" box under the layer list sets `sortByY` on the
painted layer. Every change is its own undo step.
- Such a layer comes as one draw slot per row. On its `z`, the rows
and all objects on that `z` are sorted by where they stand: the
bottom of an object's box, the bottom of a row. Ties keep the
hierarchy order, and other `z` values are untouched.
- A layer drawn as one piece and the map's own node stay behind the
sorted ones on the same `z`, since neither has a single place to
stand. Ground belongs on a lower `z` or stays unsorted.
- A new tile property "Sort offset" (whole rows, -16 to 16) lets a
crown sort with the trunk below it.
- `TilemapField` draws a container per row for such a layer, and
picking takes the row into account, so a click picks what is drawn
in front. Scene view, game view and play share the same rule.
Limits: on a rotated map "down" is world Y. A layer sorted by Y costs
about one draw call per row and tile instead of one per tile.
Tests:
- ./check.sh: 160 files, 2350 tests, CHECK EXIT 0 (before 2340).
- 10 new unit tests in tests/viewport/sort-by-y.test.ts.
- e2e 39-43: ALL PASSED, 148 checks. New check 40p-iii ticks the box
and finds the slot of row 0, undo brings back the whole layer.
- Counter checks: without the Y sort 5 unit tests fail, with the
offset ignored 5, with no row in picking 1. If TilemapField draws
no rows, exactly 40p-iii fails in e2e.
- The e2e sum in the README is 1378, counted, not yet seen in CI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>