-
Notifications
You must be signed in to change notification settings - Fork 114
zone_painter_patch_edit_plan
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.
Mode and levels. M enters patch mode (CPaintMouseListener::ModePatch, mode 4). Digits
1–5 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.
Handles. Tangent handles are selectable and movable. Identity is the PatchMesh Vecs
index (SPmPatch::Vec[8], exactly as V[4] indexes Verts), so a handle shared by two
patches along a common edge is one handle. Ownership is derived from the patch table (tangent
2e belongs to corner e, 2e+1 to corner (e+1)&3) rather than read from SPmVec::Vert, which is
absent on Max 3 files.
Handles are drawn for SELECTED corners only, so the corner selection has to survive picking a handle — drop it and the handle vanishes under the click. Both sets are then non-empty, and the handles win: clicking a handle is the artist saying "this one now", and the vertex stays the vertex selected while you drag its handle. With no handle selected the corner is the target and its handles ride it, once.
Transforms. Move, rotate and scale, on W/E/R. R was ModeProp and is now
SCALE in patch mode, resolved the way the digit row was: ModeProp became ZPKS_PAINT, so
exactly one binding is ever live.
Pivot. Five modes — selection centre, world centre, centre of all objects (held DURING an interaction, re-fitted after), centre of the selected objects, and a user point placed from the scene right-click. Object centres are BOUNDING-BOX centres, not vertex averages: an average is pulled towards whichever part is finely tessellated.
Gizmo. Drawn at every level that moves something, on the PIVOT (not the selection: the artist has to see what the next rotate will turn around before turning it), 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).
Review pass (2026-07-28), input path. A full review of the story found four interactive
defects the gates structurally cannot see — painter.patchClick and the dev drag hooks bypass
the mouse listener, which is exactly where all four lived:
- The viewer's mouse-down still gated patch-mode clicks on
SubVertex, so the edge and patch levels could neither select nor grab the gizmo with a real mouse. The dispatch now covers the same three levels the gizmo draw does. Watch for this shape: a scripted-input gate proves the machinery behind the input, never the input dispatch itself. - A gizmo drag now captures the pointer:
guiWantsMouse()used to swallow the move and the release when the pointer crossed a toolbar mid-drag, freezing the drag and leaving it armed after the button went up. - A Ctrl or Alt click that misses keeps the selection (usual behaviour); only a plain click on nothing clears.
- The gizmo stays on the pivot during a scale drag —
s_DragDeltais set by scale too (the raw plane-hit delta the factor derives from), and only a MOVE rides the drag now.
Plus one recorder gap: the preamble now snapshots the tangent selection after the corner snapshot.
Drag cancel (same day). right-click-while-dragging now cancels the transform, and
ESC cancels it ahead of the board-close/quit chain (before this, ESC mid-drag quit the tool
out from under the drag). Cancelling drops the drag state and pushes the untouched cage back
over the live-update preview; no core op ran, so undo, dirty flags and the recorder see
nothing. Finding it exposed a second dead branch of the same shape as the dispatch bug: the
right-click scene-menu handler sat BELOW the patch block's unconditional return, so the scene
context menu had never been reachable from a real right-click either — it now lives inside
the block, cancel first, menu otherwise. The pointer capture also covers mouse-down while a
drag is live, so the cancelling right click lands wherever the pointer is. And the code moved: patch edit lives in patch_edit_ops.cpp (state and
operations) and patch_edit_ui.cpp (drawing and picking), sharing the drag state through
patch_edit_internal.h — script_and_ui.cpp had grown to 4.6k lines, past the 2.5k-per-TU
refactor rule. Split verified lossless; full battery green both before and after.
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 what is changing. Building from the cage
gets the geometry right and reverts the terrain — a trap walked into twice, once by the
out-of-range rebuild and again by the mode-switch rebuild, which is why both now go through one
zpLiveZoneInfo. The bind data cannot come from the retrieve either: that reflects how the
zone is built right now, so unwelded → welded would find the cross-zone binds already gone. It
comes from the paint zone, filtered by the weld state.
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.
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 (
zpGeomVertChangedfans 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.
Welds belong to painting, not to editing. weldPaintZones derives cross-zone binds and
border vertices at load by scanning for coincident edges, so tile transitions can cross a
border and checkSeams can find one. CZone::compile then does not merely bind welded
zones, it aliases their corners —
BaseVertices[cur] = zone->getBaseVertex(vertto); // zone.cpp:551— so the two sides of a seam become one CTessVertex. A patch edit that breaks the seam is
therefore structurally invisible: there is one vertex there, holding whichever position was
written last, and the render shows a continuous surface that neither .max describes. Measured
on a welded seam, moving one side 40 units drags the neighbour's corner up with it.
So patch mode rebuilds the landscape with cross-zone binds and border vertices stripped,
and the seam is left to show. Only CROSS-zone binds go; a zone stays bound to itself or its
interior falls apart too. The paint-zone data is untouched either way — paint_core reads
BindEdges straight from it — so a mode switch is a landscape rebuild, not a reload, and the
paint core is not re-initialised: it holds no CZone pointers (every access is
m_Landscape->getZone(id)), so carriers, dirty flags and undo survive.
Seam propagation went with it. Beyond being unnecessary once the seam is visible, propagating across a session's welds made the same edit mean different things depending on which files happened to be open — and for ligo bricks the border profile is a contract across every brick sharing that edge type, not a pairwise fact about the two that are loaded.
No T-junction cracks appear along an unedited seam: the welded/unwelded difference on an unedited session is 0.4% of pixels, scattered shading noise from independent corner smoothing, not geometry.
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.
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 | patch mode builds the zones apart, asserted on the COUNT of cross-zone binds and border vertices dropped; plus a paint → patch → paint round trip proving tiles survive both rebuilds |
A render comparison used to stand where that count is, and it was unsound, not merely
fragile: in the welded build the shared CTessVertex ends up holding whichever patch refreshed
last, so how visible the alias is depends on refresh order rather than on whether the weld is
there. It passed and failed on ordering. Worth remembering as a shape — a pixel diff that
happens to correlate with the thing you mean is not a test of it.
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.
e2e/m37_patch_tangent_handles.sh — handles: the interaction rule (a handle move leaves its
corner), the ride rule (a corner move takes its handles exactly once), and bytes on all three
write targets. The delta path matters most: it proves the vec half of the mapper payload starts
at 8 + vertCount*32, since the second count word is not padding.
e2e/m38_pivot_modes.sh — the five pivot modes, in a session where all three "centre" answers
differ, plus the gizmo following the pivot.
e2e/m39_rotate_scale.sh — rotate and scale against hand-computed positions, that the pivot
decides the result, and that the arbitrary-axis form agrees with the enumerated one.
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.
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.
-
Tier B (
encodePatchMesh) for anything topological. Everything so far is Tier A. -
Unbind as an explicit op with group semantics:
BIND_25/BIND_75reference theBIND_50vertex throughPrimVert, 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
zpSelectModelists what is needed; several need art rather than code. -
Patch-mode rollout: patch mode currently shows only the Session rollout; sub-object
level switching is keyboard/script-only and the pivot face is the sole toolbar presence.
The panel spec — rollout inventory, per-level enable rules, icon and cursor motifs — is
zone_painter_legacy_rollout_spec.md.
Cross-file seams are now the artist's job, deliberately. With the zones drawn apart, an edit that breaks a border shows as a gap and both sides can be selected and moved together — every file's nodes are editable and selection is per-node. Nothing propagates on its own.
Survey: 1231 ligo .max (all four ecosystems), 6943 RklPatch objects, 1539 modifier
PatchMesh copies, 833 mappers. Tooling: pipeline_max_dump now prints a Claimed: line per
RklPatch (id(size)=bytes for small raws, C<sum> for containers) and hex-dumps every claimed
raw under PMAX_DUMP_CLAIMED_HEX=1.
The element streams (0x0BD6/0x0BE0 verts, 0x0BC2/0x0BCC vecs, 0x0BD1/0x0BD2 edges, 0x0BEA/0x0BF4 patches) were already decoded. The rest:
| id | size | is | encoder must |
|---|---|---|---|
| 0bb9 | 4 = 4 (all 6943) | PatchMesh version marker | preserve |
| 0bb8 | 4 = 0 (all) | constant scalar | preserve |
| 0c94 | 4 ∈ {0,2} | per-object scalar, topology-independent | preserve |
| 0bfe | 4, low16 ≡ 0, high16 varies | garbage/high-half field | preserve |
| 0c12 | 4, bitfield {0, 0x401, 0x20401, 0x800, …} | display flags | preserve |
| 0c1c | 4 ∈ {1,2,4} | saved sub-object level | preserve |
| 0c26 / 0c30 / 0c3a | 4+4·ceil(n/32) | vert / patch / edge selection BitArray (int32 count + dword-padded bits), n = the matching element count, zero misfits over the corpus | regenerate |
| 0c44 / 0c6c / 0c76 | 8 = (5,0)/(1,1)/(0,0) (all) | constant pairs | preserve |
| 0c4e / 0c58 / 0c62 | C, 16 children 0x300d–0x301e | the three TessApprox blocks (viewport/render/disp) | preserve |
| 0d5c / 0d7a | 4 = 5 / 0 (all) | mesh steps / constant | preserve |
| 0d66 | 4 ∈ {0,1} (30 ones) | boolean (showInterior-shaped) | preserve |
| 3440 | 24, one fixed 6-int pattern (all) | soft-selection params | preserve |
| 0d48 / 0d52 | count / count×52 | HookPoint array — 13 ints, the HookPoint layout (indices into points, vecs, patches, edges) | remap |
| 0c80 | 4+64·patches | TVPatch array: int32 header + 16 ints per patch (4 corner tv + 8 handle + 4 interior, −1 unassigned) | regenerate |
| 0c8a | 8+12·n | PatchTVert array: [int32 hdr][int32 count][count × UVW Point3] | regenerate |
0c80/0c8a appear in exactly the same 2745 objects (the mapped/KeepMapping subset — the
zonematerial-converted-* heritage class). The selection BitArrays are present in a subset
(2458/1737/5654) — presence rules fall out of the identity gate, absence is preserved.
So the encoder's job splits cleanly: regenerate the four element streams, three selection BitArrays, hooks, and map-channel arrays; preserve every remaining chunk verbatim.
The Mesh chunks (0x0906…0x2398) are RPatchMesh::mesh, written by RPO::Save via
Mesh::Save. RPO::Load reads them back — but both RPatchMesh constructors set every
validity interval to NEVER and nothing in Load validates them, so the first Display /
GetRenderMesh / SubObjectHitTest hits BuildMesh, sees invalid channels, and rebuilds from
the patches. The loaded content is never used. Policy: keep the chunks byte-verbatim
through any edit; they only have to remain parseable. (Confirmed off rpo.cpp /
nel_patch_mesh.cpp.)
151 modifier PatchMesh copies have element counts matching no base object in their file —
in-corpus Edit Patch topology edits, both directions: deletions
(zonetransition-foret-plateau-1: base 100v/78p → modifier 95v/71p) and additions
(zonematerial-converted-165_eg: 68v/53p → 70v/54p). No reference files need authoring.
Specimen decoded end-to-end (zonematerial-foret-26_landmark_e_ring2, base 200v/1552vec/181p,
output 196v/1504vec/175p):
- Each modifier's local data carries a full OUTPUT PatchMesh under 0x1140, same chunk grammar as the base stream. Both stacked modifiers (NeL Painter over NeL Patch Edit) carry their own copy.
- The Edit Patch mapper (0x1130) is INPUT-indexed: one 32-byte record per input element {OriginalStored=1, Vert = output index or −1 for deleted, Original[3], Delta[3]}, vec half after the vert half. Ring2: 200 records, 8 deleted, outputs 192–195 unmapped = added elements exist only in the output 0x1140 — which is exactly why the write-target policy already has the unmapped → stored-0x1140-position branch.
- The NeL Painter slot's 0x4001 blob is sized to the OUTPUT topology (v9, 175 patches) while the base 0x08FD still carries 181 — the topmost-carrier policy again, now confirmed for topology and not just values.
-
encodePatchMesh= one grammar serving the base stream and every 0x1140. First gate: decode→encode byte-identity over all 6943 base + 1539 modifier instances (ctest #25 pattern) BEFORE any op exists. - Topological ops rewrite: the affected PatchMesh stream(s), the three selection BitArrays,
hooks, map channels, the mapper (input records keep their slots; deleted outputs flip
Vert; added outputs stay unmapped), and the topmost 0x4001 via the existingsetRPatch. Tile/colour/bind reconciliation ports fromnel_patch_lib/nel_patch_mesh.cpp. - The 151 topology-edited files are the semantic reference corpus for op behaviour.
- Still open from the earlier gap list (design, not research): undo Kind 4 raw snapshots, painted-state migration across shifted patch indices, and the display re-evaluation path.
Paint survives topology — a headline goal, not a chore (Kaetemi, 2026-07-29). In the legacy editor any topological edit ruined the painted tiles: its reconciliation reset the painted arrays wholesale, with no index mapping. Our ops are required to compute an explicit old→new element mapping anyway (the mapper records, hook remap, and selection BitArrays all rewrite from it), and the paint blob's per-patch data (tiles, colors, edge flags) is indexed parallel to the patch array — so the same mapping carries the paint: surviving patches keep their tiles verbatim, deleted patches drop theirs, added patches get defaults. Subdivide can go further: a patch's tile grid is regular, so the four children inherit the parent's quadrants (halving per-patch U/V tess while it stays ≥ 1), preserving the painted appearance through the split. This is the concrete artist-facing payoff of Tier B beyond the ops themselves, and the acceptance bar for every topological op: a painted zone edited topologically must keep its paint everywhere the surface survived.
NeL customizations: tile/colour/bind reconciliation and RPatchMesh maintenance follow
nel_patch_lib / nel_patch_paint (Nevrax). Structural topology ops follow the on-disk
stream semantics in 6.1–6.3, the 151-file reference corpus, and standard Bezier-patch math.