diff --git a/.gitignore b/.gitignore index 9a56429a..cae07edb 100755 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 0166af90..5584abfa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -234,6 +234,7 @@ Three singletons manage core state. All run on the main thread. Access via `Clas - **Paint v2 Slice D — full PBR channel painting (#547)**: paint into any of 8 channels — **`PaintChannelNS::Channel { BaseColor, Normal, Roughness, Metallic, AO, Emissive, Height, VertexColor }`** (`src/PaintChannel.h`, pure-data + unit-tested; each maps to a canonical TUS slot `albedo`/`normal_map`/`roughness`/`metallic`/`ao`/`emissive`, Height→normal_map, VertexColor→the existing per-vertex path). Architecture: **per-channel sessions** — the live `m_layerStack`/`m_buffer` is the active channel's session; `setActiveChannel()` **stashes** the current channel's layer stack into `m_channelSessions[channel]` and **restores** the target's (so each channel keeps its own stack across switches). Channel router: `refreshSlots()` tags each TUS with its channel; `findOrCreateActiveTextureUnit()` **auto-creates** the canonical slot when the asset never shipped it. **Channel-aware bake (`bakeChannel`) — every channel MERGES with the slot's existing texture, never a blind overwrite** (so a bake only changes what the user painted): colour channels (BaseColor/Emissive) composite the painted strokes **source-over the slot's current texture** (reading it back via `loadImageAcrossGroups`, alias-aware for BaseColor's `diffuse_map`, and **skipping the transient `QMEPaint_*` paint texture** so it reads the real underlying image, not the live paint buffer; a brand-new diffuse with no source flattens onto opaque white); **scalar** channels (Roughness/Metallic/AO) are painted grayscale and collapsed via Rec.601 luminance into ONLY their lane of the packed ORM texture (`.r=AO/.g=roughness/.b=metallic`, other lanes preserved from the existing ORM) bound to the `metallic` slot the Cook-Torrance SRS reads; **Height/Normal** Sobel-bakes the painted grayscale to a tangent-space detail normal via `NormalMapGenerator::generate` then **whiteout-blends it onto the existing `normal_map`** (`n.xy=base.xy+detail.xy`, `n.z=base.z*detail.z`, normalized — untouched texels have detail normal `(0,0,1)` so the base normal is unchanged; painted texels add relief). Height has no slot of its own — it bakes INTO `normal_map` (`slotName(Height)==""`), so a mesh carries one combined normal map, not separate height+normal. **Height is NOT a selectable channel** (removed from the `paintChannels()` picker; `setActiveChannel(Height)` redirects to Normal) — a separate Height channel just produced a second normal-map bake fighting the Normal one, and there is no parallax/displacement shader that would consume a standalone height texture. Paint the **Normal** channel directly (its grayscale is Sobel-converted to a detail normal and whiteout-blended onto the existing normal, sourcing the base from the session's `m_originalTextureName` first). Height was the highest picker index (enum 6, just before VertexColor 7), so dropping it leaves the remaining picker list indices identical to their `Channel` enum values — the QML `activeChannel === index` binding stays correct with no renumbering. Live-IBL binding recipe (`bindBakedChannelTexture`): bind TUS by canonical name (alias-aware for the diffuse slot) → `RTShaderHelper::wirePbrSlotsForFFP` → `MeshImporterExporter::applyNormalMapsToEntity` (tangents, for normal maps) → `applyPbrIfTagged` **only if the material was ALREADY PBR** (never silently promote a plain material to Cook-Torrance on bake — that darkened it to near-black without IBL) → `mat->compile()` → `refreshAllPbrMaterialsForHdr()`. **Critically, `bindBakedChannelTexture` prunes the just-baked slot from `m_boundSlots` and then TEARS THE LIVE SESSION DOWN** (`stashChannelSession` + `m_buffer.clearDirty()` + `closeSession()`) instead of calling `flushDirtyToOgre()` — a trailing flush would re-upload the stale paint buffer and, since the slot was just pruned, its deferred-rebind branch would re-bind the transient `QMEPaint_*` texture straight back over the freshly-baked file (that was the "first bake changes the render, second bake wipes the texture" bug). The next brush stroke lazily rebuilds a session seeded from the baked result. Source-less channel sessions also start **transparent** (not opaque white) and defer the manual-paint-texture model rebind to the first painted stroke, so merely NAVIGATING channels never swaps the model's real slot textures for a blank paint texture. **Presets** (`src/PaintChannelPresets.h/cpp`, QML_SINGLETON, `MaterialPresetLibrary` pattern): 5 channel-aware presets — "Scratches into roughness", "Emissive sparks", "Edge wear", "Dirt build-up", "Sticker" — set active channel + brush params via the controller. UI: a 7-button channel picker + presets dropdown + per-channel "Bake" button in `texPaintCol`. Tests: `PaintChannel_test.cpp`, `PaintChannelPresets_test.cpp` (pure-data), plus `TexturePaintController_test.cpp` scene-fixture cases (channel switch auto-creates slot, scalar bake → ORM, height bake → normal_map, per-channel session isolation). **Session scoping**: `m_channelSessions` is cleared when the painted entity changes (`m_channelSessionEntity`) so one mesh's channel stacks never leak/bake onto another. **Undo across channels**: paint undo commands (`TexturePaintStrokeCommand`/`PaintLayerOpCommand`/`TexturePaintMaskActionCommand`) key on a stable `(Ogre::Entity*, channel)` — NOT the transient `QMEPaint_*` GPU texture name (which changes every channel/session switch). `undo()`/`redo()` call `ensureUndoTarget(entity, channel)` which reselects the entity + `setActiveChannel` before applying the snapshot, so undoing a prior channel's stroke after switching channels (or deselecting the mesh) correctly reactivates and restores it; a command whose entity was deleted no-ops safely. - **Paint v2 Slice E — symmetry + line stabilizer (#548)**: real-time mirrored strokes and mouse-jitter smoothing. **Symmetry**: WRITE-backed props `symmetryEnabled` (default OFF), `symmetrySpace` (`SymLocal`/`SymWorld`, default local), `symmetryAxes` (bitmask `SymAxisX=1|Y=2|Z=4`, default X → 8 combinations), `topologyMirror` (default on). Every dab is mirrored across each enabled **axis-subset** (1 point for X, 3 for X|Y, 7 for X|Y|Z — `mirrorLocalPoints` iterates nonzero subsets) *inside the same begin/end stroke window*, so mirror dabs are captured by the existing single `TexturePaintStrokeCommand` — **one undo step, no new command**. Works from BOTH the viewport (screen raycast → `m_hitCache`) and the 2D panel (`applyBrushSymmetryDabs` falls back to `findMeshPointForUV`, which also seeds the hit cache). **Geometric resolver** `uvForLocalPoint` (inverse of `findMeshPointForUV`): reflect the primary LOCAL hit across the axis — local: about the mesh origin `m_symmetryPivotLocal`; world: about the plane through the entity's `_getDerivedPosition()` (NOT world 0, else off-origin objects mirror into empty space) — then nearest-triangle → barycentric → UV. **Topology-aware mirror** (`src/SymmetryMirrorMap.{h,cpp}`, pure-data + unit-tested): for a position-symmetric mesh with an ASYMMETRIC UV unwrap, a geometric re-raycast reads the wrong UV; instead `build()` makes a per-vertex position correspondence (spatial-hash grid) **verified by triangle 1-ring adjacency** (rejects false position collisions; `coverage()`≥0.6 AND verify-ratio≥0.9 to be `valid()`), and `mirrorDab(submesh, corner[3], bary → mirror tri + permuted bary)` maps the dab to the mirror triangle and **permutes the barycentric weights to that triangle's stored corner order** (reflection reverses winding — mandatory) so the mirror samples the mirror triangle's own UVs. Per-single-axis map cached in `m_symmetryMaps` keyed by axis bit, built **lazily on first symmetric dab**, entity-guarded (`m_symmetryMapEntity`), invalidated on entity/mesh change + `closeSession`; falls back to geometric per-dab when no correspondence. **Multi-axis segment continuity (E-B)**: per-subset previous mirror UV (`m_mirrorPrevUV`/`m_mirrorHavePrevUV`, reset in `resetStrokePaintState`) so each mirror stroke fans a `paintBrushAlongSegment` and doesn't gap on fast moves. **Plane viz**: `refreshSymmetryPlaneOverlay` draws a faint translucent quad per enabled axis (X=red/Y=green/Z=blue, alpha 0.12, depth-write off) on `m_symPlaneNode/Obj` (mirrors `drawHoverRingAt`), torn down in `closeSession`. **Stabilizer**: `stabilizerMode` (`StabAverage`/`StabTrail`), `stabilizerAmount` (0..100, default **0 = exact passthrough / zero latency**). Smooths the raw SCREEN cursor before hit-testing in `updateStroke`; `beginStroke` seeds the buffer with the press point (first dab exact); `endStroke` does a **synchronous** catch-up to the true cursor *before* the deferred undo commit (Krita behaviour — stays one undo step; NEVER a timer). Pure math (`stabilizerWindow`/`stabilizeAveragePoint` weighted newest-heaviest/`stabilizeTrailPoint` lag-distance) is `static` for unit testing. Sentry breadcrumbs `paint.symmetry` (enable/space/axes/topology/map-build) + `paint.stabilizer` (mode/amount, once per stroke). QML: Symmetry + Stabilizer groups in `texPaintCol`. Tests: `SymmetryMirrorMap_test.cpp` (full-coverage build, **asymmetric-UV correctness**, no-correspondence invalid) + `TexturePaintStabilizer_test.cpp` (window growth, jitter reduction, trail lag/catch-up, amount-0 passthrough, setter clamp) — pure-data, plus a `TexturePaintController_test.cpp` one-undo fixture case. - **Paint v2 Slice F — projection/stencil painting + decals (#549)**: two image-driven paint modes that project an image onto the mesh THROUGH a camera, rasterize it in UV0 space, and write into a paint layer; both auto-create a NEW layer (never stomp the active one). Design doc: `docs/PAINT_V2_SLICE_F_DESIGN.md`. **`src/ProjectionMath.h`** (header-only) extracts the shared `projectToViewportUV` (world→viewport UV + NDC z + behind) / `sampleImage` out of `MultiViewTextureBaker` so both the #403 baker and this slice share one definition. **`src/ProjectionPainter.{h,cpp}`** (pure-data, unit-tested) is the FORKED single-projection rasterizer (NOT a mutation of `MultiViewTextureBaker::bake`, whose multi-view weighted accumulator is the wrong shape): `project(tris, View, source, out, opts, occ?)` clears `out` transparent then per texel interpolates world pos + projected source UV → facing cull → occlusion/depth-limit → sample → soft-edge → **manual src-over composite**; `projectDab(...)` is the footprint-bounded ACCUMULATING stencil-brush variant. Tris come from `MultiViewTextureBaker::fromEntity`. **Occlusion (the hard part)**: `MeshDepthRenderer` renders a depth map from the camera (grayscale = LINEAR world distance via fog, near=bright/far=dark; `RenderResult` gained `depthNear/depthFar`); per texel, project world pos through the depth map's OWN `viewProj`, reconstruct `dMap = near+(1-g)*(far-near)`, and reject when the texel's **camera-axis** distance `dot(wp-eye, camDir)` (NOT Euclidean — Euclidean self-occludes off-axis texels, i.e. depth acne) exceeds `dMap + bias` (bias > 1/255 of the range). Depth-limit rejects texels farther than a fraction-of-bounds-radius BEHIND the nearest visible surface (the sphere-with-hole case). The color `View` and the occlusion `viewProj` are kept strictly separate (the depth camera auto-frames, so its matrices differ from the live camera's). **Projection surfaces** (`TexturePaintController`): WRITE-backed props `projectionMode` (0 off / 1 stencil-brush / 2 camera-locked), `stencilImagePath`, `projBackfaceCull`, `projUseOcclusion`, `projDepthLimit`, read-only `cameraLocked`. Stencil brush: `paintColorFootprintAtUV` delegates to `projectDab` through the live (mode 1) or locked (mode 2) camera View — masked by the projected stencil alpha, painted into the active layer (normal stroke undo). `m_projTris` cached at `beginStroke`; occlusion map refreshed at stroke start (mode 1) / on `snapProjectionCamera` (mode 2), never per dab. `projectFromPhoto` projects a photo through the camera into a scratch buffer and commits via `commitProjectedLayer` (new `Generated` layer + one `PaintLayerOpCommand`). `currentProjectionView` = `cam->getProjectionMatrixWithRSDepth()*getViewMatrix()` + `getRealDirection/Position`. **Decal tool** (`ToolDecal=6`): **`src/DecalSession.{h,cpp}`** (pure-data) is a world-anchored oriented-quad state machine — `begin(img)→place(surfaceHit,normal,camUp)→Editing`; `translate/rotate(about normal)/scale`; `hitTest(rectUv)→Body/RotateCorner/ScaleEdge`; `buildCommit(softEdge)` returns an ORTHOGRAPHIC `View` (world→clip change-of-basis mapping the quad to NDC ±1, camDirection into the surface) + the decal image with a feathered soft-edge alpha. Controller: `refreshDecalOverlay` draws a world-space `ManualObject` quad + corner(rotate)/edge(scale) handle squares (depth-off so grabbable), torn down in `closeSession`; `placeDecalAt`/`decalHitTest`(ray∩rect-plane→rect-UV)/`dragDecal`/`commitDecal`(→`ProjectionPainter::project`+occlusion→new layer)/`cancelDecal`. Viewport routing: `TransformOperator::mousePressEvent` consumes decal clicks BEFORE the paint-stroke branch (place while Placing, grab a handle while Editing), `mouseMoveEvent` drags, release ends the drag (session stays open); `MainWindow::keyPressEvent` — Enter commits / Esc cancels (swallow-all, mirrors the knife). Both create a new layer + are undoable. Sentry `paint.projection.*` / `paint.decal.*`. QML: a **collapsible** Projection group (mode/stencil/Snap/Backface/Occlude/Project-from-photo) + a Decal row (Place/Commit/Cancel + hint) in `texPaintCol`. Tests: `ProjectionPainter_test.cpp` (front projection, backface, stencil gating, dab, **sphere-with-hole occlusion**, depth-limit, self-projection no-acne) + `DecalSession_test.cpp` (transitions, hit-test zones, edits, world↔rect-UV, ortho-commit corner→NDC, soft-edge) — pure-data; plus `TexturePaintController_test.cpp` fixture cases (projection-mode setters + graceful no-camera; decal begin/cancel plumbing). +- **Paint v2 Slice G — cavity / curvature / AO derived maps (#550)**: auto-generated per-mesh scalar maps that gate the brush or initialise a layer mask — the canonical edge-wear / crevice-dirt / weathering workflow. Design doc: `docs/PAINT_V2_SLICE_G_DESIGN.md`. **`src/DerivedMapGenerator.{h,cpp}`** (pure-data, unit-tested): per-vertex concavity = mean over the `HalfEdgeMesh` 1-ring of `dot(vertexNormal, normalize(neighbour - v))` — positive = concave crevice, negative = convex ridge (the mesh is welded across submeshes first so a UV seam / material split does not read as a crease); `remapForKind` then makes **Cavity** the concave half only (grime never lands on ridges) and **Curvature** a signed signal centred at 0.5 with a `flatTolerance` that pins near-flat to EXACTLY neutral (else tessellation noise on flat panels speckles into visible edge wear). Rasterisation is a LOCAL float edge-function rasteriser + coverage-vector seam dilation, deliberately NOT `VertexColorBaker::rasterizeTriangle` (RGBA8-typed → a scalar map would quantise to 8 bits and band visibly across a smooth AO gradient); the explicit-coverage discipline IS copied, because a texel whose value equals the background is otherwise indistinguishable from an unwritten one. `generate()` REFUSES `AmbientOcclusion` (pointing at `fromVertexOcclusion`) so the whole rasterisation path stays headless-testable. **`src/DerivedMapOcclusion.{h,cpp}`** (pure-data): AO WITHOUT a ray tracer — there is no BVH/kd-tree/octree in the repo and every existing ray query is a brute-force linear scan, so instead of adding an acceleration structure this reuses the depth-map visibility test `ProjectionPainter::OcclusionMap` already proves: render depth from 12 Fibonacci-lattice directions (a naive lat/long grid clusters at the poles and biases AO vertically) and count how many views can see each vertex. Distance is compared along the **camera axis** (the fog encodes linear distance along it; Euclidean self-occludes off-axis points). Two non-obvious rules, both test-pinned: back-facing views are **SKIPPED, not counted as occluding** (counting them darkens every vertex ~half regardless of geometry), and when NO view faces the normal the answer is **0 (unoccluded), not 1** (which would black out uncovered regions). A behind-eye-plane guard is explicit because `projectToViewportUV`'s perspective `behind` (w<=0) flag NEVER fires under the ORTHOGRAPHIC-ish auto-framed depth views (w stays 1), so a point behind the camera got a negative axis distance that sailed through the `<= dMap + bias` test. **`src/DerivedMapCache.{h,cpp}`**: versioned `/paint/derived_maps//.bin` following `HdrCache`'s magic+version header + 40-hex-char key validation (the key is a path component, so `../` is made UNREPRESENTABLE rather than sanitised) + temp-file-then-rename (an interrupted save must never leave a half-entry a later load trusts). **Invalidation is by CONTENT HASH, not the "EditableMesh revision counter" the issue proposed — no such counter exists, and `EditableMesh`'s public mutable `subMeshes()` accessor means one could be bypassed without incrementing.** The SHA-1 already needed for the directory name IS the invalidation; it covers positions/normals/UV0/indices and deliberately EXCLUDES vertex colour + bone weights (they cannot change these maps, so including them would force needless rebakes). Bump `kFormatVersion` when a generator's output changes for identical input — the mesh hash cannot notice that. **Controller**: WRITE-backed `derivedMapKind`/`derivedMapAsBrushMask`/`derivedMapStrength`/`derivedMapInvert`/`derivedMapContrast` + read-only `derivedMapReady`/`derivedMapStatus`; `computeDerivedMap()` walks memory → disk cache → bake; `recomputeDerivedMaps()` is the issue's "Recalculate derived maps". `derivedMapReady()` returns false while the cached hash and live geometry disagree, so a topology edit can never leave a stale map bound. `setDerivedMapContrast` clears the cache (contrast feeds the GENERATOR, not the lookup). **Brush modulation wraps the `colorAt` callback** (so tiling/stamp/gradient inherit it for free, mirroring the TilingSource wrap) and scales **ALPHA, not RGB** (scaling RGB drags paint toward black in cavities instead of hiding it); the two scalar fast paths (solid colour, `GradientLinear`) collapse the brush to one colour via the `paintBrush` overload that never calls `colorAt`, so they are SKIPPED while a map modulates — otherwise enabling the mask appears to do nothing for the most common brush setup. `applyDerivedMapToLayerMask()` fills `PaintLayerStack::ensureLayerMask` (which existed with the compositor honouring `maskAlpha` but had NO non-test caller until this slice), sampling by UV rather than assuming 1:1 texels. Recipes (`applyDerivedMapRecipe`): Edge wear (inverted curvature / bare metal), Crevice dirt (cavity / dark grime), AO darken (AO / black / Multiply) — each one masked `Generated` layer + one undo step, and each RESTORES the user's kind+invert afterwards so a recipe never silently retargets the picker. Sentry `paint.derived_map.*`. QML: a collapsible "Cavity / Curvature / AO" group in `texPaintCol`. Tests: `DerivedMapGenerator_test.cpp` (valley-vs-ridge opposite sign, cavity clamps convex away, curvature flat-pin, dilation grows coverage, wrong-sized input refused), `DerivedMapCache_test.cpp` (hash stable + geometry-sensitive + colour-insensitive, round-trip, per-kind isolation, `../` rejected, corrupt entry = clean miss), `DerivedMapOcclusion_test.cpp` (bias vs acne, back-facing skipped, no-facing-view = 0, Fibonacci spread) + `TexturePaintController_test.cpp` fixture cases (setter clamping/notify, readiness agrees with bake success, every entry point safe with no session, recipe preserves the user's kind). ### Scene Lighting (epic #482, Slice H #490) diff --git a/docs/PAINT_V2_SLICE_G_DESIGN.md b/docs/PAINT_V2_SLICE_G_DESIGN.md new file mode 100644 index 00000000..2fc9fc00 --- /dev/null +++ b/docs/PAINT_V2_SLICE_G_DESIGN.md @@ -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 `/paint/derived_maps//.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. diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index a31b812b..d9994215 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -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 @@ -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 { @@ -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 { diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b0f5867e..1a942d99 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -164,6 +164,9 @@ BrushAssetLibrary.cpp TexturePaintBuffer.cpp TexturePaintController.cpp VertexColorBaker.cpp +DerivedMapGenerator.cpp +DerivedMapCache.cpp +DerivedMapOcclusion.cpp VATBaker.cpp VATBakerController.cpp VATShaderEmitter.cpp @@ -395,6 +398,9 @@ TexturePaintBuffer.h UpdateVersion.h TexturePaintController.h VertexColorBaker.h +DerivedMapGenerator.h +DerivedMapCache.h +DerivedMapOcclusion.h ApplyAtlas.h EmbeddedTextureCache.h NormalMapGenerator.h diff --git a/src/DerivedMapCache.cpp b/src/DerivedMapCache.cpp new file mode 100644 index 00000000..f7f1c108 --- /dev/null +++ b/src/DerivedMapCache.cpp @@ -0,0 +1,226 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file — on-disk cache for cavity / curvature / AO maps +(Paint v2 Slice G, issue #550) + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) +The MIT License — see other project sources for the full header. +----------------------------------------------------------------------------------- +*/ +#include "DerivedMapCache.h" + +#include "EditableMesh.h" + +#include +#include +#include +#include +#include + +namespace { + +#pragma pack(push, 1) +struct FileHeader { + uint32_t magic = 0; + uint32_t version = 0; + int32_t width = 0; + int32_t height = 0; + uint32_t valueBytes = 0; ///< width*height*sizeof(float) + uint32_t coverageBytes = 0; ///< width*height*sizeof(uint8_t) +}; +#pragma pack(pop) + +/// A cache key becomes a path component, so validate it structurally: exactly +/// 40 lowercase-or-upper hex chars (SHA-1 hex length). This makes "../" and +/// absolute paths unrepresentable rather than relying on sanitisation. +bool isValidKey(const QString& key) +{ + if (key.size() != 40) return false; + for (const QChar c : key) { + const char ch = c.toLatin1(); + const bool hex = (ch >= '0' && ch <= '9') + || (ch >= 'a' && ch <= 'f') + || (ch >= 'A' && ch <= 'F'); + if (!hex) return false; + } + return true; +} + +QString entryFile(const QString& meshHash, DerivedMapKind kind) +{ + const QString dir = DerivedMapCache::entryDirectory(meshHash); + if (dir.isEmpty()) return {}; + return QDir(dir).filePath( + QString::fromLatin1(DerivedMapGenerator::kindName(kind)) + QStringLiteral(".bin")); +} + +} // namespace + +namespace DerivedMapCache { + +QString cacheRootDirectory() +{ + const QString base = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + if (base.isEmpty()) return {}; // guard AppData being unavailable + return QDir(base).filePath(QStringLiteral("paint/derived_maps")); +} + +QString entryDirectory(const QString& meshHash) +{ + if (!isValidKey(meshHash)) return {}; + const QString root = cacheRootDirectory(); + if (root.isEmpty()) return {}; + return QDir(root).filePath(meshHash); +} + +QString meshHash(const EditableMesh& mesh) +{ + QCryptographicHash hash(QCryptographicHash::Sha1); + // Mix in the format version so an algorithm change cannot collide with a + // stale entry that happens to share geometry. + const uint32_t ver = kFormatVersion; + hash.addData(QByteArrayView(reinterpret_cast(&ver), sizeof(ver))); + + for (const EditableSubMesh& sm : mesh.subMeshes()) { + const uint32_t vCount = static_cast(sm.vertices.size()); + const uint32_t tCount = static_cast(sm.triangles.size()); + hash.addData(QByteArrayView(reinterpret_cast(&vCount), sizeof(vCount))); + hash.addData(QByteArrayView(reinterpret_cast(&tCount), sizeof(tCount))); + for (const EditableVertex& v : sm.vertices) { + // Only geometry that can change cavity/curvature/AO. Colour and + // bone weights are excluded on purpose (they cannot). + const float buf[8] = { + v.position.x, v.position.y, v.position.z, + v.normal.x, v.normal.y, v.normal.z, + v.uv.x, v.uv.y, + }; + hash.addData(QByteArrayView(reinterpret_cast(buf), sizeof(buf))); + } + for (const EditableTriangle& t : sm.triangles) { + hash.addData(QByteArrayView(reinterpret_cast(t.indices), + sizeof(t.indices))); + } + } + return QString::fromLatin1(hash.result().toHex()); +} + +bool has(const QString& meshHash, DerivedMapKind kind) +{ + const QString path = entryFile(meshHash, kind); + return !path.isEmpty() && QFileInfo::exists(path); +} + +bool load(const QString& meshHash, DerivedMapKind kind, DerivedMap& out, QString& error) +{ + const QString path = entryFile(meshHash, kind); + if (path.isEmpty()) { error = QStringLiteral("invalid cache key"); return false; } + + QFile f(path); + if (!f.open(QIODevice::ReadOnly)) { error = QStringLiteral("cache miss"); return false; } + + FileHeader hdr; + if (f.read(reinterpret_cast(&hdr), sizeof(hdr)) != qint64(sizeof(hdr))) { + error = QStringLiteral("truncated header"); + return false; + } + // Any structural mismatch is a miss, not an error to surface: a stale entry + // from an older format must simply be regenerated. + if (hdr.magic != kMagic || hdr.version != kFormatVersion) { + error = QStringLiteral("format mismatch"); + return false; + } + if (hdr.width <= 0 || hdr.height <= 0) { error = QStringLiteral("bad dimensions"); return false; } + + const size_t n = static_cast(hdr.width) * static_cast(hdr.height); + if (hdr.valueBytes != n * sizeof(float) || hdr.coverageBytes != n) { + error = QStringLiteral("payload size mismatch"); + return false; + } + + DerivedMap m; + m.width = hdr.width; + m.height = hdr.height; + m.values.resize(n); + m.coverage.resize(n); + if (f.read(reinterpret_cast(m.values.data()), hdr.valueBytes) != qint64(hdr.valueBytes) + || f.read(reinterpret_cast(m.coverage.data()), hdr.coverageBytes) != qint64(hdr.coverageBytes)) { + error = QStringLiteral("truncated payload"); + return false; + } + out = std::move(m); + error.clear(); + return true; +} + +bool save(const QString& meshHash, DerivedMapKind kind, const DerivedMap& in, QString& error) +{ + if (in.empty()) { error = QStringLiteral("refusing to cache an empty map"); return false; } + const QString path = entryFile(meshHash, kind); + if (path.isEmpty()) { error = QStringLiteral("invalid cache key"); return false; } + + const size_t n = static_cast(in.width) * static_cast(in.height); + if (in.values.size() != n || in.coverage.size() != n) { + error = QStringLiteral("map arrays do not match its dimensions"); + return false; + } + + if (!QDir().mkpath(QFileInfo(path).absolutePath())) { + error = QStringLiteral("could not create cache directory"); + return false; + } + + // Write to a temp file and rename, so an interrupted save can never leave a + // half-written entry that a later load would treat as valid. + const QString tmpPath = path + QStringLiteral(".tmp"); + { + QFile f(tmpPath); + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + error = QStringLiteral("could not open cache file for writing"); + return false; + } + FileHeader hdr; + hdr.magic = kMagic; + hdr.version = kFormatVersion; + hdr.width = in.width; + hdr.height = in.height; + hdr.valueBytes = static_cast(n * sizeof(float)); + hdr.coverageBytes = static_cast(n); + const auto bad = [&](const char* what) { + error = QStringLiteral("short write (%1)").arg(QLatin1String(what)); + f.close(); + QFile::remove(tmpPath); + return false; + }; + if (f.write(reinterpret_cast(&hdr), sizeof(hdr)) != qint64(sizeof(hdr))) + return bad("header"); + if (f.write(reinterpret_cast(in.values.data()), hdr.valueBytes) + != qint64(hdr.valueBytes)) + return bad("values"); + if (f.write(reinterpret_cast(in.coverage.data()), hdr.coverageBytes) + != qint64(hdr.coverageBytes)) + return bad("coverage"); + f.close(); + } + QFile::remove(path); // rename() will not overwrite on all platforms + if (!QFile::rename(tmpPath, path)) { + QFile::remove(tmpPath); + error = QStringLiteral("could not finalise cache file"); + return false; + } + error.clear(); + return true; +} + +void invalidate(const QString& meshHash, DerivedMapKind kind) +{ + const QString path = entryFile(meshHash, kind); + if (!path.isEmpty()) QFile::remove(path); +} + +void invalidateAll(const QString& meshHash) +{ + const QString dir = entryDirectory(meshHash); + if (!dir.isEmpty()) QDir(dir).removeRecursively(); +} + +} // namespace DerivedMapCache diff --git a/src/DerivedMapCache.h b/src/DerivedMapCache.h new file mode 100644 index 00000000..62ac4492 --- /dev/null +++ b/src/DerivedMapCache.h @@ -0,0 +1,60 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file — on-disk cache for cavity / curvature / AO maps +(Paint v2 Slice G, issue #550) + +Versioned cache under /paint/derived_maps//.bin, so a +map is generated once per mesh rather than on every brush stroke. + +Invalidation is by CONTENT HASH, not a revision counter: the issue proposed an +"EditableMesh revision counter", but EditableMesh has no such counter and +exposes a public mutable subMeshes() accessor, so any counter could be bypassed +without incrementing. Hashing the geometry we already have to hash for the +directory name makes changed geometry a natural cache miss, with nothing to keep +in sync. See meshHash(). + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) +The MIT License — see other project sources for the full header. +----------------------------------------------------------------------------------- +*/ +#ifndef DERIVEDMAPCACHE_H +#define DERIVEDMAPCACHE_H + +#include "DerivedMapGenerator.h" + +#include + +#include + +class EditableMesh; + +namespace DerivedMapCache { + +constexpr uint32_t kMagic = 0x444D5031u; // 'DMP1' +/// Bump to invalidate every cached entry at once — do this whenever a +/// generator's output would change for identical input (algorithm tweak, +/// different remap curve, etc.), since the mesh hash alone would not notice. +constexpr uint32_t kFormatVersion = 1; + +/// /paint/derived_maps. Empty if AppData is unavailable. +QString cacheRootDirectory(); +/// /paint/derived_maps/. Empty if the key is malformed. +QString entryDirectory(const QString& meshHash); + +/// SHA-1 (40 hex chars) over the geometry that affects a derived map: +/// per-submesh vertex positions, normals, UV0 and triangle indices. Vertex +/// colours / bone weights are deliberately excluded — they cannot change +/// cavity, curvature or AO, so including them would cause needless rebakes. +QString meshHash(const EditableMesh& mesh); + +bool has(const QString& meshHash, DerivedMapKind kind); +bool load(const QString& meshHash, DerivedMapKind kind, DerivedMap& out, QString& error); +bool save(const QString& meshHash, DerivedMapKind kind, const DerivedMap& in, QString& error); + +/// Remove one kind, or the whole mesh entry ("Recalculate derived maps"). +void invalidate(const QString& meshHash, DerivedMapKind kind); +void invalidateAll(const QString& meshHash); + +} // namespace DerivedMapCache + +#endif // DERIVEDMAPCACHE_H diff --git a/src/DerivedMapCache_test.cpp b/src/DerivedMapCache_test.cpp new file mode 100644 index 00000000..8933db4e --- /dev/null +++ b/src/DerivedMapCache_test.cpp @@ -0,0 +1,201 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file — DerivedMapCache unit tests (Paint v2 Slice G, issue #550) + +Exercises the round-trip, the content-hash invalidation the issue's proposed +"revision counter" was replaced by, and the malformed-key path guard. +Writes under the test process's own AppData location; no Ogre scene / GL. + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) +The MIT License — see other project sources for the full header. +----------------------------------------------------------------------------------- +*/ +#include + +#include "DerivedMapCache.h" +#include "EditableMesh.h" + +#include +#include + +#include +#include +#include + +namespace { + +EditableMesh triMesh(float z = 0.0f) +{ + EditableMesh m; + m.subMeshes().resize(1); + EditableSubMesh& sm = m.subMeshes()[0]; + auto push = [&](float x, float y, float u, float v) { + EditableVertex ev; + ev.position = Ogre::Vector3(x, y, z); + ev.normal = Ogre::Vector3(0, 0, 1); ev.hasNormal = true; + ev.uv = Ogre::Vector2(u, v); ev.hasUV = true; + sm.vertices.push_back(ev); + }; + push(0, 0, 0, 0); push(1, 0, 1, 0); push(0, 1, 0, 1); + sm.triangles.push_back({0, 1, 2}); + return m; +} + +DerivedMap makeMap(int w, int h, float fill) +{ + DerivedMap m; + m.width = w; m.height = h; + m.values.assign(static_cast(w) * h, fill); + m.coverage.assign(static_cast(w) * h, 1u); + return m; +} + +} // namespace + +// The cache writes under , which is the USER's real data directory. +// QStandardPaths test mode redirects it to a throwaway location (the same guard +// BrushAssetLibrary_test / GamificationManager_test use), so running the suite +// never pollutes — or reads stale entries from — a real install. +class DerivedMapCacheTest : public ::testing::Test +{ +protected: + void SetUp() override { QStandardPaths::setTestModeEnabled(true); } + void TearDown() override + { + const QString root = DerivedMapCache::cacheRootDirectory(); + if (!root.isEmpty()) QDir(root).removeRecursively(); + QStandardPaths::setTestModeEnabled(false); + } +}; + +TEST_F(DerivedMapCacheTest, MeshHashIsStableAndGeometrySensitive) { + const QString a = DerivedMapCache::meshHash(triMesh(0.0f)); + const QString b = DerivedMapCache::meshHash(triMesh(0.0f)); + const QString c = DerivedMapCache::meshHash(triMesh(5.0f)); + + EXPECT_EQ(a.size(), 40) << "must be SHA-1 hex so the path guard accepts it"; + EXPECT_EQ(a, b) << "same geometry must hash identically (else every load misses)"; + EXPECT_NE(a, c) << "moved geometry must hash differently — this IS the invalidation"; +} + +TEST_F(DerivedMapCacheTest, HashIgnoresNonGeometricAttributes) { + // Vertex colour cannot change cavity/curvature/AO, so it must not force a + // rebake. If this ever starts failing, painting a vertex colour would + // silently invalidate every derived map for that mesh. + EditableMesh m1 = triMesh(); + EditableMesh m2 = triMesh(); + m2.subMeshes()[0].vertices[0].color = Ogre::ColourValue(1, 0, 0, 1); + m2.subMeshes()[0].vertices[0].hasColor = true; + EXPECT_EQ(DerivedMapCache::meshHash(m1), DerivedMapCache::meshHash(m2)); +} + +TEST_F(DerivedMapCacheTest, HashChangesWithUvAndNormals) { + // UV and normals DO affect the output (UV decides where texels land, + // normals decide the concavity sign), so both must be in the hash. + EditableMesh uvChanged = triMesh(); + uvChanged.subMeshes()[0].vertices[1].uv = Ogre::Vector2(0.5f, 0.5f); + EXPECT_NE(DerivedMapCache::meshHash(triMesh()), DerivedMapCache::meshHash(uvChanged)); + + EditableMesh nChanged = triMesh(); + nChanged.subMeshes()[0].vertices[1].normal = Ogre::Vector3(1, 0, 0); + EXPECT_NE(DerivedMapCache::meshHash(triMesh()), DerivedMapCache::meshHash(nChanged)); +} + +TEST_F(DerivedMapCacheTest, SaveLoadRoundTripsExactly) { + const QString key = DerivedMapCache::meshHash(triMesh()); + DerivedMapCache::invalidateAll(key); + + const DerivedMap in = makeMap(8, 4, 0.375f); + QString err; + ASSERT_TRUE(DerivedMapCache::save(key, DerivedMapKind::Cavity, in, err)) << err.toStdString(); + EXPECT_TRUE(DerivedMapCache::has(key, DerivedMapKind::Cavity)); + + DerivedMap out; + ASSERT_TRUE(DerivedMapCache::load(key, DerivedMapKind::Cavity, out, err)) << err.toStdString(); + EXPECT_EQ(out.width, in.width); + EXPECT_EQ(out.height, in.height); + ASSERT_EQ(out.values.size(), in.values.size()); + for (size_t i = 0; i < in.values.size(); ++i) EXPECT_FLOAT_EQ(out.values[i], in.values[i]); + EXPECT_EQ(out.coverage, in.coverage); + + DerivedMapCache::invalidateAll(key); +} + +TEST_F(DerivedMapCacheTest, KindsAreStoredSeparately) { + const QString key = DerivedMapCache::meshHash(triMesh()); + DerivedMapCache::invalidateAll(key); + QString err; + ASSERT_TRUE(DerivedMapCache::save(key, DerivedMapKind::Cavity, makeMap(4, 4, 0.1f), err)); + ASSERT_TRUE(DerivedMapCache::save(key, DerivedMapKind::Curvature, makeMap(4, 4, 0.9f), err)); + + DerivedMap cav, curv; + ASSERT_TRUE(DerivedMapCache::load(key, DerivedMapKind::Cavity, cav, err)); + ASSERT_TRUE(DerivedMapCache::load(key, DerivedMapKind::Curvature, curv, err)); + EXPECT_NEAR(cav.values[0], 0.1f, 1e-6f); + EXPECT_NEAR(curv.values[0], 0.9f, 1e-6f) << "one kind must not overwrite another"; + + // Per-kind invalidation must not take the sibling with it. + DerivedMapCache::invalidate(key, DerivedMapKind::Cavity); + EXPECT_FALSE(DerivedMapCache::has(key, DerivedMapKind::Cavity)); + EXPECT_TRUE(DerivedMapCache::has(key, DerivedMapKind::Curvature)); + + DerivedMapCache::invalidateAll(key); + EXPECT_FALSE(DerivedMapCache::has(key, DerivedMapKind::Curvature)); +} + +TEST_F(DerivedMapCacheTest, MalformedKeysAreRejectedAsPaths) { + // The key becomes a path component, so traversal attempts and wrong-length + // keys must be structurally impossible rather than sanitised. + for (const char* bad : {"../../etc/passwd", "not-hex-at-all", "", "abc", + "/absolute/path", "0123456789012345678901234567890123456789z"}) { + const QString k = QString::fromLatin1(bad); + EXPECT_TRUE(DerivedMapCache::entryDirectory(k).isEmpty()) << bad; + EXPECT_FALSE(DerivedMapCache::has(k, DerivedMapKind::Cavity)) << bad; + QString err; + EXPECT_FALSE(DerivedMapCache::save(k, DerivedMapKind::Cavity, makeMap(2, 2, 0.5f), err)) << bad; + DerivedMap out; + EXPECT_FALSE(DerivedMapCache::load(k, DerivedMapKind::Cavity, out, err)) << bad; + } + // A well-formed 40-char hex key IS accepted. + EXPECT_FALSE(DerivedMapCache::entryDirectory( + QStringLiteral("0123456789abcdef0123456789abcdef01234567")).isEmpty()); +} + +TEST_F(DerivedMapCacheTest, RefusesToCacheEmptyMap) { + const QString key = DerivedMapCache::meshHash(triMesh()); + QString err; + // An empty map means the generator failed; caching it would poison every + // later load with a valid-looking "no signal" result. + EXPECT_FALSE(DerivedMapCache::save(key, DerivedMapKind::Cavity, DerivedMap{}, err)); + EXPECT_FALSE(err.isEmpty()); +} + +TEST_F(DerivedMapCacheTest, CorruptEntryIsAMissNotACrash) { + const QString key = DerivedMapCache::meshHash(triMesh()); + DerivedMapCache::invalidateAll(key); + QString err; + ASSERT_TRUE(DerivedMapCache::save(key, DerivedMapKind::Cavity, makeMap(4, 4, 0.5f), err)); + + // Truncate the payload behind the cache's back. + const QString path = QDir(DerivedMapCache::entryDirectory(key)).filePath(QStringLiteral("cavity.bin")); + { + QFile f(path); + ASSERT_TRUE(f.open(QIODevice::ReadWrite)); + ASSERT_TRUE(f.resize(12)); // header only, no payload + } + DerivedMap out; + EXPECT_FALSE(DerivedMapCache::load(key, DerivedMapKind::Cavity, out, err)); + EXPECT_FALSE(err.isEmpty()); + + DerivedMapCache::invalidateAll(key); +} + +TEST_F(DerivedMapCacheTest, LoadOfMissingEntryFailsCleanly) { + const QString key = QStringLiteral("abcdefabcdefabcdefabcdefabcdefabcdefabcd"); + DerivedMapCache::invalidateAll(key); + DerivedMap out; + QString err; + EXPECT_FALSE(DerivedMapCache::load(key, DerivedMapKind::AmbientOcclusion, out, err)); + EXPECT_FALSE(err.isEmpty()); + EXPECT_TRUE(out.empty()); +} diff --git a/src/DerivedMapGenerator.cpp b/src/DerivedMapGenerator.cpp new file mode 100644 index 00000000..7382cb86 --- /dev/null +++ b/src/DerivedMapGenerator.cpp @@ -0,0 +1,313 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file — cavity / curvature / AO derived-map generation +(Paint v2 Slice G, issue #550) + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) +The MIT License — see other project sources for the full header. +----------------------------------------------------------------------------------- +*/ +#include "DerivedMapGenerator.h" + +#include "HalfEdgeMesh.h" + +#include +#include + +#include +#include + +namespace { + +// Edge-function half-space rasteriser over the UV triangle, matching +// VertexColorBaker::rasterizeTriangle's conventions exactly: UV -> pixel is +// (u*W, v*H) with a TOP-LEFT origin, and the winding is handled by flipping +// the edge weights when the signed area is negative (so the caller need not +// pre-sort). Kept local (rather than reusing VertexColorBaker) because that +// one is typed on RGBA8 ColourValue; a scalar map wants float precision and +// 1/4 the memory, and replicating a scalar into RGB then reading back .r +// would quantise to 8 bits — visible banding on a smooth AO gradient. +int rasteriseScalarTriangle(DerivedMap& map, + const Ogre::Vector2& uv0, const Ogre::Vector2& uv1, + const Ogre::Vector2& uv2, + float s0, float s1, float s2) +{ + const int W = map.width; + const int H = map.height; + if (W <= 0 || H <= 0) return 0; + + const float x0 = uv0.x * W, y0 = uv0.y * H; + const float x1 = uv1.x * W, y1 = uv1.y * H; + const float x2 = uv2.x * W, y2 = uv2.y * H; + + int minX = static_cast(std::floor(std::min({x0, x1, x2}))); + int maxX = static_cast(std::ceil (std::max({x0, x1, x2}))); + int minY = static_cast(std::floor(std::min({y0, y1, y2}))); + int maxY = static_cast(std::ceil (std::max({y0, y1, y2}))); + minX = std::max(0, minX); minY = std::max(0, minY); + maxX = std::min(W, maxX); maxY = std::min(H, maxY); + if (minX >= maxX || minY >= maxY) return 0; + + const float area = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0); + if (std::abs(area) < 1e-12f) return 0; // degenerate in UV space + const float invArea = 1.0f / area; + + int written = 0; + for (int py = minY; py < maxY; ++py) { + for (int px = minX; px < maxX; ++px) { + const float cx = px + 0.5f, cy = py + 0.5f; + float w0 = ((x1 - cx) * (y2 - cy) - (x2 - cx) * (y1 - cy)) * invArea; + float w1 = ((x2 - cx) * (y0 - cy) - (x0 - cx) * (y2 - cy)) * invArea; + float w2 = 1.0f - w0 - w1; + const float eps = -1e-5f; + if (w0 < eps || w1 < eps || w2 < eps) continue; + const size_t idx = static_cast(py) * W + px; + map.values[idx] = w0 * s0 + w1 * s1 + w2 * s2; + map.coverage[idx] = 1; + ++written; + } + } + return written; +} + +// Smear covered texels outward by `iterations` pixels. Mirrors +// VertexColorBaker::dilate: double-buffered so a pass cannot cascade within +// itself, first-covered-neighbour wins, early-exit when a pass changes +// nothing. +int dilateScalar(DerivedMap& map, int iterations) +{ + const int W = map.width, H = map.height; + if (W <= 0 || H <= 0 || iterations <= 0) return 0; + + int totalFlipped = 0; + for (int it = 0; it < iterations; ++it) { + std::vector nextVals = map.values; + std::vector nextCov = map.coverage; + int flipped = 0; + for (int y = 0; y < H; ++y) { + for (int x = 0; x < W; ++x) { + const size_t idx = static_cast(y) * W + x; + if (map.coverage[idx]) continue; + bool found = false; + for (int dy = -1; dy <= 1 && !found; ++dy) { + for (int dx = -1; dx <= 1 && !found; ++dx) { + if (dx == 0 && dy == 0) continue; + const int nx = x + dx, ny = y + dy; + if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue; + const size_t nidx = static_cast(ny) * W + nx; + if (!map.coverage[nidx]) continue; + nextVals[idx] = map.values[nidx]; + nextCov[idx] = 1; + found = true; + ++flipped; + } + } + } + } + if (flipped == 0) break; + map.values.swap(nextVals); + map.coverage.swap(nextCov); + totalFlipped += flipped; + } + return totalFlipped; +} + +} // namespace + +float DerivedMap::sample(float u, float v) const +{ + if (empty()) return 0.0f; + int x = static_cast(u * width); + int y = static_cast(v * height); + x = std::clamp(x, 0, width - 1); + y = std::clamp(y, 0, height - 1); + return values[static_cast(y) * width + x]; +} + +const char* DerivedMapGenerator::kindName(DerivedMapKind kind) +{ + switch (kind) { + case DerivedMapKind::Cavity: return "cavity"; + case DerivedMapKind::Curvature: return "curvature"; + case DerivedMapKind::AmbientOcclusion: return "ao"; + } + return "unknown"; +} + +float DerivedMapGenerator::backgroundFor(DerivedMapKind kind) +{ + // "No effect" per kind: cavity/AO 0 = no dirt / no occlusion; + // curvature 0.5 = flat (it is a signed signal centred at 0.5). + switch (kind) { + case DerivedMapKind::Cavity: return 0.0f; + case DerivedMapKind::AmbientOcclusion: return 0.0f; + case DerivedMapKind::Curvature: return 0.5f; + } + return 0.0f; +} + +float DerivedMapGenerator::remapForKind(float concavity, DerivedMapKind kind, + const Options& options) +{ + const float c = std::clamp(concavity * options.contrast, -1.0f, 1.0f); + switch (kind) { + case DerivedMapKind::Cavity: + // Keep only the concave half: crevices matter, ridges do not. + return std::clamp(c, 0.0f, 1.0f); + case DerivedMapKind::Curvature: { + // Signed, centred at 0.5. Pin near-flat to neutral so tessellation + // noise on flat panels does not show up as speckled edge wear. + if (std::abs(c) < options.flatTolerance) return 0.5f; + return std::clamp(0.5f + 0.5f * c, 0.0f, 1.0f); + } + case DerivedMapKind::AmbientOcclusion: + return std::clamp(c, 0.0f, 1.0f); + } + return 0.0f; +} + +size_t DerivedMapGenerator::weldedVertexCount(const EditableMesh& mesh) +{ + HalfEdgeMesh he; + if (!he.buildFromEditableMesh(mesh)) return 0; + return he.vertexCount(); +} + +std::vector DerivedMapGenerator::vertexConcavity(const EditableMesh& mesh) +{ + HalfEdgeMesh he; + if (!he.buildFromEditableMesh(mesh)) return {}; + + const size_t n = he.vertexCount(); + std::vector out(n, 0.0f); + for (size_t i = 0; i < n; ++i) { + const int vi = static_cast(i); + const HEVertex& v = he.vertex(vi); + Ogre::Vector3 nrm = v.normal; + if (!v.hasNormal || nrm.isZeroLength()) { + // No usable normal: leave flat rather than inventing a signal. + out[i] = 0.0f; + continue; + } + nrm.normalise(); + + const std::vector ring = he.verticesAroundVertex(vi); + if (ring.empty()) { out[i] = 0.0f; continue; } + + // Average, over the 1-ring, of how far each neighbour sits along the + // vertex normal relative to its distance. Neighbours "above" the + // tangent plane (positive dot) mean the surface closes in around this + // vertex => concave. Below => convex ridge. + float acc = 0.0f; + int used = 0; + for (const int ni : ring) { + const Ogre::Vector3 d = he.vertex(ni).position - v.position; + const float len = d.length(); + if (len < 1e-9f) continue; // coincident vertex + acc += nrm.dotProduct(d / len); + ++used; + } + out[i] = (used > 0) ? (acc / static_cast(used)) : 0.0f; + } + return out; +} + +DerivedMap DerivedMapGenerator::rasterise(const EditableMesh& mesh, + const std::vector& perVertex, + const Options& options, + Report* report) +{ + DerivedMap map; + Report rep; + + const int res = std::max(1, options.resolution); + HalfEdgeMesh he; + if (!he.buildFromEditableMesh(mesh)) { + rep.error = QStringLiteral("could not build half-edge mesh"); + if (report) *report = rep; + return map; + } + if (perVertex.size() != he.vertexCount()) { + rep.error = QStringLiteral("per-vertex array size %1 != welded vertex count %2") + .arg(perVertex.size()).arg(he.vertexCount()); + if (report) *report = rep; + return map; + } + + map.width = res; + map.height = res; + map.values.assign(static_cast(res) * res, options.background); + map.coverage.assign(static_cast(res) * res, 0u); + + const size_t faceCount = he.faceCount(); + for (size_t f = 0; f < faceCount; ++f) { + const std::vector fv = he.faceVertices(static_cast(f)); + if (fv.size() != 3) continue; + const HEVertex& a = he.vertex(fv[0]); + const HEVertex& b = he.vertex(fv[1]); + const HEVertex& c = he.vertex(fv[2]); + if (!a.hasUV || !b.hasUV || !c.hasUV) { ++rep.trianglesSkippedNoUv; continue; } + rep.texelsRasterised += rasteriseScalarTriangle( + map, a.uv, b.uv, c.uv, + perVertex[fv[0]], perVertex[fv[1]], perVertex[fv[2]]); + } + + rep.texelsDilated = dilateScalar(map, options.dilationPixels); + + // Report the observed range over covered texels only — an all-background + // range would hide a generator that produced nothing. + bool any = false; + for (size_t i = 0; i < map.values.size(); ++i) { + if (!map.coverage[i]) continue; + const float x = map.values[i]; + if (!any) { rep.minValue = rep.maxValue = x; any = true; } + else { rep.minValue = std::min(rep.minValue, x); rep.maxValue = std::max(rep.maxValue, x); } + } + rep.ok = true; + if (report) *report = rep; + return map; +} + +DerivedMap DerivedMapGenerator::generate(const EditableMesh& mesh, + DerivedMapKind kind, + const Options& options, + Report* report) +{ + if (kind == DerivedMapKind::AmbientOcclusion) { + Report rep; + rep.error = QStringLiteral( + "AmbientOcclusion needs scene-side occlusion sampling; " + "use fromVertexOcclusion()"); + if (report) *report = rep; + return {}; + } + + const std::vector raw = vertexConcavity(mesh); + if (raw.empty()) { + Report rep; + rep.error = QStringLiteral("mesh has no vertices / could not be welded"); + if (report) *report = rep; + return {}; + } + std::vector mapped(raw.size()); + for (size_t i = 0; i < raw.size(); ++i) + mapped[i] = remapForKind(raw[i], kind, options); + + Options opts = options; + opts.background = backgroundFor(kind); + return rasterise(mesh, mapped, opts, report); +} + +DerivedMap DerivedMapGenerator::fromVertexOcclusion(const EditableMesh& mesh, + const std::vector& occlusion, + const Options& options, + Report* report) +{ + std::vector clamped(occlusion.size()); + for (size_t i = 0; i < occlusion.size(); ++i) + clamped[i] = std::clamp(occlusion[i], 0.0f, 1.0f); + + Options opts = options; + opts.background = backgroundFor(DerivedMapKind::AmbientOcclusion); + return rasterise(mesh, clamped, opts, report); +} diff --git a/src/DerivedMapGenerator.h b/src/DerivedMapGenerator.h new file mode 100644 index 00000000..fc5c0508 --- /dev/null +++ b/src/DerivedMapGenerator.h @@ -0,0 +1,138 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file — cavity / curvature / AO derived-map generation +(Paint v2 Slice G, issue #550) + +Generates per-mesh scalar maps used as brush colour sources or layer masks: +edge wear from convex curvature, crevice dirt from cavity, weathering from AO. + +Pure data: inputs are an EditableMesh (+ optional pre-baked occlusion samples), +outputs are scalar maps in UV0 space, so the core is unit-testable headlessly. +The Ogre-scene side (depth-map rendering for AO, texture upload) lives in the +controller — see DerivedMapCache for the on-disk side. + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) +The MIT License — see other project sources for the full header. +----------------------------------------------------------------------------------- +*/ +#ifndef DERIVEDMAPGENERATOR_H +#define DERIVEDMAPGENERATOR_H + +#include "EditableMesh.h" + +#include + +#include +#include + +/// Which derived map to generate. Values are persisted in the on-disk cache +/// (as a filename component), so do NOT renumber — append only. +enum class DerivedMapKind { + Cavity = 0, ///< concave = 1 (dark/dirt), convex+flat = 0 + Curvature = 1, ///< signed, remapped to 0..1 with 0.5 = flat + AmbientOcclusion = 2, +}; + +/// A single-channel float map in UV0 space, plus the coverage mask the +/// rasteriser produced (1 = a triangle wrote this texel, 0 = background). +/// Coverage is kept alongside the values because dilation must know which +/// texels are real — a texel whose value happens to equal the background is +/// otherwise indistinguishable from an unwritten one (the same reasoning as +/// VertexColorBaker's explicit coverage vector). +struct DerivedMap { + int width = 0; + int height = 0; + std::vector values; ///< size w*h, row-major, top-left origin + std::vector coverage; ///< size w*h, 1 = written by rasterisation + + bool empty() const { return width <= 0 || height <= 0 || values.empty(); } + /// Nearest-texel sample; clamps out-of-range UV to the edge. `v` follows + /// the project's top-left-origin convention (v=0 is the first row), the + /// same as TexturePaintBuffer::uvToPixel. + float sample(float u, float v) const; +}; + +class DerivedMapGenerator +{ +public: + struct Options { + /// Output map size (square). + int resolution = 1024; + /// Pixels of seam dilation after rasterisation (0 = none). UV islands + /// need this or bilinear/MIP sampling bleeds background across seams. + int dilationPixels = 4; + /// Value written where no triangle covers a texel. 0.5 is neutral for + /// curvature (flat) and for cavity/AO means "no occlusion" once the + /// caller's convention is applied; see kind-specific defaults in + /// backgroundFor(). + float background = 0.0f; + /// Cavity/curvature: scales the raw concavity before clamping. Higher + /// = more contrast. 1.0 leaves the geometric value untouched. + float contrast = 1.0f; + /// Curvature only: values within this of flat are pinned to neutral, + /// which suppresses tessellation noise on nominally flat panels. + float flatTolerance = 0.02f; + }; + + struct Report { + bool ok = false; + QString error; + int texelsRasterised = 0; + int texelsDilated = 0; + int trianglesSkippedNoUv = 0; + float minValue = 0.0f; + float maxValue = 0.0f; + }; + + /// Per-vertex concavity in -1..1: negative = convex (edge/ridge), + /// positive = concave (crevice), ~0 = flat. Computed from the angle + /// between the vertex normal and the direction to each 1-ring neighbour: + /// a neighbour lying "in front of" the normal plane means the surface + /// bends away (concave). Averaged over the ring. + /// + /// `mesh` is welded across submeshes internally (via HalfEdgeMesh) so a UV + /// seam or material split does not read as a crease. + /// Returns one value per WELDED vertex; use `weldedVertexCount()` to size. + static std::vector vertexConcavity(const EditableMesh& mesh); + + /// Rasterise per-welded-vertex scalars into a UV-space map. + /// `perVertex` must be sized as `vertexConcavity` returns. + static DerivedMap rasterise(const EditableMesh& mesh, + const std::vector& perVertex, + const Options& options, + Report* report = nullptr); + + /// Generate cavity or curvature. AmbientOcclusion is NOT handled here — + /// it needs scene-side occlusion sampling; use `fromVertexOcclusion`. + static DerivedMap generate(const EditableMesh& mesh, + DerivedMapKind kind, + const Options& options, + Report* report = nullptr); + + /// Build an AO map from per-welded-vertex occlusion in 0..1 (1 = fully + /// occluded). The caller supplies these from whatever visibility source it + /// has (the controller uses hemisphere depth maps); keeping that out of + /// here is what lets the rasterisation path stay headless-testable. + static DerivedMap fromVertexOcclusion(const EditableMesh& mesh, + const std::vector& occlusion, + const Options& options, + Report* report = nullptr); + + /// Number of welded vertices `vertexConcavity` will return / that + /// `perVertex` inputs must match. + static size_t weldedVertexCount(const EditableMesh& mesh); + + /// Map the -1..1 concavity signal to the 0..1 stored range for `kind`. + /// Cavity keeps only the concave half; curvature centres flat at 0.5. + /// Pure math, exposed for unit tests. + static float remapForKind(float concavity, DerivedMapKind kind, + const Options& options); + + /// Default background value for a kind (the value meaning "no effect"). + static float backgroundFor(DerivedMapKind kind); + + /// Stable short name, used as the on-disk filename component. + static const char* kindName(DerivedMapKind kind); +}; + +#endif // DERIVEDMAPGENERATOR_H diff --git a/src/DerivedMapGenerator_test.cpp b/src/DerivedMapGenerator_test.cpp new file mode 100644 index 00000000..d1cf302d --- /dev/null +++ b/src/DerivedMapGenerator_test.cpp @@ -0,0 +1,280 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file — DerivedMapGenerator unit tests (Paint v2 Slice G, issue #550) + +Pure-data: builds synthetic EditableMeshes with KNOWN convex/concave geometry and +checks the cavity / curvature / AO signals land on the right sign and the right +UV texels. No Ogre scene / GL. + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) +The MIT License — see other project sources for the full header. +----------------------------------------------------------------------------------- +*/ +#include + +#include "DerivedMapGenerator.h" + +#include +#include + +#include + +namespace { + +// A flat quad in the z=0 plane, normals +Z, UV filling 0..1. Baseline: every +// vertex must read as flat (concavity ~0). +EditableMesh flatQuad() +{ + EditableMesh m; + m.subMeshes().resize(1); + EditableSubMesh& sm = m.subMeshes()[0]; + const Ogre::Vector3 n(0, 0, 1); + auto push = [&](float x, float y, float u, float v) { + EditableVertex ev; + ev.position = Ogre::Vector3(x, y, 0); + ev.normal = n; ev.hasNormal = true; + ev.uv = Ogre::Vector2(u, v); ev.hasUV = true; + sm.vertices.push_back(ev); + }; + push(-1, -1, 0, 1); push(1, -1, 1, 1); push(1, 1, 1, 0); push(-1, 1, 0, 0); + sm.triangles.push_back({0, 1, 2}); + sm.triangles.push_back({0, 2, 3}); + return m; +} + +// A "valley": two quads meeting along the y axis, folded so the shared centre +// edge is CONCAVE (a crevice). The centre verts' normals point up-and-inward, +// so their 1-ring neighbours sit above the tangent plane => positive concavity. +EditableMesh valley() +{ + EditableMesh m; + m.subMeshes().resize(1); + EditableSubMesh& sm = m.subMeshes()[0]; + auto push = [&](const Ogre::Vector3& p, const Ogre::Vector3& n, float u, float v) { + EditableVertex ev; + ev.position = p; + ev.normal = n.normalisedCopy(); ev.hasNormal = true; + ev.uv = Ogre::Vector2(u, v); ev.hasUV = true; + sm.vertices.push_back(ev); + }; + // Left wall rises to x=-1, centre trough at x=0 (z=0), right wall rises. + const Ogre::Vector3 nUp(0, 0, 1); + push({-1, -1, 1}, {-1, 0, 1}, 0.0f, 1.0f); // 0 outer left, bottom + push({-1, 1, 1}, {-1, 0, 1}, 0.0f, 0.0f); // 1 outer left, top + push({ 0, -1, 0}, nUp, 0.5f, 1.0f); // 2 centre bottom (concave) + push({ 0, 1, 0}, nUp, 0.5f, 0.0f); // 3 centre top (concave) + push({ 1, -1, 1}, { 1, 0, 1}, 1.0f, 1.0f); // 4 outer right, bottom + push({ 1, 1, 1}, { 1, 0, 1}, 1.0f, 0.0f); // 5 outer right, top + sm.triangles.push_back({0, 2, 3}); + sm.triangles.push_back({0, 3, 1}); + sm.triangles.push_back({2, 4, 5}); + sm.triangles.push_back({2, 5, 3}); + return m; +} + +// A "ridge": the mirror of valley() — centre edge pushed UP so it is CONVEX. +EditableMesh ridge() +{ + EditableMesh m = valley(); + EditableSubMesh& sm = m.subMeshes()[0]; + // Flip the profile: outer verts high -> low, centre low -> high. + sm.vertices[0].position.z = 0.0f; + sm.vertices[1].position.z = 0.0f; + sm.vertices[2].position.z = 1.0f; + sm.vertices[3].position.z = 1.0f; + sm.vertices[4].position.z = 0.0f; + sm.vertices[5].position.z = 0.0f; + return m; +} + +} // namespace + +TEST(DerivedMapGeneratorTest, FlatSurfaceHasNoConcavity) { + const auto c = DerivedMapGenerator::vertexConcavity(flatQuad()); + ASSERT_FALSE(c.empty()); + for (const float v : c) EXPECT_NEAR(v, 0.0f, 1e-4f); +} + +TEST(DerivedMapGeneratorTest, ConcaveAndConvexHaveOppositeSign) { + // The centre verts are the folded edge in both fixtures. A valley must + // read positive (concave) there and a ridge negative (convex) — if these + // ever agree in sign, cavity and edge-wear would target the same texels. + const auto vc = DerivedMapGenerator::vertexConcavity(valley()); + const auto rc = DerivedMapGenerator::vertexConcavity(ridge()); + ASSERT_GE(vc.size(), 4u); + ASSERT_EQ(vc.size(), rc.size()); + + // Welding may reorder/merge, so find the extreme of each rather than + // assuming index 2/3 survive as-is. + const float vMax = *std::max_element(vc.begin(), vc.end()); + const float rMin = *std::min_element(rc.begin(), rc.end()); + EXPECT_GT(vMax, 0.05f) << "valley centre should read concave (positive)"; + EXPECT_LT(rMin, -0.05f) << "ridge centre should read convex (negative)"; +} + +TEST(DerivedMapGeneratorTest, CavityKeepsOnlyConcaveHalf) { + DerivedMapGenerator::Options o; + // Cavity discards ridges: a convex signal must clamp to 0, a concave one + // must survive. Otherwise crevice dirt would also land on every edge. + EXPECT_FLOAT_EQ(DerivedMapGenerator::remapForKind(-0.8f, DerivedMapKind::Cavity, o), 0.0f); + EXPECT_FLOAT_EQ(DerivedMapGenerator::remapForKind(0.0f, DerivedMapKind::Cavity, o), 0.0f); + EXPECT_NEAR(DerivedMapGenerator::remapForKind(0.6f, DerivedMapKind::Cavity, o), 0.6f, 1e-5f); +} + +TEST(DerivedMapGeneratorTest, CurvatureIsSignedAroundNeutralHalf) { + DerivedMapGenerator::Options o; + o.flatTolerance = 0.02f; + // Signed: concave above 0.5, convex below, flat pinned exactly to 0.5. + EXPECT_NEAR(DerivedMapGenerator::remapForKind(0.0f, DerivedMapKind::Curvature, o), 0.5f, 1e-6f); + EXPECT_NEAR(DerivedMapGenerator::remapForKind(0.01f, DerivedMapKind::Curvature, o), 0.5f, 1e-6f) + << "within flatTolerance must pin to neutral (suppresses tessellation noise)"; + EXPECT_GT(DerivedMapGenerator::remapForKind(0.8f, DerivedMapKind::Curvature, o), 0.5f); + EXPECT_LT(DerivedMapGenerator::remapForKind(-0.8f, DerivedMapKind::Curvature, o), 0.5f); +} + +TEST(DerivedMapGeneratorTest, ContrastScalesBeforeClamping) { + DerivedMapGenerator::Options o; + o.contrast = 4.0f; + // 0.2 * 4 = 0.8, and a large value must clamp rather than overflow. + EXPECT_NEAR(DerivedMapGenerator::remapForKind(0.2f, DerivedMapKind::Cavity, o), 0.8f, 1e-5f); + EXPECT_NEAR(DerivedMapGenerator::remapForKind(0.9f, DerivedMapKind::Cavity, o), 1.0f, 1e-5f); +} + +TEST(DerivedMapGeneratorTest, RasteriseFillsUvSpaceAndReportsRange) { + const EditableMesh m = flatQuad(); + const size_t n = DerivedMapGenerator::weldedVertexCount(m); + ASSERT_GT(n, 0u); + std::vector per(n, 0.75f); + + DerivedMapGenerator::Options o; + o.resolution = 32; + o.dilationPixels = 0; + DerivedMapGenerator::Report rep; + const DerivedMap map = DerivedMapGenerator::rasterise(m, per, o, &rep); + + ASSERT_TRUE(rep.ok) << rep.error.toStdString(); + EXPECT_EQ(map.width, 32); + EXPECT_EQ(map.height, 32); + EXPECT_GT(rep.texelsRasterised, 0); + // The quad's UV covers the whole unit square, so the centre must be + // covered and carry the constant value. + EXPECT_EQ(map.coverage[static_cast(16) * 32 + 16], 1); + EXPECT_NEAR(map.sample(0.5f, 0.5f), 0.75f, 1e-5f); + EXPECT_NEAR(rep.minValue, 0.75f, 1e-5f); + EXPECT_NEAR(rep.maxValue, 0.75f, 1e-5f); +} + +TEST(DerivedMapGeneratorTest, RasteriseRejectsWrongSizedInput) { + const EditableMesh m = flatQuad(); + DerivedMapGenerator::Options o; + o.resolution = 8; + DerivedMapGenerator::Report rep; + // A mismatched per-vertex array must be refused, not read out of bounds. + const DerivedMap map = DerivedMapGenerator::rasterise(m, {1.0f, 2.0f}, o, &rep); + EXPECT_FALSE(rep.ok); + EXPECT_FALSE(rep.error.isEmpty()); + EXPECT_TRUE(map.empty()); +} + +TEST(DerivedMapGeneratorTest, DilationExtendsBeyondCoverage) { + // A half-width UV quad leaves the right half of the map uncovered; with + // dilation on, texels just past the island edge must be filled (otherwise + // bilinear/MIP sampling bleeds background across the seam). + EditableMesh m = flatQuad(); + for (auto& v : m.subMeshes()[0].vertices) v.uv.x *= 0.5f; + + const size_t n = DerivedMapGenerator::weldedVertexCount(m); + std::vector per(n, 1.0f); + + DerivedMapGenerator::Options o; + o.resolution = 32; + o.dilationPixels = 0; + DerivedMapGenerator::Report noDil; + const DerivedMap a = DerivedMapGenerator::rasterise(m, per, o, &noDil); + + o.dilationPixels = 3; + DerivedMapGenerator::Report withDil; + const DerivedMap b = DerivedMapGenerator::rasterise(m, per, o, &withDil); + + ASSERT_TRUE(noDil.ok); + ASSERT_TRUE(withDil.ok); + EXPECT_EQ(noDil.texelsDilated, 0); + EXPECT_GT(withDil.texelsDilated, 0); + int covA = 0, covB = 0; + for (size_t i = 0; i < a.coverage.size(); ++i) { covA += a.coverage[i]; covB += b.coverage[i]; } + EXPECT_GT(covB, covA) << "dilation must grow the covered region"; +} + +TEST(DerivedMapGeneratorTest, GenerateCavityOnValleyProducesSignal) { + DerivedMapGenerator::Options o; + o.resolution = 64; + o.contrast = 2.0f; + DerivedMapGenerator::Report rep; + const DerivedMap map = DerivedMapGenerator::generate( + valley(), DerivedMapKind::Cavity, o, &rep); + + ASSERT_TRUE(rep.ok) << rep.error.toStdString(); + EXPECT_GT(rep.texelsRasterised, 0); + // The trough runs down the middle of UV space (u=0.5) while the outer + // walls are at u=0 / u=1, so the centre must be dirtier than the edge. + const float centre = map.sample(0.5f, 0.5f); + const float edge = map.sample(0.02f, 0.5f); + EXPECT_GT(centre, edge) << "cavity should peak in the trough, not on the walls"; + EXPECT_GT(rep.maxValue, 0.0f); +} + +TEST(DerivedMapGeneratorTest, GenerateRejectsAoAndPointsAtTheRightCall) { + DerivedMapGenerator::Options o; + DerivedMapGenerator::Report rep; + // AO needs scene-side visibility; generate() must refuse rather than + // silently emit an empty/garbage map. + const DerivedMap map = DerivedMapGenerator::generate( + flatQuad(), DerivedMapKind::AmbientOcclusion, o, &rep); + EXPECT_FALSE(rep.ok); + EXPECT_TRUE(rep.error.contains("fromVertexOcclusion")); + EXPECT_TRUE(map.empty()); +} + +TEST(DerivedMapGeneratorTest, FromVertexOcclusionClampsAndRasterises) { + const EditableMesh m = flatQuad(); + const size_t n = DerivedMapGenerator::weldedVertexCount(m); + ASSERT_GT(n, 0u); + // Out-of-range inputs must be clamped into 0..1, not stored raw. + std::vector occ(n, 2.5f); + occ[0] = -1.0f; + + DerivedMapGenerator::Options o; + o.resolution = 16; + o.dilationPixels = 0; + DerivedMapGenerator::Report rep; + const DerivedMap map = DerivedMapGenerator::fromVertexOcclusion(m, occ, o, &rep); + + ASSERT_TRUE(rep.ok) << rep.error.toStdString(); + EXPECT_GE(rep.minValue, 0.0f); + EXPECT_LE(rep.maxValue, 1.0f); +} + +TEST(DerivedMapGeneratorTest, SampleClampsOutOfRangeUv) { + const EditableMesh m = flatQuad(); + const size_t n = DerivedMapGenerator::weldedVertexCount(m); + std::vector per(n, 0.4f); + DerivedMapGenerator::Options o; + o.resolution = 8; + o.dilationPixels = 0; + const DerivedMap map = DerivedMapGenerator::rasterise(m, per, o, nullptr); + ASSERT_FALSE(map.empty()); + // Must clamp, not index out of bounds. + EXPECT_NEAR(map.sample(-5.0f, -5.0f), map.sample(0.0f, 0.0f), 1e-6f); + EXPECT_NEAR(map.sample(9.0f, 9.0f), map.sample(0.999f, 0.999f), 1e-6f); +} + +TEST(DerivedMapGeneratorTest, KindNamesAreStableForCachePaths) { + // These strings become on-disk filename components — changing one silently + // orphans every cached map, so pin them. + EXPECT_STREQ(DerivedMapGenerator::kindName(DerivedMapKind::Cavity), "cavity"); + EXPECT_STREQ(DerivedMapGenerator::kindName(DerivedMapKind::Curvature), "curvature"); + EXPECT_STREQ(DerivedMapGenerator::kindName(DerivedMapKind::AmbientOcclusion), "ao"); + // Neutral backgrounds differ per kind: curvature's "no effect" is 0.5. + EXPECT_FLOAT_EQ(DerivedMapGenerator::backgroundFor(DerivedMapKind::Curvature), 0.5f); + EXPECT_FLOAT_EQ(DerivedMapGenerator::backgroundFor(DerivedMapKind::Cavity), 0.0f); +} diff --git a/src/DerivedMapOcclusion.cpp b/src/DerivedMapOcclusion.cpp new file mode 100644 index 00000000..c372fbd4 --- /dev/null +++ b/src/DerivedMapOcclusion.cpp @@ -0,0 +1,112 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file — per-vertex ambient occlusion from depth maps +(Paint v2 Slice G, issue #550) + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) +The MIT License — see other project sources for the full header. +----------------------------------------------------------------------------------- +*/ +#include "DerivedMapOcclusion.h" + +#include "ProjectionMath.h" + +#include +#include + +namespace DerivedMapOcclusion { + +bool isVisibleInView(const Ogre::Vector3& worldPos, const DepthView& view) +{ + if (view.depth.isNull()) return false; + + const ProjectionMath::Projected p = + ProjectionMath::projectToViewportUV(worldPos, view.viewProj); + if (p.behind) return false; + if (p.uv.x < 0.0f || p.uv.x > 1.0f || p.uv.y < 0.0f || p.uv.y > 1.0f) return false; + + // Reconstruct the nearest recorded surface distance: grayscale is linear in + // world distance over [depthNear, depthFar], near = bright. + const Ogre::ColourValue d = ProjectionMath::sampleImage(view.depth, p.uv); + const float dMap = view.depthNear + (1.0f - d.r) * (view.depthFar - view.depthNear); + + // Camera-AXIS distance, matching how the fog encoded it. Euclidean would + // over-read off-axis points and make them self-occlude. + float dPoint; + if (!view.camDirection.isZeroLength()) + dPoint = (worldPos - view.camPosition).dotProduct(view.camDirection); + else + dPoint = (worldPos - view.camPosition).length(); + + // A NEGATIVE axis distance means the point is behind the eye plane. The + // perspective `behind` flag above only catches this when w <= 0, which never + // happens under an ORTHOGRAPHIC viewProj (w stays 1), so test it explicitly + // rather than relying on the projection type. + if (dPoint < 0.0f) return false; + + return dPoint <= dMap + view.biasWorld; +} + +float occlusionAt(const Ogre::Vector3& worldPos, + const Ogre::Vector3& worldNormal, + const std::vector& views) +{ + if (views.empty()) return 0.0f; + + Ogre::Vector3 n = worldNormal; + const bool haveNormal = !n.isZeroLength(); + if (haveNormal) n.normalise(); + + int considered = 0; + int blocked = 0; + for (const DepthView& v : views) { + if (v.depth.isNull()) continue; + if (haveNormal && !v.camDirection.isZeroLength()) { + // The camera looks along camDirection, so it sits on the -camDirection + // side. Only count views on the OUTWARD side of the surface: a view + // from behind the face cannot contribute to how lit that face is, + // and counting it would darken everything by ~half uniformly. + const float facing = n.dotProduct(-v.camDirection); + if (facing <= 0.0f) continue; + } + ++considered; + if (!isVisibleInView(worldPos, v)) ++blocked; + } + if (considered == 0) return 0.0f; // nothing could see it either way + return static_cast(blocked) / static_cast(considered); +} + +std::vector occlusionForVertices(const std::vector& positions, + const std::vector& normals, + const std::vector& views) +{ + std::vector out(positions.size(), 0.0f); + for (size_t i = 0; i < positions.size(); ++i) { + const Ogre::Vector3 n = (i < normals.size()) ? normals[i] : Ogre::Vector3::ZERO; + out[i] = occlusionAt(positions[i], n, views); + } + return out; +} + +std::vector sampleDirections(int count) +{ + const int n = std::max(1, count); + std::vector dirs; + dirs.reserve(static_cast(n)); + + // Fibonacci lattice: near-uniform over the sphere with no clustering at the + // poles (which a naive lat/long grid would give, biasing AO vertically). + const float golden = static_cast(M_PI) * (3.0f - std::sqrt(5.0f)); + for (int i = 0; i < n; ++i) { + // y from +1 to -1, evenly in the z-axis-projected sense. + const float y = (n == 1) ? 0.0f + : 1.0f - 2.0f * (static_cast(i) / static_cast(n - 1)); + const float r = std::sqrt(std::max(0.0f, 1.0f - y * y)); + const float theta = golden * static_cast(i); + dirs.emplace_back(std::cos(theta) * r, y, std::sin(theta) * r); + dirs.back().normalise(); + } + return dirs; +} + +} // namespace DerivedMapOcclusion diff --git a/src/DerivedMapOcclusion.h b/src/DerivedMapOcclusion.h new file mode 100644 index 00000000..d24abe21 --- /dev/null +++ b/src/DerivedMapOcclusion.h @@ -0,0 +1,84 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file — per-vertex ambient occlusion from depth maps +(Paint v2 Slice G, issue #550) + +The issue proposed "short-ray hemispherical occlusion" on the CPU. There is no +BVH/kd-tree anywhere in the repo and every existing ray query is a brute-force +linear scan, so instead of adding an acceleration structure this reuses the +depth-map visibility test already proven by ProjectionPainter::OcclusionMap +(#549): render the mesh's depth from N directions, then for each vertex count +how many of those directions can actually see it. That fraction, inverted, is +the occlusion. + +The MATH is pure data (a vertex + a set of DepthView records -> a 0..1 scalar), +so it is unit-testable headlessly with synthetic depth images. The RENDERING of +those views touches the Ogre scene and lives in the controller. + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) +The MIT License — see other project sources for the full header. +----------------------------------------------------------------------------------- +*/ +#ifndef DERIVEDMAPOCCLUSION_H +#define DERIVEDMAPOCCLUSION_H + +#include +#include + +#include + +#include + +/// One rendered depth view: the grayscale depth image plus the exact matrices +/// used, so a world point can be projected back into it. Mirrors the fields of +/// MeshDepthRenderer::RenderResult / ProjectionPainter::OcclusionMap that the +/// visibility test actually needs. +struct DepthView { + QImage depth; ///< grayscale, near=bright / far=dark + Ogre::Matrix4 viewProj; ///< proj * view used to render `depth` + Ogre::Vector3 camPosition = Ogre::Vector3::ZERO; + Ogre::Vector3 camDirection = Ogre::Vector3::ZERO; ///< normalised + float depthNear = 0.0f; + float depthFar = 0.0f; + /// World-space slop before a texel counts as occluded. Without this a + /// surface occludes ITSELF at grazing angles (depth acne) — the same + /// problem, and the same fix, as ProjectionPainter's occlusion path. + float biasWorld = 0.0f; +}; + +namespace DerivedMapOcclusion { + +/// Is `worldPos` visible in `view`? False when it is behind the camera, falls +/// outside the depth image, or sits further along the camera axis than the +/// nearest recorded surface (plus `biasWorld`). +/// +/// Distance is measured along the CAMERA AXIS (dot of (p - eye) with the view +/// direction), NOT Euclidean: the depth map encodes linear fog distance along +/// that axis, so a Euclidean comparison reads off-axis points as further than +/// they were encoded and self-occludes them. +bool isVisibleInView(const Ogre::Vector3& worldPos, const DepthView& view); + +/// Fraction of `views` that CANNOT see `worldPos`, in 0..1 (1 = fully +/// occluded). Views whose direction faces away from `worldNormal` are skipped — +/// they are behind the surface, so counting them would darken every vertex by +/// roughly half regardless of geometry. If no view faces the normal, returns 0 +/// (unoccluded) rather than a misleading 1. +float occlusionAt(const Ogre::Vector3& worldPos, + const Ogre::Vector3& worldNormal, + const std::vector& views); + +/// Convenience: occlusion for every vertex, in the same order as the input. +/// `positions` and `normals` must be the same length. +std::vector occlusionForVertices(const std::vector& positions, + const std::vector& normals, + const std::vector& views); + +/// Evenly distributed unit directions over the sphere (Fibonacci lattice), used +/// as the set of view directions to render. `count` is clamped to >= 1. +/// Sphere rather than hemisphere because a mesh is sampled from all sides; the +/// per-vertex normal test then selects the relevant half for each vertex. +std::vector sampleDirections(int count); + +} // namespace DerivedMapOcclusion + +#endif // DERIVEDMAPOCCLUSION_H diff --git a/src/DerivedMapOcclusion_test.cpp b/src/DerivedMapOcclusion_test.cpp new file mode 100644 index 00000000..b8344b15 --- /dev/null +++ b/src/DerivedMapOcclusion_test.cpp @@ -0,0 +1,196 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file — DerivedMapOcclusion unit tests (Paint v2 Slice G, issue #550) + +Pure-data: hand-builds DepthViews with synthetic flat depth images (the same +trick ProjectionPainter_test.cpp uses) so the visibility maths is checked without +rendering anything. No Ogre scene / GL. + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) +The MIT License — see other project sources for the full header. +----------------------------------------------------------------------------------- +*/ +#include + +#include "DerivedMapOcclusion.h" + +#include + +#include +#include + +namespace { + +// Orthographic viewProj looking down -Z from +Z, matching ProjectionPainter's +// test helper: x/y in [-halfExtent, halfExtent] map to NDC [-1, 1]. +Ogre::Matrix4 orthoFromPlusZ(float eyeZ, float halfExtent, float zNear, float zFar) +{ + Ogre::Matrix4 view = Ogre::Matrix4::IDENTITY; + view[2][2] = -1.0f; // look toward -Z + view[2][3] = eyeZ; + Ogre::Matrix4 proj = Ogre::Matrix4::ZERO; + proj[0][0] = 1.0f / halfExtent; + proj[1][1] = 1.0f / halfExtent; + proj[2][2] = -2.0f / (zFar - zNear); + proj[2][3] = -(zFar + zNear) / (zFar - zNear); + proj[3][3] = 1.0f; + return proj * view; +} + +// A depth image where every pixel encodes the SAME world distance. +QImage flatDepth(int size, float surfaceDist, float near, float far) +{ + const float g = 1.0f - (surfaceDist - near) / (far - near); // near -> 1 + const int v = std::clamp(static_cast(g * 255.0f + 0.5f), 0, 255); + QImage img(size, size, QImage::Format_RGBA8888); + img.fill(QColor(v, v, v, 255)); + return img; +} + +// A view from +Z looking toward -Z, whose depth map says the nearest surface +// sits at `surfaceDist` from the eye. +DepthView viewFromPlusZ(float surfaceDist, float near = 4.0f, float far = 8.0f) +{ + DepthView v; + v.viewProj = orthoFromPlusZ(5.0f, 2.0f, 0.1f, 20.0f); + v.camPosition = Ogre::Vector3(0, 0, 5); + v.camDirection = Ogre::Vector3(0, 0, -1); + v.depthNear = near; + v.depthFar = far; + v.depth = flatDepth(32, surfaceDist, near, far); + v.biasWorld = 0.05f; + return v; +} + +} // namespace + +TEST(DerivedMapOcclusionTest, PointOnTheVisibleSurfaceIsVisible) { + // Surface recorded at distance 5; the point at z=0 IS that surface. + const DepthView v = viewFromPlusZ(5.0f); + EXPECT_TRUE(DerivedMapOcclusion::isVisibleInView(Ogre::Vector3(0, 0, 0), v)); +} + +TEST(DerivedMapOcclusionTest, PointBehindTheSurfaceIsOccluded) { + // Surface at distance 5, point at z=-2 (distance 7) is behind it. + const DepthView v = viewFromPlusZ(5.0f); + EXPECT_FALSE(DerivedMapOcclusion::isVisibleInView(Ogre::Vector3(0, 0, -2), v)); +} + +TEST(DerivedMapOcclusionTest, BiasPreventsSelfOcclusion) { + // A point marginally behind the recorded surface (within bias) must still + // count as visible — otherwise a surface occludes itself (depth acne) and + // every vertex reads fully occluded. + DepthView v = viewFromPlusZ(5.0f); + const Ogre::Vector3 justBehind(0, 0, -0.02f); // distance 5.02, bias 0.05 + EXPECT_TRUE(DerivedMapOcclusion::isVisibleInView(justBehind, v)); + v.biasWorld = 0.0f; + EXPECT_FALSE(DerivedMapOcclusion::isVisibleInView(justBehind, v)); +} + +TEST(DerivedMapOcclusionTest, OffImagePointIsNotVisible) { + const DepthView v = viewFromPlusZ(5.0f); + // halfExtent is 2, so x=10 projects outside the depth image. + EXPECT_FALSE(DerivedMapOcclusion::isVisibleInView(Ogre::Vector3(10, 0, 0), v)); +} + +TEST(DerivedMapOcclusionTest, PointBehindCameraIsNotVisible) { + // z=50 is behind the eye (at z=5) looking toward -Z, so its camera-axis + // distance is NEGATIVE. Note the perspective `behind` (w <= 0) flag cannot + // catch this here: these fixtures use an ORTHOGRAPHIC viewProj where w stays + // 1, so the sign of the axis distance is the only thing that rejects it. + // Depth-map views in this feature are auto-framed orthographic-ish renders, + // so that is the case that actually matters. + const DepthView v = viewFromPlusZ(5.0f); + EXPECT_FALSE(DerivedMapOcclusion::isVisibleInView(Ogre::Vector3(0, 0, 50), v)); +} + +TEST(DerivedMapOcclusionTest, NullDepthImageIsNotVisible) { + DepthView v = viewFromPlusZ(5.0f); + v.depth = QImage(); + EXPECT_FALSE(DerivedMapOcclusion::isVisibleInView(Ogre::Vector3(0, 0, 0), v)); +} + +TEST(DerivedMapOcclusionTest, ExposedPointHasZeroOcclusion) { + // One view that can see the point, and the point is the visible surface. + const std::vector views{viewFromPlusZ(5.0f)}; + const float occ = DerivedMapOcclusion::occlusionAt( + Ogre::Vector3(0, 0, 0), Ogre::Vector3(0, 0, 1), views); + EXPECT_NEAR(occ, 0.0f, 1e-5f); +} + +TEST(DerivedMapOcclusionTest, BuriedPointIsFullyOccluded) { + // The depth map says the nearest surface is at 5, but the point is at 7. + const std::vector views{viewFromPlusZ(5.0f)}; + const float occ = DerivedMapOcclusion::occlusionAt( + Ogre::Vector3(0, 0, -2), Ogre::Vector3(0, 0, 1), views); + EXPECT_NEAR(occ, 1.0f, 1e-5f); +} + +TEST(DerivedMapOcclusionTest, BackFacingViewsAreSkippedNotCountedAsOccluding) { + // A view looking at the BACK of the surface must be ignored entirely. If it + // were counted as "cannot see" the occlusion of a fully exposed vertex + // would read ~0.5 instead of 0, darkening the whole mesh uniformly. + DepthView behind = viewFromPlusZ(5.0f); + behind.camPosition = Ogre::Vector3(0, 0, -5); + behind.camDirection = Ogre::Vector3(0, 0, 1); // looks toward +Z + + const std::vector views{viewFromPlusZ(5.0f), behind}; + const float occ = DerivedMapOcclusion::occlusionAt( + Ogre::Vector3(0, 0, 0), Ogre::Vector3(0, 0, 1), views); // normal +Z + EXPECT_NEAR(occ, 0.0f, 1e-5f) << "the back-facing view must not contribute"; +} + +TEST(DerivedMapOcclusionTest, NoFacingViewYieldsZeroNotOne) { + // If nothing faces the normal we know nothing — report unoccluded rather + // than fully occluded, which would black out the map. + DepthView behind = viewFromPlusZ(5.0f); + behind.camPosition = Ogre::Vector3(0, 0, -5); + behind.camDirection = Ogre::Vector3(0, 0, 1); + const float occ = DerivedMapOcclusion::occlusionAt( + Ogre::Vector3(0, 0, 0), Ogre::Vector3(0, 0, 1), {behind}); + EXPECT_NEAR(occ, 0.0f, 1e-5f); +} + +TEST(DerivedMapOcclusionTest, EmptyViewSetIsUnoccluded) { + EXPECT_NEAR(DerivedMapOcclusion::occlusionAt( + Ogre::Vector3::ZERO, Ogre::Vector3::UNIT_Z, {}), 0.0f, 1e-6f); +} + +TEST(DerivedMapOcclusionTest, PerVertexHelperMatchesPerPointCall) { + const std::vector views{viewFromPlusZ(5.0f)}; + const std::vector pos{{0, 0, 0}, {0, 0, -2}}; + const std::vector nrm{{0, 0, 1}, {0, 0, 1}}; + const auto out = DerivedMapOcclusion::occlusionForVertices(pos, nrm, views); + ASSERT_EQ(out.size(), 2u); + EXPECT_NEAR(out[0], 0.0f, 1e-5f); + EXPECT_NEAR(out[1], 1.0f, 1e-5f); +} + +TEST(DerivedMapOcclusionTest, PerVertexHelperToleratesMissingNormals) { + const std::vector views{viewFromPlusZ(5.0f)}; + const std::vector pos{{0, 0, 0}, {0, 0, 0}}; + // Shorter normals array must not read out of bounds. + const auto out = DerivedMapOcclusion::occlusionForVertices(pos, {}, views); + EXPECT_EQ(out.size(), 2u); +} + +TEST(DerivedMapOcclusionTest, SampleDirectionsAreUnitAndSpreadOverTheSphere) { + const auto dirs = DerivedMapOcclusion::sampleDirections(32); + ASSERT_EQ(dirs.size(), 32u); + Ogre::Vector3 sum = Ogre::Vector3::ZERO; + for (const auto& d : dirs) { + EXPECT_NEAR(d.length(), 1.0f, 1e-4f); + sum += d; + } + // Near-uniform over the sphere => the mean direction is near zero. A + // pole-clustered lat/long grid would fail this and bias AO vertically. + EXPECT_LT(sum.length() / 32.0f, 0.2f); +} + +TEST(DerivedMapOcclusionTest, SampleDirectionsHandlesDegenerateCounts) { + EXPECT_EQ(DerivedMapOcclusion::sampleDirections(1).size(), 1u); + EXPECT_EQ(DerivedMapOcclusion::sampleDirections(0).size(), 1u); // clamped + EXPECT_EQ(DerivedMapOcclusion::sampleDirections(-5).size(), 1u); + for (const auto& d : DerivedMapOcclusion::sampleDirections(1)) + EXPECT_NEAR(d.length(), 1.0f, 1e-4f); +} diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index 1e954b6a..04704079 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -19,6 +19,9 @@ #include "TextureChannelPacker.h" #include "MeshImporterExporter.h" #include "ProjectionPainter.h" +#include "DerivedMapCache.h" +#include "DerivedMapOcclusion.h" +#include "HalfEdgeMesh.h" #include "MultiViewTextureBaker.h" #include "MeshDepthRenderer.h" #include "PaintLayerStack.h" @@ -1996,7 +1999,32 @@ bool TexturePaintController::paintColorFootprintAtUV(const Ogre::Vector2& uv, fl const float wavelength = std::max(radiusUv * 4.0f, 0.05f); const float strokeT = BrushEngine::linearStrokeT( m_strokePathLength, wavelength, m_strokePhaseJitter); - const auto colorAt = buildBrushColorAtFn(strokeT); + auto colorAt = buildBrushColorAtFn(strokeT); + + // Paint v2 Slice G (#550): modulate the brush colour by the derived map at + // the painted UV, so a stroke lands only in crevices / on edges. Wrapping + // colorAt (rather than each branch) means the tiling, stamp and gradient + // paths all inherit it for free — the same shape as the TilingSource wrap + // just below. + const bool derivedMod = m_derivedMapAsBrushMask + && m_derivedMapStrength > 0.0 + && activeDerivedMap() != nullptr; + if (derivedMod) { + const Ogre::Vector2 center = uv; + const float radiusCopy = radiusUv; + auto inner = colorAt; + colorAt = [this, inner, center, radiusCopy](float dx, float dy) { + float mu = 0.0f, mv = 0.0f; + BrushFootprint::brushOffsetToUv(center.x, center.y, radiusCopy, dx, dy, mu, mv); + const float f = derivedMapFactorAt(Ogre::Vector2(mu, mv)); + Ogre::ColourValue c = inner(dx, dy); + // Scale ALPHA, not RGB: the map decides how much of the stroke + // lands, not what colour it is. Scaling RGB would darken the paint + // toward black in cavities instead of hiding it. + c.a *= f; + return c; + }; + } if (m_footprintType == BrushFootprint::FootprintType::TilingSource) { if (m_tilingImage.empty()) @@ -2049,17 +2077,23 @@ bool TexturePaintController::paintColorFootprintAtUV(const Ogre::Vector2& uv, fl > 0; } - if (m_colorSource != ColorGradient) { + // NB both fast paths below collapse the brush to ONE colour and use the + // scalar paintBrush overload, which never calls colorAt — so they must be + // skipped while a derived map is modulating per-texel. + if (!derivedMod && m_colorSource != ColorGradient) { const QColor qc = texturePaintColor(); const Ogre::ColourValue paint(qc.redF(), qc.greenF(), qc.blueF(), qc.alphaF()); return activePaintBuffer().paintBrush(uv, radiusUv, paint, strength, falloff, shape) > 0; } - if (m_gradientMode == GradientLinear) { + if (!derivedMod && m_gradientMode == GradientLinear) { const auto sampled = colorAt(0.0f, 0.0f); return activePaintBuffer().paintBrush(uv, radiusUv, sampled, strength, falloff, shape) > 0; } - return activePaintBuffer().paintBrush(uv, radiusUv, colorAt, strength, falloff, shape) > 0; + // multiplyBlendByColorAlpha: the derived-map modulation lives in the colour's + // ALPHA, so the blend must honour it (same as the TilingSource path). + return activePaintBuffer().paintBrush(uv, radiusUv, colorAt, strength, falloff, shape, + /*multiplyBlendByColorAlpha=*/derivedMod) > 0; } void TexturePaintController::setPaintTarget(int target) @@ -7245,3 +7279,455 @@ void TexturePaintController::flushPaintTextureForExport(Ogre::Entity* entity) QStringLiteral("Flushed %1-layer composite before export") .arg(m_layerStack.layerCount())); } + +// --------------------------------------------------------------------------- +// Paint v2 Slice G (#550): cavity / curvature / AO derived maps +// --------------------------------------------------------------------------- + +void TexturePaintController::setDerivedMapKind(int kind) +{ + const int k = std::clamp(kind, 0, 2); + if (k == m_derivedMapKind) return; + m_derivedMapKind = k; + SentryReporter::addBreadcrumb("paint.derived_map", + QStringLiteral("kind=%1").arg(QLatin1String( + DerivedMapGenerator::kindName(static_cast(k))))); + emit derivedMapChanged(); +} + +void TexturePaintController::setDerivedMapAsBrushMask(bool on) +{ + if (on == m_derivedMapAsBrushMask) return; + m_derivedMapAsBrushMask = on; + SentryReporter::addBreadcrumb("paint.derived_map", + QStringLiteral("brushMask=%1").arg(on)); + emit derivedMapChanged(); +} + +void TexturePaintController::setDerivedMapStrength(double v) +{ + const double c = std::clamp(v, 0.0, 1.0); + if (std::abs(c - m_derivedMapStrength) < 1e-6) return; + m_derivedMapStrength = c; + SentryReporter::addBreadcrumb("paint.derived_map", + QStringLiteral("strength=%1").arg(c, 0, 'f', 3)); + emit derivedMapChanged(); +} + +void TexturePaintController::setDerivedMapInvert(bool on) +{ + if (on == m_derivedMapInvert) return; + m_derivedMapInvert = on; + SentryReporter::addBreadcrumb("paint.derived_map", + QStringLiteral("invert=%1").arg(on)); + emit derivedMapChanged(); +} + +void TexturePaintController::setDerivedMapContrast(double v) +{ + const double c = std::clamp(v, 0.1, 8.0); + if (std::abs(c - m_derivedMapContrast) < 1e-6) return; + m_derivedMapContrast = c; + // Contrast feeds the GENERATOR, so any cached map is now stale. + m_derivedMaps.clear(); + SentryReporter::addBreadcrumb("paint.derived_map", + QStringLiteral("contrast=%1").arg(c, 0, 'f', 2)); + emit derivedMapChanged(); +} + +QString TexturePaintController::currentMeshHash() const +{ + if (!m_paintMesh) return {}; + return DerivedMapCache::meshHash(*m_paintMesh); +} + +void TexturePaintController::invalidateDerivedMapsIfMeshChanged() +{ + const QString h = currentMeshHash(); + if (h == m_derivedMapMeshHash) return; + // The geometry changed (or the mesh went away): the maps no longer describe + // this surface. This is the content-hash invalidation that replaces the + // "EditableMesh revision counter" the issue proposed — EditableMesh has no + // such counter, and its public mutable subMeshes() accessor means one could + // be bypassed without incrementing. + m_derivedMaps.clear(); + m_derivedMapMeshHash = h; +} + +const DerivedMap* TexturePaintController::activeDerivedMap() const +{ + const auto it = m_derivedMaps.find(m_derivedMapKind); + if (it == m_derivedMaps.end() || it->second.empty()) return nullptr; + return &it->second; +} + +bool TexturePaintController::derivedMapReady() const +{ + if (!m_paintMesh) return false; + // Only "ready" while the cached map still matches the live geometry. + if (m_derivedMapMeshHash != currentMeshHash()) return false; + return activeDerivedMap() != nullptr; +} + +float TexturePaintController::derivedMapFactorAt(const Ogre::Vector2& uv) const +{ + const DerivedMap* map = activeDerivedMap(); + if (!map) return 1.0f; // no map => no modulation, callers need no branch + float v = map->sample(uv.x, uv.y); + if (m_derivedMapInvert) v = 1.0f - v; + // Blend toward 1 (unmodulated) by strength, so 0 is a true no-op. + const float s = static_cast(m_derivedMapStrength); + return std::clamp(1.0f + s * (v - 1.0f), 0.0f, 1.0f); +} + +std::vector TexturePaintController::computeVertexOcclusion(QString* errorOut) const +{ + auto* entity = m_paintMeshEntity; + if (!entity || !m_paintMesh) { + if (errorOut) *errorOut = QStringLiteral("no painted mesh"); + return {}; + } + + // Welded vertex positions/normals, in the SAME order DerivedMapGenerator + // rasterises, so the occlusion array lines up with its per-vertex input. + HalfEdgeMesh he; + if (!he.buildFromEditableMesh(*m_paintMesh)) { + if (errorOut) *errorOut = QStringLiteral("could not weld the mesh"); + return {}; + } + auto* node = entity->getParentSceneNode(); + const Ogre::Affine3 world = node ? node->_getFullTransform() : Ogre::Affine3::IDENTITY; + const Ogre::Matrix3 normalMat = world.linear().Inverse().Transpose(); + + std::vector pos, nrm; + pos.reserve(he.vertexCount()); + nrm.reserve(he.vertexCount()); + for (size_t i = 0; i < he.vertexCount(); ++i) { + const HEVertex& v = he.vertex(static_cast(i)); + pos.push_back(world * v.position); + Ogre::Vector3 n = v.hasNormal ? (normalMat * v.normal) : Ogre::Vector3::ZERO; + if (!n.isZeroLength()) n.normalise(); + nrm.push_back(n); + } + + // Render depth from evenly spread directions. 12 is enough to separate + // crevices from exposed surfaces without making the bake feel stalled; + // each render is a full RTT pass on the main thread. + constexpr int kViewCount = 12; + constexpr int kDepthSize = 256; + const auto dirs = DerivedMapOcclusion::sampleDirections(kViewCount); + const Ogre::AxisAlignedBox aabb = entity->getWorldBoundingBox(true); + const float radius = aabb.isNull() ? 1.0f : aabb.getHalfSize().length(); + + std::vector views; + views.reserve(dirs.size()); + for (const Ogre::Vector3& d : dirs) { + MeshDepthRenderer::View mv; + mv.dir = d; + // Any up not parallel to dir; the renderer re-frames the camera itself. + mv.up = (std::abs(d.dotProduct(Ogre::Vector3::UNIT_Y)) > 0.95f) + ? Ogre::Vector3(0, 0, 1) : Ogre::Vector3::UNIT_Y; + mv.name = "ao"; + MeshDepthRenderer::RenderResult r = + MeshDepthRenderer::renderDepthMapView(entity, kDepthSize, mv, nullptr); + if (r.depth.isNull()) continue; // skip a failed view rather than abort + DepthView dv; + dv.depth = r.depth; + dv.viewProj = r.projMatrix * r.viewMatrix; + dv.camPosition = r.camPosition; + dv.camDirection = r.camDirection; + dv.depthNear = r.depthNear; + dv.depthFar = r.depthFar; + // Bias must exceed one grayscale step of the encoded range, else + // quantisation alone makes a surface occlude itself (depth acne). + dv.biasWorld = std::max((r.depthFar - r.depthNear) / 255.0f * 2.0f, + radius * 0.01f); + views.push_back(std::move(dv)); + } + if (views.empty()) { + if (errorOut) *errorOut = QStringLiteral("depth rendering unavailable"); + return {}; + } + return DerivedMapOcclusion::occlusionForVertices(pos, nrm, views); +} + +bool TexturePaintController::computeDerivedMap() +{ + if (!m_paintMesh) { + m_derivedMapStatus = QStringLiteral("Select a mesh and start painting first."); + emit derivedMapChanged(); + return false; + } + invalidateDerivedMapsIfMeshChanged(); + + const auto kind = static_cast(m_derivedMapKind); + const QString kindStr = QLatin1String(DerivedMapGenerator::kindName(kind)); + + // Already in memory for this exact geometry. + if (activeDerivedMap()) { + m_derivedMapStatus = QStringLiteral("%1 map ready.").arg(kindStr); + emit derivedMapChanged(); + return true; + } + + const QString hash = currentMeshHash(); + + // Disk cache next — the whole point is not re-baking AO on every session. + { + DerivedMap cached; + QString err; + if (DerivedMapCache::load(hash, kind, cached, err) && !cached.empty()) { + m_derivedMaps[m_derivedMapKind] = std::move(cached); + m_derivedMapMeshHash = hash; + m_derivedMapStatus = QStringLiteral("%1 map loaded from cache.").arg(kindStr); + SentryReporter::addBreadcrumb("paint.derived_map.cache_hit", kindStr); + emit derivedMapChanged(); + return true; + } + } + + DerivedMapGenerator::Options opts; + opts.resolution = std::max(64, m_buffer.width() > 0 ? m_buffer.width() : 1024); + opts.contrast = static_cast(m_derivedMapContrast); + + DerivedMapGenerator::Report rep; + DerivedMap map; + if (kind == DerivedMapKind::AmbientOcclusion) { + QString err; + const std::vector occ = computeVertexOcclusion(&err); + if (occ.empty()) { + m_derivedMapStatus = QStringLiteral("AO bake failed: %1").arg( + err.isEmpty() ? QStringLiteral("unknown error") : err); + SentryReporter::addBreadcrumb("paint.derived_map.error", m_derivedMapStatus); + emit derivedMapChanged(); + return false; + } + map = DerivedMapGenerator::fromVertexOcclusion(*m_paintMesh, occ, opts, &rep); + } else { + map = DerivedMapGenerator::generate(*m_paintMesh, kind, opts, &rep); + } + + if (!rep.ok || map.empty()) { + m_derivedMapStatus = QStringLiteral("%1 bake failed: %2").arg(kindStr, + rep.error.isEmpty() ? QStringLiteral("no output") : rep.error); + SentryReporter::addBreadcrumb("paint.derived_map.error", m_derivedMapStatus); + emit derivedMapChanged(); + return false; + } + + QString saveErr; + if (!DerivedMapCache::save(hash, kind, map, saveErr)) { + // A cache write failure must not fail the bake — the map is usable now. + SentryReporter::addBreadcrumb("paint.derived_map.cache_write_failed", saveErr); + } + m_derivedMaps[m_derivedMapKind] = std::move(map); + m_derivedMapMeshHash = hash; + m_derivedMapStatus = QStringLiteral("%1 map baked (%2 texels).") + .arg(kindStr).arg(rep.texelsRasterised); + SentryReporter::addBreadcrumb("paint.derived_map.bake", + QStringLiteral("%1 texels=%2 dilated=%3") + .arg(kindStr).arg(rep.texelsRasterised).arg(rep.texelsDilated)); + emit derivedMapChanged(); + return true; +} + +bool TexturePaintController::recomputeDerivedMaps() +{ + const QString hash = currentMeshHash(); + if (!hash.isEmpty()) DerivedMapCache::invalidateAll(hash); + m_derivedMaps.clear(); + m_derivedMapMeshHash.clear(); + SentryReporter::addBreadcrumb("paint.derived_map.recalculate", hash); + return computeDerivedMap(); +} + +void TexturePaintController::fillMaskFromActiveMap(std::vector& mask, + int W, int H, + bool invert, + bool applyStrength) const +{ + const DerivedMap* map = activeDerivedMap(); + if (!map || W <= 0 || H <= 0) return; + if (mask.size() != static_cast(W) * static_cast(H)) return; + // Sample by UV, not 1:1 texels: a map may be baked at a different + // resolution than the paint buffer. + const float strength = applyStrength ? static_cast(m_derivedMapStrength) : 1.0f; + for (int y = 0; y < H; ++y) { + const float v = (y + 0.5f) / static_cast(H); + for (int x = 0; x < W; ++x) { + const float u = (x + 0.5f) / static_cast(W); + float m = map->sample(u, v); + if (invert) m = 1.0f - m; + m = std::clamp(1.0f + strength * (m - 1.0f), 0.0f, 1.0f); + mask[static_cast(y) * W + x] = + static_cast(std::lround(m * 255.0f)); + } + } +} + +bool TexturePaintController::applyDerivedMapToLayerMask() +{ + if (!hasActiveSession()) { + m_derivedMapStatus = QStringLiteral("No paint session."); + emit derivedMapChanged(); + return false; + } + if (!derivedMapReady() && !computeDerivedMap()) return false; + const DerivedMap* map = activeDerivedMap(); + if (!map) return false; + + const int idx = m_layerStack.activeIndex(); + if (idx < 0) { + m_derivedMapStatus = QStringLiteral("No active layer."); + emit derivedMapChanged(); + return false; + } + + const auto before = m_layerStack.snapshot(); + std::vector& mask = m_layerStack.ensureLayerMask(idx); + const int W = m_buffer.width(); + const int H = m_buffer.height(); + if (W <= 0 || H <= 0 || mask.size() != static_cast(W) * H) { + m_derivedMapStatus = QStringLiteral("Layer mask size mismatch."); + emit derivedMapChanged(); + return false; + } + fillMaskFromActiveMap(mask, W, H, m_derivedMapInvert, /*applyStrength=*/true); + + recomposeComposite(/*fullBuffer=*/true); + flushDirtyToOgre(); + const QString label = QStringLiteral("Mask from %1").arg( + QLatin1String(DerivedMapGenerator::kindName( + static_cast(m_derivedMapKind)))); + pushLayerOpUndo(label, before, m_layerStack.snapshot()); + invalidateLayerStrokeBaseline(); + ++m_layerPreviewVersion; + SentryReporter::addBreadcrumb("paint.derived_map.layer_mask", label); + m_derivedMapStatus = label + QStringLiteral(" applied."); + emit layersChanged(); + emit fullResPreviewChanged(); + emit derivedMapChanged(); + return true; +} + +namespace { + +// One-click recipe presets (#550). Each is (which map, inverted?, fill colour, +// blend, layer name); the MASK is what shapes the layer, so the user can paint +// over it afterwards to refine. +struct DerivedRecipe { + DerivedMapKind kind; + bool invert; + Ogre::ColourValue colour; + PaintLayerBlend::Mode blend; + const char* name; +}; + +bool derivedRecipeFor(int recipe, DerivedRecipe& out) +{ + switch (recipe) { + case 0: + // Edge wear: bare metal on CONVEX ridges. Curvature stores convex + // BELOW 0.5, so the mask must be inverted to select ridges rather + // than crevices. + out = {DerivedMapKind::Curvature, /*invert=*/true, + Ogre::ColourValue(0.78f, 0.78f, 0.80f, 1.0f), + PaintLayerBlend::Mode::Normal, "Edge wear"}; + return true; + case 1: + // Crevice dirt: dark grime in concave areas => cavity as-is. + out = {DerivedMapKind::Cavity, /*invert=*/false, + Ogre::ColourValue(0.16f, 0.13f, 0.10f, 1.0f), + PaintLayerBlend::Mode::Normal, "Crevice dirt"}; + return true; + case 2: + // AO darken: multiply the occlusion over BaseColor. + out = {DerivedMapKind::AmbientOcclusion, /*invert=*/false, + Ogre::ColourValue(0.0f, 0.0f, 0.0f, 1.0f), + PaintLayerBlend::Mode::Multiply, "AO darken"}; + return true; + default: + return false; + } +} + +} // namespace + +bool TexturePaintController::applyDerivedMapRecipe(int recipe) +{ + if (!hasActiveSession()) { + if (auto* e = activeEntity()) { (void)e; ensurePaintableTexture(1024); } + if (!hasActiveSession()) { + m_derivedMapStatus = QStringLiteral("No paint session."); + emit derivedMapChanged(); + return false; + } + } + + DerivedRecipe r; + if (!derivedRecipeFor(recipe, r)) { + m_derivedMapStatus = QStringLiteral("Unknown recipe."); + emit derivedMapChanged(); + return false; + } + + // Bake/load the recipe's own map, which may differ from the UI selection. + const int userKind = m_derivedMapKind; + const bool userInvert = m_derivedMapInvert; + m_derivedMapKind = static_cast(r.kind); + m_derivedMapInvert = r.invert; + const bool haveMap = derivedMapReady() || computeDerivedMap(); + if (!haveMap) { + m_derivedMapKind = userKind; // restore the user's selection + m_derivedMapInvert = userInvert; + emit derivedMapChanged(); + return false; + } + + const int W = m_buffer.width(); + const int H = m_buffer.height(); + if (W <= 0 || H <= 0) { + m_derivedMapKind = userKind; + m_derivedMapInvert = userInvert; + return false; + } + + // A flat colour layer; the MASK is what shapes it, so the user can paint + // over it afterwards to refine. + TexturePaintBuffer fill; + fill.resize(W, H); + for (int y = 0; y < H; ++y) + for (int x = 0; x < W; ++x) + fill.setPixel(x, y, r.colour); + + const auto before = m_layerStack.snapshot(); + const int idx = m_layerStack.addFromBuffer(fill, QLatin1String(r.name), + PaintLayerStack::LayerType::Generated); + if (idx < 0) { + m_derivedMapKind = userKind; + m_derivedMapInvert = userInvert; + return false; + } + m_layerStack.setActiveIndex(idx); + m_layerStack.setBlendMode(idx, r.blend); + + // A recipe uses its own map at FULL strength — the strength slider governs + // the interactive brush mask, not a preset's baked-in look. + std::vector& mask = m_layerStack.ensureLayerMask(idx); + fillMaskFromActiveMap(mask, W, H, r.invert, /*applyStrength=*/false); + + m_derivedMapKind = userKind; // recipes must not hijack the picker + m_derivedMapInvert = userInvert; + + recomposeComposite(/*fullBuffer=*/true); + flushDirtyToOgre(); + pushLayerOpUndo(QLatin1String(r.name), before, m_layerStack.snapshot()); + invalidateLayerStrokeBaseline(); + ++m_layerPreviewVersion; + SentryReporter::addBreadcrumb("paint.derived_map.recipe", QLatin1String(r.name)); + m_derivedMapStatus = QStringLiteral("%1 layer added.").arg(QLatin1String(r.name)); + emit layersChanged(); + emit fullResPreviewChanged(); + emit derivedMapChanged(); + return true; +} diff --git a/src/TexturePaintController.h b/src/TexturePaintController.h index 2dfbc126..acfad396 100644 --- a/src/TexturePaintController.h +++ b/src/TexturePaintController.h @@ -12,6 +12,7 @@ #include "SymmetryMirrorMap.h" #include "ProjectionPainter.h" #include "DecalSession.h" +#include "DerivedMapGenerator.h" #include #include @@ -33,6 +34,7 @@ #include #include #include +#include class EditableMesh; class OgreWidget; @@ -147,6 +149,14 @@ class TexturePaintController : public QObject // Paint v2 Slice F (#549) — decal tool (read-only status for the panel). Q_PROPERTY(bool decalSessionActive READ decalSessionActive NOTIFY projectionChanged) Q_PROPERTY(int decalState READ decalState NOTIFY projectionChanged) + // Paint v2 Slice G (#550) — cavity / curvature / AO derived maps. + Q_PROPERTY(int derivedMapKind READ derivedMapKind WRITE setDerivedMapKind NOTIFY derivedMapChanged) // 0 cavity / 1 curvature / 2 AO + Q_PROPERTY(bool derivedMapAsBrushMask READ derivedMapAsBrushMask WRITE setDerivedMapAsBrushMask NOTIFY derivedMapChanged) + Q_PROPERTY(double derivedMapStrength READ derivedMapStrength WRITE setDerivedMapStrength NOTIFY derivedMapChanged) // 0..1 blend of the modulation + Q_PROPERTY(bool derivedMapInvert READ derivedMapInvert WRITE setDerivedMapInvert NOTIFY derivedMapChanged) + Q_PROPERTY(double derivedMapContrast READ derivedMapContrast WRITE setDerivedMapContrast NOTIFY derivedMapChanged) + Q_PROPERTY(bool derivedMapReady READ derivedMapReady NOTIFY derivedMapChanged) // read-only status + Q_PROPERTY(QString derivedMapStatus READ derivedMapStatus NOTIFY derivedMapChanged) // read-only, for the panel public: enum BrushTool { @@ -475,6 +485,36 @@ class TexturePaintController : public QObject /// path calls cancelDecal/commitDecal from mainwindow. Q_INVOKABLE bool commitDecal(); Q_INVOKABLE void cancelDecal(); + + // --- Paint v2 Slice G (#550): cavity / curvature / AO derived maps --- + int derivedMapKind() const { return m_derivedMapKind; } + void setDerivedMapKind(int kind); + bool derivedMapAsBrushMask() const { return m_derivedMapAsBrushMask; } + void setDerivedMapAsBrushMask(bool on); + double derivedMapStrength() const { return m_derivedMapStrength; } + void setDerivedMapStrength(double v); + bool derivedMapInvert() const { return m_derivedMapInvert; } + void setDerivedMapInvert(bool on); + double derivedMapContrast() const { return m_derivedMapContrast; } + void setDerivedMapContrast(double v); + /// True when the ACTIVE kind's map is computed and matches the current mesh. + bool derivedMapReady() const; + QString derivedMapStatus() const { return m_derivedMapStatus; } + + /// Compute (or load from cache) the active kind's map for the painted mesh. + /// AO renders hemisphere depth views, so this must run on the main thread. + /// Returns false and sets derivedMapStatus on failure. + Q_INVOKABLE bool computeDerivedMap(); + /// Drop cached maps for the painted mesh and recompute the active kind + /// ("Recalculate derived maps"). + Q_INVOKABLE bool recomputeDerivedMaps(); + /// Initialise the ACTIVE layer's mask from the active derived map, so the + /// user can then paint freely and have it show only in crevices / on edges. + /// Undoable. Returns false if there is no session/map. + Q_INVOKABLE bool applyDerivedMapToLayerMask(); + /// One-click recipes (#550): create a new masked layer preloaded with a + /// sensible colour. 0 = edge wear, 1 = crevice dirt, 2 = AO darken. + Q_INVOKABLE bool applyDerivedMapRecipe(int recipe); /// @} /// @name Paint v2 Slice D — PBR channel painting (#547) @@ -767,6 +807,8 @@ class TexturePaintController : public QObject void stabilizerChanged(); /// Paint v2 Slice F (#549): projection mode / stencil / lock state changed. void projectionChanged(); + /// Paint v2 Slice G (#550): derived-map settings / readiness / status changed. + void derivedMapChanged(); /// Emitted when the mouse hovers over a UV-mapped triangle (from /// the 3D mesh or from the 2D texture preview panel). u,v in [0..1]; /// (-1, -1) means "no hover". @@ -1163,6 +1205,36 @@ class TexturePaintController : public QObject std::vector m_projTris; // world tris cached per stroke bool m_haveProjTris = false; + // --- Paint v2 Slice G (#550): derived maps --- + int m_derivedMapKind = 0; // DerivedMapKind as int + bool m_derivedMapAsBrushMask = false; // modulate brush colour by the map + double m_derivedMapStrength = 1.0; // 0..1 blend of the modulation + bool m_derivedMapInvert = false; // e.g. edge wear = inverse cavity + double m_derivedMapContrast = 1.0; + QString m_derivedMapStatus; + /// Cached maps for the CURRENT mesh, keyed by kind. Cleared when the mesh + /// hash changes, so a topology edit cannot leave a stale map bound. + std::map m_derivedMaps; + QString m_derivedMapMeshHash; // hash the cached maps belong to + + /// Fill `mask` (sized W*H) from the active derived map, honouring + /// `invert`. Shared by the layer-mask action and the recipes so both write + /// the mask exactly the same way. + void fillMaskFromActiveMap(std::vector& mask, int W, int H, + bool invert, bool applyStrength) const; + /// Mesh hash for the painted mesh, or empty when there is no mesh. + QString currentMeshHash() const; + /// Drop in-memory maps when the mesh geometry no longer matches them. + void invalidateDerivedMapsIfMeshChanged(); + /// The active kind's map, or nullptr when not computed. + const DerivedMap* activeDerivedMap() const; + /// Sample the active map at `uv` and fold in invert/strength. Returns 1.0 + /// (no modulation) when no map is active, so callers need no special case. + float derivedMapFactorAt(const Ogre::Vector2& uv) const; + /// Render hemisphere depth views of the painted entity and reduce them to + /// per-welded-vertex occlusion. Main thread (touches the Ogre scene). + std::vector computeVertexOcclusion(QString* errorOut) const; + // --- Paint v2 Slice F (#549): decal tool --- DecalSession m_decal; Ogre::SceneNode* m_decalNode = nullptr; diff --git a/src/TexturePaintController_test.cpp b/src/TexturePaintController_test.cpp index 4121445c..6438be87 100644 --- a/src/TexturePaintController_test.cpp +++ b/src/TexturePaintController_test.cpp @@ -1413,3 +1413,224 @@ TEST_F(TexturePaintControllerSceneTest, DecalSessionBeginCancelPlumbing) { ctrl->closeSession(); } + +// --- Paint v2 Slice G (#550): derived maps ------------------------------- +// The generators/cache/occlusion maths are covered pure-data in +// DerivedMapGenerator_test / DerivedMapCache_test / DerivedMapOcclusion_test. +// These fixture cases cover the CONTROLLER contract: setters clamp + notify, +// readiness tracks the mesh hash, and every entry point degrades safely with no +// session rather than crashing or half-applying. + +TEST_F(TexturePaintControllerSceneTest, DerivedMapSettersClampAndPersist) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("DerivedSetters"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_NE(ctrl, nullptr); + + ctrl->setDerivedMapKind(1); + EXPECT_EQ(ctrl->derivedMapKind(), 1); + // Out-of-range kinds must clamp into the enum, not index past it. + ctrl->setDerivedMapKind(99); + EXPECT_EQ(ctrl->derivedMapKind(), 2); + ctrl->setDerivedMapKind(-5); + EXPECT_EQ(ctrl->derivedMapKind(), 0); + + ctrl->setDerivedMapStrength(2.5); + EXPECT_DOUBLE_EQ(ctrl->derivedMapStrength(), 1.0); + ctrl->setDerivedMapStrength(-1.0); + EXPECT_DOUBLE_EQ(ctrl->derivedMapStrength(), 0.0); + ctrl->setDerivedMapStrength(0.4); + EXPECT_NEAR(ctrl->derivedMapStrength(), 0.4, 1e-9); + + ctrl->setDerivedMapContrast(100.0); + EXPECT_LE(ctrl->derivedMapContrast(), 8.0); + ctrl->setDerivedMapContrast(0.0); + EXPECT_GE(ctrl->derivedMapContrast(), 0.1); + + ctrl->setDerivedMapInvert(true); + EXPECT_TRUE(ctrl->derivedMapInvert()); + ctrl->setDerivedMapAsBrushMask(true); + EXPECT_TRUE(ctrl->derivedMapAsBrushMask()); + + ctrl->closeSession(); +} + +TEST_F(TexturePaintControllerSceneTest, DerivedMapSettersEmitChangeSignal) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("DerivedNotify"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_NE(ctrl, nullptr); + + int hits = 0; + const auto conn = QObject::connect(ctrl, &TexturePaintController::derivedMapChanged, + [&hits]() { ++hits; }); + ctrl->setDerivedMapKind(ctrl->derivedMapKind() == 0 ? 1 : 0); + EXPECT_GT(hits, 0) << "the panel mirrors these props, so a change must notify"; + + // A no-op write must NOT notify (else the panel churns every frame). + const int after = hits; + ctrl->setDerivedMapKind(ctrl->derivedMapKind()); + EXPECT_EQ(hits, after); + + QObject::disconnect(conn); + ctrl->closeSession(); +} + +TEST_F(TexturePaintControllerSceneTest, DerivedMapNotReadyBeforeBake) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("DerivedNotReady"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_NE(ctrl, nullptr); + // Nothing baked yet, so the panel's readiness dot must read false rather + // than advertising a map the brush would then fail to find. + EXPECT_FALSE(ctrl->derivedMapReady()); + ctrl->closeSession(); +} + +TEST_F(TexturePaintControllerSceneTest, DerivedMapEntryPointsAreSafeWithoutASession) { + // Every Q_INVOKABLE is reachable from QML at any time, so each must fail + // cleanly with no session instead of dereferencing a null mesh/buffer. + auto* ctrl = TexturePaintController::instance(); + ASSERT_NE(ctrl, nullptr); + ctrl->closeSession(); + + EXPECT_FALSE(ctrl->derivedMapReady()); + EXPECT_FALSE(ctrl->computeDerivedMap()); + EXPECT_FALSE(ctrl->applyDerivedMapToLayerMask()); + // Unknown recipe index must be rejected, not indexed into the recipe table. + EXPECT_FALSE(ctrl->applyDerivedMapRecipe(99)); + EXPECT_FALSE(ctrl->applyDerivedMapRecipe(-1)); + // A status message should explain the refusal to the user. + EXPECT_FALSE(ctrl->derivedMapStatus().isEmpty()); +} + +TEST_F(TexturePaintControllerSceneTest, DerivedMapCavityBakesAndBecomesReady) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("DerivedCavityBake"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_NE(ctrl, nullptr); + ASSERT_TRUE(ctrl->ensurePaintableTexture(64)); + + ctrl->setDerivedMapKind(0); // Cavity — geometric, no GL needed + const bool baked = ctrl->computeDerivedMap(); + // The fixture mesh is a simple quad; a bake either succeeds (and is then + // ready) or reports why. Both are acceptable, but the two must AGREE — + // "ready" while the bake failed would strand the brush mask. + EXPECT_EQ(baked, ctrl->derivedMapReady()); + EXPECT_FALSE(ctrl->derivedMapStatus().isEmpty()); + + if (baked) { + // A second call must be a cache hit, not a re-bake, and stay ready. + EXPECT_TRUE(ctrl->computeDerivedMap()); + EXPECT_TRUE(ctrl->derivedMapReady()); + } + ctrl->closeSession(); +} + +TEST_F(TexturePaintControllerSceneTest, DerivedMapBrushMaskWithNoMapDoesNotBlockPainting) { + // Enabling the mask before baking must not silently swallow every stroke: + // with no map the modulation factor is 1 (no-op), so painting still works. + ASSERT_TRUE(m_fix.setup(QStringLiteral("DerivedMaskNoMap"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_NE(ctrl, nullptr); + // setTexturePaintEnabled + hasActiveSession is the sequence the other stroke + // tests use; beginStrokeUV refuses without an enabled session. + ctrl->setTexturePaintEnabled(true); + ASSERT_TRUE(ctrl->hasActiveSession()); + + ctrl->setDerivedMapAsBrushMask(true); + ctrl->setDerivedMapStrength(1.0); + // NB deliberately NOT asserting !derivedMapReady() here: the on-disk cache + // persists across processes, so a cavity map baked by an earlier run (or an + // earlier test) legitimately loads and reports ready. What matters for this + // case is that the brush still paints — with a map the modulation applies, + // without one the factor is 1 (no-op); neither may swallow the stroke. + + // Compare actual PIXELS before/after, not just hasActiveSession(). + // NB this covers the NO-MAP path specifically: `derivedMod` requires a + // non-null map, so with nothing baked the wrapper is never installed and + // painting must proceed untouched. Modulation WITH a map is covered by + // DerivedMapBrushMaskWithMapModulatesCoverage below. + const QImage before = ctrl->snapshotBufferImage(); + ASSERT_FALSE(before.isNull()); + + ASSERT_TRUE(ctrl->beginStrokeUV(0.5, 0.5)); + ctrl->updateStrokeUV(0.55, 0.55); + ctrl->endStrokeUV(); + + const QImage after = ctrl->snapshotBufferImage(); + ASSERT_FALSE(after.isNull()); + ASSERT_EQ(before.size(), after.size()); + int changed = 0; + for (int y = 0; y < after.height() && changed == 0; ++y) + for (int x = 0; x < after.width(); ++x) + if (before.pixel(x, y) != after.pixel(x, y)) { ++changed; break; } + EXPECT_GT(changed, 0) + << "with no derived map the modulation factor must be 1 (a no-op); " + "a stroke that paints nothing means the mask is swallowing dabs"; + EXPECT_TRUE(ctrl->hasActiveSession()); + + ctrl->setDerivedMapAsBrushMask(false); + ctrl->closeSession(); +} + +TEST_F(TexturePaintControllerSceneTest, DerivedMapRecipePreservesUserKindSelection) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("DerivedRecipeKind"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_NE(ctrl, nullptr); + ASSERT_TRUE(ctrl->ensurePaintableTexture(64)); + + ctrl->setDerivedMapKind(1); // user picked Curvature + ctrl->setDerivedMapInvert(false); + // "Crevice dirt" bakes CAVITY internally; whether or not it succeeds, it + // must not leave the picker pointing somewhere the user did not choose. + ctrl->applyDerivedMapRecipe(1); + EXPECT_EQ(ctrl->derivedMapKind(), 1) << "a recipe must restore the user's kind"; + EXPECT_FALSE(ctrl->derivedMapInvert()) << "a recipe must restore invert too"; + + ctrl->closeSession(); +} + +TEST_F(TexturePaintControllerSceneTest, DerivedMapBrushMaskWithMapModulatesCoverage) { + // Exercises the modulation wrapper for real: bake a map, then paint with the + // mask at full strength and again at zero strength. Strength 0 blends the + // factor back to 1 (a documented no-op), so it must paint at least as much + // as the masked pass. If the wrapper ever stopped being installed — or + // scaled RGB instead of ALPHA — these two would come out identical. + ASSERT_TRUE(m_fix.setup(QStringLiteral("DerivedMaskWithMap"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_NE(ctrl, nullptr); + ctrl->setTexturePaintEnabled(true); + ASSERT_TRUE(ctrl->hasActiveSession()); + + ctrl->setDerivedMapKind(0); // Cavity (geometric, no GL) + if (!ctrl->computeDerivedMap()) { + // The fixture quad may yield no cavity signal; nothing to assert then. + ctrl->closeSession(); + GTEST_SKIP() << "no cavity signal on the fixture mesh"; + } + ASSERT_TRUE(ctrl->derivedMapReady()); + + const auto paintedTexels = [&](double strength) { + ctrl->setDerivedMapAsBrushMask(true); + ctrl->setDerivedMapStrength(strength); + const QImage before = ctrl->snapshotBufferImage(); + ctrl->beginStrokeUV(0.5, 0.5); + ctrl->updateStrokeUV(0.6, 0.6); + ctrl->endStrokeUV(); + const QImage after = ctrl->snapshotBufferImage(); + int n = 0; + if (!before.isNull() && !after.isNull() && before.size() == after.size()) { + for (int y = 0; y < after.height(); ++y) + for (int x = 0; x < after.width(); ++x) + if (before.pixel(x, y) != after.pixel(x, y)) ++n; + } + return n; + }; + + const int masked = paintedTexels(1.0); + const int unmasked = paintedTexels(0.0); + // Strength 0 is the documented no-op, so it can never paint LESS than a + // fully-masked pass. + EXPECT_GE(unmasked, masked); + EXPECT_GT(unmasked, 0) << "strength 0 must behave as no modulation at all"; + + ctrl->setDerivedMapAsBrushMask(false); + ctrl->closeSession(); +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1ec9769b..4f734686 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -174,6 +174,9 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/TexturePaintController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/ProjectionPainter.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/DecalSession.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/DerivedMapGenerator.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/DerivedMapCache.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/DerivedMapOcclusion.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MultiViewTextureBaker.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/VertexColorBaker.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/VATBaker.cpp