Skip to content

zone_painter_ui_stories

Jan Boon edited this page Jul 22, 2026 · 19 revisions

Zone painter — artist user stories: current tool + immersive UI flow

Status: design narrative (2026-07-21) for the zone painter UI milestones decided in pipeline_max_design.md §14-paint ("UI strategy — one in-engine editor, no Qt tier"). Part 1 documents how the shipped interim tool (nel/tools/3d/zone_painter, keyboard+HUD) is used today. Part 2 reimagines the same work as the fully immersive in-engine editor flow. Part 3 grounds every new capability against what the paint core already proves.


Part 1 — Current usage (interim keyboard+HUD tool)

"Currently" means the interim workhorse: the NeL viewer with hotkeys plus the headless script layer. The NeL-GUI editor shell, OpenXR, and WebXR presentations are decided but not built.

Within the asset creation process as a whole

1. Paint a zone without owning 3ds Max. As a landscape artist, I have terrain authored as NeL patch meshes inside .max source files, and I want to texture-paint them without launching Max or the old in-Max painter plugin, so that painting works on any machine (including Linux, even headless under xvfb/Mesa). I run zone_painter myzone.max --bank desert.smallbank --save out.max, paint, and get a .max back that is byte-faithful everywhere except the tile/color/ displace records I actually changed. The .max stays the single source of truth: the unchanged export pipeline (zone exporter → welder → lighter → client) consumes my saved file with no extra conversion step, and my edits survive stacked modifier chains because the tool writes into the same carrier the exporter reads (topmost paint snapshot, else the base patch object).

2. Paint one brick in the context of its neighbors. As an artist working on a ligo brick or a zone in a multi-zone file, I want to see the surrounding terrain while I paint, so that transitions read correctly across zone borders. Neighboring frozen zones load as reference display: they join the landscape, the border weld, and the tile-adjacency graph — so my transitions resolve against their tiles — but they are never paint targets and their data is never rewritten. With --lock-borders (or the L key) I can also protect my own border tiles against accidental strokes.

3. Paint with the village in place. As an artist texturing terrain under a settlement, I want the buildings, trees, and lights visible while I paint, so that paths and dirt line up with what actually sits on the ground. --include-meshes (or the flag my file already stores) displays every real mesh in the scene — including XRef'd constructions like village buildings, resolved through the database — lit by the scene's lights, while meta-geometry (collision meshes, cluster volumes, PACS primitives, targets) is filtered out. Textures for both the landscape tiles and the context meshes resolve automatically from the database layout, seasonal variants included, so a fresh checkout renders correctly with no manual search-path fiddling.

4. Keep painting verifiable in the pipeline. As a technical artist or pipeline maintainer, I want painting operations to be reproducible and checkable in CI, so that the paint step never silently corrupts source files. The headless layer gives me --null-edit --verify-identical (prove the save path is a byte-exact no-op), --paint-script (replay the exact same ops the mouse performs), --screenshot before/after pairs for visual review, and checkseams-style mechanical transition-legality checks — all gated on the corpus (ctest gate #25). An artist's interactive session and an automated batch job go through one shared op layer.

Within the painting process itself

5. Start a session. As an artist, I launch the viewer with my .max and the ecosystem's tile bank; the tool assembles the painting landscape exactly as authored (no export-time rotation/symmetry) and drops me into an orbit camera around it, with a HUD showing my current mode, tile set, brush, and group. My old plugin-era keys.cfg from the Max painter parses verbatim if I want my muscle-memory bindings; otherwise sensible defaults apply.

6. Paint tiles. As an artist in tile mode (T), I right-click terrain to pick the tile set already under the cursor, or step sets with PgUp/PgDn/0–9. I paint with the left mouse; the tool automatically places the correct transition tiles between different sets, recursively repairing borders as I go — including across welded zone boundaries. I toggle 128/256 tile size with B, bias variety toward a tile group with G/V, grow the stroke area with +/−, and flood-fill a patch with F (or F6/F7/F8 for rotated fills). Ctrl+Z/Ctrl+E give me bounded undo/redo, and a save after undo is byte-identical to never having painted.

7. Paint vertex colors. As an artist in color mode (C), I paint ambience with a falloff brush whose radius (+/−), hardness (Home/End), and opacity (Insert/Delete) I adjust live against the HUD. I can stamp through a grayscale bitmap mask — cycled with S or given via --brush-mask — for texture in the strokes. Shared border vertices are blended once and written identically to every zone that touches them, so my color work never cracks along seams, even across zone borders (stronger than the old plugin, which could leave per-side discontinuities).

8. Paint displacement. As an artist in displace mode (D), I select one of the 16 displacement noise maps and paint surface roughness with the same area-brush sizes as tiles, with fills available for larger regions, and undo covering it all.

9. Finish and hand off. As an artist ending a session, I press ESC and the tool saves to the --save target — never in place, so my input file is untouched until I'm satisfied. What lands in the output is exactly my paint edits and nothing else, ready for export.

Known friction (accepted, interim): no visual tileset thumbnail browser yet — sets are picked by number or by right-click-picking existing terrain — and brush parameters live on hotkeys rather than panels. Those are precisely the first flat-screen NeL-GUI editor milestone. Scene ambient in the context display also still uses a default value pending the Max-side probe of the undecoded RenderEnvironment class.


Part 2 — The immersive UI flow (design target)

The same work, reimagined as the in-engine editor. Where a story leans on something the current core already proves (or explicitly doesn't yet), Part 3 carries the grounding.

Startup and world setup

1. Open the tool, not a file. As an artist, I launch zone_painter with no arguments and never see a terminal. The tool finds the graphics repository on its own by walking up from the working directory looking for the quick-start layout marker (the .nel sibling folder that anchors the code/graphics/leveldesign checkout). If I launched it from somewhere unanchored, a NeL-GUI folder browser appears at startup and I point it at the graphics folder once; the choice is remembered. Power users and scripts can still pass the folder as the single optional argument. Everything after this point is in-engine UI.

2. Choose what world I'm working in. As an artist, after startup I'm asked one question: ecosystem or continent. Picking an ecosystem (jungle, desert, lacustre, forest, primes — discovered from what's actually present in the graphics repo, not hardcoded) means ligo-style brick authoring and selects that ecosystem's smallbank and texture roots automatically. Picking a continent (snowballs-style authoring) means working on that continent's zone set with its bank. I never manually locate a .bank file or type a --search-path again — the selection is the configuration.

3. Flip seasons at any time. As an artist, I can toggle between season variants (or whatever variant sets the graphics repo actually contains — discovered from the postfixed texture families, not assumed) from a persistent UI control mid-session. Only presentation changes: tile indices, colors, and displacement are variant-independent data, so the toggle re-resolves and reloads the bank and context textures live while my paint state stays untouched. I paint a transition once and immediately check that it reads correctly in winter and summer.

Composing the working set

4. Continent: pick zones from a map grid. As a continent artist, I see the continent as a 2D grid browser — cells laid out by zone footprint, with thumbnails where available. I click cells to mark zones editable and mark others read-only; when I mark a zone editable, its neighbors are auto-suggested as read-only so my border edits always weld and transition against real adjacent data. Read-only zones are exactly the current frozen-zone semantics: they join the landscape, the border weld, and the tile-adjacency graph, transitions resolve against their tiles, and their files are never written.

5. The grid is a lens, not a container. As an artist, I can trust the grid even though zone geometry doesn't respect it: zone AABBs don't have to match the grid, edges can be jagged or extend well past the cell for conveniently modeled complex shapes. Selection therefore resolves by actual geometry footprint, not by cell name — a zone whose patches spill into three cells is offered from all three, and the read-only suggestion ring is computed from real patch-AABB overlap and weld adjacency rather than from grid neighborhood. I never lose edge context because a neighbor's geometry happened to cross the line.

6. Ecosystem: compose a scratch layout from bricks — with real footprints. As a ligo brick artist, I also get a grid — but it's a scratch workspace, not the world, and it understands that a brick occupies a footprint, not a cell. I can browse the ecosystem's brick families and drop any .max file into any cell at any point during the session, mixing my work-in-progress brick with existing reference bricks loaded read-only around it. When I drop a brick, the tool derives its W×H extent and occupancy mask from the ligo boundary edges in the file (exporter-identical logic, .ligozone used as a cross-check when present), and the brick claims exactly those cells — including L-shapes and masked-out holes. Placement snaps by whole footprint; occupied cells refuse overlapping drops; the grid browser draws multi-cell bricks spanning their cells with the mask visible, so I always see which cells a transition or corner piece actually owns.

6b. Geometry still overhangs the footprint. As an artist, I can rely on the distinction between the logical footprint (the cell mask — what ligo placement reserves) and the geometry (which can be jagged, off-grid, and extend past the footprint for conveniently modeled complex shapes). The grid reserves cells by mask; the viewport renders true geometry; picking and welding work on geometry. Only the logical mask participates in placement legality — a neighbor's overhanging silhouette never blocks a cell it doesn't own.

7. Instance a brick against itself — edge-matched by footprint. As a brick artist doing the initial paint on a repeating zone, I place the same brick as multiple true instances — one shared backing, so a stroke on any instance appears simultaneously on all of them. Side-by-side self-instancing is how I verify a repeating edge actually repeats: the brick's edge welds against its own opposite edge in the layout, the tile-adjacency graph spans the seam, and the transition solver and color-closure rules resolve across it — so painting near the edge keeps both sides consistent by construction instead of by eyeballing. For a multi-cell brick, the self-tiling placement offers the legal adjacencies: instance offsets are whole-footprint translations (and the ligo rotations, 0/90/180/270, which rotate the mask with the geometry), so the brick's right edge cells land against its own left edge cells exactly as the world editor would place them — a 4x2 brick tiles against itself the way it will actually tile in a continent, not just edge-to-edge in the 1x1 sense.

7b. Edge inventory for multi-cell bricks. As an artist finishing a brick, I can see its border as ligo will see it: the footprint's boundary decomposed into per-cell edge segments (which is what must match neighboring bricks cell-by-cell in the world editor). Self-instancing is per-edge-pair: I can ask the layout to butt edge A of the footprint against edge B — including different edges of the same brick — to verify every advertised edge combination paints and welds cleanly, not only the trivial left-against-right case.

7c. Thumbnails come from the files themselves. As an artist, the zone and brick browsers show previews read from each .max file's own embedded OLE thumbnail (the SummaryInformation PIDSI_THUMBNAIL DIB — the same preview 3ds Max shows in its file dialogs), falling back to the exported zonebitmaps/ png when a file has none. When I save from the painter, it re-renders a top-down view of the zone and writes it back into that thumbnail slot, so every browser — this tool's, Max's own file dialog, anything OLE-aware — shows the current painted state. CLI batch saves keep byte-identity by default (thumbnail write is opt-in there); the interactive save updates it by default with a checkbox.

8. Many files, one session, honest saves. As an artist, I work across several editable .max files at once with per-file dirty indicators, and save all (or individually) from the UI. Each file is written back independently and byte-faithfully — only the carriers I actually painted change — and the tool still never silently overwrites: in-place saving stays an explicit, confirmed act. Undo history spans the session but respects file boundaries on save.

In the painting session

9. Everything on keys is also on panels. As an artist, every toggle and parameter that exists as a key binding is now also visible, discoverable UI: mode (tile/color/displace), brush size, hardness/opacity sliders with live preview, brush-mask picker, tile-group bias, 128/256, lock-borders, fill with rotation. The keys remain (plugin-era keys.cfg still parses); the panels make the tool learnable without a cheat sheet, and the HUD state I used to read from text becomes controls I can click.

10. A real tileset palette. As an artist, I pick tile sets from a thumbnail palette rendered live from the bank's own textures (which already resolve in-tool) instead of memorizing set numbers — right-click-picking from terrain remains as the fast path. The palette follows the season toggle, so I'm always choosing from what I'm currently seeing.

11. Context visibility I control. As an artist, the context display's fixed policy becomes my checkbox panel: buildings and XRef'd constructions, hidden nodes, collision meshes, cluster/portal volumes, PACS primitives, per-class toggles with live counts.

12. The same session, immersive. As an artist, the flat-screen editor is the daily driver, but the identical session — same working set, same panels reprojected, same op layer — opens in OpenXR for the genuinely spatial passes: judging displacement at eye level, placing transitions, painting vertex-color ambience with a controller ray, which is just another ray into the existing pick path. The WebXR build makes the same editor the browser showpiece.

13. The contract underneath never changes. As a pipeline maintainer, I keep the guarantee that no correctness depends on any of this UI: every action the panels perform goes through the single headless op layer, a session remains replayable as a paint script, and the null-edit, round-trip, and seam-legality gates keep running against the same core the immersive editor drives.


Part 3 — Grounding: what exists, what's new

Already load-bearing in the current tool (comes for free)

  • Shared paint backing across instances: pristine carriers are keyed by leaf/rpo POINTER; instanced modifier stacks across nodes already share one paint state (P3b). Story 7's "stroke on any instance shows on all" is the existing semantics.
  • Read-only/frozen zones: participate in landscape, weld, and the metaTile adjacency graph; never written (P3a). Stories 2 and 4 are the same mechanism at different granularity.
  • Closure-based seam continuity: border vertices blended once, written identically to every writable slot (P3c) — extends across instance seams unchanged once the graph spans them.
  • Season texture remap: the DBPATH + season-postfix resolution machinery exists (P3d follow-up); story 3 invokes it live instead of once at startup.
  • Context display + per-class stats: SContextStats was explicitly built as the data model for story 11's visibility panel (P3d follow-up TODO).
  • The headless op layer: single-path ops, --paint-script, null-edit and corpus gates — story 13 is the standing §14-paint validation contract.

Genuinely new work items

  • Instance-aware display: one shared paint state rendered at N world transforms, per-instance pick routing (transform the ray, not the data), and a transform-aware weld/adjacency graph so a zone can neighbor itself. The ligo transform display space (transformDesc/symVector) is plumbed but deliberately unassembled since P3c — this milestone assembles it, including rotated/mirrored instances.
  • The footprint model: exporter-identical W×H + occupancy-mask derivation at load time, footprint-aware placement legality in the grid, mask rotation folded into the instance transforms, and per-cell edge segmentation for the edge inventory (7b).
  • Grid browsers: continent zone selection by geometry footprint (story 5's lens-not-container rule), ecosystem scratch layout, thumbnails.
  • Multi-file session management: N editable .max files, per-file dirty state, save-all, session-spanning undo that respects file boundaries.
  • Startup flow: .nel quick-start-layout autodetection, NeL-GUI folder browser, ecosystem/continent selection driving bank + search-path configuration.
  • Tileset thumbnail palette (the known interim friction, story 10).
  • Presentations: NeL-GUI panel shell (milestone 1), OpenXR backend for the NL3D stereo framework + controller-ray picking (milestone 2), WebXR via emscripten (milestone 3) — per the §14-paint decision.

Where the multi-cell ligo footprint is stored (verified 2026-07-21)

  • CZoneBankElement (nel/include/nel/ligo/zone_bank.h:59) carries _SizeX/_SizeY plus a boolean occupancy mask over the W×H cell rectangle (setMask) — a brick isn't just "3x2"; it can be L-shaped or have holes. The size rides as a "size" = "WxH" category string (plus "square" and "filled" categories), parsed back by convertSize() (nel/src/ligo/zone_bank.cpp:83).
  • Producer: the ligo Max export (nel/tools/3d/ligo/plugin_max/script.cpp, ~1098–1267) writes the .ligozone bank element. The mask comes from one of two sources: the real CZoneTemplate::getMask — derived from the ligo boundary edges flagged in the .max and snapped to the cell grid — or the forced bounding fallback getSquareMask (geometry AABB ÷ cellSize, all cells true).
  • Caveats for the painter: .ligozone files are a build output of the build_gamedata ligo process (none exist in a fresh workspace checkout), and the initial paint on a fresh brick happens before any export — so the tool must derive the footprint from the .max itself, exporter-identically. The typed pipeline already carries the ligo selection/template logic in the zone flow (patch_eval handles the [NELLIGO] marker convention). When a .ligozone exists, it's a cross-check, not the source.
  • Geometry vs footprint: the mask is the logical placement unit; the geometry legitimately overhangs it (jagged, off-grid edges for convenient modeling). Placement legality reads the mask; welding and picking read the geometry (stories 5/6b).

Embedded .max thumbnails (verified 2026-07-21)

  • The .max OLE container carries a SummaryInformation property-set stream (kept verbatim by the pipeline_max save path, never parsed by the typed pipeline) holding the standard OLE thumbnail property (PIDSI_THUMBNAIL) as a DIB — e.g. 128×115 24bpp in zonematerial-converted-200_dz.max. This is the preview 3ds Max renders into its file-open dialog on save.
  • Reading it gives the browsers always-current previews for ALL files (the exported zonebitmaps/ pngs only cover converted-* names). Writing it at save time (top-down ortho render of the zone bbox) keeps the preview in sync with the painted state and is visible to Max itself. Byte-identity gates unaffected: null-edit and default CLI --save never touch the stream; thumbnail write is interactive-default / CLI opt-in.

Board as session hub (decided 2026-07-22)

  • The grid board is the persistent working-set screen, not a one-shot launcher: the painting viewer has a way BACK to the board with the session intact; cells show live state (closed / open-editable / open-read-only / instance-of-X, with dirty markers); per-cell actions cover open, close, and save-individual without tearing the session down. Multi-select and save-all remain.
  • Exporter-faithful eligibility: a .max containing multiple zones contributes only the zone(s) the export pipeline would actually export (name-matched/selected, per the typed pipeline's ligo zone selection); scratch/leftover zones in the file are ignored. Escape hatch flag to load every zone as before.
  • Instancing is board-driven: any opened zone can be placed as an instance at any cell, with per-instance rotation (0/90/180/270) and mirroring — assembling the transformDesc/symVector display space, with paint picks inverse-mapped through the instance transform. The M4 fixed NxN self-tile layouts are retired: ligo bricks have different edge situations per side, so deliberate edge-against-edge placement (including rotated) is the real repeating-edge workflow; uniform NxN tiling was a stopgap. Rotation and mirror are relative to the WHOLE footprint block of cells the brick occupies (not per-cell, not the geometry AABB center): a W×H brick rotated 90° occupies an H×W block on grid-aligned cells, and mirror flips across the block axis — the transform pivot is the footprint-block center so instances always land on the grid.

Grid browser design (decided 2026-07-21)

  • The continent grid browser is a true board: fixed-size square cells positioned by zone coordinate, scrollable on BOTH axes, rendering only used cells plus the empty fringe — every empty cell horizontally or vertically adjacent to a used cell (no diagonals — a diagonal contact alone doesn't decide a ligo border type) shows as a visibly empty square (non-interactive placeholder), nothing beyond the fringe exists. Sparse continents thus show their actual silhouette. Visual idiom: the minesweeper cell-grid pattern from nl_sample_gui (the large-board variant), not bespoke line-drawing. Used cells carry the zone name and (once wired) the embedded thumbnail.

Clone this wiki locally