zone_painter_editing_methods_plan - ryzom/ryzomcore GitHub Wiki
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 inpatch_topo_ops.cpp; panel buttons are the single op surface andpainter.*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).
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
Flagscarries 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 47converted-*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+zpVertexTangentsin 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)andpainter.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.
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.
zpPatchTangentWorldresolves 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.
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 carriesPVERT_HIDDENβ the on-disk bits are preserved verbatim and never interpreted; the hide set isg_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.
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.
One arc on topoExtrudePatches, as planned.
-
Outline (
outlineparam, 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:
zpExtrudePatchSelectionExcomputes 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).
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 besidetopoDetachElementsβ 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.
The painter's ResetPatch: clear a zone's paint wholesale β default tiles, white colors, displace 0.
-
As landed:
CPaintCore::opResetZonesweeps every patch through the existing fill ops (fillTile(-1) + fillColor white + fillDisplace 0) with the per-opendStrokeSUSPENDED 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 callpainter.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.
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).
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.
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.
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.
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.
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.
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.
- 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.