diff --git a/CODEMAP.md b/CODEMAP.md index e66ba3e..49c32b6 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -319,6 +319,8 @@ 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. `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 @@ -334,6 +336,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. | | `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. | @@ -453,7 +456,8 @@ remain excluded. Live fields receive one matching `UVoxelTerrainOpDefinition` he in addition to authored strate values, so an all-default strate corpus still has useful activation spread. Rolls are pure in `(corpus contents, seed, index)`: weighted 2–3-parent same-archetype selection → `FStrateGenerationParams::Lerp` or reflected native-family blend → ±15% of measured -field range jitter → editor-reflection clamps → ordered-pair repair. Bool +field range jitter (or the season-zero ±25% own-magnitude bootstrap for near-zero ranges) → +editor-reflection clamps → ordered-pair repair. Bool `bTunnelsFlowTowardOrigin` inherits from the dominant parent; integer/enum SNAP fields are not jittered. This API is not called by runtime generation. The settings audit found **one unique path**, `/Game/VoxelForge/DA_Strate3`, which explains the old one-vector corpus; the project assets were @@ -468,6 +472,13 @@ it. `RequiredResources` / `ProvidedResources` close the room-state hole that cha could not express. `VoxelForgeComposerStructureRollTest` rolls 64 candidates, measures them through the offline sampler, and brute-forces every uniform box verdict from the novel stack itself. +**`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. + ### 3.9 Player edits — `Public/VoxelDiffLayer.h` + `.cpp` `UVoxelDiffLayer : UObject` (h:77). Stores `FVoxelModification` (h:43: Center/Radius/Strength; **negative Strength = carve, positive = fill**) grouped by chunk in `TMap ChunkMods`. @@ -531,9 +542,9 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor | `VoxelForgeOpStackIslandTest.cpp` | `VoxelForge.OpStack.FloatingIslandEquivalence` | The port that runs the stack **backwards** — void + fill vs rock + carve, same classes with the opposite sign. Counts interior-solid and open-void samples separately (on this archetype an aggregate "N solid" is dominated by the seal bands and says nothing about the islands). Counts `AllSolid` and `AllAir` verdicts **separately** too: `AllAir` is the one no cave archetype could ever prove, and it is the entire perf argument here. | | `VoxelForgeOpStackMazeTest.cpp` | `VoxelForge.OpStack.MazeEquivalence` | **Phase 1's load-bearing test.** The 7-op Maze stack vs `GetMazeDensity` over 20k points (aiming for bit-identity; a side-of-iso disagreement is the hard fail), plus purity across workers and brute force on every box verdict the stack emits. Reports how many tiles the stack can prove uniform — today's `ClassifyTile` proves **zero** for any cave archetype. | | `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. Current detail-enabled run: **59/64 survival (92.2%)**, **1,332 Mixed + 553 AllSolid + 675 AllAir = 1,228 proved boxes**, **1,634,468 lattice voxels checked, 0 violations**, 113.317 s total. Feature scale **0→376**; walkable **0.000000→0.388506**; all 17 TunnelNetwork/Underwater candidates carried live detail activation. | +| `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, checks exact arrival→departure connectivity, rerolls every recipe/stack for determinism, and brute-forces every uniform box verdict. Focused run: **46/64 survival (71.9%)**, **64 distinct recipes**, **RoomGraph 15 / Lattice 19 / Shaft 8 / Island 14 / Noise 8**, **k=4:11 / 5:13 / 6:11 / 7:10 / 8:19**, **489 proved boxes / 650,859 voxels / 0 violations**. | +| `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**. | | `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 0f90985..a90d5f2 100644 --- a/COMPOSER-NOTES.md +++ b/COMPOSER-NOTES.md @@ -424,6 +424,29 @@ while the candidate sweep retained step 4. The test asserts zero violations and to future changed candidates rather than treating this clean run as a license to tune the survival rate. +### Season-zero bootstrap follow-up (2026-09-04) + +The homogeneous-field trap is now handled explicitly. A field whose measured corpus range is at +or below `1e-6` takes a **bootstrap** jitter of ±25% of its own magnitude, with a one-native-unit +floor for values near zero; fields with useful spread still take the real ±15% of `max-min` rule. +The safety clamps remain a final guard and never become roll ranges. `FVoxelStrateRollInfo` reports +both the number of bootstrap applications and the distinct `archetype:field` names. The comment in +the implementation makes the lifecycle explicit: this is a bootstrap for a starved season-zero +corpus, and Tier 5 promotion should retire it as the corpus grows. + +The follow-up 64-roll measurement applied the bootstrap **1,422 times across 168 distinct fields**. +It did **not** broaden the measured distributions beyond the previous run: feature scale stayed +**0→376 voxels** and walkable fraction was **0.000000→0.286766**, below the previous **0.388506** +maximum. The bootstrap is therefore active, but these two measurements are insensitive to the +changed fields in this sample; this is more useful than pretending the new knob created visible +world variety. The run had **38/64 survivors**, 60 non-vacuous candidates, 55 meeting the largest +component threshold, and 42 exact law passes; those counts are observations, not tuning targets. + +The wider structure-roll jitter remained sound: **659 boxes proved**, **877,129 voxels checked**, +and **0 violations** (1,901 Mixed, 266 AllSolid, 393 AllAir). The parameter-roll box check likewise +proved **1,204 boxes**, checked **1,602,524 voxels**, and found **0 violations** (1,356 Mixed, 608 +AllSolid, 596 AllAir). A violation would reopen §6.2; this run did not. + ### 3.4 The measurement pass — one grid, one flood fill, three jobs Sample a candidate strate into a coarse voxel grid, flood-fill the air **once**, derive everything from @@ -456,8 +479,37 @@ one path at full resolution** — cheap, because only one corridor is verified. describes a place nobody will stand in. Report **distributions (median + spread), not single values**. **This is also F1.** `fable-idea.md`'s top-pick 2D world-preview tool is this pass with a visualisation -on top. Build the measurement, get the preview nearly free — and it is needed anyway to review a season -before publishing. +on top. The measurement hand-off and deterministic preview are now built; they are needed anyway to +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: + +`Saved/ComposerPreview/structure_seed_0_corpus_9dbcea85_step_4_radius_256/` + +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`. + +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. ### 3.5 Validation in three layers, and how "good" is ever judged @@ -739,14 +791,14 @@ sealed pocket. Worth doing even if the composer never happens. **It was real**: ### Tier 2 — the measurement pass (§3.4) — ✅ BUILT (2026-09-03), see §13 `VoxelStrateMeasure.h/.cpp` + `VoxelForge.Generation.StrateConnectivity` / `...Refinement`. *Standalone value:* it **is** F1, `fable-idea.md`'s top-pick world-preview tool — the measurement -half. The visualisation on top is not built yet. +half plus the deterministic editor/automation visualisation described in §3.4. **It paid for itself immediately**: it found the `VerticalShafts` primordial-law violation (§14), which no amount of playing was likely to surface, and it caught three of its own artifacts before they became "findings" (§13). -⬜ Remaining: the *preview* (F1's visualisation), and the danger/rarity metric families from §3.4 -(fall exposure, openness, traversal mix, tortuosity, corpus-centroid distance). +⬜ Remaining: the danger/rarity metric families from §3.4 (fall exposure, openness, traversal mix, +tortuosity, corpus-centroid distance). ### Tier 3 — op-system prerequisites for free composition - ✅ **Channel read/write declarations** on every op, plus the stack DAG validator (§3.2; built @@ -762,11 +814,12 @@ roughness and should stay late in the stack. ### Tier 4 — the composer proper - ✅ **Parameter roll (§3.3, Tier 4a)** — corpus spread + deterministic blend/jitter implementation - and the 64-candidate measurement pass are built; the first run exposed a one-vector settings corpus - and a vacuous cave box-verdict scan, both recorded above. + and the 64-candidate measurement pass are built; the bootstrap follow-up is 38/64 survival with + 0 box-verdict violations (the first-run corpus/vacuity findings remain recorded above). - ✅ **Structure roll (§3.2, Tier 4b)** — serialisable recipes, root polarity, declaration-derived resource-safe modifier legality, mandatory posts, 64-candidate measurement, and direct box-verdict - brute force are built; focused result is 46/64 survival with 0 invalid recipes and 0 violations. + brute force are built; the bootstrap follow-up is 42/64 survival with 0 invalid recipes and 0 + violations, plus 64 rendered preview pairs and an index.html contact sheet. - ⬜ Reject-and-resample driven by Tier 2 (§3.5) · the **offline season pipeline** with Jahni's review and veto (§3.1). diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeComposerParameterRollTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeComposerParameterRollTest.cpp index 4653807..b599132 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeComposerParameterRollTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeComposerParameterRollTest.cpp @@ -19,6 +19,12 @@ namespace constexpr int32 NumCandidates = 64; constexpr float LargestComponentSurvivalThreshold = 0.50f; constexpr int32 BoxChecksPerCandidate = 40; + // Baseline reported by the pre-bootstrap 64-candidate run. Kept as a comparison point in the + // automation output; it is not a pass/fail threshold. + constexpr float PreviousFeatureScaleMin = 0.0f; + constexpr float PreviousFeatureScaleMax = 376.0f; + constexpr float PreviousWalkableMin = 0.0f; + constexpr float PreviousWalkableMax = 0.388506f; const TCHAR* VF_FieldKindName(EVoxelStrateFieldKind Kind) { @@ -704,7 +710,8 @@ bool FVoxelForgeComposerParameterRollTest::RunTest(const FString& Parameters) static_cast(UE_ARRAY_COUNT(DeadTerrainDetailFields)), 11); FString SpreadTable = TEXT( - "CORPUS SPREAD (population stddev; jitter range = ±15% of max-min; clamps are safety only)\n" + "CORPUS SPREAD (population stddev; spread jitter = ±15% of max-min; near-zero spread " + "uses ±25% of field magnitude with a one-unit floor; clamps are safety only)\n" "archetype | struct | field | kind | excluded | reflected | authored | op-defaults | samples | min | max | mean | stddev | clamp\n"); for (const FVoxelStrateFieldSpread& Spread : Corpus.GetFieldSpreads()) { @@ -799,6 +806,12 @@ bool FVoxelForgeComposerParameterRollTest::RunTest(const FString& Parameters) int32 LiveActivationVariationCounts[9] = {}; TArray BoxViolationCandidates; FString FirstBoxViolation; + int32 TotalBootstrapJitterFields = 0; + TSet BootstrapJitterFieldNames; + float FeatureScaleMin = FLT_MAX; + float FeatureScaleMax = -FLT_MAX; + float WalkableMin = FLT_MAX; + float WalkableMax = -FLT_MAX; const double RollAndMeasureStartSeconds = FPlatformTime::Seconds(); for (int32 CandidateIndex = 0; CandidateIndex < NumCandidates; ++CandidateIndex) @@ -807,6 +820,12 @@ bool FVoxelForgeComposerParameterRollTest::RunTest(const FString& Parameters) Corpus, AuthoredSettings->Seed, CandidateIndex); TestTrue(FString::Printf(TEXT("candidate %d roll is valid"), CandidateIndex), Roll.bValid); + TotalBootstrapJitterFields += Roll.BootstrapJitterFieldCount; + for (const FString& FieldName : Roll.BootstrapJitterFieldNames) + { + BootstrapJitterFieldNames.Add(FieldName); + } + FString ParentText = VF_FormatParents(Corpus, Roll); FString AirText = TEXT("invalid"); FString LargestText = TEXT("invalid"); @@ -879,7 +898,9 @@ bool FVoxelForgeComposerParameterRollTest::RunTest(const FString& Parameters) && VF_AreStrateArchetypeParamsBitIdentical( RepeatRoll.ArchetypeParams, Roll.ArchetypeParams, Roll.Archetype) && RepeatRoll.ParentEntryIndices == Roll.ParentEntryIndices - && RepeatRoll.ParentWeights == Roll.ParentWeights); + && RepeatRoll.ParentWeights == Roll.ParentWeights + && RepeatRoll.BootstrapJitterFieldCount == Roll.BootstrapJitterFieldCount + && RepeatRoll.BootstrapJitterFieldNames == Roll.BootstrapJitterFieldNames); // Put the invented vector into a transient fixture definition. This is an offline test // world only; the runtime manager/generation code and authored assets are untouched. @@ -957,6 +978,10 @@ bool FVoxelForgeComposerParameterRollTest::RunTest(const FString& Parameters) LargestText = FString::Printf(TEXT("%.6f"), Metrics.LargestComponentShare); WalkableText = FString::Printf(TEXT("%.6f"), Metrics.WalkableFraction); FeatureText = FString::Printf(TEXT("%.6f"), Metrics.MedianFeatureScale); + FeatureScaleMin = FMath::Min(FeatureScaleMin, Metrics.MedianFeatureScale); + FeatureScaleMax = FMath::Max(FeatureScaleMax, Metrics.MedianFeatureScale); + WalkableMin = FMath::Min(WalkableMin, Metrics.WalkableFraction); + WalkableMax = FMath::Max(WalkableMax, Metrics.WalkableFraction); } const FBoxVerdictReport BoxReport = VF_CheckRolledBoxVerdicts( @@ -996,6 +1021,31 @@ bool FVoxelForgeComposerParameterRollTest::RunTest(const FString& Parameters) const double RollAndMeasureSeconds = FPlatformTime::Seconds() - RollAndMeasureStartSeconds; AddInfo(CandidateTable); + const bool bHaveFeatureDistribution = FeatureScaleMin != FLT_MAX; + const bool bHaveWalkableDistribution = WalkableMin != FLT_MAX; + const bool bFeatureBroadened = bHaveFeatureDistribution + && (FeatureScaleMin < PreviousFeatureScaleMin + || FeatureScaleMax > PreviousFeatureScaleMax); + const bool bWalkableBroadened = bHaveWalkableDistribution + && (WalkableMin < PreviousWalkableMin + || WalkableMax > PreviousWalkableMax); + AddInfo(FString::Printf( + TEXT("Bootstrap jitter: %d field applications across 64 rolls; %d distinct " + "archetype fields took the bootstrap path. Spread-derived jitter remains ±15%%; " + "bootstrap is ±25%% of own magnitude with a one-unit near-zero floor."), + TotalBootstrapJitterFields, BootstrapJitterFieldNames.Num())); + AddInfo(FString::Printf( + TEXT("Measured distribution after bootstrap: feature scale %.6f..%.6f voxels " + "(previous %.6f..%.6f; broadened=%s); walkable %.6f..%.6f " + "(previous %.6f..%.6f; broadened=%s)."), + FeatureScaleMin, FeatureScaleMax, + PreviousFeatureScaleMin, PreviousFeatureScaleMax, + bFeatureBroadened ? TEXT("yes") : TEXT("no"), + WalkableMin, WalkableMax, + PreviousWalkableMin, PreviousWalkableMax, + bWalkableBroadened ? TEXT("yes") : TEXT("no"))); + TestTrue(TEXT("zero-spread bootstrap path is exercised by the 64-roll corpus"), + TotalBootstrapJitterFields > 0 && BootstrapJitterFieldNames.Num() > 0); AddInfo(FString::Printf( TEXT("Survival: %d/%d (%.1f%%). Criteria: non-vacuous (valid, sampled, air>0, solid>0), " "largest component share >= %.2f, and exact unsnapped arrival->departure law " diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeComposerStructureRollTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeComposerStructureRollTest.cpp index 6fc0927..1f271d9 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeComposerStructureRollTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeComposerStructureRollTest.cpp @@ -11,6 +11,8 @@ #include "VoxelSettings.h" #include "VoxelStrateComposer.h" #include "VoxelStrateMeasure.h" +#include "VoxelStratePreview.h" +#include "Misc/Paths.h" namespace { @@ -321,6 +323,18 @@ bool FVoxelForgeComposerStructureRollTest::RunTest(const FString& Parameters) int64 TotalBoxCheckedVoxels = 0; int32 TotalBoxViolations = 0; FString FirstBoxViolation; + int32 TotalBootstrapJitterFields = 0; + TSet BootstrapJitterFieldNames; + TArray PreviewCandidates; + PreviewCandidates.Reserve(NumCandidates); + FVoxelStratePreviewWindow PreviewWindow; + bool bPreviewWindowSet = false; + const FString PreviewRunId = FString::Printf( + TEXT("structure_seed_%d_corpus_%08x_step_%d_radius_%d"), + AuthoredSettings->Seed, Corpus.GetContentsHash(), + MeasureSettings.SampleStep, MeasureSettings.RadiusInVoxels); + const FString PreviewDirectory = FPaths::ProjectSavedDir() + / TEXT("ComposerPreview") / PreviewRunId; TArray ShapeCounts; ShapeCounts.Init(0, 5); int32 KCounts[9] = { 0 }; @@ -366,6 +380,7 @@ bool FVoxelForgeComposerStructureRollTest::RunTest(const FString& Parameters) FVoxelStrateArchetypeParams CandidateParams; bool bParameterRollsValid = true; + double CandidateDistanceSquared = 0.0; for (const FBlockSpec& Spec : GBlockSpecs) { const FVoxelStrateRollInfo BlockRoll = VF_RollStrateParamsDetailedForArchetype( @@ -380,6 +395,14 @@ bool FVoxelForgeComposerStructureRollTest::RunTest(const FString& Parameters) *BlockRoll.FailureReason)); continue; } + TotalBootstrapJitterFields += BlockRoll.BootstrapJitterFieldCount; + for (const FString& FieldName : BlockRoll.BootstrapJitterFieldNames) + { + BootstrapJitterFieldNames.Add(FieldName); + } + const double BlockDistance = VF_DistanceFromStrateCorpusCentroid( + Corpus, BlockRoll.ArchetypeParams, Spec.Archetype); + CandidateDistanceSquared += BlockDistance * BlockDistance; VF_CopyRollBlock(CandidateParams, BlockRoll, Spec.Archetype); } VF_SetAllRuntimeBounds(CandidateParams, TopWorldZ, BottomWorldZ); @@ -417,9 +440,10 @@ bool FVoxelForgeComposerStructureRollTest::RunTest(const FString& Parameters) Stack.PrepareChunk(Context); FStackDensitySampler Sampler(Stack); + FVoxelStrateSampleGrid SampleGrid; const FVoxelStrateMetrics Metrics = VF_MeasureStrateWithSampler( Sampler, BottomVoxelZ, TopVoxelZ + 1, - Context.EdgeSealThickness, MeasureSettings); + Context.EdgeSealThickness, MeasureSettings, &SampleGrid); FVoxelConnectivityDiagnostics Law; if (ArrivalCount == 1 && DepartureCount == 1) @@ -455,6 +479,27 @@ bool FVoxelForgeComposerStructureRollTest::RunTest(const FString& Parameters) FeatureText = FString::Printf(TEXT("%.6f"), Metrics.MedianFeatureScale); } + FString RejectionReason; + if (!bNonVacuous) + { + RejectionReason = TEXT("vacuous"); + } + else + { + if (!bLargestEnough) + { + RejectionReason = TEXT("fragmented"); + } + if (!bLawPass) + { + if (!RejectionReason.IsEmpty()) + { + RejectionReason += TEXT("; "); + } + RejectionReason += TEXT("law failed"); + } + } + const FBoxVerdictReport BoxReport = VF_CheckStackBoxVerdicts( Stack, Context, CandidateIndex, BottomVoxelZ, TopVoxelZ); TotalBoxMixed += BoxReport.Mixed; @@ -468,6 +513,50 @@ bool FVoxelForgeComposerStructureRollTest::RunTest(const FString& Parameters) FirstBoxViolation = BoxReport.FirstViolation; } + const double CandidateDistance = FMath::Sqrt( + FMath::Max(0.0, CandidateDistanceSquared)); + const FVoxelStratePreviewWindow CandidateWindow = + VF_GetStratePreviewWindow(SampleGrid); + if (CandidateWindow.IsValid()) + { + if (!bPreviewWindowSet) + { + PreviewWindow = CandidateWindow; + bPreviewWindowSet = true; + } + } + TestTrue(FString::Printf(TEXT("candidate %d preview captures its measurement grid"), + CandidateIndex), + !Metrics.bValid || SampleGrid.IsValid()); + + FVoxelStratePreviewCandidate PreviewCandidate; + FString PreviewError; + const bool bPreviewWritten = VF_WriteStratePreviewCandidate( + PreviewDirectory, + CandidateIndex, + VF_FormatStrateStructureRecipe(Recipe), + SampleGrid, + Metrics, + MeasureSettings.HeadroomCells, + CandidateDistance, + LawText, + !(bNonVacuous && bLargestEnough && bLawPass), + RejectionReason, + PreviewCandidate, + PreviewError); + 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); + if (!bPreviewWritten) + { + AddError(FString::Printf(TEXT("candidate %d preview failed: %s"), + CandidateIndex, *PreviewError)); + } + PreviewCandidates.Add(MoveTemp(PreviewCandidate)); + // Same recipe + same block vectors + same seed must yield the same world values. This is // checked on a fixed point array for every candidate, including cache-backed sources. FVoxelOpStack RepeatStack; @@ -548,6 +637,51 @@ bool FVoxelForgeComposerStructureRollTest::RunTest(const FString& Parameters) TotalBoxMixed, TotalBoxAllSolid, TotalBoxAllAir, TotalBoxProved, TotalBoxCheckedVoxels, TotalBoxViolations, FirstBoxViolation.IsEmpty() ? TEXT("No violations.") : *FirstBoxViolation)); + FString PreviewIndexPath; + FString PreviewError; + const bool bPreviewIndexWritten = bPreviewWindowSet + && PreviewCandidates.Num() == NumCandidates + && VF_WriteStratePreviewIndex( + PreviewDirectory, + FString::Printf(TEXT("VoxelForge composer structure preview — seed %d"), + AuthoredSettings->Seed), + PreviewWindow, + PreviewCandidates, + PreviewIndexPath, + PreviewError); + TestTrue(TEXT("64-candidate composer preview index is written"), bPreviewIndexWritten); + if (!bPreviewIndexWritten) + { + AddError(FString::Printf(TEXT("composer preview index failed: %s"), *PreviewError)); + } + if (bPreviewWindowSet && PreviewCandidates.Num() > 0) + { + const FVoxelStratePreviewCandidate& FirstPreview = PreviewCandidates[0]; + int32 NumRenderedPairs = 0; + for (const FVoxelStratePreviewCandidate& Candidate : PreviewCandidates) + { + if (Candidate.bRendered) + { + ++NumRenderedPairs; + } + } + AddInfo(FString::Printf( + TEXT("Composer preview: %d candidates, %d rendered 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."), + PreviewCandidates.Num(), + NumRenderedPairs, + FirstPreview.VerticalImageWidth, FirstPreview.VerticalImageHeight, + FirstPreview.PlanImageWidth, FirstPreview.PlanImageHeight, + FirstPreview.PlanSliceWorldZ, + *PreviewIndexPath, + *PreviewWindow.Describe())); + } + AddInfo(FString::Printf( + TEXT("Bootstrap jitter: %d field applications across the six structure blocks; %d " + "distinct archetype fields took the bootstrap path."), + TotalBootstrapJitterFields, BootstrapJitterFieldNames.Num())); TestEqual(TEXT("exactly 64 structure candidates were requested"), NumCandidates, 64); TestEqual(TEXT("invalid recipes emitted by the roller/build path"), NumInvalidRecipes, 0); diff --git a/Source/VoxelForge/Private/VoxelStrateComposer.cpp b/Source/VoxelForge/Private/VoxelStrateComposer.cpp index 3862f54..19fef09 100644 --- a/Source/VoxelForge/Private/VoxelStrateComposer.cpp +++ b/Source/VoxelForge/Private/VoxelStrateComposer.cpp @@ -22,6 +22,9 @@ namespace { constexpr float GJitterFraction = 0.15f; + constexpr float GBootstrapJitterFraction = 0.25f; + constexpr double GNearZeroSpread = 1.0e-6; + constexpr double GBootstrapMagnitudeFloor = 1.0; static const ECaveGeneratorType GAllArchetypes[] = { @@ -861,10 +864,41 @@ namespace } } + void VF_JitterMeasuredField(double& Value, + const FVoxelStrateFieldSpread& Spread, + ECaveGeneratorType Archetype, + FRandomStream& Rng, + int32& OutBootstrapFieldCount, + TArray& OutBootstrapFieldNames) + { + const double CorpusRange = FMath::Max(0.0, Spread.Max - Spread.Min); + const bool bUseBootstrap = CorpusRange <= GNearZeroSpread; + + // Spread-derived jitter is the real rule. This bootstrap is only for a starved corpus: + // when season zero has supplied no useful measured range, use a fraction of this field's + // own magnitude, with a one-native-unit floor near zero. Promotion (Tier 5) should retire + // this fallback as the corpus grows. + const double JitterScale = bUseBootstrap + ? FMath::Max(FMath::Abs(Value), GBootstrapMagnitudeFloor) + : CorpusRange; + const float JitterFraction = bUseBootstrap + ? GBootstrapJitterFraction : GJitterFraction; + Value += static_cast((Rng.FRand() * 2.0f - 1.0f) * JitterFraction) + * JitterScale; + + if (bUseBootstrap) + { + ++OutBootstrapFieldCount; + OutBootstrapFieldNames.AddUnique(VF_SpreadKey(Archetype, Spread.FieldName)); + } + } + void VF_JitterNativeParams(FVoxelStrateArchetypeParams& Params, ECaveGeneratorType Archetype, const FVoxelStrateCorpus& Corpus, - FRandomStream& Rng) + FRandomStream& Rng, + int32& OutBootstrapFieldCount, + TArray& OutBootstrapFieldNames) { void* Memory = VF_GetParamMemory(Params, Archetype); UStruct* Struct = VF_GetParamStruct(Archetype); @@ -889,17 +923,11 @@ namespace continue; } - const double CorpusRange = FMath::Max(0.0, Spread.Max - Spread.Min); - if (!(CorpusRange > 0.0)) - { - continue; - } - double Value = 0.0; if (VF_ReadNamedField(Memory, Archetype, Spread.FieldName, Value)) { - Value += static_cast((Rng.FRand() * 2.0f - 1.0f) * GJitterFraction) - * CorpusRange; + VF_JitterMeasuredField(Value, Spread, Archetype, Rng, + OutBootstrapFieldCount, OutBootstrapFieldNames); VF_WriteNamedField(Memory, Archetype, Spread.FieldName, Value); } } @@ -920,17 +948,11 @@ namespace continue; } - const double CorpusRange = FMath::Max(0.0, Spread->Max - Spread->Min); - if (!(CorpusRange > 0.0)) - { - continue; - } - double Value = 0.0; if (VF_ReadPropertyValue(Pair.Value, Memory, Value)) { - Value += static_cast((Rng.FRand() * 2.0f - 1.0f) * GJitterFraction) - * CorpusRange; + VF_JitterMeasuredField(Value, *Spread, Archetype, Rng, + OutBootstrapFieldCount, OutBootstrapFieldNames); VF_WritePropertyValue(Pair.Value, Memory, Value); } } @@ -1247,7 +1269,9 @@ namespace VF_CopyDominantBooleans(Result.ArchetypeParams, Entries[DominantParent].ArchetypeParams, Result.Archetype); - VF_JitterNativeParams(Result.ArchetypeParams, Result.Archetype, Corpus, Rng); + VF_JitterNativeParams(Result.ArchetypeParams, Result.Archetype, Corpus, Rng, + Result.BootstrapJitterFieldCount, + Result.BootstrapJitterFieldNames); VF_ClampNativeParams(Result.ArchetypeParams, Result.Archetype, Corpus); VF_RepairNativeOrderedPairs(Result.ArchetypeParams, Result.Archetype); VF_ResetExcludedFields(Result.ArchetypeParams, Result.Archetype); @@ -2089,6 +2113,47 @@ bool VF_AreStrateArchetypeParamsBitIdentical( && FMemory::Memcmp(&ABottom, &BBottom, sizeof(ABottom)) == 0; } +double VF_DistanceFromStrateCorpusCentroid( + const FVoxelStrateCorpus& Corpus, + const FVoxelStrateArchetypeParams& Params, + ECaveGeneratorType Archetype) +{ + const void* Memory = VF_GetParamMemory(Params, Archetype); + if (Memory == nullptr) + { + return 0.0; + } + + double DistanceSquared = 0.0; + for (const FVoxelStrateFieldSpread& Spread : Corpus.GetFieldSpreads()) + { + if (Spread.Archetype != Archetype || Spread.bExcluded + || Spread.Kind == EVoxelStrateFieldKind::Boolean + || Spread.Kind == EVoxelStrateFieldKind::Enum) + { + continue; + } + + double Value = 0.0; + if (!VF_ReadNamedField(Memory, Archetype, Spread.FieldName, Value)) + { + continue; + } + + const double CorpusRange = FMath::Max(0.0, Spread.Max - Spread.Min); + const double Scale = CorpusRange > GNearZeroSpread + ? CorpusRange + : FMath::Max(FMath::Abs(Spread.Mean) * GBootstrapJitterFraction, + GBootstrapMagnitudeFloor); + if (FMath::IsFinite(Value) && FMath::IsFinite(Spread.Mean) && Scale > 0.0) + { + const double Normalized = (Value - Spread.Mean) / Scale; + DistanceSquared += Normalized * Normalized; + } + } + return FMath::Sqrt(FMath::Max(0.0, DistanceSquared)); +} + FVoxelStrateRollInfo VF_RollStrateParamsDetailed(const FVoxelStrateCorpus& Corpus, int32 Seed, int32 Index) { diff --git a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp index 98526bb..124ff14 100644 --- a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp +++ b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp @@ -387,6 +387,29 @@ namespace VoxelStrateMeasurePrivate return true; } + void ExportSampleGrid(FSampleGrid& Source, FVoxelStrateSampleGrid& Destination) + { + Destination = FVoxelStrateSampleGrid(); + Destination.bValid = true; + Destination.NumX = Source.NumX; + Destination.NumY = Source.NumY; + Destination.NumZ = Source.NumZ; + Destination.SampleStep = Source.SampleStep; + Destination.CellCount = Source.CellCount; + Destination.MinX = Source.MinX; + Destination.MinY = Source.MinY; + Destination.MinZ = Source.MinZ; + Destination.MaxX = Source.MaxX; + Destination.MaxY = Source.MaxY; + Destination.MaxZ = Source.MaxZ; + Destination.ResolvedMarginVoxels = Source.ResolvedMarginVoxels; + Destination.SampledMinZ = Source.SampledMinZ; + Destination.SampledMaxZ = Source.SampledMaxZ; + // 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); + } + void DecodeIndex(const FSampleGrid& Grid, int32 Index, int32& OutX, int32& OutY, int32& OutZ) { const int32 Plane = Grid.NumX * Grid.NumY; @@ -1257,9 +1280,14 @@ FVoxelStrateMetrics VF_MeasureStrate( const UVoxelGenerator& Generator, const UVoxelStrateManager& Manager, int32 StrateIndex, - const FVoxelStrateMeasureSettings& Settings) + const FVoxelStrateMeasureSettings& Settings, + FVoxelStrateSampleGrid* OutSampleGrid) { FVoxelStrateMetrics Result; + if (OutSampleGrid != nullptr) + { + *OutSampleGrid = FVoxelStrateSampleGrid(); + } VoxelStrateMeasurePrivate::FSampleGrid Grid; if (!VoxelStrateMeasurePrivate::BuildSampleGrid( &Generator, &Manager, nullptr, StrateIndex, 0, 0, 0.0f, false, @@ -1284,6 +1312,10 @@ FVoxelStrateMetrics VF_MeasureStrate( { Result.RefusalReason = TEXT("The measurement grid was empty."); } + if (OutSampleGrid != nullptr && Result.bValid) + { + VoxelStrateMeasurePrivate::ExportSampleGrid(Grid, *OutSampleGrid); + } return Result; } @@ -1292,9 +1324,14 @@ FVoxelStrateMetrics VF_MeasureStrateWithSampler( int32 StrateBottomWorldZ, int32 StrateTopWorldZ, float BoundarySealThickness, - const FVoxelStrateMeasureSettings& Settings) + const FVoxelStrateMeasureSettings& Settings, + FVoxelStrateSampleGrid* OutSampleGrid) { FVoxelStrateMetrics Result; + if (OutSampleGrid != nullptr) + { + *OutSampleGrid = FVoxelStrateSampleGrid(); + } VoxelStrateMeasurePrivate::FSampleGrid Grid; if (!VoxelStrateMeasurePrivate::BuildSampleGrid( nullptr, nullptr, &Sampler, INDEX_NONE, StrateBottomWorldZ, StrateTopWorldZ, @@ -1319,6 +1356,10 @@ FVoxelStrateMetrics VF_MeasureStrateWithSampler( { Result.RefusalReason = TEXT("The measurement grid was empty."); } + if (OutSampleGrid != nullptr && Result.bValid) + { + VoxelStrateMeasurePrivate::ExportSampleGrid(Grid, *OutSampleGrid); + } return Result; } diff --git a/Source/VoxelForge/Private/VoxelStratePreview.cpp b/Source/VoxelForge/Private/VoxelStratePreview.cpp new file mode 100644 index 0000000..fe1dee7 --- /dev/null +++ b/Source/VoxelForge/Private/VoxelStratePreview.cpp @@ -0,0 +1,723 @@ +// Deterministic editor/automation strate previews. + +#include "VoxelStratePreview.h" + +FString FVoxelStratePreviewWindow::Describe() const +{ + return FString::Printf( + TEXT("interior margin=%d voxels; sample step=%d voxels; Z=[%d,%d) voxels; " + "XY=[%.0f,%.0f) x [%.0f,%.0f) voxels; grid=%dx%dx%d"), + ResolvedMarginVoxels, + SampleStep, + SampledMinZ, + SampledMaxZ, + MinX, + MaxX, + MinY, + MaxY, + NumX, + NumY, + NumZ); +} + +FVoxelStratePreviewWindow VF_GetStratePreviewWindow(const FVoxelStrateSampleGrid& Grid) +{ + FVoxelStratePreviewWindow Window; + Window.SampleStep = Grid.SampleStep; + Window.ResolvedMarginVoxels = Grid.ResolvedMarginVoxels; + Window.SampledMinZ = Grid.SampledMinZ; + Window.SampledMaxZ = Grid.SampledMaxZ; + Window.NumX = Grid.NumX; + Window.NumY = Grid.NumY; + Window.NumZ = Grid.NumZ; + Window.MinX = Grid.MinX; + Window.MaxX = Grid.MaxX; + Window.MinY = Grid.MinY; + Window.MaxY = Grid.MaxY; + return Window; +} + +#if WITH_EDITOR + +#include "HAL/FileManager.h" +#include "IImageWrapper.h" +#include "IImageWrapperModule.h" +#include "Misc/FileHelper.h" +#include "Modules/ModuleManager.h" + +namespace +{ + constexpr int32 GMaxPreviewImageDimension = 512; + constexpr int32 GPreviewScaleBarFooterPixels = 20; + + const FColor GSolidColor(18, 23, 30, 255); + const FColor GAirColor(218, 225, 231, 255); + const FColor GWalkableColor(245, 166, 54, 255); + const FColor GScaleBarColor(245, 245, 245, 255); + const FColor GScaleBarBackground(9, 12, 17, 255); + + struct FPlanSliceChoice + { + int32 CellZ = INDEX_NONE; + int32 BoundaryTransitions = 0; + int32 MixedCells = 0; + int32 WalkableCells = 0; + int32 SolidCells = 0; + bool bHasMixedLayer = false; + }; + + FString VF_HtmlEscape(const FString& Source) + { + FString Escaped = Source; + Escaped = Escaped.Replace(TEXT("&"), TEXT("&")); + Escaped = Escaped.Replace(TEXT("<"), TEXT("<")); + Escaped = Escaped.Replace(TEXT(">"), TEXT(">")); + Escaped = Escaped.Replace(TEXT("\""), TEXT(""")); + Escaped = Escaped.Replace(TEXT("'"), TEXT("'")); + return Escaped; + } + + FString VF_FormatMetric(bool bValid, float Value) + { + return bValid ? FString::Printf(TEXT("%.6f"), Value) : TEXT("invalid"); + } + + FString VF_FormatIntegerMetric(bool bValid, int32 Value) + { + return bValid ? FString::Printf(TEXT("%d"), Value) : TEXT("invalid"); + } + + bool VF_IsWalkableCell(const FVoxelStrateSampleGrid& Grid, + int32 X, int32 Y, int32 Z, int32 HeadroomCells) + { + if (Grid.Air[Grid.Index(X, Y, Z)] == 0u || Z == 0 + || Grid.Air[Grid.Index(X, Y, Z - 1)] != 0u) + { + return false; + } + + for (int32 H = 0; H < HeadroomCells; ++H) + { + const int64 HeadroomZ = static_cast(Z) + H; + if (HeadroomZ >= Grid.NumZ + || Grid.Air[Grid.Index(X, Y, static_cast(HeadroomZ))] == 0u) + { + return false; + } + } + return true; + } + + int32 VF_SourceBegin(int32 OutputIndex, int32 OutputCount, int32 SourceCount) + { + return static_cast( + (static_cast(OutputIndex) * static_cast(SourceCount)) + / static_cast(OutputCount)); + } + + int32 VF_SourceEnd(int32 OutputIndex, int32 OutputCount, int32 SourceCount) + { + const int32 Begin = VF_SourceBegin(OutputIndex, OutputCount, SourceCount); + const int32 End = static_cast( + (static_cast(OutputIndex + 1) * static_cast(SourceCount) + + static_cast(OutputCount) - 1) + / static_cast(OutputCount)); + return FMath::Clamp(FMath::Max(Begin + 1, End), 0, SourceCount); + } + + FColor VF_AggregateVerticalPixel(const FVoxelStrateSampleGrid& Grid, + int32 FixedY, + int32 XBegin, + int32 XEnd, + int32 ZBegin, + int32 ZEnd, + int32 HeadroomCells) + { + bool bAnyAir = false; + bool bAnyWalkable = false; + for (int32 Z = ZBegin; Z < ZEnd; ++Z) + { + for (int32 X = XBegin; X < XEnd; ++X) + { + const int32 Cell = Grid.Index(X, FixedY, Z); + if (Grid.Air[Cell] != 0u) + { + bAnyAir = true; + bAnyWalkable |= VF_IsWalkableCell(Grid, X, FixedY, Z, HeadroomCells); + } + } + } + return bAnyWalkable ? GWalkableColor : bAnyAir ? GAirColor : GSolidColor; + } + + FColor VF_AggregatePlanPixel(const FVoxelStrateSampleGrid& Grid, + int32 FixedZ, + int32 XBegin, + int32 XEnd, + int32 YBegin, + int32 YEnd, + int32 HeadroomCells) + { + bool bAnyAir = false; + bool bAnyWalkable = false; + for (int32 Y = YBegin; Y < YEnd; ++Y) + { + for (int32 X = XBegin; X < XEnd; ++X) + { + const int32 Cell = Grid.Index(X, Y, FixedZ); + if (Grid.Air[Cell] != 0u) + { + bAnyAir = true; + bAnyWalkable |= VF_IsWalkableCell(Grid, X, Y, FixedZ, HeadroomCells); + } + } + } + return bAnyWalkable ? GWalkableColor : bAnyAir ? GAirColor : GSolidColor; + } + + FPlanSliceChoice VF_ChoosePlanSlice(const FVoxelStrateSampleGrid& Grid, + int32 HeadroomCells) + { + FPlanSliceChoice Best; + const int32 CentreZ = Grid.NumZ / 2; + + auto IsBetterMixed = [CentreZ](const FPlanSliceChoice& Candidate, + const FPlanSliceChoice& Existing) + { + if (Candidate.BoundaryTransitions != Existing.BoundaryTransitions) + { + return Candidate.BoundaryTransitions > Existing.BoundaryTransitions; + } + if (Candidate.MixedCells != Existing.MixedCells) + { + return Candidate.MixedCells > Existing.MixedCells; + } + if (Candidate.WalkableCells != Existing.WalkableCells) + { + return Candidate.WalkableCells > Existing.WalkableCells; + } + return FMath::Abs(Candidate.CellZ - CentreZ) + < FMath::Abs(Existing.CellZ - CentreZ); + }; + + auto IsBetterFallback = [CentreZ](const FPlanSliceChoice& Candidate, + const FPlanSliceChoice& Existing) + { + if (Candidate.SolidCells != Existing.SolidCells) + { + return Candidate.SolidCells > Existing.SolidCells; + } + if (Candidate.MixedCells != Existing.MixedCells) + { + return Candidate.MixedCells > Existing.MixedCells; + } + return FMath::Abs(Candidate.CellZ - CentreZ) + < FMath::Abs(Existing.CellZ - CentreZ); + }; + + for (int32 Z = 0; Z < Grid.NumZ; ++Z) + { + FPlanSliceChoice Candidate; + Candidate.CellZ = Z; + for (int32 Y = 0; Y < Grid.NumY; ++Y) + { + for (int32 X = 0; X < Grid.NumX; ++X) + { + const bool bAir = Grid.Air[Grid.Index(X, Y, Z)] != 0u; + if (bAir) + { + ++Candidate.WalkableCells; + if (!VF_IsWalkableCell(Grid, X, Y, Z, HeadroomCells)) + { + --Candidate.WalkableCells; + } + } + else + { + ++Candidate.SolidCells; + } + if (X + 1 < Grid.NumX + && bAir != (Grid.Air[Grid.Index(X + 1, Y, Z)] != 0u)) + { + ++Candidate.BoundaryTransitions; + } + if (Y + 1 < Grid.NumY + && bAir != (Grid.Air[Grid.Index(X, Y + 1, Z)] != 0u)) + { + ++Candidate.BoundaryTransitions; + } + } + } + Candidate.MixedCells = FMath::Min( + Candidate.SolidCells, + Grid.NumX * Grid.NumY - Candidate.SolidCells); + Candidate.bHasMixedLayer = Candidate.SolidCells > 0 + && Candidate.SolidCells < Grid.NumX * Grid.NumY; + + if (Candidate.bHasMixedLayer) + { + if (!Best.bHasMixedLayer || IsBetterMixed(Candidate, Best)) + { + Best = Candidate; + } + } + else if (!Best.bHasMixedLayer + && (Best.CellZ == INDEX_NONE || IsBetterFallback(Candidate, Best))) + { + Best = Candidate; + } + } + + return Best; + } + + int32 VF_CellWorldCoordinate(float Min, float Max, int32 SampleStep, int32 Cell) + { + const float Extent = Max - Min; + const float Offset = FMath::Min( + static_cast(Cell) * static_cast(SampleStep) + + 0.5f * static_cast(SampleStep), + Extent - 0.5f); + return FMath::RoundToInt(Min + Offset); + } + + void VF_DrawScaleBar(TArray& Pixels, int32 Width, int32 ContentHeight, + int32 SourceWidth, int32 SampleStep) + { + if (Width <= 0 || ContentHeight < 0 || Pixels.Num() <= 0 || SourceWidth <= 0 + || SampleStep <= 0) + { + return; + } + + const int32 ImageHeight = Pixels.Num() / Width; + const int32 BarY = FMath::Clamp(ContentHeight + 8, 0, ImageHeight - 1); + const int32 BarHeight = FMath::Min(4, ImageHeight - BarY); + const int32 BarX = FMath::Min(8, Width - 1); + const double ChunkPixels = static_cast(Width) * 32.0 + / (static_cast(SourceWidth) * static_cast(SampleStep)); + const int32 BarWidth = FMath::Clamp( + FMath::RoundToInt(static_cast(ChunkPixels)), 1, FMath::Max(1, Width - BarX)); + + for (int32 Y = 0; Y < ImageHeight; ++Y) + { + for (int32 X = 0; X < Width; ++X) + { + if (Y >= ContentHeight) + { + Pixels[Y * Width + X] = GScaleBarBackground; + } + } + } + for (int32 Y = BarY; Y < BarY + BarHeight; ++Y) + { + for (int32 X = BarX; X < BarX + BarWidth && X < Width; ++X) + { + Pixels[Y * Width + X] = GScaleBarColor; + } + } + } + + bool VF_RenderVertical(const FVoxelStrateSampleGrid& Grid, int32 HeadroomCells, + int32 FixedY, TArray& OutPixels, + int32& OutWidth, int32& OutHeight) + { + const int32 MaxContentDimension = GMaxPreviewImageDimension + - GPreviewScaleBarFooterPixels; + OutWidth = FMath::Min(Grid.NumX, MaxContentDimension); + const int32 ContentHeight = FMath::Min(Grid.NumZ, MaxContentDimension); + OutHeight = ContentHeight + GPreviewScaleBarFooterPixels; + OutPixels.SetNumUninitialized(OutWidth * OutHeight); + + for (int32 PixelZ = 0; PixelZ < ContentHeight; ++PixelZ) + { + // Put the highest sampled cells at the top of the image, as a person reading an XZ + // section expects; the source grid itself remains in its deterministic Z order. + const int32 ImageZ = ContentHeight - 1 - PixelZ; + const int32 ZBegin = VF_SourceBegin(ImageZ, ContentHeight, Grid.NumZ); + const int32 ZEnd = VF_SourceEnd(ImageZ, ContentHeight, Grid.NumZ); + for (int32 PixelX = 0; PixelX < OutWidth; ++PixelX) + { + const int32 XBegin = VF_SourceBegin(PixelX, OutWidth, Grid.NumX); + const int32 XEnd = VF_SourceEnd(PixelX, OutWidth, Grid.NumX); + OutPixels[PixelZ * OutWidth + PixelX] = VF_AggregateVerticalPixel( + Grid, FixedY, XBegin, XEnd, ZBegin, ZEnd, HeadroomCells); + } + } + VF_DrawScaleBar(OutPixels, OutWidth, ContentHeight, Grid.NumX, Grid.SampleStep); + return OutWidth > 0 && OutHeight > 0; + } + + bool VF_RenderPlan(const FVoxelStrateSampleGrid& Grid, int32 HeadroomCells, + int32 FixedZ, TArray& OutPixels, + int32& OutWidth, int32& OutHeight) + { + const int32 MaxContentDimension = GMaxPreviewImageDimension + - GPreviewScaleBarFooterPixels; + OutWidth = FMath::Min(Grid.NumX, MaxContentDimension); + const int32 ContentHeight = FMath::Min(Grid.NumY, MaxContentDimension); + OutHeight = ContentHeight + GPreviewScaleBarFooterPixels; + OutPixels.SetNumUninitialized(OutWidth * OutHeight); + + for (int32 PixelY = 0; PixelY < ContentHeight; ++PixelY) + { + const int32 YBegin = VF_SourceBegin(PixelY, ContentHeight, Grid.NumY); + const int32 YEnd = VF_SourceEnd(PixelY, ContentHeight, Grid.NumY); + for (int32 PixelX = 0; PixelX < OutWidth; ++PixelX) + { + const int32 XBegin = VF_SourceBegin(PixelX, OutWidth, Grid.NumX); + const int32 XEnd = VF_SourceEnd(PixelX, OutWidth, Grid.NumX); + OutPixels[PixelY * OutWidth + PixelX] = VF_AggregatePlanPixel( + Grid, FixedZ, XBegin, XEnd, YBegin, YEnd, HeadroomCells); + } + } + 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) + { + IImageWrapperModule& Module = FModuleManager::LoadModuleChecked( + FName("ImageWrapper")); + const TSharedPtr Wrapper = Module.CreateImageWrapper(EImageFormat::PNG); + if (!Wrapper.IsValid() + || !Wrapper->SetRaw(Pixels.GetData(), + static_cast(Pixels.Num()) * sizeof(FColor), + Width, Height, ERGBFormat::BGRA, 8)) + { + OutError = FString::Printf(TEXT("failed to encode PNG %s"), *Path); + return false; + } + + const TArray64& Png = Wrapper->GetCompressed(100); + if (!FFileHelper::SaveArrayToFile(Png, *Path)) + { + OutError = FString::Printf(TEXT("failed to write PNG %s"), *Path); + return false; + } + return true; + } +} + +bool VF_WriteStratePreviewCandidate( + const FString& OutputDirectory, + int32 CandidateIndex, + const FString& RecipeString, + const FVoxelStrateSampleGrid& Grid, + const FVoxelStrateMetrics& Metrics, + int32 HeadroomCells, + double DistanceFromCorpusCentroid, + const FString& ArrivalDepartureVerdict, + bool bRejected, + const FString& RejectionReason, + FVoxelStratePreviewCandidate& OutCandidate, + FString& OutError) +{ + OutCandidate = FVoxelStratePreviewCandidate(); + OutCandidate.CandidateIndex = CandidateIndex; + OutCandidate.RecipeString = RecipeString; + OutCandidate.ArrivalDepartureVerdict = ArrivalDepartureVerdict; + OutCandidate.RejectionReason = RejectionReason; + OutCandidate.bRejected = bRejected; + OutCandidate.bMetricsValid = Metrics.bValid; + OutCandidate.AirFraction = Metrics.AirFraction; + OutCandidate.LargestComponentShare = Metrics.LargestComponentShare; + OutCandidate.WalkableFraction = Metrics.WalkableFraction; + OutCandidate.MedianFeatureScale = Metrics.MedianFeatureScale; + OutCandidate.MedianVerticalClearance = Metrics.MedianVerticalClearance; + OutCandidate.DistanceFromCorpusCentroid = FMath::IsFinite(DistanceFromCorpusCentroid) + ? DistanceFromCorpusCentroid : 0.0; + + if (!Grid.IsValid()) + { + // An invalid recipe/measurement still gets a visible row in the contact sheet; there is + // simply no honest grid from which to manufacture images. + OutCandidate.RenderFailureReason = Metrics.RefusalReason.IsEmpty() + ? TEXT("no sampled grid was available") : Metrics.RefusalReason; + return true; + } + OutCandidate.Window = VF_GetStratePreviewWindow(Grid); + if (HeadroomCells < 0) + { + OutError = TEXT("preview HeadroomCells cannot be negative"); + return false; + } + + const FPlanSliceChoice Plan = VF_ChoosePlanSlice(Grid, HeadroomCells); + const float CentreY = 0.5f * (Grid.MinY + Grid.MaxY); + const float Step = static_cast(Grid.SampleStep); + const int32 FixedY = FMath::Clamp( + FMath::FloorToInt((CentreY - Grid.MinY) / Step), 0, Grid.NumY - 1); + + TArray VerticalPixels; + int32 VerticalWidth = 0; + int32 VerticalHeight = 0; + if (!VF_RenderVertical(Grid, HeadroomCells, FixedY, VerticalPixels, + VerticalWidth, VerticalHeight)) + { + OutError = FString::Printf(TEXT("failed to rasterise vertical slice for candidate %d"), + CandidateIndex); + return false; + } + + TArray PlanPixels; + int32 PlanWidth = 0; + int32 PlanHeight = 0; + if (!VF_RenderPlan(Grid, HeadroomCells, Plan.CellZ, PlanPixels, PlanWidth, PlanHeight)) + { + OutError = FString::Printf(TEXT("failed to rasterise plan slice for candidate %d"), + CandidateIndex); + 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.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)) + { + return false; + } + + OutCandidate.bRendered = true; + OutCandidate.VerticalSliceCellY = FixedY; + OutCandidate.VerticalSliceWorldY = VF_CellWorldCoordinate( + Grid.MinY, Grid.MaxY, Grid.SampleStep, FixedY); + OutCandidate.VerticalImageWidth = VerticalWidth; + OutCandidate.VerticalImageHeight = VerticalHeight; + OutCandidate.PlanSliceCellZ = Plan.CellZ; + OutCandidate.PlanSliceWorldZ = VF_CellWorldCoordinate( + Grid.MinZ, Grid.MaxZ, Grid.SampleStep, Plan.CellZ); + OutCandidate.PlanImageWidth = PlanWidth; + OutCandidate.PlanImageHeight = PlanHeight; + OutCandidate.PlanSliceBoundaryTransitions = Plan.BoundaryTransitions; + OutCandidate.PlanSliceMixedCells = Plan.MixedCells; + return true; +} + +bool VF_WriteStratePreviewIndex( + const FString& OutputDirectory, + const FString& RunTitle, + const FVoxelStratePreviewWindow& Window, + const TArray& Candidates, + FString& OutIndexPath, + FString& OutError) +{ + if (!Window.IsValid()) + { + OutError = TEXT("cannot write preview index for an invalid measurement window"); + return false; + } + if (!IFileManager::Get().MakeDirectory(*OutputDirectory, true)) + { + OutError = FString::Printf(TEXT("failed to create preview directory %s"), + *OutputDirectory); + return false; + } + + TArray SortedCandidates = Candidates; + SortedCandidates.Sort([](const FVoxelStratePreviewCandidate& A, + const FVoxelStratePreviewCandidate& B) + { + if (A.bRejected != B.bRejected) + { + return !A.bRejected; + } + if (A.DistanceFromCorpusCentroid != B.DistanceFromCorpusCentroid) + { + // Outliers should be visible near the top of each group. + return A.DistanceFromCorpusCentroid > B.DistanceFromCorpusCentroid; + } + return A.CandidateIndex < B.CandidateIndex; + }); + + const FString WindowDescription = Window.Describe(); + bool bAllWindowsSame = true; + for (const FVoxelStratePreviewCandidate& Candidate : SortedCandidates) + { + if (Candidate.Window.IsValid() + && Candidate.Window.Describe() != WindowDescription) + { + bAllWindowsSame = false; + break; + } + } + const FString WindowPreamble = bAllWindowsSame + ? FString::Printf(TEXT("Measurement window for every metric below: %s."), + *VF_HtmlEscape(WindowDescription)) + : TEXT("Measurement windows: candidates resolve their own exact window " + "from the same measurement settings; each card repeats its window."); + FString Html; + Html.Reserve(256 * FMath::Max(1, SortedCandidates.Num())); + Html += TEXT("\n\n"); + Html += FString::Printf(TEXT("%s\n"), *VF_HtmlEscape(RunTitle)); + Html += TEXT( + "\n"); + Html += FString::Printf(TEXT("

%s

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

%s " + "Density polarity is explicit: density > 0 is air. One chunk = 32 voxels = 8 m " + "(at this sample step, one chunk spans %d sampled cells). The image footer contains " + "a one-chunk scale bar.

\n"), + *WindowPreamble, + 32 / Window.SampleStep); + Html += TEXT( + "

Connectivity caveat: these are only 2D slices from the " + "measurement grid. Two regions that look joined in one slice may still be disconnected " + "in 3D. The arrival→departure verdict is the separate coarse-route/full-resolution " + "connectivity check, not something the pictures prove.

\n" + "
solid" + "air" + "walkable cell
\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, " + "pixels aggregate its already-sampled cells and never resample the world.

\n" + "

The corpus-centroid distance is a normalized roll-space ordering aid, " + "not a sampled-world measurement. Every world metric and slice choice uses the exact " + "measurement window printed on its card.

\n" + "
\n"); + + for (const FVoxelStratePreviewCandidate& Candidate : SortedCandidates) + { + const FString Status = Candidate.bRejected ? TEXT("REJECTED") : TEXT("SURVIVOR"); + const FString StatusClass = Candidate.bRejected ? TEXT("rejected-badge") : TEXT("survivor"); + const FString CardClass = Candidate.bRejected ? TEXT("candidate rejected") : TEXT("candidate"); + const FString MetricWindow = Candidate.Window.IsValid() + ? Candidate.Window.Describe() : WindowDescription; + Html += FString::Printf( + TEXT("

#%d %s

\n"), + *CardClass, Candidate.CandidateIndex, *StatusClass, *Status); + Html += FString::Printf(TEXT("
%s
\n"), + *VF_HtmlEscape(Candidate.RecipeString)); + if (Candidate.bRejected) + { + Html += FString::Printf(TEXT("

Why rejected: %s

\n"), + *VF_HtmlEscape(Candidate.RejectionReason.IsEmpty() + ? TEXT("unspecified") : Candidate.RejectionReason)); + } + Html += FString::Printf(TEXT("

Numbers and slice choices use: %s

\n"), + *VF_HtmlEscape(MetricWindow)); + Html += TEXT("
\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"), + *VF_HtmlEscape(Candidate.VerticalImageFile), Candidate.CandidateIndex, + Candidate.VerticalSliceWorldY, + *VF_HtmlEscape(Candidate.PlanImageFile), Candidate.CandidateIndex, + Candidate.PlanSliceWorldZ, Candidate.PlanSliceBoundaryTransitions); + } + else + { + Html += FString::Printf(TEXT("
No image: %s
\n"), + *VF_HtmlEscape(Candidate.RenderFailureReason)); + } + Html += TEXT("
\n\n"); + Html += FString::Printf( + TEXT("\n" + "\n" + "\n" + "\n" + "\n" + "\n" + "\n
Grid measurements use the window above; " + "centroid distance is roll-space
air fraction%s
largest component share%s
walkable fraction%s
feature scale (voxels)%s
vertical clearance (voxels)%s
arrival → departure%s
distance from corpus centroid (roll-space)%.6f
\n
\n"), + *VF_FormatMetric(Candidate.bMetricsValid, Candidate.AirFraction), + *VF_FormatMetric(Candidate.bMetricsValid, Candidate.LargestComponentShare), + *VF_FormatMetric(Candidate.bMetricsValid, Candidate.WalkableFraction), + *VF_FormatMetric(Candidate.bMetricsValid, Candidate.MedianFeatureScale), + *VF_FormatIntegerMetric(Candidate.bMetricsValid, Candidate.MedianVerticalClearance), + *VF_HtmlEscape(Candidate.ArrivalDepartureVerdict), + Candidate.DistanceFromCorpusCentroid); + } + Html += TEXT("
\n

Generated offline by VoxelForge's editor/automation " + "composer preview. No CDN, JavaScript framework, or runtime generation hook is " + "required.

\n\n"); + + OutIndexPath = OutputDirectory / TEXT("index.html"); + if (!FFileHelper::SaveStringToFile( + Html, *OutIndexPath, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM)) + { + OutError = FString::Printf(TEXT("failed to write preview index %s"), *OutIndexPath); + return false; + } + return true; +} + +#else + +bool VF_WriteStratePreviewCandidate( + const FString& OutputDirectory, + int32 CandidateIndex, + const FString& RecipeString, + const FVoxelStrateSampleGrid& Grid, + const FVoxelStrateMetrics& Metrics, + int32 HeadroomCells, + double DistanceFromCorpusCentroid, + const FString& ArrivalDepartureVerdict, + bool bRejected, + const FString& RejectionReason, + FVoxelStratePreviewCandidate& OutCandidate, + FString& OutError) +{ + (void)OutputDirectory; + (void)CandidateIndex; + (void)RecipeString; + (void)Grid; + (void)Metrics; + (void)HeadroomCells; + (void)DistanceFromCorpusCentroid; + (void)ArrivalDepartureVerdict; + (void)bRejected; + (void)RejectionReason; + OutCandidate = FVoxelStratePreviewCandidate(); + OutError = TEXT("strate preview rendering is editor-only"); + return false; +} + +bool VF_WriteStratePreviewIndex( + const FString& OutputDirectory, + const FString& RunTitle, + const FVoxelStratePreviewWindow& Window, + const TArray& Candidates, + FString& OutIndexPath, + FString& OutError) +{ + (void)OutputDirectory; + (void)RunTitle; + (void)Window; + (void)Candidates; + OutIndexPath.Reset(); + OutError = TEXT("strate preview rendering is editor-only"); + return false; +} + +#endif diff --git a/Source/VoxelForge/Public/VoxelStrateComposer.h b/Source/VoxelForge/Public/VoxelStrateComposer.h index 4a3641e..047e6f6 100644 --- a/Source/VoxelForge/Public/VoxelStrateComposer.h +++ b/Source/VoxelForge/Public/VoxelStrateComposer.h @@ -2,7 +2,8 @@ // // This is deliberately not part of the runtime generation path. The composer is a build-box // tool/test API: it loads authored vectors, measures their spread, and makes deterministic -// candidates by blending those vectors before applying a small spread-relative jitter. +// candidates by blending those vectors before applying spread-relative (or season-zero bootstrap) +// jitter. #pragma once @@ -203,6 +204,11 @@ struct VOXELFORGE_API FVoxelStrateRollInfo TArray ParentEntryIndices; TArray ParentWeights; int32 DominantParentPosition = INDEX_NONE; + + // Provenance for the starved-corpus fallback. Names are keyed as "archetype:field" so a + // report can distinguish field descriptors from the number of times they were applied. + int32 BootstrapJitterFieldCount = 0; + TArray BootstrapJitterFieldNames; }; /** @@ -306,3 +312,15 @@ VOXELFORGE_API bool VF_AreStrateArchetypeParamsBitIdentical( const FVoxelStrateArchetypeParams& A, const FVoxelStrateArchetypeParams& B, ECaveGeneratorType Archetype); + +/** + * Normalised distance from the measured centroid of one exact-archetype corpus group. + * + * This is an offline ordering aid for the preview only. Continuous/integer fields are measured + * in their corpus units; zero-spread fields use a conservative scale floor so the value remains + * finite without turning clamps into roll ranges. It never participates in generation. + */ +VOXELFORGE_API double VF_DistanceFromStrateCorpusCentroid( + const FVoxelStrateCorpus& Corpus, + const FVoxelStrateArchetypeParams& Params, + ECaveGeneratorType Archetype); diff --git a/Source/VoxelForge/Public/VoxelStrateMeasure.h b/Source/VoxelForge/Public/VoxelStrateMeasure.h index 07978d6..77b9e49 100644 --- a/Source/VoxelForge/Public/VoxelStrateMeasure.h +++ b/Source/VoxelForge/Public/VoxelStrateMeasure.h @@ -44,6 +44,55 @@ struct VOXELFORGE_API FVoxelStrateMeasureSettings int32 InteriorMarginVoxels = -1; // <0 = derive from BoundarySealThickness (2x, clamped); explicit 0 includes the seal. }; +/** + * Optional capture of the exact coarse 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. + * It is an editor/automation hand-off for tools such as the composer preview, not a runtime + * generation cache. + */ +struct VOXELFORGE_API FVoxelStrateSampleGrid +{ + bool bValid = false; + int32 NumX = 0; + int32 NumY = 0; + int32 NumZ = 0; + int32 SampleStep = 1; + int32 CellCount = 0; + + float MinX = 0.0f; + float MinY = 0.0f; + float MinZ = 0.0f; + float MaxX = 0.0f; + float MaxY = 0.0f; + float MaxZ = 0.0f; + + int32 ResolvedMarginVoxels = 0; + int32 SampledMinZ = 0; + int32 SampledMaxZ = 0; + + // 1 = air (density > 0), 0 = solid (density <= 0), matching the measurement polarity. + TArray Air; + + FORCEINLINE int32 Index(int32 X, int32 Y, int32 Z) const + { + return static_cast( + static_cast(X) + + static_cast(NumX) * (static_cast(Y) + + static_cast(NumY) * static_cast(Z))); + } + + bool IsValid() const + { + return bValid && NumX > 0 && NumY > 0 && NumZ > 0 + && static_cast(CellCount) + == static_cast(NumX) * static_cast(NumY) + * static_cast(NumZ) + && Air.Num() == CellCount; + } +}; + /** Metrics derived from one coarse grid and its single air flood fill. */ struct VOXELFORGE_API FVoxelStrateMetrics { @@ -128,7 +177,8 @@ VOXELFORGE_API FVoxelStrateMetrics VF_MeasureStrate( const UVoxelGenerator& Generator, const UVoxelStrateManager& Manager, int32 StrateIndex, - const FVoxelStrateMeasureSettings& Settings); + const FVoxelStrateMeasureSettings& Settings, + FVoxelStrateSampleGrid* OutSampleGrid = nullptr); /** The same Tier 2 pass over an explicit voxel Z window and a custom read-only sampler. */ VOXELFORGE_API FVoxelStrateMetrics VF_MeasureStrateWithSampler( @@ -136,7 +186,8 @@ VOXELFORGE_API FVoxelStrateMetrics VF_MeasureStrateWithSampler( int32 StrateBottomWorldZ, int32 StrateTopWorldZ, float BoundarySealThickness, - const FVoxelStrateMeasureSettings& Settings); + const FVoxelStrateMeasureSettings& Settings, + FVoxelStrateSampleGrid* OutSampleGrid = nullptr); /** * Test coarse connectivity and re-check recovered coarse routes at full resolution. diff --git a/Source/VoxelForge/Public/VoxelStratePreview.h b/Source/VoxelForge/Public/VoxelStratePreview.h new file mode 100644 index 0000000..2e0ccc0 --- /dev/null +++ b/Source/VoxelForge/Public/VoxelStratePreview.h @@ -0,0 +1,103 @@ +// Deterministic editor/automation previews for one measured strate sample grid. +// +// The renderer never calls a density function. It consumes the optional capture emitted by +// VF_MeasureStrate/VF_MeasureStrateWithSampler, so the pictures and the reported metrics describe +// the same sampled world. + +#pragma once + +#include "CoreMinimal.h" +#include "VoxelStrateMeasure.h" + +/** The window facts printed on the contact sheet beside every measured number. */ +struct VOXELFORGE_API FVoxelStratePreviewWindow +{ + int32 SampleStep = 0; + int32 ResolvedMarginVoxels = 0; + int32 SampledMinZ = 0; + int32 SampledMaxZ = 0; + int32 NumX = 0; + int32 NumY = 0; + int32 NumZ = 0; + float MinX = 0.0f; + float MaxX = 0.0f; + float MinY = 0.0f; + float MaxY = 0.0f; + + bool IsValid() const + { + return SampleStep > 0 && NumX > 0 && NumY > 0 && NumZ > 0 + && SampledMaxZ > SampledMinZ; + } + + FString Describe() const; +}; + +/** One lightweight row/card record; sampled cells are rendered immediately and are not retained. */ +struct VOXELFORGE_API FVoxelStratePreviewCandidate +{ + int32 CandidateIndex = INDEX_NONE; + FVoxelStratePreviewWindow Window; + FString RecipeString; + FString ArrivalDepartureVerdict; + FString RejectionReason; + FString RenderFailureReason; + FString VerticalImageFile; + FString PlanImageFile; + + bool bRejected = false; + bool bMetricsValid = false; + bool bRendered = false; + + float AirFraction = 0.0f; + float LargestComponentShare = 0.0f; + float WalkableFraction = 0.0f; + float MedianFeatureScale = 0.0f; + int32 MedianVerticalClearance = 0; + double DistanceFromCorpusCentroid = 0.0; + + int32 VerticalSliceCellY = INDEX_NONE; + int32 VerticalSliceWorldY = 0; + int32 VerticalImageWidth = 0; + int32 VerticalImageHeight = 0; + int32 PlanSliceCellZ = INDEX_NONE; + int32 PlanSliceWorldZ = 0; + int32 PlanImageWidth = 0; + int32 PlanImageHeight = 0; + int32 PlanSliceBoundaryTransitions = 0; + int32 PlanSliceMixedCells = 0; +}; + +/** Copy the exact measurement-window metadata needed by the index page. */ +VOXELFORGE_API FVoxelStratePreviewWindow VF_GetStratePreviewWindow( + const FVoxelStrateSampleGrid& Grid); + +/** + * Render one candidate's XZ and XY views and fill its contact-sheet record. + * + * The source grid is the measurement capture, not a sampler. The renderer is deterministic and + * has no random stream. It returns true for a valid measurement with no grid only when it can + * record a non-rendered candidate (for example an invalid recipe); PNG failures return false. + */ +VOXELFORGE_API bool VF_WriteStratePreviewCandidate( + const FString& OutputDirectory, + int32 CandidateIndex, + const FString& RecipeString, + const FVoxelStrateSampleGrid& Grid, + const FVoxelStrateMetrics& Metrics, + int32 HeadroomCells, + double DistanceFromCorpusCentroid, + const FString& ArrivalDepartureVerdict, + bool bRejected, + const FString& RejectionReason, + FVoxelStratePreviewCandidate& OutCandidate, + FString& OutError); + +/** Write one self-contained, offline contact sheet for all candidate records. */ +VOXELFORGE_API bool VF_WriteStratePreviewIndex( + const FString& OutputDirectory, + const FString& RunTitle, + const FVoxelStratePreviewWindow& Window, + const TArray& Candidates, + FString& OutIndexPath, + FString& OutError); diff --git a/fable-idea.md b/fable-idea.md index 90e7a45..623b772 100644 --- a/fable-idea.md +++ b/fable-idea.md @@ -91,7 +91,14 @@ tiles.) In a tall multi-strate world most chunks in the desired set are full bed ### A. Tooling first — force multipliers for everything after -**F1 — 2D world-preview editor tool.** ★ my top pick. An editor utility that samples `f_XY` (terrain height, relief M, water mask — later the biome map) over an N×N window into a `UTexture2D`, with the Surface|Macro knobs live. Tuning ReliefStrength/biome layout becomes seconds instead of regen-and-fly. Directly addresses the standing "hard to dial blind" pain (Worm passages, today's Stage 0 knobs). Extend with a top-view passage-path overlay (the data exists in `bDebugDrawPassages`). +**F1 — 2D world-preview editor tool. ✅ BUILT 2026-09-04 (measurement + preview).** ★ my top pick. The +editor/automation pass captures the exact coarse measurement grid, writes deterministic PNG XZ and +XY slices plus a self-contained 64-candidate contact sheet, and labels walkable cells, scale, metric +windows, rejection reasons, and the 2D-connectivity caveat. Output is under +`Saved/ComposerPreview//`; no runtime generation path is involved. The original idea remains: +sample `f_XY` (terrain height, relief M, water mask — later the biome map) over an N×N window with +the Surface|Macro knobs live, then extend with a top-view passage-path overlay (the data exists in +`bDebugDrawPassages`). **F2 — Determinism validator button.** ✅ DONE 2026-07-04 (`AVoxelWorld::ValidateDeterminism`, Live Edit category). CallInEditor: sample a band of densities from two different chunk-window alignments, diff, report max delta. Turns the scariest invariant (§8.4 — window invariance) into a one-click regression test *before* biome code starts landing.