diff --git a/CODEMAP.md b/CODEMAP.md index 49c32b6..0527671 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -309,7 +309,7 @@ This is **where terrain shape lives.** ### 3.6b Strate measurement — `Public/VoxelStrateMeasure.h` + `Private/VoxelStrateMeasure.cpp` Headless, read-only Tier 2 measurement pass. `VF_MeasureStrate` samples one strate between a -boundary-seal-derived (or explicitly overridden, including explicit zero) interior margin into a bounded coarse grid. +boundary-seal-derived (or explicitly overridden, including explicit zero) interior margin into a bounded grid. When both `CoverPointA` and `CoverPointB` are set, the XY window is their AABB expanded by `CoverMarginVoxels`; otherwise it is the legacy square centered on `CenterXY` with `RadiusInVoxels`. @@ -319,8 +319,11 @@ the inclusive/exclusive voxel Z window used, the exact sampled dimensions/bounds component's deterministic lowest-cell representative point, cell count, and count of components holding at least 1% of the air. The flood fill also retains component cell counts in discovery order, so diagnostics can classify small non-largest components without a second source sample. -Callers may optionally receive that exact polarity grid as `FVoxelStrateSampleGrid`; the editor-only -composer preview consumes this hand-off rather than sampling the world again. +Callers may optionally receive that exact polarity grid and scalar values as `FVoxelStrateSampleGrid`; the +editor-only composer preview consumes this hand-off rather than sampling the world again. The filled view uses +the captured `Air` polarity, while the paired density=0 contour uses the captured scalar field directly (no blur +of the coarse raster). Capture refuses a grid over `MaxCells` before allocating it; ordinary metric callers do +not retain either buffer. `VF_AreConnected` recovers a deterministic BFS parent path from the same kind of grid and checks candidate routes at full voxel resolution before returning `Connected`. When a candidate is refuted, its specific coarse cell edge is added to a deterministic array blocklist and BFS is @@ -336,7 +339,7 @@ No UObject state, cache, actor, world, or PIE is required. | Symbol | Role | |--------|------| | `FVoxelStrateMeasureSettings` / `FVoxelStrateMetrics` | Plain settings/result structs for bounded strate sampling and derived measurements; callers may override the interior margin (including zero to sample the seal) or fit the XY window to two points with a margin, and results identify the resolved window/dimensions/Z range and deterministic component representatives/sizes. | -| `FVoxelStrateSampleGrid` | Optional one-pass `density > 0` air/solid capture exported by the measurement functions; bounded by `MaxCells`, consumed by editor/automation previews, never retained on ordinary metrics calls. | +| `FVoxelStrateSampleGrid` | Optional one-pass `density > 0` `Air` polarity plus exact scalar `Density` capture exported by measurement functions; bounded by `MaxCells`, consumed by editor/automation previews, never retained on ordinary metrics calls. | | `VF_MeasureStrate` | One-grid/one-flood-fill strate metrics; resolves either the legacy centered square or the two-point fitted AABB, and refuses invalid bounds or a grid over `MaxCells`. | | `VF_AreConnected` | Coarse 6-connected BFS plus deterministic blocked-edge retries, with a full-resolution air recheck for every candidate route and explicit endpoint-solid, out-of-window, `NotConnectedAtThisResolution`, `CoarseLiedBudgetExhausted` (unknown), and connected outcomes; diagnostics include endpoint component facts and the retry count from that same grid. | | `VF_DiagnoseConnectivity` | The same query plus each mouth's air-component size/share and exact geometric distance to the nearest cell of the other component; proximity is measured in coarse-cell coordinates and does not claim a route through solid. | @@ -474,10 +477,13 @@ the offline sampler, and brute-forces every uniform box verdict from the novel s **`Public/VoxelStratePreview.h` + `Private/VoxelStratePreview.cpp`** — editor/automation-only PNG renderer and self-contained contact sheet. It consumes `FVoxelStrateSampleGrid` from the measurement -pass, emits deterministic centre-Y XZ and data-selected XY slices via `IImageWrapper`, caps each -image at 512 pixels (including its scale footer), and sorts the 64-card sheet by survivor status then -descending corpus-centroid distance. The page repeats each candidate's exact measurement window, -marks rejection reasons, explains the 2D-connectivity limitation, and has no runtime generation hook. +pass, emits deterministic centre-Y XZ and data-selected XY slices via `IImageWrapper`, and shows filled-cell +and scalar density=0 contour views side by side without blurring the sampled raster. Each image is capped at +512 pixels (including its scale footer). `FVoxelStrateFinePreviewSettings` is a separate caller-selected +fine pass: the composer selects only survivors at step 1, radius 64 (a 128×128 XY ROI), and `MaxCells=2,000,000`; +the writer labels every image with its exact ROI and step and records cap refusals in the page. The 64-card +sheet remains sorted by survivor status then descending corpus-centroid distance, repeats exact measurement +windows, marks rejection reasons, explains the 2D-connectivity limitation, and has no runtime generation hook. ### 3.9 Player edits — `Public/VoxelDiffLayer.h` + `.cpp` `UVoxelDiffLayer : UObject` (h:77). Stores `FVoxelModification` (h:43: Center/Radius/Strength; @@ -544,7 +550,7 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor | `VoxelForgeStrateParamCoverageTest.cpp` | `VoxelForge.Determinism.StrateParamBlendCoverage` | **The X-macro guard** (added 2026-08-17). `FStrateGenerationParams::Lerp` blends the hand-written `VF_STRATE_PARAM_FIELDS` list, **not** the struct — so a field added to one and not the other compiles, tests green, and silently takes its **default** inside every Gradient/Interleaved transition band. This expands the X-macro a **third** way (after LERP and SNAP), into a name list, and diffs it against the struct's UObject reflection. Pure shape test: no fixture, no world, instant. `GExemptFieldNames` is **empty** — every reflected field is covered today, and any exemption must be written down as a decision. Stakes rise with the world composer, which intends to invent parameter sets through this same `Lerp` (`COMPOSER-NOTES.md`). | | `VoxelForgeComposerParameterRollTest.cpp` | `VoxelForge.Composer.ParameterRoll` | Asset-Registry corpus audit + complete per-archetype spread/exclusion/clamp table; asserts bit-identical deterministic rerolls and same-archetype parents; measures 64 transient candidate strates with `VF_MeasureStrate` plus the exact unsnapped arrival→departure law; brute-forces every rolled production box verdict. Bootstrap follow-up: **1,422 applications / 168 distinct fields**; **38/64 survival**, feature scale **0→376**, walkable **0.000000→0.286766** (no broadening beyond the prior run); **1,356 Mixed + 608 AllSolid + 596 AllAir = 1,204 proved boxes**, **1,602,524 lattice voxels checked, 0 violations**, 146.250 s total in the final full-namespace run. | | ″ | `VoxelForge.Composer.TerrainDetailLiveness` | Fixed 4,096-point `GetDensityAt` lattice, legacy and operator-stack paths; changes one terrain-detail group at a time with an empty terrain-op pool. Proves 9 live groups / 23 fields and 3 dead groups / 11 fields; all 24 rows match. | -| `VoxelForgeComposerStructureRollTest.cpp` | `VoxelForge.Composer.StructureRoll` | Rolls root polarity → legal shape source → polarity-derived conversion → 4–8 declaration-legal modifiers → mandatory structural posts; blends the six native parameter families independently, measures 64 novel stacks, captures the same grid for the deterministic XZ/XY preview, checks exact arrival→departure connectivity, rerolls every recipe/stack for determinism, and brute-forces every uniform box verdict. Focused run: **42/64 survival (65.6%)**, **64 distinct recipes**, **6,848 bootstrap applications / 107 distinct fields**, **659 proved boxes / 877,129 voxels / 0 violations**, **64 rendered pairs + index.html**. | +| `VoxelForgeComposerStructureRollTest.cpp` | `VoxelForge.Composer.StructureRoll` | Rolls root polarity → legal shape source → polarity-derived conversion → 4–8 declaration-legal modifiers → mandatory structural posts; blends the six native parameter families independently, measures 64 novel stacks, captures the same grid for the deterministic filled/contour XZ/XY preview, runs a separate step-1 radius-64 ROI pass for the 42 survivors, checks exact arrival→departure connectivity, rerolls every recipe/stack for determinism, and brute-forces every uniform box verdict. Focused run: **42/64 survival (65.6%)**, **64 distinct recipes**, **6,848 bootstrap applications / 107 distinct fields**, **659 proved boxes / 877,129 voxels / 0 violations**, **64 coarse filled + 64 coarse contour pairs**, **42 fine ROI filled + 42 fine contour pairs**, **184.659 s**, **0 refusals**. | | `VoxelForgeLayoutOrderIndependenceTest.cpp` | `VoxelForge.Determinism.LayoutOrderIndependence` | Builds a known transient soft-pointer pool, then rebuilds it in original, reversed, and swapped orders. Requires a non-empty layout and passage set, and compares every slot's definition/Z/height plus passage endpoints, radius, type, control geometry, and bounds bit-for-bit. | | `VoxelForgePassageOpenSpaceTest.cpp` | `VoxelForge.Determinism.PassageLandsInOpenSpace` | Uses the real fixture density path to check every generated inter-strate passage whose destination query answers: a 16-point ring outside the mouth's carve/blend band has at least half its samples in destination air, and the endpoint matches the pure open-point result within the mouth's float envelope. This is a connectivity proxy, not a flood-fill proof. Reports checked passages and false/unanswerable archetypes; fails if it inspects zero passages. | | `VoxelForgeStrateConnectivityTest.cpp` | `VoxelForge.Generation.StrateConnectivity` / `VoxelForge.Generation.StrateConnectivityRefinement` / `VoxelForge.Generation.VerticalShaftSeamFreedom` | Bounded strate metrics with density-polarity and solid-gap controls, deterministic route rechecks, and refinement sweeps. The refinement test fits the measurement AABB to each arrival/departure mouth pair, reports exact before/after seed-6 cell counts, reruns every negative with doubled margin, measures tree orphan candidates/path reachability and roughness-bubble proxies, asserts axis landings, checks source/mirror physical paths, and requires 16/16 effective arrival→departure results across the 16 VerticalShafts seeds. Margin-binding negatives are reported as measurement limits, never as gap findings. The seam test re-evaluates cell-boundary positions after warming distinct neighbouring chunk contexts in both legacy and operator-stack paths. | diff --git a/COMPOSER-NOTES.md b/COMPOSER-NOTES.md index a90d5f2..b0f3894 100644 --- a/COMPOSER-NOTES.md +++ b/COMPOSER-NOTES.md @@ -484,32 +484,46 @@ review a season before publishing. ### ✅ F1 measurement + preview built (2026-09-04) -`FVoxelStrateSampleGrid` is an opt-in export of the exact `Air` polarity grid already sampled by -`VF_MeasureStrate` / `VF_MeasureStrateWithSampler`. The editor/automation renderer consumes that -buffer and never calls a density sampler, so each candidate's images and numbers come from one -window and one sample pass. `VoxelForge.Composer.StructureRoll` now writes two PNGs per candidate -and one self-contained `index.html` to: +`FVoxelStrateSampleGrid` is an opt-in export of both the exact `Air` polarity and the scalar `Density` +values already sampled by `VF_MeasureStrate` / `VF_MeasureStrateWithSampler`. The editor/automation +renderer consumes that buffer and never calls a density sampler, so each candidate's metrics and images +come from one window and one sample pass. The filled-cell view is paired with a marching-squares-style +`density = 0` contour from the scalar field. The contour is not an anti-aliased or blurred coarse image: +it is a legibility aid alongside the honest sampled-cell evidence. -`Saved/ComposerPreview/structure_seed_0_corpus_9dbcea85_step_4_radius_256/` +The step-4 measurement and 64-card contact sheet are unchanged in scope. Each card now shows the coarse +filled and contour XZ/XY pairs. `VoxelForge.Composer.StructureRoll` writes them, plus an optional fine +pass, to: -The sheet contains all **64 candidates / 128 PNGs**, survivors first and then descending normalized -distance from the corpus centroid so outliers are near the top. Every card repeats its exact resolved -interior margin, sample step, XY bounds, and inclusive/exclusive Z span before listing air, largest -component share, walkable fraction, feature scale, clearance, and arrival→departure. Rejected cards -are visibly marked and name `vacuous`, `fragmented`, and/or `law failed`. +`Saved/ComposerPreview/structure_seed_0_corpus_9dbcea85_step_4_radius_256_fine_step_1_fine_radius_64/` + +The fine pass is explicit and bounded: the current composer selects the 42 coarse survivors, samples +step 1 in a radius-64 (128×128 XY) ROI, and refuses before allocation when `MaxCells=2,000,000` would be +exceeded. The default is radius 64 because it stays around the observed 1.8M-cell budget while exposing +voxel-scale detail; radius 128 is already roughly 7.1–7.5M cells and radius 256 exceeds the 8M coarse +cap. The public settings let another editor/automation caller choose step, radius, centre, margin, and +cap, or the writer can be called for an explicitly selected list. Fine filenames, captions, and each +card's text include the sample step and exact resolved XY/Z window, so they cannot be mistaken for the +coarse render. A refused fine render is shown as a refusal with its reason rather than silently omitted. The vertical image is an XZ section through the sampled cell nearest the strate-window centre Y. The plan image scans every sampled Z layer, keeps layers containing both air and solid, and chooses the one with the largest number of XY solid/air boundary transitions; ties prefer more mixed cells, more walkable cells, then the layer nearest the vertical centre. If no mixed layer exists it chooses the most solid fallback layer, so a slab does not silently produce a blank mid-height sheet. The focused -run's first candidate was **128×48** for vertical and **128×148** for plan; dimensions vary with the -resolved Z window. Both images use dark solid, light air, orange walkable cells, and a one-chunk -footer scale bar. The page states **one chunk = 32 voxels = 8 m**, the density sign (`> 0` is air), -the exact measurement window, the 512-pixel-per-dimension cap (including a 20-pixel footer), and -that larger source grids aggregate already-sampled cells rather than resampling. It also carries the -required caveat: a 2D apparent join is not a 3D connectivity proof; the separate arrival→departure -verdict is the connectivity check. +run's first candidate was **128×48** for coarse vertical, **128×130** for fine vertical, and **128×148** +for both plan images; dimensions vary with the resolved Z window. Both views use dark solid, light air, +orange walkable cells, a turquoise scalar contour, and a one-chunk footer scale bar. The page states +**one chunk = 32 voxels = 8 m**, the density sign (`> 0` is air), every exact window, the 512-pixel cap +(including a 20-pixel footer), and that larger source grids aggregate already-sampled cells rather than +resampling. It also carries the required caveat: a 2D apparent join is not a 3D connectivity proof; +the separate arrival→departure verdict is the connectivity check. + +The focused run measured **0.456 s** for the coarse render, **70.271 s** for the 42 fine scalar samples, +and **0.573 s** for their filled/contour rendering. Peak fine `Air` + `Density` capture allocation was +**9,338,880 bytes** and the peak single RGBA raster was **75,776 bytes**. The full StructureRoll test +completed in **184.659 s**, with 64 coarse filled pairs, 64 coarse contour pairs, 42 fine filled pairs, +42 fine contour pairs, and 0 refusals. ### 3.5 Validation in three layers, and how "good" is ever judged diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeComposerStructureRollTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeComposerStructureRollTest.cpp index 1f271d9..2ee3c85 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeComposerStructureRollTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeComposerStructureRollTest.cpp @@ -21,6 +21,9 @@ namespace constexpr int32 BoxChecksPerCandidate = 40; constexpr int32 BoxStep = 1; constexpr int32 BoxCells = 8; + constexpr int32 FinePreviewSampleStep = 1; + constexpr int32 FinePreviewRadiusVoxels = 64; + constexpr int32 FinePreviewMaxCells = 2000000; const TCHAR* VF_ConnectivityName(EVoxelConnectivityResult Result) { @@ -308,6 +311,14 @@ bool FVoxelForgeComposerStructureRollTest::RunTest(const FString& Parameters) MeasureSettings.HeadroomCells = 2; MeasureSettings.InteriorMarginVoxels = -1; + FVoxelStrateFinePreviewSettings FinePreviewSettings; + FinePreviewSettings.SampleStep = FinePreviewSampleStep; + FinePreviewSettings.RadiusInVoxels = FinePreviewRadiusVoxels; + FinePreviewSettings.MaxCells = FinePreviewMaxCells; + TestTrue(TEXT("fine preview defaults are a bounded valid ROI"), FinePreviewSettings.IsValid()); + const FVoxelStrateMeasureSettings FineMeasureSettings = + FinePreviewSettings.MakeMeasureSettings(MeasureSettings); + FString CandidateTable = TEXT( "candidate | recipe (compact) | air | largest share | walkable | feature scale | arrival->departure\n"); int32 NumSurvivors = 0; @@ -329,10 +340,22 @@ bool FVoxelForgeComposerStructureRollTest::RunTest(const FString& Parameters) PreviewCandidates.Reserve(NumCandidates); FVoxelStratePreviewWindow PreviewWindow; bool bPreviewWindowSet = false; + int32 NumFineRequested = 0; + int32 NumFineRendered = 0; + int32 NumFineContourRendered = 0; + int32 NumFineRefused = 0; + int64 TotalFineCells = 0; + int64 PeakFineCaptureBytes = 0; + int64 PeakFineRasterBytes = 0; + double FineMeasureSeconds = 0.0; + double FineRenderSeconds = 0.0; + double CoarsePreviewSeconds = 0.0; + bool bFineCapRefusalChecked = false; const FString PreviewRunId = FString::Printf( - TEXT("structure_seed_%d_corpus_%08x_step_%d_radius_%d"), + TEXT("structure_seed_%d_corpus_%08x_step_%d_radius_%d_fine_step_%d_fine_radius_%d"), AuthoredSettings->Seed, Corpus.GetContentsHash(), - MeasureSettings.SampleStep, MeasureSettings.RadiusInVoxels); + MeasureSettings.SampleStep, MeasureSettings.RadiusInVoxels, + FinePreviewSettings.SampleStep, FinePreviewSettings.RadiusInVoxels); const FString PreviewDirectory = FPaths::ProjectSavedDir() / TEXT("ComposerPreview") / PreviewRunId; TArray ShapeCounts; @@ -440,6 +463,23 @@ bool FVoxelForgeComposerStructureRollTest::RunTest(const FString& Parameters) Stack.PrepareChunk(Context); FStackDensitySampler Sampler(Stack); + + if (!bFineCapRefusalChecked) + { + FVoxelStrateMeasureSettings RefusalSettings = FineMeasureSettings; + RefusalSettings.MaxCells = 1; + FVoxelStrateSampleGrid RefusedFineGrid; + const FVoxelStrateMetrics RefusedFineMetrics = VF_MeasureStrateWithSampler( + Sampler, BottomVoxelZ, TopVoxelZ + 1, + Context.EdgeSealThickness, RefusalSettings, &RefusedFineGrid); + TestTrue(TEXT("fine preview refuses an over-cap ROI before allocation"), + !RefusedFineMetrics.bValid + && RefusedFineGrid.Air.Num() == 0 + && RefusedFineGrid.Density.Num() == 0 + && RefusedFineMetrics.RefusalReason.Contains(TEXT("MaxCells"))); + bFineCapRefusalChecked = true; + } + FVoxelStrateSampleGrid SampleGrid; const FVoxelStrateMetrics Metrics = VF_MeasureStrateWithSampler( Sampler, BottomVoxelZ, TopVoxelZ + 1, @@ -527,10 +567,11 @@ bool FVoxelForgeComposerStructureRollTest::RunTest(const FString& Parameters) } TestTrue(FString::Printf(TEXT("candidate %d preview captures its measurement grid"), CandidateIndex), - !Metrics.bValid || SampleGrid.IsValid()); + !Metrics.bValid || SampleGrid.HasScalarDensity()); FVoxelStratePreviewCandidate PreviewCandidate; FString PreviewError; + const double CoarsePreviewStartSeconds = FPlatformTime::Seconds(); const bool bPreviewWritten = VF_WriteStratePreviewCandidate( PreviewDirectory, CandidateIndex, @@ -544,17 +585,83 @@ bool FVoxelForgeComposerStructureRollTest::RunTest(const FString& Parameters) RejectionReason, PreviewCandidate, PreviewError); + CoarsePreviewSeconds += FPlatformTime::Seconds() - CoarsePreviewStartSeconds; TestTrue(FString::Printf(TEXT("candidate %d preview rasterises without a second sample"), CandidateIndex), bPreviewWritten); TestTrue(FString::Printf(TEXT("candidate %d has images when its measurement is valid"), CandidateIndex), - !Metrics.bValid || PreviewCandidate.bRendered); + !Metrics.bValid + || (PreviewCandidate.bRendered && PreviewCandidate.bContourRendered)); if (!bPreviewWritten) { AddError(FString::Printf(TEXT("candidate %d preview failed: %s"), CandidateIndex, *PreviewError)); } + + const bool bCandidateSurvivor = bNonVacuous && bLargestEnough && bLawPass; + if (bCandidateSurvivor) + { + ++NumFineRequested; + FVoxelStrateSampleGrid FineGrid; + const double FineMeasureStartSeconds = FPlatformTime::Seconds(); + const FVoxelStrateMetrics FineMetrics = VF_MeasureStrateWithSampler( + Sampler, BottomVoxelZ, TopVoxelZ + 1, + Context.EdgeSealThickness, FineMeasureSettings, &FineGrid); + FineMeasureSeconds += FPlatformTime::Seconds() - FineMeasureStartSeconds; + + const FString FineFailureReason = FineMetrics.bValid + ? FString() : FineMetrics.RefusalReason; + const double FineRenderStartSeconds = FPlatformTime::Seconds(); + FString FinePreviewError; + const bool bFineWritten = VF_WriteStratePreviewFineCandidate( + PreviewDirectory, CandidateIndex, FineGrid, + FineMeasureSettings.HeadroomCells, FineFailureReason, + PreviewCandidate, FinePreviewError); + FineRenderSeconds += FPlatformTime::Seconds() - FineRenderStartSeconds; + + TestTrue(FString::Printf(TEXT("candidate %d fine preview records without a second renderer sample"), + CandidateIndex), + bFineWritten); + TestTrue(FString::Printf(TEXT("candidate %d fine capture keeps scalar density"), + CandidateIndex), + !FineMetrics.bValid || FineGrid.HasScalarDensity()); + if (FineMetrics.bValid) + { + TotalFineCells += FineGrid.CellCount; + PeakFineCaptureBytes = FMath::Max( + PeakFineCaptureBytes, + static_cast(FineGrid.Air.GetAllocatedSize()) + + static_cast(FineGrid.Density.GetAllocatedSize())); + } + if (!FineMetrics.bValid) + { + ++NumFineRefused; + } + if (PreviewCandidate.bFineRendered) + { + ++NumFineRendered; + PeakFineRasterBytes = FMath::Max( + PeakFineRasterBytes, + static_cast(PreviewCandidate.FineVerticalImageWidth) + * static_cast(PreviewCandidate.FineVerticalImageHeight) + * static_cast(sizeof(FColor))); + PeakFineRasterBytes = FMath::Max( + PeakFineRasterBytes, + static_cast(PreviewCandidate.FinePlanImageWidth) + * static_cast(PreviewCandidate.FinePlanImageHeight) + * static_cast(sizeof(FColor))); + } + if (PreviewCandidate.bFineContourRendered) + { + ++NumFineContourRendered; + } + if (!bFineWritten) + { + AddError(FString::Printf(TEXT("candidate %d fine preview failed: %s"), + CandidateIndex, *FinePreviewError)); + } + } PreviewCandidates.Add(MoveTemp(PreviewCandidate)); // Same recipe + same block vectors + same seed must yield the same world values. This is @@ -637,6 +744,17 @@ bool FVoxelForgeComposerStructureRollTest::RunTest(const FString& Parameters) TotalBoxMixed, TotalBoxAllSolid, TotalBoxAllAir, TotalBoxProved, TotalBoxCheckedVoxels, TotalBoxViolations, FirstBoxViolation.IsEmpty() ? TEXT("No violations.") : *FirstBoxViolation)); + AddInfo(FString::Printf( + TEXT("Preview cost: coarse step=%d 64-card pass render %.3fs; fine ROI requested=%d " + "(survivors=%d), rendered=%d, contour pairs=%d, refused=%d; fine sample %.3fs + " + "render %.3fs; fine cells sampled=%lld; peak fine Air+Density capture=%lld bytes; " + "peak single RGBA raster=%lld bytes; ROI step=%d radius=%d MaxCells=%d."), + MeasureSettings.SampleStep, CoarsePreviewSeconds, + NumFineRequested, NumSurvivors, NumFineRendered, NumFineContourRendered, NumFineRefused, + FineMeasureSeconds, FineRenderSeconds, TotalFineCells, + PeakFineCaptureBytes, PeakFineRasterBytes, + FinePreviewSettings.SampleStep, FinePreviewSettings.RadiusInVoxels, + FinePreviewSettings.MaxCells)); FString PreviewIndexPath; FString PreviewError; const bool bPreviewIndexWritten = bPreviewWindowSet @@ -658,20 +776,28 @@ bool FVoxelForgeComposerStructureRollTest::RunTest(const FString& Parameters) { const FVoxelStratePreviewCandidate& FirstPreview = PreviewCandidates[0]; int32 NumRenderedPairs = 0; + int32 NumRenderedContourPairs = 0; for (const FVoxelStratePreviewCandidate& Candidate : PreviewCandidates) { if (Candidate.bRendered) { ++NumRenderedPairs; } + if (Candidate.bContourRendered) + { + ++NumRenderedContourPairs; + } } AddInfo(FString::Printf( - TEXT("Composer preview: %d candidates, %d rendered pairs, vertical=%dx%d, plan=%dx%d, " + TEXT("Composer preview: %d candidates, %d filled pairs + %d contour pairs, " + "vertical=%dx%d, plan=%dx%d, " "plan Z chosen from each candidate's measured layer with the highest solid/air " "boundary count (first candidate Z=%d); index=%s; first-candidate window=%s; " - "image cap=512 px including 20 px scale footer."), + "fine previews are linked on survivor cards; image cap=512 px including 20 px " + "scale footer."), PreviewCandidates.Num(), NumRenderedPairs, + NumRenderedContourPairs, FirstPreview.VerticalImageWidth, FirstPreview.VerticalImageHeight, FirstPreview.PlanImageWidth, FirstPreview.PlanImageHeight, FirstPreview.PlanSliceWorldZ, @@ -686,6 +812,14 @@ bool FVoxelForgeComposerStructureRollTest::RunTest(const FString& Parameters) TestEqual(TEXT("exactly 64 structure candidates were requested"), NumCandidates, 64); TestEqual(TEXT("invalid recipes emitted by the roller/build path"), NumInvalidRecipes, 0); TestEqual(TEXT("custom-stack box verdict violations"), TotalBoxViolations, 0); + TestTrue(TEXT("fine cap refusal was checked before any fine allocation"), + bFineCapRefusalChecked); + TestEqual(TEXT("fine previews are requested only for coarse survivors"), + NumFineRequested, NumSurvivors); + TestEqual(TEXT("fine preview outcomes account for every requested survivor"), + NumFineRendered + NumFineRefused, NumFineRequested); + TestEqual(TEXT("every rendered fine preview has a scalar contour pair"), + NumFineContourRendered, NumFineRendered); TestTrue(TEXT("structure roller emits 64 distinct structural recipes"), DistinctRecipes.Num() == NumCandidates); diff --git a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp index 124ff14..d5e2d0a 100644 --- a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp +++ b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp @@ -32,6 +32,8 @@ namespace VoxelStrateMeasurePrivate // 1 = air, 0 = solid. The polarity is deliberately explicit at the sampling site below. TArray Air; + // Optional exact scalar samples retained only for an explicit preview capture. + TArray Density; FORCEINLINE int32 Index(int32 X, int32 Y, int32 Z) const { @@ -84,6 +86,7 @@ namespace VoxelStrateMeasurePrivate float ExplicitBoundarySealThickness, bool bUseExplicitBounds, const FVoxelStrateMeasureSettings& Settings, + bool bCaptureDensity, FSampleGrid& OutGrid, FString& OutReason) { @@ -356,6 +359,10 @@ namespace VoxelStrateMeasurePrivate OutGrid.SampledMinZ = static_cast(InteriorMinZ); OutGrid.SampledMaxZ = static_cast(InteriorMaxZ); OutGrid.Air.SetNumUninitialized(OutGrid.CellCount); + if (bCaptureDensity) + { + OutGrid.Density.SetNumUninitialized(OutGrid.CellCount); + } bool bSawNonFiniteDensity = false; int32 CellIndex = 0; @@ -375,6 +382,10 @@ namespace VoxelStrateMeasurePrivate } // MC convention in this codebase: negative is solid, positive is air. + if (bCaptureDensity) + { + OutGrid.Density[CellIndex] = Density; + } OutGrid.Air[CellIndex++] = Density > 0.0f ? 1u : 0u; } } @@ -408,6 +419,7 @@ namespace VoxelStrateMeasurePrivate // Move, rather than copy, the already-built polarity grid. The caller owns this buffer // only when it explicitly requested a capture. Destination.Air = MoveTemp(Source.Air); + Destination.Density = MoveTemp(Source.Density); } void DecodeIndex(const FSampleGrid& Grid, int32 Index, int32& OutX, int32& OutY, int32& OutZ) @@ -1237,7 +1249,7 @@ namespace VoxelStrateMeasurePrivate if (!BuildSampleGrid(Generator, Manager, Sampler, StrateIndex, ExplicitBottomWorldZ, ExplicitTopWorldZ, ExplicitBoundarySealThickness, bUseExplicitBounds, - Settings, Grid, RefusalReason)) + Settings, false, Grid, RefusalReason)) { if (OutDiagnostics != nullptr) { @@ -1291,7 +1303,7 @@ FVoxelStrateMetrics VF_MeasureStrate( VoxelStrateMeasurePrivate::FSampleGrid Grid; if (!VoxelStrateMeasurePrivate::BuildSampleGrid( &Generator, &Manager, nullptr, StrateIndex, 0, 0, 0.0f, false, - Settings, Grid, Result.RefusalReason)) + Settings, OutSampleGrid != nullptr, Grid, Result.RefusalReason)) { return Result; } @@ -1335,7 +1347,8 @@ FVoxelStrateMetrics VF_MeasureStrateWithSampler( VoxelStrateMeasurePrivate::FSampleGrid Grid; if (!VoxelStrateMeasurePrivate::BuildSampleGrid( nullptr, nullptr, &Sampler, INDEX_NONE, StrateBottomWorldZ, StrateTopWorldZ, - BoundarySealThickness, true, Settings, Grid, Result.RefusalReason)) + BoundarySealThickness, true, Settings, OutSampleGrid != nullptr, + Grid, Result.RefusalReason)) { return Result; } diff --git a/Source/VoxelForge/Private/VoxelStratePreview.cpp b/Source/VoxelForge/Private/VoxelStratePreview.cpp index fe1dee7..f0c387c 100644 --- a/Source/VoxelForge/Private/VoxelStratePreview.cpp +++ b/Source/VoxelForge/Private/VoxelStratePreview.cpp @@ -53,6 +53,8 @@ namespace const FColor GSolidColor(18, 23, 30, 255); const FColor GAirColor(218, 225, 231, 255); const FColor GWalkableColor(245, 166, 54, 255); + const FColor GContourBackground(12, 19, 25, 255); + const FColor GContourLineColor(77, 226, 190, 255); const FColor GScaleBarColor(245, 245, 245, 255); const FColor GScaleBarBackground(9, 12, 17, 255); @@ -375,6 +377,262 @@ namespace return OutWidth > 0 && OutHeight > 0; } + struct FContourPoint + { + float X = 0.0f; + float Y = 0.0f; + }; + + FContourPoint VF_InterpolateContourPoint(const FContourPoint& A, + const FContourPoint& B, + float ValueA, + float ValueB) + { + const float Denominator = ValueA - ValueB; + const float Alpha = FMath::IsNearlyZero(Denominator) + ? 0.5f + : FMath::Clamp(ValueA / Denominator, 0.0f, 1.0f); + return { + FMath::Lerp(A.X, B.X, Alpha), + FMath::Lerp(A.Y, B.Y, Alpha) + }; + } + + void VF_DrawContourLine(TArray& Pixels, int32 Width, int32 Height, + const FContourPoint& A, const FContourPoint& B) + { + // The caller passes the content height so the scale-bar footer is never painted over. + // The pixel buffer is intentionally taller than this region. + if (Width <= 0 || Height <= 0 || Pixels.Num() < Width * Height) + { + return; + } + + int32 X0 = FMath::Clamp(FMath::RoundToInt(A.X), 0, Width - 1); + int32 Y0 = FMath::Clamp(FMath::RoundToInt(A.Y), 0, Height - 1); + const int32 X1 = FMath::Clamp(FMath::RoundToInt(B.X), 0, Width - 1); + const int32 Y1 = FMath::Clamp(FMath::RoundToInt(B.Y), 0, Height - 1); + const int32 DeltaX = FMath::Abs(X1 - X0); + const int32 StepX = X0 < X1 ? 1 : -1; + const int32 DeltaY = -FMath::Abs(Y1 - Y0); + const int32 StepY = Y0 < Y1 ? 1 : -1; + int32 Error = DeltaX + DeltaY; + + for (;;) + { + Pixels[Y0 * Width + X0] = GContourLineColor; + if (X0 == X1 && Y0 == Y1) + { + break; + } + const int32 DoubleError = 2 * Error; + if (DoubleError >= DeltaY) + { + Error += DeltaY; + X0 += StepX; + } + if (DoubleError <= DeltaX) + { + Error += DeltaX; + Y0 += StepY; + } + } + } + + void VF_DrawContourQuad(TArray& Pixels, int32 Width, int32 Height, + const FContourPoint& A, const FContourPoint& B, + const FContourPoint& C, const FContourPoint& D, + float ValueA, float ValueB, float ValueC, float ValueD) + { + if (!FMath::IsFinite(ValueA) || !FMath::IsFinite(ValueB) + || !FMath::IsFinite(ValueC) || !FMath::IsFinite(ValueD)) + { + return; + } + + // A/B/C/D wind around one scalar quad. The bit mask is deliberately based on the same + // strict sign rule as Air: density > 0 is air, density <= 0 is solid. + const int32 Mask = (ValueA > 0.0f ? 1 : 0) + | (ValueB > 0.0f ? 2 : 0) + | (ValueC > 0.0f ? 4 : 0) + | (ValueD > 0.0f ? 8 : 0); + if (Mask == 0 || Mask == 15) + { + return; + } + + const FContourPoint Edge0 = VF_InterpolateContourPoint(A, B, ValueA, ValueB); + const FContourPoint Edge1 = VF_InterpolateContourPoint(B, C, ValueB, ValueC); + const FContourPoint Edge2 = VF_InterpolateContourPoint(C, D, ValueC, ValueD); + const FContourPoint Edge3 = VF_InterpolateContourPoint(D, A, ValueD, ValueA); + const float CentreValue = 0.25f * (ValueA + ValueB + ValueC + ValueD); + + auto Draw = [&](const FContourPoint& First, const FContourPoint& Second) + { + VF_DrawContourLine(Pixels, Width, Height, First, Second); + }; + + switch (Mask) + { + case 1: Draw(Edge3, Edge0); break; + case 2: Draw(Edge0, Edge1); break; + case 3: Draw(Edge3, Edge1); break; + case 4: Draw(Edge1, Edge2); break; + case 5: + // The diagonal case is decided from the scalar centre, not from the binary image. + // This makes the saddle deterministic while keeping the isoline tied to the field. + if (CentreValue > 0.0f) + { + Draw(Edge0, Edge1); + Draw(Edge2, Edge3); + } + else + { + Draw(Edge3, Edge0); + Draw(Edge1, Edge2); + } + break; + case 6: Draw(Edge0, Edge2); break; + case 7: Draw(Edge2, Edge3); break; + case 8: Draw(Edge2, Edge3); break; + case 9: Draw(Edge0, Edge2); break; + case 10: + if (CentreValue > 0.0f) + { + Draw(Edge3, Edge0); + Draw(Edge1, Edge2); + } + else + { + Draw(Edge0, Edge1); + Draw(Edge2, Edge3); + } + break; + case 11: Draw(Edge1, Edge2); break; + case 12: Draw(Edge1, Edge3); break; + case 13: Draw(Edge0, Edge1); break; + case 14: Draw(Edge0, Edge3); break; + default: break; + } + } + + FContourPoint VF_MapVerticalContourPoint(int32 X, int32 Z, + int32 SourceWidth, int32 SourceHeight, + int32 OutputWidth, int32 ContentHeight) + { + return { + SourceWidth > 1 + ? static_cast(X) * static_cast(OutputWidth - 1) + / static_cast(SourceWidth - 1) + : 0.0f, + SourceHeight > 1 + ? static_cast(SourceHeight - 1 - Z) + * static_cast(ContentHeight - 1) + / static_cast(SourceHeight - 1) + : 0.0f + }; + } + + FContourPoint VF_MapPlanContourPoint(int32 X, int32 Y, + int32 SourceWidth, int32 SourceHeight, + int32 OutputWidth, int32 ContentHeight) + { + return { + SourceWidth > 1 + ? static_cast(X) * static_cast(OutputWidth - 1) + / static_cast(SourceWidth - 1) + : 0.0f, + SourceHeight > 1 + ? static_cast(Y) * static_cast(ContentHeight - 1) + / static_cast(SourceHeight - 1) + : 0.0f + }; + } + + bool VF_RenderVerticalContour(const FVoxelStrateSampleGrid& Grid, int32 FixedY, + TArray& OutPixels, + int32& OutWidth, int32& OutHeight) + { + if (!Grid.HasScalarDensity()) + { + return false; + } + + const int32 MaxContentDimension = GMaxPreviewImageDimension + - GPreviewScaleBarFooterPixels; + OutWidth = FMath::Min(Grid.NumX, MaxContentDimension); + const int32 ContentHeight = FMath::Min(Grid.NumZ, MaxContentDimension); + OutHeight = ContentHeight + GPreviewScaleBarFooterPixels; + OutPixels.Init(GContourBackground, OutWidth * OutHeight); + if (Grid.NumX > 1 && Grid.NumZ > 1) + { + for (int32 Z = 0; Z + 1 < Grid.NumZ; ++Z) + { + for (int32 X = 0; X + 1 < Grid.NumX; ++X) + { + const FContourPoint A = VF_MapVerticalContourPoint( + X, Z, Grid.NumX, Grid.NumZ, OutWidth, ContentHeight); + const FContourPoint B = VF_MapVerticalContourPoint( + X + 1, Z, Grid.NumX, Grid.NumZ, OutWidth, ContentHeight); + const FContourPoint C = VF_MapVerticalContourPoint( + X + 1, Z + 1, Grid.NumX, Grid.NumZ, OutWidth, ContentHeight); + const FContourPoint D = VF_MapVerticalContourPoint( + X, Z + 1, Grid.NumX, Grid.NumZ, OutWidth, ContentHeight); + VF_DrawContourQuad( + OutPixels, OutWidth, ContentHeight, A, B, C, D, + Grid.Density[Grid.Index(X, FixedY, Z)], + Grid.Density[Grid.Index(X + 1, FixedY, Z)], + Grid.Density[Grid.Index(X + 1, FixedY, Z + 1)], + Grid.Density[Grid.Index(X, FixedY, Z + 1)]); + } + } + } + VF_DrawScaleBar(OutPixels, OutWidth, ContentHeight, Grid.NumX, Grid.SampleStep); + return OutWidth > 0 && OutHeight > 0; + } + + bool VF_RenderPlanContour(const FVoxelStrateSampleGrid& Grid, int32 FixedZ, + TArray& OutPixels, + int32& OutWidth, int32& OutHeight) + { + if (!Grid.HasScalarDensity()) + { + return false; + } + + const int32 MaxContentDimension = GMaxPreviewImageDimension + - GPreviewScaleBarFooterPixels; + OutWidth = FMath::Min(Grid.NumX, MaxContentDimension); + const int32 ContentHeight = FMath::Min(Grid.NumY, MaxContentDimension); + OutHeight = ContentHeight + GPreviewScaleBarFooterPixels; + OutPixels.Init(GContourBackground, OutWidth * OutHeight); + if (Grid.NumX > 1 && Grid.NumY > 1) + { + for (int32 Y = 0; Y + 1 < Grid.NumY; ++Y) + { + for (int32 X = 0; X + 1 < Grid.NumX; ++X) + { + const FContourPoint A = VF_MapPlanContourPoint( + X, Y, Grid.NumX, Grid.NumY, OutWidth, ContentHeight); + const FContourPoint B = VF_MapPlanContourPoint( + X + 1, Y, Grid.NumX, Grid.NumY, OutWidth, ContentHeight); + const FContourPoint C = VF_MapPlanContourPoint( + X + 1, Y + 1, Grid.NumX, Grid.NumY, OutWidth, ContentHeight); + const FContourPoint D = VF_MapPlanContourPoint( + X, Y + 1, Grid.NumX, Grid.NumY, OutWidth, ContentHeight); + VF_DrawContourQuad( + OutPixels, OutWidth, ContentHeight, A, B, C, D, + Grid.Density[Grid.Index(X, Y, FixedZ)], + Grid.Density[Grid.Index(X + 1, Y, FixedZ)], + Grid.Density[Grid.Index(X + 1, Y + 1, FixedZ)], + Grid.Density[Grid.Index(X, Y + 1, FixedZ)]); + } + } + } + VF_DrawScaleBar(OutPixels, OutWidth, ContentHeight, Grid.NumX, Grid.SampleStep); + return OutWidth > 0 && OutHeight > 0; + } + bool VF_WritePng(const FString& Path, const TArray& Pixels, int32 Width, int32 Height, FString& OutError) { @@ -461,6 +719,41 @@ bool VF_WriteStratePreviewCandidate( return false; } + if (!IFileManager::Get().MakeDirectory(*OutputDirectory, true)) + { + OutError = FString::Printf(TEXT("failed to create preview directory %s"), + *OutputDirectory); + return false; + } + + const FString Stem = FString::Printf(TEXT("candidate_%03d"), CandidateIndex); + OutCandidate.VerticalImageFile = Stem + TEXT("_vertical.png"); + OutCandidate.VerticalContourImageFile = Stem + TEXT("_vertical_contour.png"); + OutCandidate.PlanImageFile = Stem + TEXT("_plan.png"); + OutCandidate.PlanContourImageFile = Stem + TEXT("_plan_contour.png"); + const FString VerticalPath = OutputDirectory / OutCandidate.VerticalImageFile; + if (!VF_WritePng(VerticalPath, VerticalPixels, VerticalWidth, VerticalHeight, OutError)) + { + return false; + } + + const bool bHasContour = Grid.HasScalarDensity(); + if (bHasContour) + { + int32 ContourWidth = 0; + int32 ContourHeight = 0; + if (!VF_RenderVerticalContour(Grid, FixedY, VerticalPixels, + ContourWidth, ContourHeight) + || ContourWidth != VerticalWidth || ContourHeight != VerticalHeight + || !VF_WritePng(OutputDirectory / OutCandidate.VerticalContourImageFile, + VerticalPixels, ContourWidth, ContourHeight, OutError)) + { + OutError = FString::Printf(TEXT("failed to write vertical density contour for candidate %d"), + CandidateIndex); + return false; + } + } + TArray PlanPixels; int32 PlanWidth = 0; int32 PlanHeight = 0; @@ -471,25 +764,34 @@ bool VF_WriteStratePreviewCandidate( return false; } - if (!IFileManager::Get().MakeDirectory(*OutputDirectory, true)) + const FString PlanPath = OutputDirectory / OutCandidate.PlanImageFile; + if (!VF_WritePng(PlanPath, PlanPixels, PlanWidth, PlanHeight, OutError)) { - OutError = FString::Printf(TEXT("failed to create preview directory %s"), - *OutputDirectory); return false; } - - const FString Stem = FString::Printf(TEXT("candidate_%03d"), CandidateIndex); - OutCandidate.VerticalImageFile = Stem + TEXT("_vertical.png"); - OutCandidate.PlanImageFile = Stem + TEXT("_plan.png"); - const FString VerticalPath = OutputDirectory / OutCandidate.VerticalImageFile; - const FString PlanPath = OutputDirectory / OutCandidate.PlanImageFile; - if (!VF_WritePng(VerticalPath, VerticalPixels, VerticalWidth, VerticalHeight, OutError) - || !VF_WritePng(PlanPath, PlanPixels, PlanWidth, PlanHeight, OutError)) + if (bHasContour) { - return false; + int32 ContourWidth = 0; + int32 ContourHeight = 0; + if (!VF_RenderPlanContour(Grid, Plan.CellZ, PlanPixels, + ContourWidth, ContourHeight) + || ContourWidth != PlanWidth || ContourHeight != PlanHeight + || !VF_WritePng(OutputDirectory / OutCandidate.PlanContourImageFile, + PlanPixels, ContourWidth, ContourHeight, OutError)) + { + OutError = FString::Printf(TEXT("failed to write plan density contour for candidate %d"), + CandidateIndex); + return false; + } } OutCandidate.bRendered = true; + OutCandidate.bContourRendered = bHasContour; + if (!bHasContour) + { + OutCandidate.RenderFailureReason = TEXT( + "no scalar density capture was supplied; filled cells were rendered, but no density=0 contour is available"); + } OutCandidate.VerticalSliceCellY = FixedY; OutCandidate.VerticalSliceWorldY = VF_CellWorldCoordinate( Grid.MinY, Grid.MaxY, Grid.SampleStep, FixedY); @@ -505,6 +807,126 @@ bool VF_WriteStratePreviewCandidate( return true; } +bool VF_WriteStratePreviewFineCandidate( + const FString& OutputDirectory, + int32 CandidateIndex, + const FVoxelStrateSampleGrid& FineGrid, + int32 HeadroomCells, + const FString& FineFailureReason, + FVoxelStratePreviewCandidate& InOutCandidate, + FString& OutError) +{ + InOutCandidate.bFineRequested = true; + InOutCandidate.FineRenderFailureReason = FineFailureReason; + if (!FineGrid.IsValid()) + { + if (InOutCandidate.FineRenderFailureReason.IsEmpty()) + { + InOutCandidate.FineRenderFailureReason = TEXT("fine ROI measurement was refused"); + } + return true; + } + InOutCandidate.FineWindow = VF_GetStratePreviewWindow(FineGrid); + if (HeadroomCells < 0) + { + OutError = TEXT("preview HeadroomCells cannot be negative"); + return false; + } + + const FPlanSliceChoice Plan = VF_ChoosePlanSlice(FineGrid, HeadroomCells); + const float CentreY = 0.5f * (FineGrid.MinY + FineGrid.MaxY); + const float Step = static_cast(FineGrid.SampleStep); + const int32 FixedY = FMath::Clamp( + FMath::FloorToInt((CentreY - FineGrid.MinY) / Step), 0, FineGrid.NumY - 1); + + if (!IFileManager::Get().MakeDirectory(*OutputDirectory, true)) + { + OutError = FString::Printf(TEXT("failed to create preview directory %s"), + *OutputDirectory); + return false; + } + + const FString Stem = FString::Printf(TEXT("candidate_%03d_fine_step_%d"), + CandidateIndex, FineGrid.SampleStep); + InOutCandidate.FineVerticalImageFile = Stem + TEXT("_vertical_filled.png"); + InOutCandidate.FineVerticalContourImageFile = Stem + TEXT("_vertical_contour.png"); + InOutCandidate.FinePlanImageFile = Stem + TEXT("_plan_filled.png"); + InOutCandidate.FinePlanContourImageFile = Stem + TEXT("_plan_contour.png"); + + TArray Pixels; + int32 Width = 0; + int32 Height = 0; + if (!VF_RenderVertical(FineGrid, HeadroomCells, FixedY, Pixels, Width, Height) + || !VF_WritePng(OutputDirectory / InOutCandidate.FineVerticalImageFile, + Pixels, Width, Height, OutError)) + { + OutError = FString::Printf(TEXT("failed to write fine vertical filled view for candidate %d"), + CandidateIndex); + return false; + } + + const bool bHasContour = FineGrid.HasScalarDensity(); + if (bHasContour) + { + int32 ContourWidth = 0; + int32 ContourHeight = 0; + if (!VF_RenderVerticalContour(FineGrid, FixedY, Pixels, + ContourWidth, ContourHeight) + || ContourWidth != Width || ContourHeight != Height + || !VF_WritePng(OutputDirectory / InOutCandidate.FineVerticalContourImageFile, + Pixels, ContourWidth, ContourHeight, OutError)) + { + OutError = FString::Printf(TEXT("failed to write fine vertical density contour for candidate %d"), + CandidateIndex); + return false; + } + } + + InOutCandidate.FineVerticalSliceCellY = FixedY; + InOutCandidate.FineVerticalSliceWorldY = VF_CellWorldCoordinate( + FineGrid.MinY, FineGrid.MaxY, FineGrid.SampleStep, FixedY); + InOutCandidate.FineVerticalImageWidth = Width; + InOutCandidate.FineVerticalImageHeight = Height; + + if (!VF_RenderPlan(FineGrid, HeadroomCells, Plan.CellZ, Pixels, Width, Height) + || !VF_WritePng(OutputDirectory / InOutCandidate.FinePlanImageFile, + Pixels, Width, Height, OutError)) + { + OutError = FString::Printf(TEXT("failed to write fine plan filled view for candidate %d"), + CandidateIndex); + return false; + } + if (bHasContour) + { + int32 ContourWidth = 0; + int32 ContourHeight = 0; + if (!VF_RenderPlanContour(FineGrid, Plan.CellZ, Pixels, + ContourWidth, ContourHeight) + || ContourWidth != Width || ContourHeight != Height + || !VF_WritePng(OutputDirectory / InOutCandidate.FinePlanContourImageFile, + Pixels, ContourWidth, ContourHeight, OutError)) + { + OutError = FString::Printf(TEXT("failed to write fine plan density contour for candidate %d"), + CandidateIndex); + return false; + } + } + + InOutCandidate.bFineRendered = true; + InOutCandidate.bFineContourRendered = bHasContour; + InOutCandidate.FinePlanSliceCellZ = Plan.CellZ; + InOutCandidate.FinePlanSliceWorldZ = VF_CellWorldCoordinate( + FineGrid.MinZ, FineGrid.MaxZ, FineGrid.SampleStep, Plan.CellZ); + InOutCandidate.FinePlanImageWidth = Width; + InOutCandidate.FinePlanImageHeight = Height; + if (!bHasContour && InOutCandidate.FineRenderFailureReason.IsEmpty()) + { + InOutCandidate.FineRenderFailureReason = TEXT( + "no scalar density capture was supplied; filled cells were rendered, but no density=0 contour is available"); + } + return true; +} + bool VF_WriteStratePreviewIndex( const FString& OutputDirectory, const FString& RunTitle, @@ -572,12 +994,17 @@ bool VF_WriteStratePreviewIndex( ".candidate.rejected{border-color:#8b3b42;background:#21171a}.candidate h2{margin:0 0 6px;font-size:18px}\n" ".badge{border-radius:999px;padding:2px 7px;font-size:11px;font-weight:700;letter-spacing:.04em}.survivor{background:#1f6f43;color:#d7ffe5}.rejected-badge{background:#9b3440;color:#fff0f1}\n" ".reason{color:#ffb4b8;margin:6px 0}.recipe{color:#c9d1d9;overflow-wrap:anywhere;margin-bottom:8px}.recipe code{font:12px ui-monospace,SFMono-Regular,monospace}\n" - ".window{font-size:11px;color:#8b949e;margin:5px 0 10px}.images{display:grid;grid-template-columns:1fr 1fr;gap:8px}.images figure{margin:0}.images img{display:block;width:100%;height:auto;background:#090c11;image-rendering:pixelated;border:1px solid #30363d}.images figcaption{font-size:11px;color:#aab4c0;margin-top:3px}\n" + ".window{font-size:11px;color:#8b949e;margin:5px 0 10px}.view-section{margin:12px 0}.view-section h3{font-size:12px;letter-spacing:.03em;color:#e6edf3;margin:10px 0 3px}.pair{display:grid;grid-template-columns:1fr 1fr;gap:8px}.pair figure{margin:0}.pair img{display:block;width:100%;height:auto;background:#090c11;image-rendering:pixelated;border:1px solid #30363d}.pair figcaption{font-size:11px;color:#aab4c0;margin-top:3px}.fine-section{border-top:1px solid #6e4e1f;margin-top:14px;padding-top:4px}.fine-section h3{color:#ffd580}.not-requested{color:#8b949e;font-size:12px;margin:8px 0}.contour-note{color:#8cebd1}\n" ".no-image{min-height:40px;border:1px dashed #8b3b42;color:#ffb4b8;padding:12px;font-size:12px}\n" "table{width:100%;border-collapse:collapse;margin-top:10px;font-size:12px}th,td{padding:3px 4px;border-bottom:1px solid #30363d;text-align:left}th{color:#aab4c0;font-weight:500}td{text-align:right;font-variant-numeric:tabular-nums}.verdict{font-weight:650}\n" ".footer{color:#8b949e;font-size:11px;margin-top:18px}\n" "\n"); Html += FString::Printf(TEXT("

%s

\n"), *VF_HtmlEscape(RunTitle)); + Html += FString::Printf( + TEXT("

How to read coarse images: At sample step %d, " + "hard edges can come from sampling, not necessarily hard world walls. A " + "lattice-sourced strate is genuinely rectilinear at step 1; that is expected for a maze.

\n"), + Window.SampleStep); Html += FString::Printf( TEXT("

%s " "Density polarity is explicit: density > 0 is air. One chunk = 32 voxels = 8 m " @@ -592,7 +1019,12 @@ bool VF_WriteStratePreviewIndex( "connectivity check, not something the pictures prove.

\n" "
solid" "air" - "walkable cell
\n" + "walkable cell" + "density=0 contour\n" + "

Fine ROI: the separate step-1 pass is requested only " + "for selected candidates (this run selects coarse survivors). Its exact window is printed " + "on the card. If its cell cap is exceeded, no fine allocation is made and the refusal is " + "shown on that card.

\n" "

Ordering: survivors first, then each group by descending distance from " "the corpus centroid so the strangest candidates appear early. The preview raster cap is " "512 px per image dimension including a 20 px scale-bar footer; if a source grid is larger, " @@ -622,24 +1054,127 @@ bool VF_WriteStratePreviewIndex( } Html += FString::Printf(TEXT("

Numbers and slice choices use: %s

\n"), *VF_HtmlEscape(MetricWindow)); - Html += TEXT("
\n"); + Html += TEXT("

COARSE XZ — filled cells and density=0 contour

\n"); if (Candidate.bRendered) { Html += FString::Printf( - TEXT("
\"candidate
" - "Vertical XZ @ centre Y=%d voxels
\n" - "
\"candidate
" - "Plan XY @ selected Z=%d voxels; %d solid/air boundary transitions
\n"), + TEXT("

Exact coarse window: %s; slice Y=%d voxels.

\n" + "
\"candidate
" + "COARSE · filled cells · step=%d
\n"), + *VF_HtmlEscape(MetricWindow), Candidate.VerticalSliceWorldY, *VF_HtmlEscape(Candidate.VerticalImageFile), Candidate.CandidateIndex, - Candidate.VerticalSliceWorldY, - *VF_HtmlEscape(Candidate.PlanImageFile), Candidate.CandidateIndex, - Candidate.PlanSliceWorldZ, Candidate.PlanSliceBoundaryTransitions); + Candidate.Window.SampleStep); + if (Candidate.bContourRendered) + { + Html += FString::Printf( + TEXT("
\"candidate
" + "COARSE · density=0 contour · scalar field · step=%d
\n"), + *VF_HtmlEscape(Candidate.VerticalContourImageFile), Candidate.CandidateIndex, + Candidate.Window.SampleStep); + } + else + { + Html += FString::Printf( + TEXT("
No coarse contour: %s
\n"), + *VF_HtmlEscape(Candidate.RenderFailureReason)); + } } else { - Html += FString::Printf(TEXT("
No image: %s
\n"), + Html += FString::Printf(TEXT("
No coarse image: %s
\n"), *VF_HtmlEscape(Candidate.RenderFailureReason)); } + Html += TEXT("
\n

COARSE XY — filled cells and density=0 contour

\n"); + if (Candidate.bRendered) + { + Html += FString::Printf( + TEXT("

Exact coarse window: %s; selected Z=%d voxels; %d solid/air boundary transitions.

\n" + "
\"candidate
" + "COARSE · filled cells · step=%d
\n"), + *VF_HtmlEscape(MetricWindow), Candidate.PlanSliceWorldZ, + Candidate.PlanSliceBoundaryTransitions, + *VF_HtmlEscape(Candidate.PlanImageFile), Candidate.CandidateIndex, + Candidate.Window.SampleStep); + if (Candidate.bContourRendered) + { + Html += FString::Printf( + TEXT("
\"candidate
" + "COARSE · density=0 contour · scalar field · step=%d
\n"), + *VF_HtmlEscape(Candidate.PlanContourImageFile), Candidate.CandidateIndex, + Candidate.Window.SampleStep); + } + else + { + Html += FString::Printf( + TEXT("
No coarse contour: %s
\n"), + *VF_HtmlEscape(Candidate.RenderFailureReason)); + } + } + else + { + Html += FString::Printf(TEXT("
No coarse image: %s
\n"), + *VF_HtmlEscape(Candidate.RenderFailureReason)); + } + Html += TEXT("\n

FINE ROI — filled cells and density=0 contour

\n"); + if (!Candidate.bFineRequested) + { + Html += TEXT("

Fine ROI not requested for this candidate; it did not pass the coarse survivor screen.

\n"); + } + else if (!Candidate.bFineRendered) + { + const FString FineFailureText = Candidate.FineRenderFailureReason.IsEmpty() + ? TEXT("unspecified fine preview refusal") : Candidate.FineRenderFailureReason; + Html += FString::Printf( + TEXT("
Fine ROI requested but refused/not rendered: %s
\n"), + *VF_HtmlEscape(FineFailureText)); + } + else + { + const FString FineWindowDescription = Candidate.FineWindow.Describe(); + Html += FString::Printf( + TEXT("

Exact fine ROI window: %s; slice Y=%d voxels.

\n" + "
\"candidate
" + "FINE ROI · filled cells · step=%d
\n"), + *VF_HtmlEscape(FineWindowDescription), Candidate.FineVerticalSliceWorldY, + *VF_HtmlEscape(Candidate.FineVerticalImageFile), Candidate.CandidateIndex, + Candidate.FineWindow.SampleStep); + if (Candidate.bFineContourRendered) + { + Html += FString::Printf( + TEXT("
\"candidate
" + "FINE ROI · density=0 contour · scalar field · step=%d
\n"), + *VF_HtmlEscape(Candidate.FineVerticalContourImageFile), Candidate.CandidateIndex, + Candidate.FineWindow.SampleStep); + } + else + { + Html += FString::Printf( + TEXT("
No fine contour: %s
\n"), + *VF_HtmlEscape(Candidate.FineRenderFailureReason)); + } + + Html += FString::Printf( + TEXT("

Exact fine ROI window: %s; selected Z=%d voxels.

\n" + "
\"candidate
" + "FINE ROI · filled cells · step=%d
\n"), + *VF_HtmlEscape(FineWindowDescription), Candidate.FinePlanSliceWorldZ, + *VF_HtmlEscape(Candidate.FinePlanImageFile), Candidate.CandidateIndex, + Candidate.FineWindow.SampleStep); + if (Candidate.bFineContourRendered) + { + Html += FString::Printf( + TEXT("
\"candidate
" + "FINE ROI · density=0 contour · scalar field · step=%d
\n"), + *VF_HtmlEscape(Candidate.FinePlanContourImageFile), Candidate.CandidateIndex, + Candidate.FineWindow.SampleStep); + } + else + { + Html += FString::Printf( + TEXT("
No fine contour: %s
\n"), + *VF_HtmlEscape(Candidate.FineRenderFailureReason)); + } + } Html += TEXT("\n\n"); Html += FString::Printf( @@ -703,6 +1238,25 @@ bool VF_WriteStratePreviewCandidate( return false; } +bool VF_WriteStratePreviewFineCandidate( + const FString& OutputDirectory, + int32 CandidateIndex, + const FVoxelStrateSampleGrid& FineGrid, + int32 HeadroomCells, + const FString& FineFailureReason, + FVoxelStratePreviewCandidate& InOutCandidate, + FString& OutError) +{ + (void)OutputDirectory; + (void)CandidateIndex; + (void)FineGrid; + (void)HeadroomCells; + (void)FineFailureReason; + (void)InOutCandidate; + OutError = TEXT("strate preview rendering is editor-only"); + return false; +} + bool VF_WriteStratePreviewIndex( const FString& OutputDirectory, const FString& RunTitle, diff --git a/Source/VoxelForge/Public/VoxelStrateMeasure.h b/Source/VoxelForge/Public/VoxelStrateMeasure.h index 77b9e49..f672bbb 100644 --- a/Source/VoxelForge/Public/VoxelStrateMeasure.h +++ b/Source/VoxelForge/Public/VoxelStrateMeasure.h @@ -45,10 +45,12 @@ struct VOXELFORGE_API FVoxelStrateMeasureSettings }; /** - * Optional capture of the exact coarse grid used by one measurement. + * Optional capture of the exact grid used by one measurement. * * Air is deliberately stored as a polarity bit: 1 means density > 0 (air), 0 means solid. - * The capture is opt-in so ordinary metrics callers do not retain an 8-million-cell buffer. + * Density retains the scalar sample that produced that bit, so an editor preview can draw a + * density=0 contour without inventing geometry by blurring the binary view. Both arrays are + * opt-in so ordinary metrics callers do not retain a multi-million-cell buffer. * It is an editor/automation hand-off for tools such as the composer preview, not a runtime * generation cache. */ @@ -75,6 +77,10 @@ struct VOXELFORGE_API FVoxelStrateSampleGrid // 1 = air (density > 0), 0 = solid (density <= 0), matching the measurement polarity. TArray Air; + // Exact scalar density at the same cell centre as Air. Present on measurement captures; + // empty on legacy/binary-only callers. This is never used by generation. + TArray Density; + FORCEINLINE int32 Index(int32 X, int32 Y, int32 Z) const { return static_cast( @@ -91,6 +97,11 @@ struct VOXELFORGE_API FVoxelStrateSampleGrid * static_cast(NumZ) && Air.Num() == CellCount; } + + bool HasScalarDensity() const + { + return IsValid() && Density.Num() == CellCount; + } }; /** Metrics derived from one coarse grid and its single air flood fill. */ diff --git a/Source/VoxelForge/Public/VoxelStratePreview.h b/Source/VoxelForge/Public/VoxelStratePreview.h index 2e0ccc0..35ae823 100644 --- a/Source/VoxelForge/Public/VoxelStratePreview.h +++ b/Source/VoxelForge/Public/VoxelStratePreview.h @@ -33,6 +33,39 @@ struct VOXELFORGE_API FVoxelStratePreviewWindow FString Describe() const; }; +/** Parameters for the optional selected-candidate fine pass. */ +struct VOXELFORGE_API FVoxelStrateFinePreviewSettings +{ + // The default is deliberately one voxel: the pass exists to expose detail that step 4 cannot. + int32 SampleStep = 1; + // radius=64 gives a 128x128 XY ROI and stays below the default two-million-cell cap for the + // four-chunk fixture while retaining the same origin-centred frame as the coarse sweep. + int32 RadiusInVoxels = 64; + int32 MaxCells = 2000000; + FVector2D CenterXY = FVector2D::ZeroVector; + int32 InteriorMarginVoxels = -1; + + bool IsValid() const + { + return SampleStep > 0 && RadiusInVoxels > 0 && MaxCells > 0 + && FMath::IsFinite(CenterXY.X) && FMath::IsFinite(CenterXY.Y); + } + + FVoxelStrateMeasureSettings MakeMeasureSettings( + const FVoxelStrateMeasureSettings& BaseSettings) const + { + FVoxelStrateMeasureSettings Settings = BaseSettings; + Settings.SampleStep = SampleStep; + Settings.RadiusInVoxels = RadiusInVoxels; + Settings.CenterXY = CenterXY; + Settings.CoverPointA.Reset(); + Settings.CoverPointB.Reset(); + Settings.MaxCells = MaxCells; + Settings.InteriorMarginVoxels = InteriorMarginVoxels; + return Settings; + } +}; + /** One lightweight row/card record; sampled cells are rendered immediately and are not retained. */ struct VOXELFORGE_API FVoxelStratePreviewCandidate { @@ -43,11 +76,26 @@ struct VOXELFORGE_API FVoxelStratePreviewCandidate FString RejectionReason; FString RenderFailureReason; FString VerticalImageFile; + FString VerticalContourImageFile; FString PlanImageFile; + FString PlanContourImageFile; + + // Fine files are intentionally separate from the coarse names, even when the ROI happens to + // have the same raster dimensions. The exact fine window is repeated on the card. + FString FineRenderFailureReason; + FString FineVerticalImageFile; + FString FineVerticalContourImageFile; + FString FinePlanImageFile; + FString FinePlanContourImageFile; + FVoxelStratePreviewWindow FineWindow; bool bRejected = false; bool bMetricsValid = false; bool bRendered = false; + bool bContourRendered = false; + bool bFineRequested = false; + bool bFineRendered = false; + bool bFineContourRendered = false; float AirFraction = 0.0f; float LargestComponentShare = 0.0f; @@ -66,6 +114,15 @@ struct VOXELFORGE_API FVoxelStratePreviewCandidate int32 PlanImageHeight = 0; int32 PlanSliceBoundaryTransitions = 0; int32 PlanSliceMixedCells = 0; + + int32 FineVerticalSliceCellY = INDEX_NONE; + int32 FineVerticalSliceWorldY = 0; + int32 FineVerticalImageWidth = 0; + int32 FineVerticalImageHeight = 0; + int32 FinePlanSliceCellZ = INDEX_NONE; + int32 FinePlanSliceWorldZ = 0; + int32 FinePlanImageWidth = 0; + int32 FinePlanImageHeight = 0; }; /** Copy the exact measurement-window metadata needed by the index page. */ @@ -93,6 +150,23 @@ VOXELFORGE_API bool VF_WriteStratePreviewCandidate( FVoxelStratePreviewCandidate& OutCandidate, FString& OutError); +/** + * Attach a separately measured fine ROI to an existing candidate record. + * + * The caller chooses which candidates receive this call. An invalid grid is recorded as a + * refused/not-rendered fine preview and returns true, so a MaxCells refusal remains visible in + * the contact sheet without being mistaken for a PNG failure. The renderer consumes the exact + * captured Air and Density arrays and never calls a sampler. + */ +VOXELFORGE_API bool VF_WriteStratePreviewFineCandidate( + const FString& OutputDirectory, + int32 CandidateIndex, + const FVoxelStrateSampleGrid& FineGrid, + int32 HeadroomCells, + const FString& FineFailureReason, + FVoxelStratePreviewCandidate& InOutCandidate, + FString& OutError); + /** Write one self-contained, offline contact sheet for all candidate records. */ VOXELFORGE_API bool VF_WriteStratePreviewIndex( const FString& OutputDirectory,
Grid measurements use the window above; " "centroid distance is roll-space