Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ __pycache__/
/multiview_bake_*.png
/multiview_bake_*i.png
!docs/SKINNING_QUALITY.md
!docs/PAINT_V2_SLICE_F_DESIGN.md
!docs/PAINT_V2_SLICE_G_DESIGN.md
!docs/img
!docs/img/twist_bar_rest_lbs.png
!docs/img/twist_bar_90_lbs.png
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md

Large diffs are not rendered by default.

147 changes: 147 additions & 0 deletions docs/PAINT_V2_SLICE_G_DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# Paint v2 Slice G — Cavity / Curvature / AO masks (#550)

Auto-generated per-mesh **derived maps** that drive the classic weathering
workflow: dirt in crevices, wear on edges, shadowing in occluded areas. Each map
can gate the brush or initialise a layer mask, and three one-click recipes wire
up the common cases.

Parent epic: #543. Depends on Slice C (#546, layers).

## The three maps

| Map | Meaning | Stored range | Typical use |
|---|---|---|---|
| **Cavity** | concave only | `0` flat/convex → `1` deep crevice | crevice dirt, grime |
| **Curvature** | signed | `0` convex ← `0.5` flat → `1` concave | edge wear (inverted) |
| **AO** | ambient occlusion | `0` open → `1` fully occluded | weathering, contact shadow |

Cavity and curvature come from the same geometric signal: for each vertex,
average the dot of its normal with the direction to each 1-ring neighbour
(`HalfEdgeMesh::verticesAroundVertex`). Neighbours sitting *above* the tangent
plane mean the surface closes in — concave. Below — a convex ridge.

- **Cavity** keeps only the concave half, so grime never lands on ridges.
- **Curvature** keeps the sign and centres flat at `0.5`, with a `flatTolerance`
that pins near-flat values to exactly neutral. Without it, tessellation noise
on nominally flat panels speckles into visible edge wear.

The mesh is welded across submeshes first (via `HalfEdgeMesh`), so a UV seam or
a material split does not read as a crease.

## AO without a ray tracer

The issue specified "short-ray hemispherical occlusion" on the CPU. There is no
BVH, kd-tree or octree anywhere in the repo, and every existing ray query is a
brute-force linear scan — so a real ray AO would have meant adding an
acceleration structure.

Instead AO reuses the **depth-map visibility test** already proven by
`ProjectionPainter::OcclusionMap` (#549): render the mesh's depth from 12 evenly
spread directions (Fibonacci lattice — a naive lat/long grid clusters at the
poles and biases AO vertically), then per vertex count how many of those views
can actually see it. That fraction is the occlusion.

Two behaviours that matter, both pinned by tests:

- **Back-facing views are skipped, not counted as occluding.** A view looking at
the back of a face cannot tell you how lit the front is; counting it would
darken every vertex by roughly half regardless of geometry.
- **When no view faces the normal the result is `0`** (unoccluded), not `1`.
Reporting "fully occluded" for a surface the view set happens not to cover
would black out whole regions of the map.

The **bias** is `max(2 grayscale steps of the encoded range, 1% of the bounds
radius)`. Below that, depth quantisation alone makes a surface occlude itself
(depth acne). Distance is compared along the **camera axis**, not Euclidean,
because the depth map encodes linear fog distance along that axis.

The visibility *maths* is pure data (a vertex + `DepthView`s → a scalar) and
unit-tested headlessly against synthetic depth images; only the rendering of
those views touches the Ogre scene.

## Rasterisation

Per-vertex scalars are rasterised into UV0 with an edge-function half-space
test, then **seam-dilated** — UV islands need this or bilinear/MIP sampling
bleeds background across the seams.

This is a local float implementation rather than a call into
`VertexColorBaker::rasterizeTriangle`, which the issue suggested reusing. That
one is typed on RGBA8 `ColourValue`, so a scalar map would quantise to 8 bits
and band visibly across a smooth AO gradient. The *discipline* is copied
verbatim, including the explicit coverage vector: a texel whose value happens to
equal the background is otherwise indistinguishable from an unwritten one, so
dilation cannot infer coverage from "differs from background".

## Cache and invalidation

Maps live in `<AppData>/paint/derived_maps/<mesh-hash>/<kind>.bin`, with the
magic+version header and 40-hex-char key validation from `HdrCache` — the key
becomes a path component, so `../` is made *unrepresentable* rather than
sanitised. Writes go to a temp file and are renamed, so an interrupted save
cannot leave a half-written entry that a later load would trust.

**Invalidation is by content hash, not a revision counter.** The issue proposed
invalidating "via `EditableMesh` revision counter", but no such counter exists —
and `EditableMesh` exposes a public mutable `subMeshes()` accessor, so any
counter could be bypassed without incrementing and would not be authoritative.
The SHA-1 already needed for the directory name *is* the invalidation: changed
geometry hashes differently and misses naturally, with nothing to keep in sync.

The hash covers positions, normals, UV0 and indices. It deliberately **excludes**
vertex colour and bone weights, which cannot change any of these maps —
including them would force needless rebakes.

Bump `DerivedMapCache::kFormatVersion` whenever a generator's output would change
for identical input (an algorithm tweak, a different remap curve). The mesh hash
alone cannot notice that.

## Using a map

**Gate the brush** — "Mask the brush" multiplies each dab by the map value at
the painted UV, so a stroke lands only in crevices or only on edges. It scales
the colour's **alpha**, not RGB: scaling RGB would drag paint toward black in
cavities instead of hiding it there.

**Initialise a layer mask** — "Mask active layer" fills the active layer's
`maskAlpha` from the map. Paint freely afterwards; the mask keeps it in the right
places. Sampling is by UV, not 1:1 texels, since a map may be baked at a
different resolution than the paint buffer.

**One-click recipes** — each adds its own masked `Generated` layer as a single
undo step:

| Recipe | Map | Colour | Blend |
|---|---|---|---|
| Edge wear | curvature, inverted | light bare metal | Normal |
| Crevice dirt | cavity | dark grime | Normal |
| AO darken | AO | black | Multiply |

A recipe temporarily switches the active kind to bake its own map and then
**restores the user's picker selection**, so clicking one does not silently
retarget the UI.

## Files

| File | Role |
|---|---|
| `src/DerivedMapGenerator.{h,cpp}` | concavity, remaps, scalar rasterise + dilate (pure data) |
| `src/DerivedMapOcclusion.{h,cpp}` | depth-map visibility → per-vertex AO (pure data) |
| `src/DerivedMapCache.{h,cpp}` | versioned on-disk cache + mesh hashing |
| `src/TexturePaintController.{h,cpp}` | properties, bake orchestration, brush/mask/recipes |
| `qml/PropertiesPanel.qml` | the collapsible "Cavity / Curvature / AO" group |

Breadcrumbs: `paint.derived_map`, `.bake`, `.cache_hit`, `.cache_write_failed`,
`.error`, `.layer_mask`, `.recipe`, `.recalculate`.

## Known limits

- AO renders on the **main thread** (it drives the Ogre RTT), so a bake briefly
blocks the UI. It is cached, so the cost is paid once per geometry.
- AO quality is bounded by the 12-view count and the 256² depth resolution;
small contact details can be missed. Increasing either trades bake time.
- Curvature/cavity are per-**vertex** signals, so their detail is bounded by
mesh density — a low-poly mesh yields broad, soft masks. Sub-vertex detail
would need a per-texel normal-difference pass.
- The maps are baked in the mesh's own UV0 layout; a mesh with overlapping UVs
gets overlapping map data, exactly as any other UV-space bake would.
200 changes: 200 additions & 0 deletions qml/PropertiesPanel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -4546,6 +4546,15 @@ Rectangle {
property bool cameraLocked: TexturePaintController.cameraLocked
property bool decalActive: TexturePaintController.decalSessionActive
property int decalState: TexturePaintController.decalState
// Paint v2 Slice G — cavity / curvature / AO derived maps (#550).
property int derivedKind: TexturePaintController.derivedMapKind
property bool derivedAsBrushMask: TexturePaintController.derivedMapAsBrushMask
property real derivedStrength: TexturePaintController.derivedMapStrength
property bool derivedInvert: TexturePaintController.derivedMapInvert
property real derivedContrast: TexturePaintController.derivedMapContrast
property bool derivedReady: TexturePaintController.derivedMapReady
property string derivedStatus: TexturePaintController.derivedMapStatus
property bool derivedExpanded: false
// Projection group collapse state (UI density — the group is off by
// default so it starts collapsed).
property bool projExpanded: false
Expand Down Expand Up @@ -4617,6 +4626,15 @@ Rectangle {
texPaintCol.decalActive = TexturePaintController.decalSessionActive
texPaintCol.decalState = TexturePaintController.decalState
}
function onDerivedMapChanged() {
texPaintCol.derivedKind = TexturePaintController.derivedMapKind
texPaintCol.derivedAsBrushMask = TexturePaintController.derivedMapAsBrushMask
texPaintCol.derivedStrength = TexturePaintController.derivedMapStrength
texPaintCol.derivedInvert = TexturePaintController.derivedMapInvert
texPaintCol.derivedContrast = TexturePaintController.derivedMapContrast
texPaintCol.derivedReady = TexturePaintController.derivedMapReady
texPaintCol.derivedStatus = TexturePaintController.derivedMapStatus
}
}

Text {
Expand Down Expand Up @@ -5138,6 +5156,188 @@ Rectangle {
font.pixelSize: 9; opacity: 0.7; wrapMode: Text.Wrap
}

// ---- Derived maps: cavity / curvature / AO (Paint v2 Slice G #550) ----
// Collapsible: this is an occasional-use group, and the panel is
// already dense.
Rectangle {
width: parent.width - 16
height: 22; radius: 4
color: PropertiesPanelController.controlBgColor
border.color: PropertiesPanelController.borderColor; border.width: 1
Text {
anchors.left: parent.left; anchors.leftMargin: 6
anchors.verticalCenter: parent.verticalCenter
text: (texPaintCol.derivedExpanded ? "\u25be " : "\u25b8 ")
+ "Cavity / Curvature / AO"
color: PropertiesPanelController.textColor; font.pixelSize: 10
}
MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor
onClicked: texPaintCol.derivedExpanded = !texPaintCol.derivedExpanded }
}

Column {
visible: texPaintCol.derivedExpanded
width: parent.width - 16
spacing: 6

// Map picker. Indices match DerivedMapKind, so the comparison
// below is index-based like the channel picker.
Row {
spacing: 4
Repeater {
model: ["Cavity", "Curvature", "AO"]
Rectangle {
width: 62; height: 22; radius: 4
color: texPaintCol.derivedKind === index
? PropertiesPanelController.highlightColor
: PropertiesPanelController.controlBgColor
border.color: PropertiesPanelController.borderColor; border.width: 1
Text { anchors.centerIn: parent; text: modelData
color: PropertiesPanelController.textColor; font.pixelSize: 10 }
MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor
onClicked: TexturePaintController.derivedMapKind = index }
}
}
}

Row {
spacing: 6
Rectangle {
width: 74; height: 22; radius: 4
color: PropertiesPanelController.controlBgColor
border.color: PropertiesPanelController.borderColor; border.width: 1
Text { anchors.centerIn: parent; text: "Bake"
color: PropertiesPanelController.textColor; font.pixelSize: 10 }
MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor
onClicked: TexturePaintController.computeDerivedMap() }
}
Rectangle {
width: 96; height: 22; radius: 4
color: PropertiesPanelController.controlBgColor
border.color: PropertiesPanelController.borderColor; border.width: 1
Text { anchors.centerIn: parent; text: "Recalculate"
color: PropertiesPanelController.textColor; font.pixelSize: 10 }
MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor
onClicked: TexturePaintController.recomputeDerivedMaps() }
}
Rectangle {
width: 16; height: 16; radius: 8
anchors.verticalCenter: parent.verticalCenter
color: texPaintCol.derivedReady ? "#4caf50" : "#8a8a8a"
border.color: PropertiesPanelController.borderColor; border.width: 1
}
}

Text {
width: parent.width
visible: texPaintCol.derivedStatus !== ""
text: texPaintCol.derivedStatus
color: PropertiesPanelController.textColor
font.pixelSize: 9; opacity: 0.7; wrapMode: Text.Wrap
}

// Use the map to gate the brush.
Row {
spacing: 6
Rectangle {
width: 108; height: 22; radius: 4
color: texPaintCol.derivedAsBrushMask
? PropertiesPanelController.highlightColor
: PropertiesPanelController.controlBgColor
border.color: PropertiesPanelController.borderColor; border.width: 1
Text { anchors.centerIn: parent; text: "Mask the brush"
color: PropertiesPanelController.textColor; font.pixelSize: 10 }
MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor
onClicked: TexturePaintController.derivedMapAsBrushMask =
!texPaintCol.derivedAsBrushMask }
}
Rectangle {
width: 62; height: 22; radius: 4
color: texPaintCol.derivedInvert
? PropertiesPanelController.highlightColor
: PropertiesPanelController.controlBgColor
border.color: PropertiesPanelController.borderColor; border.width: 1
Text { anchors.centerIn: parent; text: "Invert"
color: PropertiesPanelController.textColor; font.pixelSize: 10 }
MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor
onClicked: TexturePaintController.derivedMapInvert =
!texPaintCol.derivedInvert }
}
}

Row {
spacing: 6
Text {
text: "Strength"; width: 70
color: PropertiesPanelController.textColor; font.pixelSize: 11
anchors.verticalCenter: parent.verticalCenter
}
Slider {
width: 110
from: 0.0; to: 1.0; stepSize: 0.01
value: texPaintCol.derivedStrength
onMoved: TexturePaintController.derivedMapStrength = value
}
Text {
text: texPaintCol.derivedStrength.toFixed(2)
color: PropertiesPanelController.textColor; font.pixelSize: 10
anchors.verticalCenter: parent.verticalCenter
}
}

Row {
spacing: 6
Text {
text: "Contrast"; width: 70
color: PropertiesPanelController.textColor; font.pixelSize: 11
anchors.verticalCenter: parent.verticalCenter
}
Slider {
width: 110
from: 0.1; to: 8.0; stepSize: 0.1
value: texPaintCol.derivedContrast
onMoved: TexturePaintController.derivedMapContrast = value
}
Text {
text: texPaintCol.derivedContrast.toFixed(1)
color: PropertiesPanelController.textColor; font.pixelSize: 10
anchors.verticalCenter: parent.verticalCenter
}
}

Rectangle {
width: 150; height: 22; radius: 4
color: PropertiesPanelController.controlBgColor
border.color: PropertiesPanelController.borderColor; border.width: 1
Text { anchors.centerIn: parent; text: "Mask active layer"
color: PropertiesPanelController.textColor; font.pixelSize: 10 }
MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor
onClicked: TexturePaintController.applyDerivedMapToLayerMask() }
}

// One-click recipes: each adds its own masked layer.
Text {
text: "One-click recipes"
color: PropertiesPanelController.textColor
font.pixelSize: 9; opacity: 0.7
}
Row {
spacing: 4
Repeater {
model: ["Edge wear", "Crevice dirt", "AO darken"]
Rectangle {
width: 76; height: 22; radius: 4
color: PropertiesPanelController.controlBgColor
border.color: PropertiesPanelController.borderColor; border.width: 1
Text { anchors.centerIn: parent; text: modelData
color: PropertiesPanelController.textColor; font.pixelSize: 9 }
MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor
onClicked: TexturePaintController.applyDerivedMapRecipe(index) }
}
}
}
} // end collapsible Derived maps body

// Texture slot picker \u2014 populated by selection (advanced override:
// lets the user target a specific TUS regardless of channel mapping)
Row {
Expand Down
Loading
Loading