Skip to content

zone_painter_patch_edit_plan

Jan Boon edited this page Jul 28, 2026 · 30 revisions

Zone painter — patch editing: state, invariants, and the node/object model

Status: working plan (2026-07-28). Companion to zone_painter_ui_stories.md and pipeline_max_design.md §14-paint. Written as a resume point.

The first milestone of the patch-editing story is complete and gated. The node/object model change is done, and with it the multi-file defect that was open when this document was first written. Five further pieces landed on top: the display-frame fixes, the model change itself, the out-of-range rebuild, welded-seam propagation, and the edge and patch sub-object levels.


Part 1 — What is shipped

Mode and levels. M enters patch mode (CPaintMouseListener::ModePatch, mode 4). Digits 15 pick the sub-object level, using the legacy plugin's EP_OBJECT / VERTEX / EDGE / PATCH / TILE values from rpo.h, so a level means the same thing in the tool as in the file it came from. Entering the mode lands on Object level. Vertex, Edge and Patch all select and move; Tile selects a level and draws the cage only.

The digit row is shared with tile-set selection and only one binding is ever live, because TileSetDigits is scoped ZPKS_PAINT and SubObjectDigits is ZPKS_PATCH. This is what the mode-scope mask in the key table was built for — no ordering trick, no "and not in patch mode" guard.

SRPatchMesh::SelLevel is deliberately not written back. Looking at a zone in vertex mode is not an edit, and writing it would dirty every file the artist opened.

Cage and markers. The control cage draws from the display CPatchInfo, not from the evaluated SPatchMesh: that data is already world space and already carries a shared-corner identity in BaseVertices[], which is NeL's own per-zone vertex index. A corner touched by four patches is one vertex, one marker, one selection entry.

Vertex markers are small solid squares (solid squares; crosses read as hatching at cage density), snapped to the pixel grid in both size and centre so they cannot shimmer between pixel counts as the camera moves. White is free, black is bound, red is selected.

Selection. Click selects, Ctrl adds, Alt removes, a click on nothing clears. Identity is (node, vertex) and the rules on top of it are per-object — see Part 3.

Edge and Patch levels keep their own selection sets (g_PatchEdgeSel keyed on the ordered corner PAIR, so an edge drawn by two patches is one edge; g_PatchFaceSel on (zone, patch)) and project them onto the vertex set. The level's set is the authority and the projection is recomputed from scratch, which is what makes dropping one of two edges that shared a corner leave that corner selected. Every rebuild re-applies the weld and alias rules.

Edge picking measures against the DRAWN chain V → T → T → V, not the straight line between corners — a patch edge with real tangents bows visibly away from it. Patch picking is point-in-quad on the projected corners, nearest first.

The cage is drawn in two passes, plain then selected. A shared edge is drawn once per patch, so a single pass lets the neighbour drawn later paint over the highlight; a selected patch came out with two red edges and two blue ones depending on patch order.

Patch mode intercepts mouse-down ahead of the existing Button == leftButton exact-equality test, which otherwise drops Ctrl+left and Alt+left — precisely the pair add/remove needs.

Gizmo. Drawn at every level that moves something. Move gizmo on the selection centroid (counted once per unique vertex), with three axes, three plane handles whose inner corners meet on the origin, and an invisible screen handle that lights all three. Sized by the fit-at-rest model: it holds a world length, re-fitted only between interactions, hidden entirely while the view moves. Everything else is in pixels so arrowheads and pick radii are unaffected by axis foreshortening.

Move and write. Dragging commits through the geometry write-target policy, with undo, and the live landscape follows without a rebuild — except past the packed range, where the zone is rebuilt (Part 3).


Part 2 — Invariants discovered, not to be re-derived

These cost real time to establish. Changing any of them needs a reason.

Overlays run after editorUI->draw(), which leaves the driver in NLGUI's 2D setup. World -space CDRU lines silently draw nothing there. Every patch overlay projects through the camera by hand and emits 2D lines, exactly as zpDrawZoneOutline already did. Also note CDRU::drawLine's 2D overload takes the driver before the colour while the 3D one takes it after — the world-space version compiles fine and draws nothing.

There are two render paths, the interactive loop and --screenshot, each with its own overlay pass. Anything new goes in one helper both call.

Bound vertices are derived, not authored. RPatchMesh::UpdateBindingPos recomputes BIND_25, BIND_50, BIND_75 and BIND_SINGLE alike by interpolating the target edge's Bézier, so the test is Binded, not the bind type. Keying on "not BIND_SINGLE" shows nothing at all on real data: zonematerial-bassin-ilot_croix has 94 bound vertices and every one is a single. A bound vertex must never accept a move — it is recomputed on load, so the write could not survive a round trip.

The geometry write target is not the tile carrier, and what it stores is not the same quantity in every case. Per output vertex of the topmost edit-patch modifier (its output is displayed, so the policy is local to it and never recurses):

case write holds
mapper record with Vert >= 0 the record's Delta (0x1130) a delta from its Original
unmapped the stored 0x1140 position an absolute object position
no modifier stack the base RklPatch PatchMesh position an absolute object position

That last column is load-bearing and cost a real bug: a display update that read the stored value as a position dropped mapper-path vertices at roughly the node origin, with byte-perfect file output. The geom-changed callback therefore carries the object-space DELTA, which is well defined for every target; undo passes the difference of its two stored values.

All three are 12-byte in-place overwrites; the mapper record is a flat 32-byte stride (OriginalStored, Vert, Original[3], Delta[3]), so its Delta has a computable offset. No chunk resizes, no re-encode — an untouched file stays byte-identical. Tier A covers every path.

Survey, 40 lacustre ligo files / 72 zone nodes: 67 modifier slots all carrying a 0x1140; 46 nodes with a mapper; 2752 mapped vertices; 26 nodes with no modifier stack at all. Neither the mapper path nor the base path is an edge case.

ObjectTM is not the display frame. It is the frame the FILE authored, which is the display frame only for a node sitting at the board origin untransformed. SPaintZone::DisplayTM is the full object → displayed-world transform and is what any recomputation must use; the three routines that move display geometry (translateZonesXY, cloneInstanceZone, placeContextRange) each compose onto it. instanceDisplayTM derives its matrix by SAMPLING transformInstanceXY rather than restating the rotation table, so the matrix and the vertices cannot drift apart.

Geometry dirtiness needs its own flag. isZoneDirty re-encodes the RPO pristine copy; a vertex move writes the PatchMesh or a mapper delta, which no RPO encode can see. Without markGeomDirty a moved zone reports clean and the save skips the file.

Undo re-resolves its target rather than caching a CStorageRaw*, which would not survive a working-set rebuild — and re-running the policy is the only way to be sure undo cannot drift from the forward path. A whole selection lands as one stroke.

Live surface updates need no rebuild in range. CZone::refreshTesselationGeometry re-derives every tessellation vertex from computeVertex, so writing control points and refreshing is the whole mechanism. Bind neighbours must be refreshed with the moved patches or the surface cracks along the seam.

Control points are 16-bit fixed point (CVector3s) around PatchBias/PatchScale, one LSB per PatchScale — a few millimetres on a normal zone. The live surface therefore sits on a coarser lattice than the .max, which keeps full float precision. The range check belongs to CZone::setPatchGeometry and must test what pack() can represent (sint16), not the 32760 computeBBScaleBias divides by: the bbox is sized from vertices plus a noise margin while tangents may lie outside that hull, so corpus control points legitimately sit between the two. Checking at 32760 declines zones nobody edited.

A zone rebuild must take its data from the LIVE zone, not from the display cage. pz.Patches carries the geometry, but its tile records are the ones assembly loaded: every tile, colour and lumel painted since lives in the landscape zone and the pristine carrier. So CZone::retrieve the live zone whole and replace only the Bézier. Building from the cage gets the geometry right and reverts the terrain.

Byte identity is measured against the --null-edit output, never the source .max — the OLE container is rewritten on any save. Sessions must run --no-hint-stamp --no-thumbnail or hint/thumbnail bytes drown the signal.

A byte gate cannot see the display. Both frame bugs above wrote perfect bytes. Display correctness is gated through painter.patchVertexPos, which returns the world position the marker is drawn at.


Part 3 — The model: nodes and objects

There is no "primary" node and no second-class "instance". An SPaintZone is a node: an object plus a transform. Several nodes may carry the same Node pointer, and that pointer is the object — one storage, one paint carrier, one set of vertices.

Three explicit per-node facts, none of them an id comparison:

field means
Editable the object may be written through this node (a property of the object)
InFile the .max holds this node; session-added ones are never saved back
DisplayTM object → where this node is drawn

kInstanceZoneIdBase survives only as an id allocator: session-added nodes take landscape ids from 10000, above the per-file bases (index * 1000), which is what caps a session at ten open files. Comparing a zone id against it to decide anything else is how the display frame and the write frame drifted apart in the first place.

Consequences, all gated in m35:

  • Every node of an editable object is editable. An edit through any of them writes the one object, and every node showing it follows through its own transform (zpGeomVertChanged fans out over the object, not over a zone id).
  • The same edit expressed in each node's own displayed space writes byte-identical output, including through a 90°-rotated node. This is the strongest form of the claim that the per-node transforms are right, and it is why the rotated/mirrored refusal is gone.
  • Prop mode has no display-copy case to exclude: zone properties are appdata on the node pointer the nodes share.
  • Selection identity is (object, vertex). One underlying vertex reached through two nodes is one selection — selecting it twice would apply the drag to a single storage location twice, with two object-space deltas that do not even agree. Different vertices of one object through different nodes share nothing and stay allowed: authoring an edge from whichever node shows it best is the point.

Welded seams. Two zones welded along a shared border are drawn at the same place but are separate storage. Selecting a vertex on a seam selects its welded partners, transitively (a corner where four zones meet is one point however it is reached). At selection time, not at the commit: the markers, the gizmo centroid and the live preview all follow the selection, so the artist sees what will move; commit-time propagation would draw a seam tearing open and heal it on release. Read-only partners are counted and skipped — their files cannot be written, so that seam genuinely does open and the status line says so. PatchWeldSelect in the vars cfg turns it off; on by default.

Out of range. A move past the zone's packed bounds rebuilds the zone, which recomputes PatchBias/PatchScale. Commit path only — during a drag the preview lives in a temporary cage while the display patches still hold the un-moved shape, so a rebuild would faithfully rebuild the OLD geometry and pay for it every frame. The surface lags past the boundary until release. Seam neighbours are collected before the removeZone that unbinds them, and the rebuilt zone's own patches are dropped from the refresh set since the pointers gathered for it die with it.


Part 4 — Gates

e2e/m34_patch_move_byte_gates.sh — the .max bytes. Three files chosen so all three write targets are covered and none can pass by luck (the gate asserts which target the log reports):

file target
material-fond modifier PatchMesh
material-bassin mapper delta
zonematerial-bassin-1 base PatchMesh (no modifier stack)

Each asserts: patch mode entered with nothing moved is byte-identical to the baseline; one vertex moved 1.5 along Z changes exactly 2 bytes; two vertices moved then one undo returns to the baseline byte for byte.

e2e/m35_patch_display_frame_gates.sh — everything a byte gate cannot see:

case asserts
m35-1 all three write targets: the cage follows the edit, undo restores it, the live push accepts
m35-2 a second editable file placed one board cell away edits in the frame it is drawn in
m35-3 two nodes on one object: fan-out through each transform, and both halves of the alias rule
m35-4 the node used to reach the object does not change the bytes (rot 90 vs the first node)
m35-5 an out-of-range move rebuilds the zone and keeps its tiles (band mean 0.147 vs 0.198 for authored ground)
m35-6 a welded seam moves as one point, and the same script with PatchWeldSelect=0 is asserted to tear

e2e/m36_patch_sub_object_levels.sh — the edge and patch levels:

case asserts
m36-1 an edge move writes exactly its two corners, and undo restores them
m36-2 dropping one of two edges that shared a corner keeps that corner — the projection rule
m36-3 switching level clears, and a patch move writes exactly its four corners
m36-4 picking: a click finds a face, an edge-level sweep crosses the cage, a click on nothing clears

painter.patchClick exists for m36-4: a script that calls selectPatchEdge proves the selection machinery and nothing about whether clicking on an edge finds that edge, and --screenshot has no pointer.

m35-3 and m35-4 run with PatchWeldSelect=0: vertex 5 sits on the seam those two nodes weld along, and each test should measure one thing.

Dev hooks, alongside the existing ZONE_PAINTER_ZOOM_EXTENTS: ZONE_PAINTER_GIZMO_HOVER=<handle> forces a handle hot, ZONE_PAINTER_GIZMO_DRAG="h:x,y,z" forces a live drag. A --screenshot run has no pointer, so without these the hover and drag states cannot be seen, let alone gated. The drag hook pins state every frame, so an interactive session with it set cannot drag normally.


Part 5 — Backlog

Sequencing note: the model change is done, so the sub-object levels are no longer blocked.

  • Tile sub-object level: selects a level and draws the cage; does not edit. Unlike edge and patch it is not a geometry selection at all — the painter already addresses tiles through the paint modes, so what this level should do is an open question rather than missing work.
  • Transform keys and gizmos: W/E/R. W is free; R currently means ModeProp, which is ZPKS_ANY, so a scoped R for scale would collide — resolve before binding.
  • Tier B (encodePatchMesh) for anything topological. Everything so far is Tier A.
  • Unbind as an explicit op with group semantics: BIND_25/BIND_75 reference the BIND_50 vertex through PrimVert, so unbinding one of a group leaves the siblings evaluating against a vertex that is no longer their anchor. Auto-unbind on drag was considered and rejected: distance cannot discriminate "dragged off deliberately" from "followed along in a multi-selection", the discriminator has to be whether the bind's target edge is itself moving, and a silent unbind opens a crack in the surface.
  • Cursors: the manifest above zpSelectMode lists what is needed; several need art rather than code.

Open design question for the next session. Welded-seam propagation currently reaches every editable partner, which means moving a vertex in one file can dirty another file the artist did not select. That is the only behaviour that keeps the exported zones agreeing, and it is visible (the partners' markers turn red before the move) and switchable, but whether the default should be per-session, per-file, or prompted is a workflow call rather than a correctness one.

Clone this wiki locally