-
Notifications
You must be signed in to change notification settings - Fork 113
zone_painter_ui_stories
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.
"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.
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.
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.
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.
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.
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.
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.
- 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:
SContextStatswas 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.
-
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
.maxfiles, per-file dirty state, save-all, session-spanning undo that respects file boundaries. -
Startup flow:
.nelquick-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.
-
CZoneBankElement(nel/include/nel/ligo/zone_bank.h:59) carries_SizeX/_SizeYplus 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 byconvertSize()(nel/src/ligo/zone_bank.cpp:83). -
Producer: the ligo Max export (
nel/tools/3d/ligo/plugin_max/script.cpp, ~1098–1267) writes the.ligozonebank element. The mask comes from one of two sources: the realCZoneTemplate::getMask— derived from the ligo boundary edges flagged in the.maxand snapped to the cell grid — or the forced bounding fallbackgetSquareMask(geometry AABB ÷ cellSize, all cells true). -
Caveats for the painter:
.ligozonefiles 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.maxitself, 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.ligozoneexists, 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).
- The
.maxOLE container carries aSummaryInformationproperty-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 inzonematerial-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 coverconverted-*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--savenever touch the stream; thumbnail write is interactive-default / CLI opt-in.
- 3ds-Max-style horizontal toolbar at the top of the viewer: square buttons, text-only until icons exist, grouped with separators: board / save /// undo / redo /// tile / color / displace /// current season (the season button shows the active season and opens a context-menu selection instead of blind cycling). These global actions, edit mode switches, and display switches move OUT of the right panel; the panel keeps the current-mode editing functions (tile set, brush controls, lock borders, fill, palette toggle). Everything still routes through the shared key/UI bridge handlers. The toolbar drags by a slim grip on its LEFT edge, not a top title bar — the Ryzom client's toolbars (hands/gestem bar etc. in the client interfaces_v3 XML) are the reference idiom.
- The Painter panel is a 3ds-Max-style ROLLOUT column: the NLGUI container-tree idiom (the GUI sample's Contacts window — parent container, openable tree-child containers with collapsible headers, scrollable parent). Sections: Session / Tiles / Brush / Fill / Displace / Properties. Mode switches set default open states; the user can expand/collapse any rollout regardless of mode (collapsed sections stay accessible — that is the point). Future-proofs the panel for complex brush libraries and further tool sections.
- A FOURTH editing mode alongside Tile/Color/Displace: Prop (property editing). Selection behaves like modern 3ds Max object selection: hover = thin outline of the zone under the cursor; click = selection with a THICK outline around the visible mesh boundary (cel-shading-style silhouette against background/non-same meshes, CPU- computed; the zone's boundary-edge outline is the accepted fallback if a true screen-space silhouette is infeasible). Only read-write editable zones are selectable.
- The mode-specific panel section edits the selected zone's export properties — the editable-in-Max set: Ligo Rotate (0-3), Ligo Symmetry, Passable, Use Bounding Box — written as the same Max-shape appdata the exporters read, plus read-only info (name, footprint size/source, eligibility). USE_BOUNDINGBOX edits re-derive the footprint live. Property edits mark the file dirty and ride the normal save paths.
- Undo/redo fully covers property edits on the SAME stack as paint ops.
- 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.
- Build outputs (.ligozone etc.) are NEVER relied on — the authoring flow derives everything from source .max files.
- The legacy 9-zone brick files embed frozen COPIES of the 8 neighbors (stale snapshots from the old single-file Max workflow). In board-driven sessions these are NOT displayed (the board selection is the sole authority on what loads — stale copies would overlap the real neighbor files); the legacy direct-.max CLI keeps them.
- Instead they serve as HINTS: board neighbor suggestion priority is (1) new appdata neighbor hints written by the painter on save — ONE appdata entry holding a versioned variable-length list of (dx,dy -> basename) pairs, offsets in cells relative to the zone's footprint origin (multi-cell zones have more or fewer than 8 adjacent positions, so no fixed slot count) — a zone reopens with the working set it was painted against, including for ecosystem bricks, (2) names parsed from legacy embedded copies, (3) continent grid-name derivation. New appdata ids are registered in export_ids.h after a repo-wide clash scan.
- 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.