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>