On a screen scaled to 125 % or 150 % the editor looked soft beside
a page the browser draws itself. The layout counts in whole CSS
pixels, and most of those fall between two device pixels there. The
GPU averages both: text loses its edges, and a 1 px line becomes a
pixel and a half of mixed colour. Now text and boxes are rounded to
device pixels, and a line is one device pixel wide.
- ui/pixelGrid.ts holds the rule. crispText() makes a text with
Pixi's roundPixels, crisp() does the same for a Graphics of
upright boxes, and hairline() is the width of one device pixel.
- Every text of the interface is made with crispText() instead of
new Text().
- crisp() only rounds where the scale is not a whole number. At
100 % and 200 % nothing changes.
- drawControlBox, drawHeaderBar and drawSectionBand draw their
lines a hairline wide and flush with the edge. The widgets that
draw lines of their own do the same.
- Two bands share exactly the line they draw, and a column still
ends on a whole pixel.
- The stage is nudged by 1/32 px where the scale is not whole. At
150 % every odd pixel is an exact half, and without the nudge one
band came out a row shorter than its neighbours.
- Curves, icons and everything of the game stay unrounded. Rounding
their points would bend them.
- The harness takes deviceScaleFactor as an option, and the hooks
deviceColumn, edgeContrast and pixelGrid read the frame as the
screen gets it.
Not in here: the subpixel smoothing a browser gives its own text.
Pixi draws a text into a transparent picture first, and there only
greyscale smoothing is possible.
Measured at 150 %, edge contrast before and after: top bar 31.5 to
47.8, the lists on the left 68.6 to 86.4, inspector 27.6 to 38.4.
At 100 % it is 46.5, 90.1 and 37.9, and the picture there is the
same as before pixel for pixel, apart from the fps display.
Tests:
- Gate on melektaus-atx: 206 files, 3114 tests (3104 + 10), exit 0
- Full run on melektaus-atx: 1655 of 1656 checks passed, smokes
24/19/21. The one that failed was a condition of the new step
62 itself: it scrolled an inspector that had nothing to scroll.
The step now makes the window lower for that reading.
- e2e 61,62 after that fix: 14 checks. The full run was not
repeated, and CI is switched off for now.
- Ten unit tests, nine in tests/ui/pixel-grid.test.ts
- Eight e2e checks in the new step 62, which runs at 150 %
- Eleven unit counter-probes fail tests
- Four browser counter-probes fail their checks. They ran before
the fix to step 62 and were not repeated for it.
- Screenshots looked at for 125 % and 150 %
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A vector drawing was one flat list of shapes. With many shapes the
list got hard to read, and several shapes could not be kept or moved
together. Now the shapes stand on layers, as in pixel art, and can be
put into groups that nest.
- A drawing has layers, each with a name and an eye. A hidden layer
is neither in the tab nor in the picture a sprite wears, and its
shapes cannot be pressed. One layer is the one worked on: the pen
and Add shape draw onto it, and SHAPES lists what is on it.
- The inspector has a list LAYERS over the shapes. Its plus adds a
layer, the cross on a row removes it with all on it, the last one
stays. A double-click renames, a drag sorts.
- Group (Ctrl+G) puts the selected shapes into a group, Ungroup
(Ctrl+Shift+G) takes it away. A group stands over its rows in the
list, the rows one step in, and a row is dragged into a group or
out of it.
- In the tab a press on a shape takes its outermost group, and a drag
moves the group as one undo step.
- The model has layers with a tree of shapes and groups instead of a
flat shapes array. The rules are pure functions in
core/model/vectorTree.ts. A drawing saved before opens with all its
shapes on one layer.
- SortableList takes rows with a depth and drops into a tree. The
flat lists of tilemap and pixel art run as before.
Decided here:
- A group has no transformation of its own and cannot be hidden or
locked. Rotating and scaling is the follow-up #291.
- A group is never empty: the one that loses its last shape goes
with it.
- Several selected shapes are dragged together, grouped or not, and
a click on a shape of the selection keeps the selection. This is
what #217 asked for.
- Shift+click selects like Ctrl+click. With Select, Shift pans only
beside the shapes, as in the scene view. The middle button pans
anywhere, and with the pen and Paint Shift pans as before.
- A row of the list picks one shape of a group alone.
- Shapes change their layer through a row Layer in the inspector,
which is there from two layers on. Rows are not dragged between
layers.
- A group cannot span two layers, the button is off then.
- The pen does not draw onto a hidden layer, the hint says so.
- Groups are always shown unfolded.
- The four order buttons work inside the own group or layer.
Tests:
- Gate: 204 files, 3067 tests (3008 + 59), exit 0
- e2e 47,48: 108 checks. e2e 39,40,47,48 before the Shift change:
209 checks. No full run, the sum of 1645 (1639 + 6) is reckoned
and CI measures it.
- 59 unit tests: 36 in tests/core/vector-tree.test.ts, 23 in
tests/editor/vector-layers.test.ts
- Six e2e checks with a real mouse in block 47j
- 47 unit counter-probes for layers and groups and three for the
Shift click all fail tests, run on stein2
- Three browser counter-probes fail their checks
- Two screenshots looked at (group in the list, hidden layer)
- Only unit tested: the row Layer, sorting and removing layers,
copying a group, the Shift pan beside the shapes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Four editors edit points, and all of them put a point down on a single
press on the line and took one away on a double-click. A stray click
added a point, and there was no way to see which point a key would hit.
All four now work the same way: the Curve tab, the Vector art tab, a
polygon collider in the scene view and a frame's collision outline in
the Texture tab.
- A double-click on a line, edge or segment puts a point there, in the
Curve tab on free space too. The point is picked and stays in place.
It can be dragged with the next press, as answered in the issue.
- A click on a point picks it, and it is drawn filled. A double-click
on a point takes nothing away any more.
- Delete or Backspace takes the picked point away. Without a picked
point the key removes the shape, the object or the outline as before.
- A right-click on a point picks it and opens a menu with Delete
point. In the Curve and Vector art tab the four point types stand
below it. Alt-click stays.
- A polygon keeps three corners and a curve keeps its ends. The entry
is greyed out then.
- The pick ends with a click beside the points, with another object,
shape or frame, and after the point is gone.
- editor/pointGestures.ts holds the rule as a pure function, and
editor/pointMenu.ts builds the menu for all four.
Decided here:
- A single click on a line no longer adds anything. In Vector art it
drags the shape, on a polygon it drops the pick, and in the Texture
tab it makes that outline the active one and drops the pick.
- In the Texture tab a click inside the frame still appends a corner,
and that corner is picked. Delete takes the last corner first. To
remove the whole outline, click one of its edges before.
- The point the Pen put down last is picked, so Delete takes it and
not the shape.
- Delete hits the picked point also when the tab has lost the keyboard
focus, after a click in the shape list or after the menu.
- A vector shape that loses its last point is removed whole. Before,
an empty shape stayed in the list.
Findings:
- Fixed: the new hint line in the Texture tab was cut off at the edge.
The shorter text was not looked at in a picture again.
- Not fixed: in the scene view the collider's name label covers a
corner that sits near the centre. That was so before.
Tests:
- Gate: 200 files, 2967 tests (2944 + 23), exit 0
- e2e, full run: 1629 checks passed (1625 + 4), exit 0
- Smokes: 24 / 19 / 21, exit 0
- After the full run an unused parameter and a comment were tidied.
For that final state the gate and e2e 42,47,53 (105 checks) ran
again, the full run did not.
- 23 unit tests in tests/editor/point-gestures.test.ts, for the rule
and for each of the four editors
- Four e2e checks with a real mouse, two in module 42 and one each in
47 and 53. The existing checks for adding and removing a point use
the new gestures.
- 20 unit counter-probes all fail tests, run on stein2
- Two browser counter-probes fail, the build went through both times.
Without the Delete wiring the key removes the collider. Without the
right-click hook the menu is missing, and that run stops in module
42, so 47 and 53 were not reached with it.
- Three screenshots looked at, the picked point is filled. None of
the Vector art tab.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Recurring cleanup round over the code since round 6 (056a622), 105
commits. Behaviour is unchanged. Duplicated code in src went from 29
clones with 229 lines (0.32 %) to 14 clones with 109 lines (0.15 %),
measured with jscpd and the same settings as round 6.
- BehaviorSystem.ts shrinks from 941 to 589 lines. Collecting the
pulses (keys, timers, collisions, animations) moved to
behaviorPulses.ts. The NodeContext the nodes receive moved to
behaviorContext.ts. Imports from outside stay the same, TimeScaled
is still exported from BehaviorSystem.ts.
- PointKindButton and drawPointGrip replace the "Point:" button with
its menu and the point and handle drawing that the curve and the
vector art tab each had on their own.
- lerpVec, cubicAt and splitCubic in src/core/util/vec.ts replace four
copies of the cubic curve and two de Casteljau splits.
- mapNested in Properties.ts is the one walk over lists and structs
that liveObjects and objectRefs did separately. withFieldRenamed
and withoutField replace three copies of renaming a struct field
and two of removing one.
- jsonBody and staleRevision build the JSON body and turn a 409 into
StaleRevisionError for all stores. The auth store turns a 401 into
its error in one place.
- Smaller helpers: structAssetFrom for the two "Make struct type"
commands, shownObject in the inspector, selectedItem for the size
gizmo and the polygon corners, solidTiles in the tilemap collision,
requireVariablesAsset and requireBehaviorVariable.
- Left as is on purpose: the plain forwarding from the collision and
physics systems to Solids, the pointer wiring of the canvases and
the identical last lines of the two make commands.
- Removed unused code: tilemapTileIds and tilemapsUsing were never
called since #128 and #131. EditorState.runtimeProject became unused
with GameRun in #266. The assetId getter of the make commands was
read only by a test.
- 60 values used only in their own file lost their export. The shader
and particle node catalogues stay exported as in round 6.
- Five comments that told history now describe the current state. The
doc comment of BehaviorSystem sits on the class again.
- README lists the struct, clip area and curve assets and mentions the
game link from #266.
Findings:
- Deleting a tile that is painted on a tilemap leaves its cells in
place, and they draw nothing. The delete dialog does not mention the
tilemaps. That was probably the job of the unused tilemapsUsing.
Left as is, because fixing it changes behaviour.
- The solid filter for tile surfaces was untested. A bent filter
stayed green. A new test in tests/core/surface.test.ts now fails
against it.
Tests:
- ./check.sh: 195 files, 2815 tests, exit 0 (one test removed with
tilemapsUsing, one added for tile surfaces).
- Full e2e run: ALL PASSED, 1580 checks.
- Smokes jumprun, tetris, actor: 24, 19 and 21 checks, all passed.
The example project files were restored byte for byte afterwards.
- Counter-checks, 12, all fail as expected: 10 in unit tests (among
them mapNested, splitCubic, cubicAt, staleRevision, the stay pulse,
the late timer and the time scale), 2 in the browser (Point button
always enabled fails 47, selectedItem without a hit fails 29).
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>
f4cc681 fills tilemap solids only for the selected map and layer, and
texture collision only for the selected frame. Neither the scene view
wiring nor the texture canvas had a test for that.
- __gm.tilemap.solids() now reports the cells that were filled, and
__gm.texture.drawInfo() counts the filled outlines.
- e2e 40l-iii: a selected tile fills nothing. The map selected with
the upper layer fills only cell (4,0), with the lower layer only the
3x3 block. Two selected objects fill nothing again.
- e2e 42: a selected frame fills its one outline. Deselected it fills
none, while the outline stays visible.
- README check total goes from 1375 to 1377. CI confirmed 1375, the
new total is counted, not yet measured.
Tests:
- ./check.sh: 159 files, 2337 tests, CHECK EXIT 0
- e2e 39-43: ALL PASSED, 147 checks (before 145)
- Counter checks: a scene view that fills every map fails only the
tilemap check. A texture canvas that fills every frame fails only
the texture check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The faint fill on every frame outline and on every tilemap made the
scene and the texture editor look green all over. Like the collider
before, the fill now shows only on what is selected, and a bit
stronger, so it marks the thing being worked on.
- Texture collision: all frame outlines stay visible. Only the
outlines of the selected frame are filled, at alpha 0.3 instead of
0.15 on every frame.
- Tilemap: the outline still shows where the whole map blocks. The
fill appears only while the tilemap alone is selected, and only for
the part the layer chosen in the panel adds. Solid is 0.3 and
one-way 0.15, before 0.12 and 0.06 on every map.
- New query layerSolids(layerId) in items.ts. It builds the same
solids for one layer and runs only for the selected map.
- The small landing wedge on one-way edges stays filled. It belongs
to the edge marker, not to the area.
Not covered by tests: the texture canvas and the scene view wiring
that picks the map and the layer. Neither was looked at in a browser.
Tests:
- ./check.sh: 159 files, 2337 tests, CHECK EXIT 0 (before 2334)
- e2e 1-17,39-43: ALL PASSED, 668 checks
- 3 new unit tests in tests/viewport/tilemap-fill.test.ts
- Counter-check: with layerSolids ignoring the layer, exactly the
layer test fails. Original restored, checked with cmp.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With a large sheet the texture tab stalled on every mouse move. A
profile over a pan with 1728 frames showed two causes. Half of the time
went into convexPieces, which split every outline into convex pieces
only to pick its colour. And every pointer move during a pan rebuilt
and re-tessellated the whole overlay geometry.
- TextureCanvas draws on two layers. `sheet` holds every frame except
the selected one and is rebuilt only when the zoom, the selection or
one of those frames changes. `overlay` holds the selected frame, its
handles and the rubber band of a new frame.
- Both layers are drawn without the pan. A pan only moves the
container and places the name labels again.
- SheetCanvas has a new hook `panned()`. By default it calls `redraw()`
as before, so Pixel Art and Vector Art are unchanged.
- The validity of an outline is remembered per outline and comes from
polygonProblem, with the same result. This works because the model
replaces an outline list on every change and never edits it in
place, the same as frameOutline.ts already relies on.
- The name plates stay below the outlines and the texts on top. Checked
on a screenshot: the corner handle next to a label sits on top.
- New debug hook `__gm.texture.drawInfo()` with the rebuild count, the
outline count and the screen positions of outlines and names.
Measured with a throwaway probe, 1728 frames, 120 moves, main thread
time per move, before and after in the same browser:
- Middle-click pan: 76.9 ms -> 4.7 ms (4.4 ms without frames)
- Dragging a frame: 51.7 ms -> 10.1 ms
- Wheel zoom: 52.2 ms -> 30.9 ms
- Selecting another frame: 67.6 ms -> 41.0 ms
Findings:
- Wheel zoom and selection still rebuild `sheet` once on purpose. That
stays a single hitch per notch or click on very large sheets.
Tests:
- ./check.sh: 148 files, 2214 tests, CHECK EXIT 0.
- e2e 1-16,37,39-43 on the final build: ALL PASSED, 641 checks.
- Three new checks in 42c-ii. A corner drag does not rebuild the other
frames. An undo on an unselected frame reaches the picture (1 -> 2
outlines). A pan moves outlines and names by exactly 40/25 px without
a rebuild.
- Counter-checks: a missing outline in the cache key and a pan that
does not move each fail at their check. The selected frame in the
key fails alone with 9 rebuilds during an 8-step drag.
- Check total 1333 -> 1336 (confirmed by CI at ceaa44e, task 259).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Scene view, texture tab and graph called state.preview() on every
pointer move. Each call rebuilds panels, the editor run and generated
images, so a drag in a busy project cost up to 34 ms per move. The
model is still written on every move. Only the preview now waits for
the next frame.
- New FramePreview in src/editor/framePreview.ts. request() goes into
the move handler, cancel() into the release. The rule lives in one
place instead of six copies.
- Scene view: object drag, polygon corners and tilemap strokes use it.
- Texture tab: frames, slice edges and collision corners use it.
- Graph: nodes, groups and comments use it through GraphDrags. That
covers the state machine, shader and particle tabs as well.
- Vector art is unchanged. Its frame already writes the model since
#129.
Findings:
- The texture tab paid twice per move (232 project events for 120
moves). Pixi sends a second pointer event 4 to 8 ms after each real
move as a hover update. It now lands in the same frame for free.
- Inspector scrubbing and gradient stop dragging still preview on
every input. Both lie outside the three canvases of the issue.
Measured with a throwaway probe, 120 moves at 8 ms, 120 extra objects:
scene 26.2 -> 18.5 ms/move, texture 34.2 -> 17.9, graph 28.8 -> 17.0.
Project events dropped from 120/232/119 to 48/48/41.
Tests:
- ./check.sh: 147 files, 2204 tests, exit 0 (before 146 / 2199).
- 5 new unit tests in tests/editor/frame-preview.test.ts, including
the real GraphDrags. Counter-checks (direct preview in the graph,
no cancel on release, one key per name) each fail the expected test.
- e2e 1-16,29,31,36,37,39-43: 708 checks passed.
- e2e 1-16,26,28: 634 checks passed.
- No new e2e check, the check sum stays at 1314.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Recurring cleanup round. Behaviour is unchanged. Duplicated code in
src went from 27 clones with 299 lines to 6 clones with 56 lines
(jscpd, the two identical OFL licence texts not counted).
- SheetCanvas is the new base of the texture, pixel art and vector art
canvases. It holds background, mask, hint, panning, wheel zoom, fit,
view save and restore and the screen mapping. Pixel art keeps its
whole-pixel panning and fixed zoom steps.
- mixColor in src/core/util/color.ts replaces three copies in
animation, gradient and particles. It clamps t like the particle one.
- fingerprint now lives once in generated.ts, next to the revision
contract from #185.
- MoveCommand next to RenameCommand is the base of the three reorder
commands for tile pictures, tilemap layers and vector shapes.
- The two raycast nodes share inputs, settings and filter via rayOf.
- canvasRect() lives once in AssetTabEditor.
- Removed unused code: findPixelArt, isDrawableShape,
GeneratedPictures.has and SnapToggle.snapping. Tests now check the
same things through equivalent paths.
- 38 values used only in their own file lost their export.
- Three comments that told history now describe the current state.
The bundle stayed md5-identical for those edits.
- README lists vector art, pixel art and palette, and links the two
wiki pages.
Findings:
- PaintTilesCommand.changes is meant to prevent empty undo steps but
has not been called since #128. The rectangle tool therefore records
a no-op undo step when it paints the same tile over itself. Left as
is, because fixing it changes behaviour.
- No test covered middle-click panning in any sheet tab before. 13f
and 48a now check it.
- The default values of the ray inputs were untested. Length 1000
could become 10 without a red test. tests/core/raycast.test.ts now
covers it.
- CI measured 1308 editor checks at d7c0b7c, not the 1307 counted for
#164. The sum is now 1310 (1308 + 2), computed.
Tests:
- ./check.sh: 144 files, 2143 tests, exit 0 (before 2142).
- e2e 1-16,37,39-43,47,48 fresh: ALL PASSED, 729 checks.
- e2e 1-13,47,48 with the new checks: ALL PASSED, 518 checks.
- Counter-checks, each restored via cp and cmp: halved panning fails
both new pan checks; unclamped mix fails 1 test, no blue channel 3;
coarser hash 3; broken reorder undo 2; triggers setting forced on 2;
bent ray length 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After signing out and back in, a texture fill stayed in its base
colour until something else redrew the vector tab. The texture
library is a singleton that outlives the editor. Four widgets
subscribed to it and never unsubscribed. After a new sign-in the
first loaded texture called a destroyed canvas, which threw and
stopped the loop before the vector tab heard about the image.
- TextureCanvas, TileCanvas, ShaderPreview and WorldViewport now
unsubscribe in destroy(). This is the pattern FontPreview and
VectorArtCanvas already use. WorldViewport leaves both the
texture and the font library.
- FileLibrary.notifyChange catches errors per listener. A broken
listener no longer starves the others. The error is still
logged as "[textures] a listener failed".
- e2e 47i no longer forces a redraw. It waits up to 3 s for the
tab to show the texture on its own.
Findings:
- The trigger was e2e module 45 (sign out, sign in), not the
texture fill. My note in #196 that the browser showed no
exception was wrong. The counter-check shows it clearly.
Tests:
- ./check.sh: 137 files, 2051 tests, exit 0 (before 136 / 2049)
- 2 new unit tests in tests/viewport/file-library.test.ts
- GM_STEPS=45,47 after a fresh seed: ALL PASSED, 71 checks
- Counter-check with the old source and the new check: 1 FAIL
(shape stays #0000ff) plus the TypeError. Old FileLibrary.ts
fails the first unit test.
- No full e2e run locally. The check count is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drawing an outline by hand for every frame of a sheet is slow, and the
outline mostly follows the opaque part of the image anyway. The texture
tab now traces it: one click, a corner budget, and every Frame
collision collider and tile using that frame blocks with the result.
- "Generate collision..." sits in the texture tab header and is off
while the texture has no frames.
- The dialog has Max points (default 8, 3 to 64), Solid from ... %
alpha (default 50) and All frames of the texture. A live line says
what Generate will write, and Generate stays off when nothing is
solid enough.
- traceCollision walks each island of solid pixels along the pixel
edges, then drops the least important corners until at most N are
left and no stair step remains. A slope becomes one straight edge.
- An outline never crosses itself. Where the image would force that,
the island's convex hull is taken instead.
- Crumbs under 1 % of the frame area are dropped unless they are the
only island. Holes are not reproduced.
- Existing outlines are replaced. One frame or all frames is a single
undo step.
- TextureLibrary hands out the sheet's RGBA data, and a pure function
cuts a frame's region from it.
Findings:
- The wedge in the e2e fixture gets 4 corners, not 3. Its tip is one
pixel wide in the image, and that real edge stays while corners are
still free.
Tests:
- ./check.sh: 131 files, 1941 tests, CHECK EXIT 0 (was 130 / 1931).
- 10 new unit tests in tests/core/trace-collision.test.ts, including
150 random shapes that must all be valid and within the budget.
- GM_STEPS=42, freshly seeded: ALL PASSED, 20 checks (was 16).
- Three counter-probes bite: no hull fallback, no stair threshold,
dialog ignoring the alpha threshold.
- Check sum 1202 in README is computed (1198 + 4), CI measures it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Refactoring round one: HttpFontStore/HttpSoundStore/HttpTextureStore collapse into a generic
HttpFileStore over a FileStore contract; fontImport/soundImport/textureImport share assetImport
with an ImportKind per sort; ImagePicker/FontPicker/SoundPicker sit on FilePicker and describe
only their row. The group/comment drag logic leaves GraphCanvas for annotationDrag.ts (with its
own unit test), declaredRows.ts replaces the duplicated row build in particles.ts and shaders.ts,
and dead code in font/sound/compile/emission/transform/actions is gone. README: asset list,
check count 958, the two-job CI, the Scenes wiki page.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
TextureFrame carries optional `border` (left/right/top/bottom) and
`fill` (stretch | repeat); absent keys mean "not sliced", so no format
bump and undo restores the plain frame instead of four zeroes.
The sliced sprite builds its own geometry instead of the shared
UNIT_QUAD: a 4x4 grid, and for `repeat` one quad per tile carrying the
frame cell's real UVs -- tiling in the geometry, not in the sampler, so
a frame out of a sheet cannot pull its neighbour in. Above 64 tiles per
axis it stretches, to bound the buffer.
A sprite narrower than left+right scales both borders down together
until the middle is exactly 0, so the corners meet at a seam and never
overlap.
The texture editor gets four draggable border lines plus the same four
numbers in the inspector. Hit order is resize handles, then the lines,
then the frame body -- handleAt reports 'move' anywhere inside a frame,
so lines placed after it were unreachable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Typing "variable" into the node palette landed on `Run behavior`, because the
filter weighed label, type and description alike and then sorted by category.
`searchRank` in `src/ui/search.ts` now ranks a match: exact label or type, then
prefix, then label, then type, and only last a hit that lives in the
description alone. Category and alphabet order within a rank. The searchable
`Menu` uses the same function, so searching means the same thing everywhere.
The `+` menu grew to fifteen entries in declaration order. It is built in two
alphabetical groups now -- the types that go into a scene, then the ones that
stay in the library -- with a separator between them, the split `isPlaceableType`
already draws. `ASSET_TYPES` itself stays untouched: it is also the
serialisation and check order. A search flattens the groups and drops the lines.
Popups were capped at fixed heights (320 menu, 260 palette, 340 pickers) while
the canvas had room to spare. `Popup.roomFor()` measures its own chrome and
takes the window height minus a tenth at each end. Placement clamps
asymmetrically: the bottom margin is enforced, the top is only a budget, so a
menu anchored near the top stays at its button instead of being pushed off it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The twin of the font picker fixed earlier today: every tile was a Graphics
carrying its sprite and caption, which Pixi v8 answers with "addChild:
Only Containers will be allowed to add children". The background moves
into its own Graphics inside a Container, and the step watches the console
while the picker opens, as the font one does.
Editor suite: 784 checks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Only the picture was masked, so the checkerboard, the frame outlines, the
handles and the name plates were drawn over the panel next door once the
sheet was zoomed past the tab. The dark block in the report is the
checkerboard's base: checker.ts fills the whole image rect and clips only
the light squares to the widget.
Everything that pans and zooms now sits in one masked container, the
pattern WorldViewport and AnimationCanvas already use. A check samples
pixels outside the tab and names the frames drawn past it; a guard in
front of it asserts that zooming really pushes the sheet over every edge,
or the pixel probes would measure nothing and stay green forever.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An upload sends its file name next to the raw bytes, and the server keeps
it in a sidecar next to the content-addressed file. Both pickers caption
every tile and row with it, and an asset made there is named after the
file instead of "Texture" / "Font". Last upload of the same content wins.
The API's own set_error_handler ignores error_reporting(), so @-silenced
warnings still throw: a missing sidecar took the whole listing down with a
500. Existence is checked instead of suppressed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An animation asset is a list of pictures (whole textures or frames) with
frames per second and Loop/Once/Ping-pong playback. A sprite wears it
through its texture property, so the picker, instance overrides and the
Properties node work unchanged; there is no dedicated play node.
A different animation starts from its first frame, the same one set again
runs on, so a graph may choose Run or Idle every frame. The clock lives on
the entity, not in props, which the world writes back as overrides. The
AnimationSystem ticks before behaviors, and On animation finished fires
once when a Once animation reaches its end.
The Animation tab plays a preview above a frame strip: reorder by dragging,
Delete, duplicate, + Frames, pause/resume, and wheel zoom about the pointer
with pan and Fit. Scene and Container tabs show the first frame; edit mode
has no clock. Right-clicking a texture creates an animation from its frames.
Button now measures its label with the bold font it draws in, which cut
the Animation tab and red dialog buttons short.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PropertyValue was number | string | boolean | list. A struct joins it, which
costs two things the typechecker found rather than I did.
An object on a data edge used to mean a live entity, so asEntity would have
taken a struct for one and asString would have read .name off it. EntityHandle
now carries a symbol brand – a symbol so no field name can impersonate it, and
it never serializes, which suits a thing that exists only while the game runs.
It is built in exactly one place.
String(value) would have written a struct as [object Object] at 39 call sites.
They go through textOf(), which is identical for primitives.
normalizeGraph dropped everything but primitives from node params, so the List
node lost its value on every save ([3,1,4] round-tripped to undefined).
Variables were spared: they have a schema, params have none.
objectSection.ts had grown to 559 lines around three separable parts: the
queries, the vector rows and the parent row. Four object commands repeated the
same capture/restore preamble – applyCommands stays out, its five carry extra
undo state whose restore order matters.
58 dashed banner comments are gone. The nine labels in nodes/index.ts stay:
they are the only structure a flat 90-entry array has.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
src/viewport/ held three classes only src/editor/ ever used, and framing.ts sat
under graph/ although the scene view wants the same bounds sum. The graph, the
texture and the container tab repeated the same header, subscription and
disposal code with two silent divergences: the graph tab said "nothing shown"
with null instead of '', and the texture tab refell back to the first asset on
a selection change but not on a project event.
These arrive together because GraphCanvas ties them: it reads framing from its
new home, hands its fold marks to folding.ts, and its '' change is what
GraphEditor must agree with.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both stores carried their own ~28-line request<T>, and formatBytes existed
twice – the second copy had lost the "B" branch, so a small sprite would have
read as 0 KB had its caller ever passed one under a kilobyte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"+ → Texture" opened the machine's file dialog, so a picture that was uploaded once
but lost its asset – an undone import, a deleted asset – was unreachable from the
editor while still sitting on the server. Nothing collects those files, by design.
It now opens a grid of every picture the server holds, with thumbnails: pick one and
"Use image" makes a texture asset on that file, "Upload…" is the way in for bytes the
server does not have yet and lands in the same import as a drop. A tile captioned with
an asset name is one this project already wears – picking it selects that asset instead
of making a second one, since two assets on one file would fight over `filter`.
- `GET ?r=textures` now reports `width`/`height`; a texture asset holds the pixel size,
and reading it off the GPU texture would tie the asset's correctness to load timing.
- `TextureLibrary.load(file)` loads without touching `scaleMode` – a thumbnail must not
restage the filter of a picture the scene is drawing with.
- The grid geometry lives in `texture/imageGrid.ts` as pure functions; inside the widget
no test could reach it.
- The part after an upload is now one function shared by the drop import and the picker,
so "one file = one asset" is stated in a single place.
12 new E2E checks; the strongest one undoes an import, leaving the file on the server
with no asset naming it, and picks exactly that file back out of the dialog.
`decimals` only reached `formatNumber`, so it changed the label and nothing else: typing 640.5 into
a field with `decimals: 0` stored 640.5 and displayed 641. The two existing users got away with it
because rounding happened outside the widget (`normalizeFrameRect`, the slice dialog), which their
comments had to point out.
`NumberField.clampValue` now rounds before it clamps, and every path that sets a value goes through
it (typing, scrubbing, arrow keys, the constructor), so the stored number matches the shown one and
`min`/`max` stay exact. The rounding is `roundTo` in math.ts, which `snapToStep` already did inline.
With that safe, camera `View width`/`View height` and text `Font size` become integers – they were
showing 640.000 and 32.000. Everything else that looked integral is not: positions, sprite and
collider sizes and zoom are all scrubbed, and `z` is a summed coordinate (walk.ts), not a layer.
Verified remotely against the RTX 4070 host: 270 checks, all passed (step 10 gained a check that the
field text has no decimal point and survives a reopen). `npm run check` green, 407 tests.
Scene labels used `small` (11 px), the Texture tab used `body` (13 px). Both now
read `Theme.viewportLabelSize` through two tokens that differ only in colour.
Dropping the per-frame `label.style.fill` assignment matters: textStyle() returns
shared cached instances, so with both views on one token it would have recoloured
the scene labels too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Remove comments that only restate the code, keeping the section banners
and the notes that carry a reason, a measured fact or an external
contract.
ButtonOptions.width claimed to be a fixed width but only set minWidth,
so the Graph and Texture pickers grew with the name they showed. It is
now what it promises, with a separate minWidth for the buttons that have
two forms. Narrow buttons shrink their padding, otherwise a 24px icon
button had 8px left for its glyph and truncate dropped it entirely.
__gm.labelsDropped() guards that class: the overflow guard only saw text
that was too wide, never a label truncated to nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- NumberField takes a decimals option (default 3); frame rects and the
slice dialog use 0, matching their integer model.
- Button truncates its label to the drawn width; Dropdown reserves the
chevron slot. NodeView reuses the shared truncate().
- Setting scaleMode leaves the style's shared resource id in place, so
the old GPU sampler stayed in use until a reload. Call style.update().
- Graph gets a header bar like the Texture tab: GraphEditor is now the
frame, the former editor is GraphCanvas. A selection the tab does not
concern no longer unloads the behavior.
- New e2e step 14 guards text overflow across all widgets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A new Texture tab next to Scene/Game/Graph shows a texture sheet and lets
frames be drawn on it: drag to create, move, resize via eight handles, edit
name and rect in the inspector, Delete to remove. Every change is a command,
so one gesture is one undo step. "Slice..." cuts a grid from columns and rows
with optional offset and spacing, previewing the resulting frame count and
cell size live.
Sprites pick a frame instead of the whole texture through the same `texture`
property: it now holds either a texture id or a frame id. Frame ids come from
the same generator and are document-wide unique, so one picker, one property
and one set of instance overrides cover both cases, and every sprite written
before this still means the same thing.
A Graphics fill ignores a Pixi sub-texture's frame and draws the whole image,
because it derives its uvs from the shape bounds and the inverted style
matrix. The cutout is therefore a matrix (`viewport/textureFill.ts`), which
keeps a sheet at a single GPU upload no matter how many of its frames are on
screen at once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>