zone_painter_editing_methods_plan - ryzom/ryzomcore GitHub Wiki

Zone painter β€” editing methods plan: the remaining legacy set, and the later-Max additions

Status: working plan (2026-07-30). Companion to zone_painter_patch_edit_plan.md (invariants, shipped state, gates m31–m54) and zone_painter_legacy_rollout_spec.md (the legacy panel spec). Written as the resume point for the next sessions: Part A (missing legacy methods) is the next session's work, in the order given. Part B is the queue after it.

House rules that apply to every method below, stated once:

  • Ops ride the shared topo runner (Kind 6 snapshots, writeBack-before-mutation, rebuild(skipWriteBack, keepUndo), anchor-cell fixup) unless they are pure Tier A geometry/value writes, which ride the paint core's stroke records.
  • Transforms live in nelpatch/ as pure struct ops with refusals that print for gates; session glue in patch_topo_ops.cpp; panel buttons are the single op surface and painter.* bindings alias them (the dispatch-blind-spot lesson).
  • Mapper meshes: reads through the eval mirror, writes through the Tier A rule (mapped β†’ Delta, unmapped β†’ stored). Bound vertices are derived β€” never written.
  • Every method lands with its mNN gate: counts, positions, paint survival, undo byte identity, encoder round-trip, persistence, refusals β€” and geometry math gets a SCULPTED fixture (the m50 lesson: uniform lattices hide arithmetic bugs).
  • Directional display claims get pinned with a rot-0 fill legend / ring-axis probes, never by eyeballing authored terrain (the m53 lesson).

Part A β€” the missing legacy methods (next session)

A1. Coplanar / Corner vertex continuity β€” DONE 2026-07-30 (gate m55)

The per-vertex tangent-continuity type, the legacy viewport right-click pair at vertex level. With a Coplanar vertex, dragging one handle keeps the vertex's other handles coplanar; a Corner vertex frees them.

The bit was pinned empirically before the op existed β€” a temporary --pm-flags-probe mode on pipeline_max_corpus_test (kept in the tree) swept every PatchMesh of the graphics + snowballs corpora and histogrammed all three element Flags words, cross-tabbing the VERTEX flag against ring-derived tangent coplanarity (best pair-cross plane, max deviation):

  • Vertex Flags carries ONLY the values 0 and 1 corpus-wide. Of 172,115 flags=1 vertices with 3+ handles, 172,103 measure coplanar within 1e-6 (12 in the float-noise tail at ~1e-5); flags=0 spreads to ~0.5 deviation. Bit 0 = PVERT_COPLANAR, corner = 0 β€” matching the host convention.
  • Bonus pins for the sections below: vec Flags only {0,1} (the interior bit, reconfirmed); patch Flags = 1 (PATCH_AUTO) on 645k patches, 0 (= MANUAL interior) on 1,073 patches across 47 converted-* desert files (real manual data for A2), and bit 1 (PATCH_HIDDEN) on only 4 patches corpus-wide β€” the corpus is effectively clean of hidden flags, which settles A3 session-only.

As landed:

  • Toggle: value op through the shared runner (the SmGroup shape) β€” zpSetVertexCoplanar / painter.setVertexCoplanar(on), no-change refusal, Kind 6 undo. Toggling moves NO geometry (deliberate divergence from the host, which planarizes on switch: the corpus flag is authored data, and the constraint does the work at edit time). Scene context menu gains the checked Coplanar/Corner pair at vertex level (zp_vertex_type; tri-state pushed = whole selection carries the type).
  • Constraint: at HANDLE-move time only (a moving corner takes its handles rigidly β€” a rigid motion keeps the plane). One shared helper (zpCoplanarSiblingReaim + zpVertexTangents in patch_edit_ops, exposed via patch_edit_internal.h) serves the commit (zpApplyPatchXform appends re-aim writes to the same stroke) and the drag preview (zpTanOffset's tail branch) β€” the parity discipline. The rule: new plane = minimal tilt of the current best pair-cross plane that contains the moved handle's direction (two+ moved dirs span it outright, sign-aligned); siblings re-aim into the plane keeping their own length; negligible (<1e-5 m) or degenerate re-aims write nothing. Bound owners are excluded wholesale (their handles refuse to move anyway).
  • Reads: painter.patchVertFlags(zone, v) and painter.patchCornerVert(zone, p, c) (the RING corner as BaseVertices β€” the stored edge records' V1/V2 order is arbitrary, a trap the first gate draft walked into).
  • Gate m55: toggle = exactly 1 byte, undo byte-identity, reopen persistence, encoder round-trip, no-change refusal writes nothing; the constrained move asserted against an INDEPENDENT Lua computation of the rule from pre-move positions (sibling re-aimed onto the plane, perpendicular sibling held, lengths kept), one undo restores the whole stroke; corner vertex frees the handles (exactly 1 element written); modifier-stream target line asserted; bound refusal unchanged.

A2. Auto / Manual interior β€” DONE 2026-07-30 (gate m59)

The second context-menu pair, patch level. PATCH_AUTO = patch Flags bit 0 (the probe: 645k auto, 1,073 authored manual patches across 47 converted desert files).

  • Toggle (painter.setPatchAuto(on), scene-menu Interior Auto/Manual pair at patch level): value op through the runner. Auto β†’ manual BAKES the derived interiors into the stored vecs β€” the eval mirror's interior positions ARE the derived values (patch_eval recomputes auto interiors in place), so the bake copies them; a MAPPED interior takes the value through Delta against the record's Original. Manual β†’ auto abandons the stored values (evaluation re-derives; the bytes stay as the corpus leaves them β€” dead-byte convention, exactly what the converted files show).
  • Editing: manual interiors join the handle machinery β€” drawn VIOLET for selected corners only (the handle discipline), offered by the real tangent pick, moved through the ordinary Tier A vec path. zpPatchTangentWorld resolves interior vec indices now (display Interiors corner), which gave the whole downstream β€” preview, rotate/scale, screen-pos, drag β€” interior support at once. AUTO interiors are never drawn, never picked, and scripted selection refuses ("switch the patch to manual").
  • New reads: painter.patchFlags(zone, p), painter.patchInteriorIndex(zone, p, slot).
  • Gate m59: displayed interiors identical before/after the bake (bake == derivation); flag flips, persists across reopen, file round-trips; toggle undo byte-identical; a manual interior selects through the REAL pick at its projected position, moves +2 m, and the move survives reopen (manual eval honours the store); auto interiors refuse; no-change refusal writes nothing.

A3. Hide / Unhide All β€” DONE 2026-07-30 (gate m56)

Vertex/edge/patch level in the legacy Geometry group. On dense zones, the way to work on the back of a cliff.

  • SESSION-ONLY, by the A1 probe's verdict: PATCH_HIDDEN (patch Flags bit 1) is carried by exactly 4 patches over the whole corpus and no vertex ever carries PVERT_HIDDEN β€” the on-disk bits are preserved verbatim and never interpreted; the hide set is g_PatchHidden, patch-keyed like the selections. Cleared by every working-set rebuild (topology ops shift indices, open/close re-bases zone ids β€” a stale entry would hide the WRONG patch), surviving plain mode switches (those rebuild the landscape, not the working set).
  • As landed: hide resolves the current level's selection to the PATCHES it touches (vertex level = patches using a selected vertex, edge level = patches of selected edges); the selection then clears β€” hidden elements cannot stay selected (the orphaned-tangent rule wholesale). Hidden patches drop from the cage collector, the markers, the handle draw, all four pick paths and the overlay arrows; selection setters refuse hidden targets (a vertex is hidden iff ALL its patches are β€” a rim vertex shared with a visible patch stays workable, which is exactly the work-on-the-back-of-a-cliff case). The LANDSCAPE keeps rendering hidden patches β€” hiding is a cage/editing concept, not a rendering hole. NOT undoable (display state, like the weld view and the arrows).
  • Panel: Hide + Unhide All row in the Patch rollout (Hide frozen without a level-scoped selection; Unhide All frozen while nothing is hidden). painter.hideSelection() / unhideAll() / patchHidden(zone, p).
  • Gate m56: the REAL pick path (patchClick at the patch's projected centre, with a positive control before the hide) no longer finds a hidden patch; scripted select refuses; unhide restores the pick; hide+save is byte-identical to null-edit; the fully-hidden vertex refuses while the rim vertex selects; a topology rebuild clears the set.

A4. Edge-level Subdivide + Propagate β€” DONE 2026-07-30 (gate m57)

The spec enables Subdivide at Edge level; Propagate carries the split across neighbours for continuity. This is B4's engine (the directional loop split needs only the pick gesture on top).

As landed (topoSubdivideEdges in patch_topo_subdiv.cpp; the TU refactored into shared machinery first β€” splitEdgeOnce, buildChildren4, the fresh buildChildren2 β€” with m43 + m50 green before AND after, so the proven 1β†’4 is exactly the code it was):

  • The general model is per-patch axis masks: every patch adjacent to a listed edge splits along the parameter CROSSING that edge (slots 0/2 = the a-cut halving the v tile axis, slots 1/3 = the b-cut halving u). Two opposite listed edges = one cut; two ADJACENT listed edges = both cuts = the proven 1β†’4 body. The cut necessarily splits the patch's opposite edge too; an unsplit neighbour there takes the canonical T-junction bind (BIND_SINGLE at the curve's 0.5 point). The single-axis body splits rows ONLY β€” the children are the exact halves β€” and keeps the parent ring orientation (lo child = parent slot + reused interiors, hi appended), so paint copies by plain half translation with only the split axis's order halved, the colour midline duplicated, outer edge flags inherited, the internal edge clear.
  • Propagate (painter.setSubdividePropagate(on), panel Prop checkbox at edge level): closure before splitting β€” each affected patch's opposite edge joins the set, transitively, until the walk loops or exits an open border; the whole strip splits with NO T-junctions along the walk.
  • New refusal beyond the 1β†’4 set: a crossed edge's neighbour that splits along the PARALLEL axis (the T-bind would target a patch the same op is replacing) β€” "select coherently". Order-1 refusal is now axis-specific.
  • The one Subdiv button serves both levels (patch selection β†’ 1β†’4, edge selection β†’ 1β†’2); painter.subdivideEdgeSelection().
  • Gate m57: one interior edge β†’ +2 patches/+3 verts, exactly the two opposite-edge midpoints bind, split axis halves on the lo child, painted markers survive into the right halves, undo byte-identity, encoder round-trip; the midpoint and reused outer tangent asserted against an INDEPENDENT computation on an in-scene SCULPTED edge (the m50 lesson); propagate walks the whole 5-patch bassin strip (+5/+6, zero binds) and stops at the open borders; the adjacent pair degenerates to 1β†’4 (+5 with its two edge neighbours); the mapper scene moves a corner first and the midpoint follows the EVALUATED curve on the modifier target.

A5 + A6. Bevel (outline) + Extrude "Normal: Local" β€” DONE 2026-07-30 (gate m58)

One arc on topoExtrudePatches, as planned.

  • Outline (outline param, default 0): per boundary vertex, the outward direction in the XY plane β€” the average of its adjacent boundary edges' XY normals, oriented away from the island patch's centroid β€” scaled by the outline metres. Applied to the island's boundary COPIES after the raise, their riding island tangents with them (the ride-in-file rule); interior island verts stay; the wall tops follow for free (they ARE those vertices); the vertical wall tangents sit at the thirds of the OUTLINED per-vertex vector, so the wall sides lean smoothly bottom to top. Wall-less open border edges are not in the boundary list, so a border-touching ring outlines only its walled portion (and a border-only ring cannot be outlined at all).
  • Normal Local is glue-only, as planned: zpExtrudePatchSelectionEx computes the area-weighted eval normal over the face selection in OBJECT space (the eval mirror; the diagonal cross (V2βˆ’V0)Γ—(V3βˆ’V1) is twice the quad's area vector, so the raw sum is area-weighted for free; the ring's XY-CCW convention makes +h rise on flat ground β€” probed before gating) and passes hΒ·nΜ‚ through the existing (dx, dy, dz). Group semantics: one selection, one normal; refuses a degenerate normal.
  • UI: the extrude dialog gains the Outline field and the Z / Local radio pair (dialog-local toggle state, seeded from the last use); the shift-drag stays Z-constrained by design. The legacy Bevel smoothing radios are deliberately absent β€” walls are fresh paintable surface, smoothing is the no-smooth flag's job. painter.extrudePatchSelection(h [, outline [, local]]) (the 1-arg form unchanged).
  • Gate m58: outline 0 == plain extrude byte-for-byte; the outlined ring asserted against an INDEPENDENT in-scene computation of the outward rule (h=8, outline=βˆ’2 on the interior bassin patch: 4 walls, 4 copies, exact in-plane offsets); one undo restores; round-trips; a freshly built WALL extruded along Local moves its island sideways (|d| = h, |dz| small); and the whole m54 gate re-runs green (the Z path is untouched).

A7. Detach with Copy β€” DONE 2026-07-30 (gate m60)

The legacy Detach dialog's Copy checkbox: duplicate the selection as a new island (or a new brick file) WITHOUT removing it from the source.

  • Element form: topoCopyElements (its own function beside topoDetachElements β€” copy shares no rewiring with the split): clones the selection's patches and every element they reference as a COINCIDENT island appended to the mesh; the original is untouched (nothing rewires, nothing renumbers). Positions copy from EVAL (clones are unmapped); paint records copy verbatim; binds INTERNAL to the selection re-establish on the clone with remapped targets and caches, crossing binds drop on the copy (a second record onto the same outside edge would double-bind it). Rides the shared runner: Kind 6 undo, refusals, encode round-trip.
  • File form: detachToFile(name, true) = pass 1 only (the brick is written, the session changes NOTHING) β€” deliberately not undoable: no session mutation exists, and a save is never undone.
  • Panel: the Detach Copy checkbox row (patch level); the Detach button reads it. painter.detachPatchSelection([copy]) / detachToFile(name [, copy]).
  • Gate m60: +1 patch/+4 verts, clone corners COINCIDE, marker copies while the original keeps its own, the clone is its own Element, one undo removes it byte-exactly, round-trip; weld-back PINNED as refusal β€” welding the stacked clone's corners onto the originals refuses with "more than two patches on one edge" and merges nothing; the ilot_croix island copy grows the bound count (internal binds re-established) and undoes byte-exactly; copy-to-file writes a round-tripping brick while the session saves byte-identical to null-edit.

A8. Reset paint β€” DONE 2026-07-30 (gate m61)

The painter's ResetPatch: clear a zone's paint wholesale β€” default tiles, white colors, displace 0.

  • As landed: CPaintCore::opResetZone sweeps every patch through the existing fill ops (fillTile(-1) + fillColor white + fillDisplace 0) with the per-op endStroke SUSPENDED for the sweep (m_SuspendStroke) β€” one reset, ONE undo stroke. The panel button (Session rollout) confirms through the reset modal showing the resolved target zone; scripts call painter.resetZonePaint(zone) bare. The button's target: the Prop-selected zone when set, else the session's only editable object β€” a multi-file session without a Prop selection refuses (wiping the wrong brick is not a mistake to design around).
  • Gate m61: reset β†’ ONE undo β†’ save is byte-identical to the null-edit baseline on the authored fixture (the richest paint the fixture has); every sampled tile empties in-session; the never-painted equality asserted on a SYNTHETIC workspace per the plan's rule β€” reset+save gives emptyA, painting markers over emptyA and resetting again saves byte-identical to null-edit(emptyA); unknown-zone refusal writes nothing.

PART A IS CLOSED (2026-07-30, one session): A1 m55 β†’ A3 m56 β†’ A4 m57 β†’ A5+A6 m58 β†’ A2 m59 β†’ A7 m60 β†’ A8 m61, every method gated and committed individually. The deferred set (named selections, bind drag mode, attach pick mode, Tile level) stays deferred on its stated dependencies; Add Tri / View-Render Steps / Tiled-mode group / Keep Mapping / Material ID remain CLOSED as not-applicable. Part B is the queue: B3 (flatten) first, then B2 β†’ B1 (the sculpt ghost) β†’ B4 (on the m57 engine) β†’ B5 β†’ B6 β†’ B7.


Part B β€” the later-Max additions

B1. Sculpt mode: the tile-level ghost + patch fitting (Kaetemi's design)

Paint deformation is unusable at coarse vertex level β€” a control point bends 32 m of surface, the brush thinks in 2 m. The ghost fixes the granularity mismatch: the brush edits an invisible tile-level displacement field, and the PATCHES ARE FITTED to it.

  • The ghost: per-patch grids of 3D DISPLACEMENT VECTORS on the tile-corner lattice ((OrderS+1)Γ—(OrderT+1), the color lattice), seam samples aliased coherently across patches exactly as color painting already does. NOT a heightmap β€” displacements are vectors, so cliff walls and any patch orientation sculpt the same way. Session-scoped to sculpt mode; the mode also snapshots the BASE surface (eval at entry) the displacements are relative to. The ghost PERSISTS across strokes β€” successive strokes keep targeting the accumulated fine surface, never a fit-of-fit (that smearing is the whole failure mode of naive approaches).
  • Brushes: Push/Pull (displace along the sample's surface normal; world-Z as an option), Relax (average neighbouring samples' displaced positions β€” operates on the ghost, so it is cheap and resolution-true). Radius/hardness/opacity ride the color brush's existing stroke UX; falloff measured in world distance to the sample's DISPLACED position (walls sculpt from the side).
  • The fit (the load-bearing part): after each stroke, LEAST-SQUARES fit the affected patches' controls to base+ghost over the WHOLE domain β€” corner-sampling interpolation is wrong (it leaves the between-corner surface unconstrained: the lumpiness). Solve GLOBALLY over the region's UNIQUE controls (corners and tangents are shared through the edge records; a per-patch fit cracks the seams): unknowns = unpinned corner verts
    • tangent vecs (3 coords, same SPD system); AUTO interiors fold in as linear functions of corners+tangents (not unknowns; manual interiors are); pinned = controls of untouched patches, bound vertices, locked-border vertices (the ligo contract). Small sparse SPD system β€” Gauss–Seidel or a dense solve over the mapped unknowns. Untouched samples carry zero displacement and anchor the fit to the base β€” partial strokes do not disturb the rest of a patch.
  • Writes/undo: the fitted control deltas commit as ONE Tier A stroke (mapper-aware for free); each stroke's record carries its GHOST DELTA (sparse: the samples it touched) so undo/redo restores the ghost with full fidelity β€” the undo stack preserves the ghost, not just the controls. Topology ops / working-set rebuilds drop the mode (base invalid).
  • Display: the FITTED surface, through the ordinary live push β€” the artist sees what will save, never chases detail patches cannot hold. Optional later: a residual overlay.
  • Gate mB1: sculpt a known bump on a sculpted fixture, assert the fitted surface's residual against base+ghost under a bound; seams C0 across the fit region (shared corners single-valued by construction β€” assert eval agreement); locked borders hold; stroke undo restores controls AND ghost; the fit is deterministic (same script, same bytes).

B2. Soft selection

Falloff-weighted transforms: move/rotate/scale scale each element's delta by a falloff weight from the selection. Pairs with the existing per-element delta machinery (the weight multiplies the per-element world delta before the object-space conversion). Falloff radius on the panel; weights by world distance to the selection's nearest member. The on-disk soft-selection params chunk (0x3440) stays PRESERVED verbatim β€” our soft selection is session state, not the Max chunk (note the divergence). Preview must use the same weights (zpVertOffset/zpTanOffset gain the weight through one shared predicate β€” the parity discipline). Gate: weighted move positions hand-computed, weight 0 elements byte-untouched.

B3. Make Planar / flatten to height

Set the selection's Z to a plane: the pivot's Z (default), a typed height, or the best-fit plane (full Make Planar). Trivial per-element Tier A deltas; handles ride. Panel: Flatten button + height field beside the tess block; painter.flattenSelection([z]). Gate: plateau positions exact, tangents ride, undo byte-exact.

B4. Directional loop split

The SwiftLoop/Connect equivalent on the quad grid: a single-axis 1β†’2 split propagated along the patch strip β€” add a row of patches along a cliff line without densifying everything. IS mA4's propagate with the walk defaulted on and a pick gesture: click an edge, the loop previews (overlay highlight through the wireframe pass), click commits. Builds directly on the mA4 machinery; the gesture waits for mA4.

B5. Bridge

Build patches connecting two OPEN edge chains (two rims, two islands): per matched edge pair, one quad β€” the extrude wall builder generalized to arbitrary rings (it already handles chains, shared vertical edges, orientation against both sides). Matching rule: equal-length chains pair in order; refuse mismatched lengths (state it β€” no interpolated N:M bridging). Tiling: along-chain order matches each side where they agree, else the max; across = distance-derived like extrude walls. Gate: bridge two detached islands' facing rims, weld-clean seams, paint empty, undo/round-trip.

B6. Snap-to-grid moves

Snap the committed move (and the drag preview, parity rule) to the 2 m tile lattice or the 160 m cell lattice β€” the ligo discipline for border work. A toggle + step choice on the panel; applies to the world delta before per-element conversion. Gate: snapped move lands exactly, unsnapped byte-path unchanged when off.

B7. Noise

Seeded procedural displacement on the selection (the old Noise modifier's role): painter.noiseSelection(seed, amplitude, scale [, octaves]), Z-only default. The seed makes it recorder-replayable (the painter.seed precedent). Per-element Tier A deltas; bound/border rules as ever. Gate: same seed same bytes, different seed different, amplitude 0 refuses.

B-order: B3 (trivial, immediately useful) β†’ B2 β†’ B1 (the big one; B2's falloff plumbing feeds it) β†’ B4 (on mA4) β†’ B5 β†’ B6 β†’ B7.


Part C β€” cross-cutting notes

  • New gates continue the mNN series (mA1.. map to m55.. in file names when built; the letters here are planning names only).
  • Every value-op (A1, A2 toggle, SmGroup precedent) follows the m52 shape: runner ride, no-change refusal, modifier-target assertion.
  • The wireframe now has TWO phases (depth-tested 3D + screen-space) β€” new overlays pick their phase deliberately: world annotations (loop preview, bridge preview, sculpt residual) go 3D; pixel-true widgets stay 2D.
  • The panel keeps growing: when the Patch rollout crowds, the level-swapped properties block (the legacy third-rollout pattern, already specced) is the pressure valve.
⚠️ **GitHub.com Fallback** ⚠️