Design scoped resources - orlandomenke/Lightbox GitHub Wiki
Follows Q30, which settled that a character is a folder carrying character data and that resources declared on a folder accumulate down the tree with the nearest declaration winning ties. This works out what that means for the rest of the shared machinery, driven by four workflows the owner supplied from game development.
The headline, before the detail: the four workflows need two different mechanisms, not one β and the codebase already has both, each attached to a single resource and unavailable to the others.
characters/
knight/ β palette, reference sheet, export config
locomotion/ β animations
combat/
abilities/
characters/
knight/ β palette
walk.lightbox β animations, directly
run.lightbox
These two differ only in depth, and that is the point of including both. The artist's gesture is identical β the palette belongs to the knight β and the resolution has to be identical too. This settles three things:
- Resolution walks up until it finds one. Not "the parent", not "two levels". Depth is an organisational choice and must not be an authoring one.
-
Reorganising cannot break resolution. Splitting
locomotionintolocomotion/groundandlocomotion/airadds a level, and everything still resolves because the walk still reachesknight. This is the property that makes the folder tree safe to rearrange, and it is the strongest argument against the explicit per document option that Q30 rejected β that one breaks precisely here. - A resource is declared once, where it conceptually belongs. Never re-declared per subfolder, and never declared on each document.
"One large environment document outlining all environments as reference. Both the environment assets and the characters could benefit, so project-wide distribution proves valuable."
environments/
overview.lightbox β lives here, must be visible everywhere
characters/
knight/combat/attack.lightbox β wants to draw against it
Cascade cannot express this. environments/ is not an ancestor of
characters/knight/combat/, so no amount of walking up reaches it. This is a
sideways reach and it is a different mechanism.
The naive fix β declare it at the project root β is wrong for a reason worth
stating: the document belongs in environments/. Filing it at the root to
make it visible would make the tree lie about what the thing is. So:
Where a resource lives and how far it reaches are two different properties, and this workflow is the proof.
"A tool in the game and environmental storytelling. Characters, environments and props may want to use it. Project-wide distribution."
Structurally identical to 3, and already solved β by symbols. From
Doc.Symbols, unedited:
A symbol normally lives on the project, above the animations that place it; that is what makes editing the sword once change every animation holding it.
The existing design already reached this workflow, with this example. What it cannot do is the opposite: there is no way to scope a symbol to the knight.
Checking the design against the roadmap turned up the thing that should govern it. Three shipped features independently invented a scope chain, and every one of them landed the same two properties.
| Feature | Roadmap | The chain | Precedence pinned by | Renders-alone pinned by |
|---|---|---|---|---|
| Brush tips | Pillar 0, "A tip library, scoped like palettes: project, user, and inlined on export" | user β project β inlined into the document on export | AProjectTipComesBeforeAUserTip |
DeletingFromTheLibraryCannotChangeADrawing |
| Symbols | Pillar 3, "Global and project symbol scopes β a personal library beside the project's" | global library β project, copied on place |
PlacingAGlobalSymbolCopiesItIntoTheProject, EditingALibrarySymbolDoesNotReachIntoAProjectThatPlacedIt
|
TheProjectRendersWithTheLibraryGone |
| Palettes | Pillar 1, "Shared palette across a character's animations" | project β character β document | TwoAnimationsUnderOneCharacterPaintFromOnePalette |
colour stored literally beside SwatchId
|
Three consequences, and they are worth more than anything I would have designed from scratch:
-
The determinism boundary is not a proposal, it is already the rule and
already tested.
DeletingFromTheLibraryCannotChangeADrawingandTheProjectRendersWithTheLibraryGoneare the same assertion twice, written by whoever built each feature without a shared statement to point at. The section on it below is a restatement, not a new constraint β which is the strongest possible position for it. - The tip item literally says "scoped like palettes". The intent to unify was written down; what was missing was a mechanism to unify onto.
-
There is already a verb for moving a resource up a scope: promote.
PromotingCopiesUpAndKeepsTheIdis symbols' existing gesture, and it is exactly what "publish this palette project-wide" needs. Reuse the word.
And one correction this forces on the model below. Tips and symbols scope on a user β project axis β a personal library that outlives any one project. Q30 adds a tree axis, folder depth within a project. These are different axes and both are wanted: a personal tip library is not a folder, and a knight's palette is not a machine-level preference. So the full chain is
user / global library β project β folder path (walk up) β document
with nearest winning throughout. Designing only the tree axis would have stranded the two features that already ship the other one.
| Reach | Today | Serves | |
|---|---|---|---|
| Cascade | ancestor β descendants | palettes (via Character.PaletteId), references (via Character.References) |
workflows 1, 2 |
| Publish | anywhere β everywhere | symbols, Manifest.Palettes, Manifest.Brush, Manifest.Tips
|
workflows 3, 4 |
Both exist. Neither is available to the other's resources:
- Symbols are project-wide and cannot be scoped down. Every symbol in a project is offered to every document, which is right for the sword and wrong for a knight-specific prop in a project with forty characters.
- Palettes and references are scoped and cannot be published. They attach to a character, so the environment reference in workflow 3 has no way to reach the knight.
So the redesign is not inventing a mechanism. It is making the two that exist available to everything, and letting one declaration choose between them.
A shared resource is declared at a scope β any folder, or the project root β and carries a reach:
| Reach | Meaning | Default? |
|---|---|---|
Subtree |
visible to documents at or below the declaring folder | yes β writes no key |
Project |
visible to every document in the project | opt-in |
Resolution for a document is: walk from the document's folder to the root,
accumulating every declaration; then add every Project-reach declaration from
anywhere. Nearest declaration wins ties, and a Subtree declaration nearer the
document beats a Project one β locality is the tie-break, so the knight's own
red can override the studio's red without unpublishing anything.
Declaring at the root with Subtree reach and declaring anywhere with Project
reach converge on the same visibility, which is a consistency check rather than
a redundancy: the root's subtree is the project.
Subtree is the default because declaring should be cheap and local, while
publishing is a claim on everyone's picker. It also satisfies optional means
absent: an ordinary declaration writes no reach key at all.
The record is already generic. Only its scope and its label are not.
ReferenceSheet { Name, Views[] }
ReferenceView { Name, Width, Height, Layers[] } // static; no timelineNothing in that is about characters. It is a named set of views, each a static layer stack β the character-ness lives entirely in the default view names (Front, Side, Back, Expressions) and in the UI calling it "Character sheet". That is good news and it means the redesign is mostly about where a sheet can live, not about restructuring the type.
Two real problems, both of scope rather than shape:
-
A sheet lives in
Doc.ReferenceSheetsβ inside one document. So it cannot be filed anywhere, cannot be shared, and workflow 1's "a character sheet at the knight folder is valuable as all knight files have direct access to it" is unbuildable. -
Character.ReferencesisList<string>β paths, attached to a character. Workflow 3's environment document cannot be one.
Workflows 1 and 3 want reference art of two different shapes, and a generic system has to hold both:
| Shape | Example | |
|---|---|---|
| Multi-view sheet | several small views, each its own canvas | Front / Side / Back / Expressions |
| A document | one large drawing, ordinary canvas | the environment overview |
So the generalisation is not "make the sheet bigger". It is:
A reference is a declaration at a scope that names something to draw against. What it points at is one of: a multi-view sheet authored in place, an ordinary document in the project, or an imported image.
Character.References is already a pointer list β path-only and
character-scoped. This widens what it can point at and where it can hang, which
is a smaller change than it sounds.
Recommended naming: keep ReferenceSheet for the multi-view record, because
it is accurate and already generic, and introduce ReferenceRef for the
declaration that points at one. Renaming the record to something like
SubjectSheet would churn every call site to fix a problem that lives in the UI
label β "Character sheet" becomes "Reference sheet" there, and the ninth
panel keeps working.
Everything currently on Doc or pinned to one project-wide slot, judged against
the same question β would an artist want this shared by a folder?
| Resource | Today | Verdict |
|---|---|---|
Gradients Doc.Gradients
|
per document | Yes, strongest of the additions. A gradient is a named colour resource an artist reuses, exactly like a palette; today one made for the knight's shield cannot be used by the next animation. Same argument, same fix, and it is odd that palettes and gradients are already asymmetric |
| Guides | per document |
Yes. A character height guide at the knight folder so every knight animation shares it β the roadmap already carries [?] Character height guide and this is what it wants to be |
| Export configuration | per document | Yes. The knight exports at one cell size, the boss at another; per-folder is exactly the grain a sprite pipeline needs. Named in the owner's Q30 answer |
Brush + tips + textures Manifest.Brush, Manifest.Tips, Doc.BrushTips, Doc.Textures
|
project-wide library, per-document raster | Yes, as libraries. The manifest comment already says "Pillar 1 says a character's work shares one palette and one brush set" β scoped was always the intent and project-wide was the only scope available. The raster must keep travelling into each document (see the boundary below) |
Templates Doc.IsTemplate
|
shipped β Pillar 6, "Animation templates β a document in the project marked as a template" (IsTemplate, TemplateId, NewFromTemplate, ANewDocumentFromATemplateIsACopyNotALink) |
Yes, and it is the sharpest of the small ones β and cheaper than it looked. The template machinery exists; what is missing is a default template per scope, so workflow 1's locomotion folder makes new animations that already know what they are. One field on the scope, resolved by the same walk |
| Frame markers / tags |
shipped β Pillar 6, FrameMarker, IsEvent, Note, and "Frame tagging β this is frame markers, shipped"
|
Yes, as a shared vocabulary. Markers, notes and event flags all exist per document; what does not is an agreed set of names (anticipation, contact, breakdown) so a tag means the same thing across forty animations and can be queried. This is also the substrate the market-research item "Expression and pose metadata tagging" needs β that item is unbuildable while every document invents its own words |
| Timing presets |
shipped app-level β Pillar 3, TimingPresetStore, ASavedPatternPersistsAndComesBackOnTheNextLaunch
|
Probably, and it already has the wrong half of the axis. Persisting per launch is the user tier; "this show is on 2s" is the project tier and cannot be said. Low value only because the app-level default is usually right |
Palette folders Doc.PaletteFolders
|
per document | Follows palettes wherever they go β not a separate decision |
| Onion skin settings | per document | Marginal. Mostly a per-artist preference; a folder-level default would rarely be reached for |
| Camera | per scene | No. Not a shared resource β it is authored content belonging to one scene |
Clip regions Doc.ClipRegions
|
per document | No, and this one is a defect if changed. See below |
Doc.ClipRegions is invariant 3: a selection is a content-hashed entry
referenced by Stroke.ClipId, and it is provenance β the record of what a
stroke was actually painted under. Sharing it across documents would mean a
stroke's clip could be edited from outside the document that owns the stroke,
and a reload would render something the artist never drew. It stays per
document, and the reason is worth keeping written down because "it is a
dictionary keyed by id, like gradients" makes it look eligible.
This is the constraint that makes the whole design safe, and getting it wrong would break invariants 1 and 4 together.
Scoped resources are a library to choose from, not something rendering reads. Resolution happens when an artist picks, not when a frame renders.
When a stroke is painted it captures what it needs β colour, tip raster, texture, gradient β into the document. Otherwise moving a document between folders would change its pixels, which breaks invariant 1 (a reload renders the same image) and invariant 4 (settings that reach pixels are stored per stroke).
The codebase already states this for tips, and the sentence should govern everything added here:
The raster still travels into each document that paints with it β this is a library to choose from, not what a drawing renders out of.
Live recolour is a feature: Stroke.SwatchId links a stroke to a swatch so
changing the palette recolours existing art. That is render-time resolution on
purpose, and it means palettes alone can change a document's appearance based on
where it sits.
Mostly this is safe β the colour is stored literally as well as linked, so a stroke whose palette is gone still renders. The hazard is narrow and worth pinning:
Drag
attack.lightboxfromknight/togoblin/, and if a swatch id resolves in the new scope, the art recolours.
Swatch ids are generated, so independently authored palettes will not collide.
Duplicated palettes will β and duplicating a palette to tweak it is the
obvious thing to do. The cheap fix is to record the palette id alongside the
swatch id on the stroke, so resolution is unambiguous and a missing palette falls
back to the literal colour rather than to a stranger's swatch of the same id.
AMovedDocumentKeepsItsColours is the test.
Not a schedule β an ordering, so each step is landable alone and nothing is left half-migrated.
-
The scope record and resolution.
ResourceScopeonProjectFolder, walk-up accumulation, the four-tier chain, nearest wins. No resource moves yet; the mechanism is testable on its own. Model it onSymbolScopes, which already resolves a two-tier chain and has the tests to copy. -
Palettes onto it β Q30 answered them, and Pillar 1's "Shared palette across a character's animations" (
TwoAnimationsUnderOneCharacterPaintFromOnePalette) is the behaviour that has to survive with a folder in place of the character. Plus the palette-id guard above. -
References, widening
Character.Referencesinto a scopedReferenceRefpointing at a sheet, a document or an image. Workflows 1 and 3 close here. Touches Pillar 1's "Character workspace" and Pillar 0's "Reference image panel" (bothReferenceSheet,ReferenceTabTests), and it is where the UI label stops saying "Character". -
Gradients, guides, export config, default templates β landed in part, 2026-08-06, and the split is the finding. Gradients and default templates joined by naming a kind and nothing else, which is the evidence step 1 was the right shape. Guides and export configuration could not, for the same reason both times: neither has a project-level record with an id to point at. A guide lives on a document and nowhere else; export settings are chosen per export. Scoping needs something that outlives one document, so those two want that record built first β they are not a line of resolver away, and the next person to try will otherwise rediscover it.
Guides followed on 2026-08-06, once
GuideSetexisted to point at: an id, a name and the guides themselves, held on the manifest and absent until one is made. The roadmap's[?] Character height guideis that and a declaration and nothing else. Note the boundary holds the ordinary way here rather than the palette way β guides are copied into a document when used, not resolved at render time, because a drawing whose snapping changed when it was dragged into another folder is the defect scoping exists to prevent.Export configuration β see the section below. I twice described this as "there is no export-settings record", which was wrong and is corrected there:
ExportPresetexists and is good. It is in the wrong assembly and is missing one field. Two roadmap items close as a side effect:[?] Character height guidebecomes an ordinary guide set declared on the knight folder, and Pillar 6's shipped template machinery gains the per-scope default it was missing. -
Symbols gain the tree axis β landed 2026-08-06. A narrowing of shipped behaviour rather than a widening, so it came last. Pillar 3's "Global and project symbol scopes" already has the userβproject half; this adds folder depth beneath it. A symbol with no declared scope stays project-wide, which is what every existing project means β and
TheProjectRendersWithTheLibraryGonemust keep passing untouched. It did, and that is the useful part of the evidence: the narrowing lives inSymbolScopes.VisibleTo, which the picker reads and the renderer cannot, becauseSymbolRegistryresolves by id and takes no manifest.ScopingNarrowsThePickerAndNeverWhatIsAlreadyDrawnstates it from the other side.Two things the implementation settled that the plan had not:
- The global library is never narrowed. A scope governs the project's own symbols; the artist's library is theirs in every project, and placing one adopts it into the project, where it becomes declarable like anything else.
- The first declaration is announced. Everywhere else the first share is invisible because it only adds; here it flips the project from every symbol everywhere to only what is declared, so the status line says so. A picker that quietly loses most of its contents is the worst way to learn a rule.
Two that sit outside the ordering. Brush libraries β Pillar 0's tip library already says "scoped like palettes", so it joins whenever step 1 exists β and the frame-tag vocabulary, which blocks nothing but unblocks the market-research metadata item.
Worth listing, because several unbuilt items are waiting on exactly this and do not say so:
| Item | Pillar | Why it needs this |
|---|---|---|
[?] Character height guide |
6 | It is a guide set declared on a scope. There is no other thing it could be |
[?] Sprite atlas generation across characters |
5 | "Across characters" is a scope question β which subtree's assets pack together |
| Expression and pose metadata tagging | 1 (research) | Needs a shared tag vocabulary or every document invents its own words |
| Studio dashboard | 6 | Reads status across the tree; the folder walk is the same traversal |
| Collaborative palette sync | Tier 3 | Cannot sync a palette that has no scope to be synced at |
| Style guide enforcement | Tier 3 | A style guide is a scoped resource plus a checker |
| One registry of features, defaults per project type | Architecture | Sibling mechanism, same shape β derived defaults, nearest wins, nothing gated. Worth building the two consistently rather than discovering later that a project type and a folder disagree about what a default is |
- Whether a scope can decline an inherited resource. "The knight uses none of the studio palettes" has no expression here. Probably wanted eventually; deliberately not invented now, because every guess at a negation syntax before someone needs one has been wrong.
-
Migration. Q30 answered new projects only, so existing projects keep
character palettes and
Character.Referencesβ and the code keeps both paths. That is recorded in Q30 with the consequence named.
A correction first, because I got this wrong twice. I said there was no
export-settings record and that one would have to be designed. There is:
ExportPreset in Lightbox.App/Services, with Target, Trim, Pack,
Columns, Padding and Background, a ExportPresetStore that persists them,
an ExportRunner that is deliberately thin β a preset in, files and a report
out β and an AutoExport that already decides when. Pillar 5's last mile is
built. The plan below is therefore much smaller than the one I implied, and it
is mostly about moving one type and adding one field.
Nothing is broken in the sense of failing. What changed is that three of the export design's assumptions were true when a project was a flat pile and are not true now.
| Assumption | Was fine because | Now |
|---|---|---|
A preset is a user setting, held in ExportPresetStore beside the app's other preferences |
Every project exported roughly one way | The knight exports at one cell size and the boss at another. A preset is a property of part of a project, not of the person |
| Export is a thing you do to the open document | There was one obvious document to mean | An artist wants export this folder, and "the knight's locomotion cycles" is now a thing that can be named |
Auto-export is one global on/off (AutoExportSettings.Enabled) |
One rule could describe a whole project | A production wants finished shots exported and work-in-progress not, which is a rule about a subtree rather than about the application |
1 Β· Give ExportPreset an Id and move it to Core. It is a record of enums
and numbers with no UI dependency, so the move is mechanical. Name is what it
has today and a name is not an identity β two folders can reasonably both have a
preset called Sheet, and a scope declaration has to point at one of them.
2 Β· Scope it. ExportScopes beside the four that exist, kind export,
resolved with Nearest rather than Resolve β a document exports one way at a
time, so accumulating presets would be offering a choice nobody made. Q30's
migration hinge applies unchanged: a project that declares none keeps using the
user's store exactly as it does today.
3 Β· Export a scope, not just a document. ExportRunner already takes a
preset and a path, so this is a caller change rather than an engine one: walk
the folder's documents, run each through the resolved preset, and return one
report. The interesting part is what it does about mixed results β thirty
documents where two are missing files is a report, not an exception, and
ExportRun already carries Omitted and Suspected for exactly that kind of
partial truth.
4 Β· Let auto-export be a rule on a scope. AutoExportSettings becomes
declarable per scope rather than only globally β finished shots under act-1/
export on status change; nothing else does. AutoExport.Decide already takes
settings as an argument, so it needs a different caller rather than different
logic.
- Is a preset per target, or one preset with per-target overrides? A studio shipping to Unity and Godot wants both from one authoring pass. Per-target presets are simpler and duplicate cell size and trimming; one preset with overrides keeps the shared parts shared and is a more complicated record. I lean per-target presets plus scoping, because the scope already gives the sharing β declare the common one on the character, the Godot-specific one on the folder that needs it β and that avoids inventing an override mechanism next to one that exists.
-
Does cell size belong to the preset or to the document? Today's
TrimandPackare preset-side, which is right for a sheet. A per-document override would let one oversized attack frame break a character's grid, which is exactly the consistency the assets output target exists to protect. I lean preset only, and let a document that genuinely differs live under a folder with its own preset. - What does "export this folder" do about nested folders? Recursive is the obvious reading and is what an artist means by export the knight. It is also how somebody accidentally writes four hundred files. I lean recursive with the count in the confirm β the number is the safeguard, not a checkbox.
None of the three blocks step 1 or 2, which is why they are worth landing first.
Asked directly β what is a scope, for export β and the answer is that "scope" was doing three jobs at once:
- Where the settings live β which folder declares the preset.
- What was selected β the folder you asked to export.
- What becomes one file β is the knight one sheet, or eight?
Only the third constrains anything. A sprite sheet is one artifact from many documents, so everything in it must share a cell size β you cannot resolve settings per document and then pack them together. A PNG sequence is one artifact per document and does not care.
An export scope is the boundary of one deliverable, and the declaration is that boundary. Declaring a sheet preset on
knight/says everything under here packs into one knight sheet; declaring it onknight/locomotion/instead makes locomotion its own.
Nearest-wins then gives the grouping for free, and selection becomes independent:
exporting characters/ finds every scope at or under it and produces one artifact
each. No declaration anywhere means per-document, which is today's behaviour and
the migration path. It also makes the assets-versus-shots split concrete β for
assets the grouping is the point, and for shots the deliverable is naturally
per-shot so grouping never arises.
The owner pushed on this and was right: grouping answers how things package and says nothing about when they are allowed out. Both of the real workflows β "let me test one animation" and "when it is ready, update everything" β are about state, not structure.
DocumentRef.Status already exists (Design, Draft, In development, Review,
Ready, Reopened) and AutoExport.Decide already fires on a status change. The
state axis is largely built; what it lacks is a scope to belong to.
So a preset carries three things rather than one:
| Comes from | Answers | |
|---|---|---|
| Grouping | the tree | what packages together |
Filter (IncludeStatuses) |
status | what is allowed into the artifact |
| Trigger | a status change | when it is rebuilt |
Testing one animation is a different destination, not a smaller export. If a test overwrites the shipped sheet, looking at one cycle has broken the build. So it is its own verb: Test export uses the resolved preset, forces grouping to per-document, ignores the filter, and writes somewhere scratch. Optionally automatic β In development β test export β which is what puts a fresh frame where a running engine can hot-reload it.
"When ready, update everything" is where grouping earns its place. The trigger is per document and the effect is per artifact: one animation reaching Ready means the sheet holding it is rebuilt. Grouping is what tells you what to rebuild when one thing changes, which is a build graph, and without it a status change cannot know what it invalidated.
The awkward case is a shipped sheet where one animation goes back to Reopened. The filter says it is no longer eligible, so a rebuild would drop it and leave a hole in a sheet an engine is already loading.
docs/DESIGN-symbols.md S7 already solved this shape. Symbol.Version is an
integer bumped on every edit; SymbolPlacement.SeenVersion records what the
placement was made against; the two differing is staleness. Nothing else is
needed β no history, no diffing, no store.
The same pair generalises without inventing anything:
-
DocumentRef.Version, bumped when the document is saved. - The artifact records the version of each document it was built from.
- Any difference means the artifact is stale, named down to which documents moved.
So the Reopened case answers itself: the artifact keeps what it had and reads stale. Removing work from a deliverable because somebody reopened it for polish is the kind of helpfulness that breaks a build at 2am; the staleness is visible where the hole would not be.
This is also the lightweight versioning the owner asked for, and it arrives as a
side effect rather than as a feature. Two integers per document give: export
staleness, the market-research character version tagging item, and
frame-to-version linking β all three of which currently assume a versioning
system nobody has built, when the pattern has been shipping in symbols since
Pillar 3. Worth stating plainly: this is not a new subsystem, it is
Symbol.Version applied to a second kind of thing.
It is deliberately not snapshots. Pillar 6's version snapshots and undo history browser are a different feature with a different cost β they store document states, and they are blocked on the undo record becoming data. A version integer is not blocked on anything.
ExportPreset moves to Core and gains, in one go rather than one bug report at a
time: Id, Grouping, IncludeStatuses, and a trigger rule. DocumentRef
gains Version. Both default to the behaviour that exists today, so a project
that declares nothing exports exactly as it does now.
All three went to the recommendation, so the reasoning below is the why it holds rather than a record of disagreement β worth writing down because the alternatives are each reasonable and somebody will propose them again.
A preset produces one thing for one engine, and a character shipping to Unity and Godot declares two β the common one where it belongs, the engine-specific one on the folder that needs it.
The reason this beats an override mechanism is that it does not add a second precedence rule. Scoping already answers which value applies here with nearest-wins; per-target overrides would put a second answer beside it, and "why did I get this cell size" would have two places to look instead of one. The cost is real and accepted: a character shipping to two engines with identical geometry writes the geometry twice.
Rejected, and worth naming: one preset with per-target overrides keeps shared parts visibly shared, which is the honest expression of the intent β it loses on the second precedence rule alone.
A document cannot say its cell is bigger. The whole value of the assets output target is that frame bounds stay consistent, and an override lets one document break the grid every other frame in the sheet depends on β discovered at import time in the engine, which is the worst available place to find it.
The exception is still expressible: a document that genuinely differs lives under a folder with its own preset. That costs a folder rather than a checkbox, and that is the right price, because it makes the exception visible in the tree instead of hidden in one document's settings.
Rejected: size the cell to the largest frame. It never clips and nobody maintains a number, but the memory cost of an entire sheet is then set by its worst member and changes without anyone editing a setting β a performance characteristic that moves on its own is worse than one that is wrong and stated.
Export the knight means the knight, including everything filed under it. The confirmation says how much β "3 folders, 47 documents, 4 sheets" β and the number is the safeguard rather than a checkbox: a wrong scope reads as an obviously wrong count, which a yes/no prompt cannot convey.
This is the same argument B87's delete confirmation already makes β "Delete 'Art' and the 1 folder and 1 document inside it?" rather than are you sure β so the two dangerous bulk operations in the application say the same kind of thing in the same way.
Rejected: immediate children only is never surprising and is not what the words mean, so the common case needs three exports and a document filed one level deeper is silently missed. No confirmation is right for the twentieth export of a polish pass and offers no moment to notice the project root was selected.
ExportPlan happily describes an artifact holding forty documents.
ExportRunner.Run takes one Doc. So OneArtifact and PerChildFolder
are expressible in the plan and not yet runnable, and that was not visible until
something tried to run one.
Two ways across, and it is a real design choice rather than a detail:
-
Merge the documents into one synthetic
Docand hand that to the existing exporter. Nothing in the export path changes. But merging is not free β differing canvas sizes, layer stacks that do not correspond, and a frame order that has to come from somewhere β and a synthetic document is a thing that can be wrong in ways neither input was. -
Teach
SpriteSheetExporterto take several documents. Honest about what a sheet is, and the packer already works in cells rather than in one document's geometry. It is a change to a tested component with pixel assertions on it, which is exactly the kind of change worth doing deliberately.
I lean toward the second: a sheet from several cycles is what a sprite sheet is, and the first buys "no engine change" at the cost of a merge step that has to answer questions the exporter already answers.
What this does not block. Per-document export already runs β it is what an unscoped project does and what a test export is β so the verb below and the existing export window are unaffected. What waits is the confirmation over a grouped plan, because confirming a count and then failing to produce it is worse than not offering it.
SpriteSheetExporter.Export(IReadOnlyList<Doc>, β¦) is the real entry point now
and the single-document signature delegates to it, so every existing sheet test
exercises the multi-document path and the two cannot drift.
Four questions the merge would also have had to answer, answered here instead β which is the argument for this route rather than the other one, restated as decisions rather than as a preference:
| Question | Answer, and why |
|---|---|
| Whose canvas? | The largest, with smaller documents at the cell's top-left. That is where composing at their own size and drawing at the cell origin already puts them, so every pivot, anchor and hurtbox offset stays arithmetically identical to the one-document path. |
| Whose pivot? | Each frame's own. One pivot for the sheet would put every character after the first with their feet somewhere else, and nothing in the file would say why. |
| Whose fps? | Each frame's own, as duration. A sheet can hold a 12 fps cycle and a 24 fps one; the header's single fps takes the leading clip's and the per-frame durations stay authoritative. |
| Which layers are background? | Decided per document. "Is this layer a background" is a question about one drawing's stack, and deciding once across all of them would let one document's paper silence another's art. |
And one thing the merge route would have lost outright: each document becomes a
frame tag, named after its scene and spanning its range, with the document's
own tags and events shifted to where its frames landed. A sheet of three cycles
with no way to tell where the walk ends and the run begins is data loss rather
than a formatting preference β and frame tags are the mechanism engines already
read for exactly that. Only when there is more than one document, so a
single-document sheet that declares no tags still writes no frameTags key.
Still to wire: ExportRunner.Run is the one-Doc signature, so a grouped
plan is runnable by the exporter and not yet by the runner. The engine targets
(Unity, Godot, Unreal, GameMaker) each build their importer sidecar from a single
document's clips, and what "one Unity artifact from three documents" means is a
decision those sidecars need, not one the sheet writer can make.
A test is a different destination, not a smaller export: it writes to
test-exports/ beside the project, never over a deliverable, so looking at one
cycle cannot break the build. It forces PerDocument and drops the status
filter β grouping is about the deliverable and a test is not one, and the filter
exists to keep work in progress out of a shipped sheet, which is precisely what
a test wants in.
Beside the project rather than the system temp folder, so an engine watching the project tree can hot-reload it β which is the roadmap's live engine preview item getting a destination for free.