diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 66c4216..b1d35a2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -48,13 +48,17 @@ keeping natural chambers heterogeneous and allowing 20 m-plus cathedral volumes. Validation is deliberately split by cost: bounded regression fixtures remain 4 chunks, while the production definition, season defaults, and owner-facing showcase use 8 chunks. The historical pre-tree production-height sweep exhausted 256 rolls and found **0/8** complete hard-gate survivors; -its 7 fine renders, 1 refusal, and 1 blank are retained as the scale baseline. The final +its 7 fine renders, 1 refusal, and 1 blank are retained as the scale baseline. The historical final `VoxelForge` run (`VoxelForgeFullMazeTreeFinal3`) recorded **30 clean successes, 2 warning-bearing, 0 failures, 0 not-run** in **2,523.978 s**; its regenerated showcase produced **6 fine renders, 2 refusals, and 0 blanks**. All box-verdict violation counts were **0**, `WorldRadiusVoxels` stayed **0**, lateral regions stayed gated, and operator-stack equivalence paths remained bit-identical. -The two authored-corpus relation findings (2/12) are conservative experiment-envelope findings, -not box-verdict violations. +The 2026-09-06 landing validation (`VoxelForgeLandingFinal5`) recorded **30 clean successes, 2 +warning-bearing successes, 0 failures, 0 not-run** across 32 tests in **3,607.746 s**. The strict +`Composer.Promotion` assertions remain intact and passed; no assertion was weakened. The landing, +connectivity, seal, box-soundness, determinism, and equivalence tests passed. The two warnings are +the known gated lateral-region result and the known TunnelNetwork outside-gate sample count; they +are not landing or box-verdict violations. ### 8.1 Archetypes (`ECaveGeneratorType`, VoxelStrateTypes.h) Each archetype has its own param `USTRUCT` (on `UVoxelStrateDefinition`, EditCondition-gated @@ -72,7 +76,7 @@ by `GeneratorType`) and its own density function in `VoxelGenerator.cpp`, dispat | Underwater | `FStrateGenerationParams` + water | (reuses `GetDensityWithParams`) | tunnel rock + high water table | All density fns share the convention: internal **positive=solid**, apply origin spine → -vertical boundary seal → inter-strate passages → XY edge seal, then `return -Density` (MC: +vertical boundary seal → inter-strate passage tube + landing/floor → XY edge seal, then `return -Density` (MC: negative=solid). The final MC-facing edge pass in `GetDensityAt` is repeated after disturbances so the global rim cannot be reopened by a post-process; the diff layer remains the explicit player override. @@ -99,7 +103,7 @@ internal densities**, never their parameter vectors: a shaft spacing and a slab commensurable. Internal density is positive solid; the parent is negated once at the MC boundary. Region cores are built without structural posts. `FLateralRegionBlendOp` resolves the creative -field, then one parent appends the global spine, vertical seal, passage carve, and XY edge seal in +field, then one parent appends the global spine, vertical seal, passage carve/landing floor, and XY edge seal in the existing fixed order. No region can disable or duplicate those posts. The manifest is an offline artifact and the editor hand-off copies it into a worker-local per-chunk cache: the chunk prepares a small lattice-site window plus an integer XY query grid, so ordinary voxel evaluation is @@ -115,12 +119,13 @@ that equal all-air/all-solid signs are safe, but no cross-region uniformity is i single sample. The same key is checked when a season manifest is loaded, and `WorldRadiusVoxels` remains zero during offline materialisation. -This mechanism is implemented and instrumented, but not ready to promote: the seam-specific -16-seed passage-mouth audit currently reports **9/16** connected opposite-region pairs, while the -exact region-zero controls report **11/16**. The same cases are **16/16 valid, non-vacuous, and -above the 0.50 largest-component survival threshold** before the law gate. No corridor or -threshold tuning was added. The failed primordial-law gate is therefore retained as a design -blocker; see `COMPOSER-NOTES.md §3.2b` and the validation log in §10. +This mechanism is implemented and instrumented, but not ready to promote: the pre-landing +16-seed passage-mouth audit reported **9/16** connected opposite-region pairs. After the landing +pass, the current audit reports **4/16**, while the exact region-zero controls also report **4/16**. +The same cases are **16/16 valid, non-vacuous, and above the 0.50 largest-component survival +threshold** before the law gate. No corridor or threshold tuning was added. The failed primordial-law +gate is therefore retained as a design blocker; see `COMPOSER-NOTES.md §3.2b` and the validation log +in §10. ⚠️ **The `switch` above is no longer the only density path.** All 8 archetypes also exist as **operator stacks**, selected per strate by @@ -173,12 +178,14 @@ idiom as the vertical seal; at and beyond `R` it forces positive `BaseDensity`. negates once for marching cubes, so the externally visible sealed density is negative (solid). This is the fourth structural post and is appended automatically by -`FVoxelOpStack::AppendStructuralPost`, after the spine, vertical seal, and passage carve. Passage -carving deliberately remains before the edge seal: passages may pierce a vertical strate seal, but -a passage whose mouth or body reaches the XY rim is overwritten by the edge force and cannot open a -hole through the world boundary. The legacy density functions apply the same post in the same -order. `GetDensityAt` reapplies the MC-facing form after disturbances as a backstop for out-of-layout -air and any disturbance carve; player diff edits still run last by design and remain authoritative. +`FVoxelOpStack::AppendStructuralPost`, after the spine, vertical seal, and passage tube + landing +floor. Passage carving deliberately remains before the edge seal: the tube is the intentional +vertical-boundary crossing, while each landing/connector is clamped inside the strate's vertical +seal. A passage whose mouth, landing, or body reaches the XY rim is overwritten by the edge force +and cannot open a hole through the world boundary. The legacy density functions apply the same post +in the same order. `GetDensityAt` reapplies the MC-facing landing air/floor after disturbances as an +idempotent support backstop, before the final XY seal; player diff edits still run last by design +and remain authoritative. `FXYEdgeSealOp` is `IsXYPure() == true`, uses only a squared radial early-out in the common interior case, and returns `AllSolid` from `ClassifyBox` only when the closest point of the whole box is at @@ -471,14 +478,16 @@ with a flat-top envelope → organic squirm (NOT a 1D zigzag, NOT a same-freq 2- `EvaluateModifierSDF` (per voxel) first builds a `thread_local` **per-chunk shortlist** of passages whose bounds reach this chunk (rebuilt on chunk change / `PassagesVersion` bump) — chunks with no passage near return `FLT_MAX` immediately — then **bounding-sphere-culls** each shortlisted passage -(`FVoxelPassage::BoundCenter/BoundRadiusSq`). Both are perf-critical (§8.10). The (0,0) surface entry -is a simple straight tube. Placement, reach, and shape draws for inter-strate passages are keyed by -`(seed, upper-strate index, connection index, per-value salt)` through `VoxelHash`, so one passage's -connection count cannot shift any later passage. Global passage settings were removed from -`VoxelSettings`. +(`FVoxelPassage::BoundCenter/BoundRadiusSq`). The same shortlist serves the tube, landing SDF, and +landing-floor membership; no source query, flood fill, or topology search is in the voxel loop. Both +are perf-critical (§8.10). The (0,0) surface entry is a simple straight tube. Placement, reach, and +shape draws for inter-strate passages are keyed by `(seed, upper-strate index, connection index, +per-value salt)` through `VoxelHash`, so one passage's connection count cannot shift any later +passage. Global passage settings were removed from `VoxelSettings`. -After a passage's XY is selected, `GeneratePassages` asks the **destination** strate for a full lower-mouth -landing point through the pure free function `VF_SuggestLandingPoint` (`VoxelCaveMorphology`). +After each passage's XY is selected, `GeneratePassages` asks **both** participating strates for a +player-fit source point through the pure free function `VF_SuggestLandingPoint` +(`VoxelCaveMorphology`). TunnelNetwork and Underwater keep the nearest hash-room vertical placement at the requested XY, with `MakeStrateSeed(world-seed, strate-index)` matching the room graph's existing identity; FlatPlain and CrystalChamber recompute their slab void band and reject column-overlap points. Maze snaps to the @@ -487,13 +496,49 @@ axis of a roughness-safe shaft on the drainage tree within two shaft spacings at density, and FloatingIslands to a validated blob top within one island spacing. Those three return false when no footing exists inside that explicit lateral budget. GeneratePassages -interpolates the snapped XY through the control-point chain and recomputes its conservative bound; +interpolates each accepted XY through the control-point chain and recomputes its conservative bound; SurfaceWorld remains deliberately unanswerable because production terrain can be selected through -manager-owned biome/per-column context. -Declined answers (unsupported or no-footing) keep the historical random reach and emit a warning. This query is source-level -only — it does not construct an operator stack, touch a cache, or call back into -`UVoxelStrateManager` while `Initialize` is building the layout. The resulting lower endpoint is -clamped to the destination's seal-free interior. +manager-owned biome/per-column context. Declined answers (unsupported or no-footing) keep the +historical random reach and are reported diagnostically. + +The source query proves only a local pose; it does not flood-fill the live network. Therefore every +inter-strate end also receives an explicit `FVoxelPassageLanding`: a rounded chamber with a hard +flat floor and a deterministic connector to the strate's guaranteed origin-root network at `(0,0)`. +This is the join guarantee, including for a source-fit answer — it is not a probability claim about +a nearby room. The connector is a swept flat-floor corridor with a 5-voxel (1.25 m) radius / 2.5 m +clear width and a 12-voxel (3 m) clear height. Its 4.5-voxel support inset is enough for the +1.36-voxel player radius and avoids capping unrelated shaft air; support stops at the configured +origin-spine radius, so the existing vertical root column remains open. At the root, every connector +joins an annular hub outside the spine, with a common per-strate floor derived from the sealed +interior bounds. If the direct floor run would exceed 44°, the deterministic builder inserts a +level dog-leg before the final ramp. The hub and the connector floor are therefore a real walkable +network, not a point anchor or nearest-component guess, and no per-passage ordering is involved. + +The chamber dimensions come directly from the capsule: player diameter `2×0.34/0.25 = 2.72` +voxels; required floor width `3/0.25 = 12` voxels; capsule height `2×0.88/0.25 = 7.04` voxels; +one metre of headroom adds 4 voxels. `HalfWidth=max(7, MouthRadius+2)` leaves +`2×(HalfWidth−1) >= 12` voxels of flat support after the one-voxel wall inset; the authored room +is therefore at least 14 voxels (3.5 m) wide. Height is +`max(12, 7.04+4, 2×MouthRadius+2)`; the stock 8-voxel mouth is an 18-voxel (4.5 m) room. +`FloorZ=StandingPoint.Z−0.5`, floor thickness is 3 voxels (0.75 m), and the tube centreline +meets the room at `FloorZ+MouthRadius`, so its lower tangent is the floor and there is no step lip. +The final-density floor audit samples the flat plane and checks its normal against the explicit ≤44° +landing contract; it also checks the analytic connector ramps against the same limit. The movement +CDO currently reports 44.8°, so the landing audit is intentionally the stricter criterion. + +The landing is part of the structural post, in the fixed order +`origin spine → vertical seal → passage tube + landing/floor → XY edge seal`. The floor is applied +after the passage carve and after MC-space disturbances as a support backstop, still before the +final XY edge seal. Room/floor Z is clamped inside the vertical seal; the XY edge seal is last and +wins over every landing or tube near the rim. Thus no landing can breach a strate boundary or the +world edge. The op-stack `FPassageCarveOp` calls the same manager operation as the legacy path, +including the floor, and `ClassifyTile` kills both uniform hypotheses around the bidirectional +landing floor (`Both` in the stack), so an air proof cannot delete support. + +The query remains pure — it does not construct an operator stack, touch a cache, or call back into +`UVoxelStrateManager` while `Initialize` builds the layout. Landing descriptors, connector choice, +door direction, and bounds are all produced once from the passage seed salts; passages never read +another passage's result. `LayoutOrderIndependence` compares the complete descriptors bit-for-bit. ### 8.9 Carving — brush shapes + editor controls `FVoxelModification` has `EVoxelBrushShape {Sphere,Box,Capsule}` + `BoxExtent`/`CapsuleEnd`/ @@ -565,7 +610,9 @@ driven by `EditorBrush*` props. (incremented in `GeneratePassages`). Most chunks have NO passage near → instant `FLT_MAX` return instead of walking the whole `Passages` array per voxel. Conservative superset (chunk bounding sphere vs passage bound) ⇒ bit-identical carve. Store indices + version, never pointers (the array - is rebuilt on `RebuildStrates`). + is rebuilt on `RebuildStrates`). Landing rooms, connector SDFs, and floor membership reuse this + exact cache. The final landing audit measured **0.665 μs** per forced rebuild and **0.646 μs** + per hot call on the validation machine; source-fit queries run only during `GeneratePassages`. - **Gen tasks run at `UE::Tasks::ETaskPriority::BackgroundNormal`** (`LoadTile`): worker gen yields to foreground game/render tasks. Without it, raising `MaxConcurrentTasks` past the spare-core count saturates the scheduler and starves the frame (the "concurrency > ~12 = stutter" symptom). Keep gen diff --git a/CODEMAP.md b/CODEMAP.md index 9dcfa94..e84cc7a 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -137,7 +137,7 @@ reference path for opt-out strates. See [OPSTACK-PLAN.md](OPSTACK-PLAN.md) for t | Symbol | Notes | |--------|-------| -| `EVoxelOpRole` | The four roles: `FieldSource` (what the field IS) · `Combiner` (how fields merge) · `DetailModifier` (today's `UVoxelTerrainOpDefinition`) · `StructuralPost` (spine→vertical seal→passage→XY edge seal→diff, appended automatically, never author-omittable). | +| `EVoxelOpRole` | The four roles: `FieldSource` (what the field IS) · `Combiner` (how fields merge) · `DetailModifier` (today's `UVoxelTerrainOpDefinition`) · `StructuralPost` (spine→vertical seal→passage tube/landing floor→XY edge seal→diff, appended automatically, never author-omittable). | | `EVoxelOpChannel` / `EVoxelOpChannelMask` | The explicit `FVoxelOpSample` channel set: `Density` and `Sdf` (plus `None`/`All` masks). Every density op declares the channels it reads and writes. | | `EVoxelOpCombine` | `Replace`/`Union`(min)/`Subtract`(max)/`SmoothUnion`/`SmoothSubtract`/`Add`/`Mask`. Sign reminder: negative = solid, so "add solid" is `min`. | | `EVoxelOpEffect` | `Identity`/`CarveOnly`/`FillOnly`/`Both`. Conservative: `Both` is always safe, the wrong one is a hole. | @@ -149,7 +149,8 @@ reference path for opt-out strates. See [OPSTACK-PLAN.md](OPSTACK-PLAN.md) for t ### 3.2c Structural primitives — `Public/VoxelDensityPrimitives.h` `VF_ApplyOriginSpine` · `VF_ApplyBoundarySeal` · `VF_ApplyPassageCarving` · `VF_ApplyXYEdgeSeal` — -the four world invariants every archetype appends, **moved here 2026-07-27** so the generator and +the four world invariants every archetype appends, with landing/floor geometry joined by the +manager passage post, **moved here 2026-07-27** so the generator and the operator stack share ONE copy. The XY helper is a radial smooth ramp in actor-space and is a true no-op when `WorldRadiusVoxels == 0`; `VF_XYEdgeSealForcedMarginOverBox` is the shared sound proof for `ClassifyBox` and T1.d's global shell skip. `VoxelGenerator.cpp` keeps same-named @@ -181,7 +182,7 @@ bit. They are port-correctness oracles, not fidelity checks: the acceptance bar |--------|------|-------| | `FVoxelOpStack` | — | Ordered move-only list of `TUniquePtr` entries. `Add` snapshots channel metadata; `PrepareChunk` / `EvalInternal` / `EvalMC` / `ClassifyBox` (the fold lets a later forcing post overwrite a dead hypothesis). | | `FVoxelOpStack::ValidateChannelOrder` | — | Assembly/diagnostic-only channel/resource DAG validator. It requires prior producers, rejects write-only clobbers and invalid additive declarations, and requires room/shaft/surface state providers before consumers. It permits the one root SDF identity fold used by Maze. `VoxelForge.OpStack.ChannelDAG` validates all 8 shipping stacks. | -| `FVoxelOpStack::AppendStructuralPost` | 4 | Appends spine → vertical seal → passage → XY edge seal **in that fixed order**. An author cannot omit or reorder them. The diff layer is NOT here yet — it still lives in `GetDensityAt` after the MC negate, with disturbances. | +| `FVoxelOpStack::AppendStructuralPost` | 4 | Appends spine → vertical seal → passage tube/landing floor → XY edge seal **in that fixed order**. An author cannot omit or reorder them. The diff layer is NOT here yet — it still lives in `GetDensityAt` after the MC negate, with disturbances. | | `VoxelDensityOps::MakeConstantRockSource` | 1 | `Density = BaseDensity`. `ClassifyBox` → **AllSolid**, exact and free. Shared by TunnelNetwork, Maze, VerticalShafts and bedrock gaps. Class is `FConstantFieldSource` (one class, two factories). | | `VoxelDensityOps::MakeConstantVoidSource` | 1 | The **same class, negated**: `Density = -BaseDensity`, and `ClassifyBox` → **AllAir** — the first source in the plugin that can prove it. FloatingIslands' root; that verdict is what makes a mostly-empty island strate skippable. | | `VoxelDensityOps::MakeLatticeCorridorSource` | 1 | Maze corridors, SDF channel. Each canonical lower-node/axis edge is open when it is a parent edge in the origin-directed tree or passes a capped loop roll; both endpoints are hashed locally, so adjacent chunks cannot disagree. The source reports its own conservative SDF interval; a later converter supplies its own carve/fill response, so the source is independent of the chosen consumer. | @@ -422,15 +423,25 @@ Header is rich with inline docs. Two namespaces + a per-chunk cache system. landing-site query: it evaluates the local archetype source through the same capsule/support contract as the measurement pass — support within step height, walkable slope, and full capsule clearance. Room queries use a room-floor core, slabs keep their requested XY, and the sparse - sources use bounded feature cores: a horizontal lattice corridor (one Maze cell), a selected - VerticalShafts tree shaft plus deterministic ledge-side offsets (up to two local shaft spacings at the 0.6 -site density), or a validated blob top (one IslandSpacing). It is intentionally not a flood fill: -the pure query can prove a local pose, not a large component. The +sources use bounded feature cores: a horizontal lattice corridor (one Maze cell), a selected +VerticalShafts tree shaft plus deterministic ledge-side offsets (up to two local shaft spacings at +the 0.6 site density), or a validated blob top (one IslandSpacing). It is intentionally not a +flood fill: the pure query can prove a local pose, not a large component. The VerticalShafts result is therefore on the drainage tree, not merely in an open roughness disk. It returns `false` when those feature-bearing conditions cannot be proven inside the supplied budget; SurfaceWorld remains refused because its production height can depend on manager-owned biome/per-column context. It never touches a generator, operator stack, manager, cache, or mutable state. +`FVoxelPassageLanding`, `VF_BuildPassageLanding`, `VF_EvaluatePassageLandingSDF`, and +`VF_IsPassageLandingFloor` (h:~56/~189, implemented near `.cpp:2059`) turn each inter-strate mouth +into a deterministic rounded room plus a hard support slab. The room is sized from the player +capsule (minimum 12 voxels / 3 m flat floor, 12 voxels / 3 m clear height, and at least 14 voxels / +3.5 m authored width); every end also gets a flat-floor connector to the guaranteed `(0,0)` root +network because the pure source query is local rather than a component proof. The manager's shared +thread-local passage shortlist serves tube, room, connector, and floor evaluation; no landing +search occurs per voxel. `GeneratePassages` stores the full descriptors and the order-independence +test compares them bit-for-bit. + Performance note (h:209-220): caching rooms/tunnels once per chunk instead of per voxel is the single biggest CPU win. @@ -462,14 +473,18 @@ groups by generator type. **`Public/VoxelStrateManager.h` + `.cpp`** — `UVoxelStrateManager : UObject` (h:108). Maps depth→strate at runtime; owns passages. -- `FVoxelPassage` (h:39): endpoints, radius, type, control points. +- `FVoxelPassage` (h:39): standing endpoints, radius, type, control points, and upper/lower landing descriptors (room, floor, door, and root connector). - `FStrateSlot` (h:84): definition + chunk-Z range + index. | Method | .cpp line | Role | |--------|-----------|------| | `Initialize` | 10 | Keeps the existing sequencing through `GeneratePassages`, but selects one slot source: a validated cooked season (exact ordered bounds/vector/recipe) or the unchanged authored fixed+sorted/shuffled pool. Assigned-invalid seasons fail closed. Authored source definitions are duplicated as content bags; generated slots use deterministic C++ defaults and zero auto-passages because schema 2 does not store passage configuration. | -| `GeneratePassages` | 247 | Deterministic passages between consecutive strates (per-strate `PassageConfig::Style` control points; auto passages retain the existing `EVoxelPassageType` default); placement and shape values are independently salted hashes of seed + boundary slot + connection index. Each mouth independently queries its own strate's pure player-fit `VF_SuggestLandingPoint` with its contract seed (cave room salt, otherwise world seed), may move within its archetype-specific budget, then is clamped outside the destination seal bands and interpolated through the control points; unsupported/no-fit answers preserve the old random reach. | -| `EvaluateModifierSDF` | 357 | SDF of passages at a point (for carving). Per-chunk `thread_local` shortlist (`PassagesVersion`-stamped) → far chunks return `FLT_MAX` without walking `Passages`. §8.10. | +| `GeneratePassages` | 247 | Deterministic passages between consecutive strates (per-strate `PassageConfig::Style` control points; auto passages retain the existing `EVoxelPassageType` default); placement and shape values are independently salted hashes of seed + boundary slot + connection index. Each mouth independently queries its own strate's pure player-fit `VF_SuggestLandingPoint` with its contract seed (cave room salt, otherwise world seed), may move within its archetype-specific budget, then receives a body-sized room/floor and a connector to the common per-strate origin-root annular hub. Unsupported/no-fit answers preserve the old random reach; a deterministic level dog-leg is inserted when the final ramp needs it to stay ≤44°. All descriptors and bounds are generated without reading another passage. | +| `EvaluateModifierSDF` | 357 | SDF of passage tubes plus landing rooms/connectors at a point. A per-chunk `thread_local` shortlist (`PassagesVersion`-stamped) → far chunks return `FLT_MAX` without walking `Passages`; the same cache serves floor membership. §8.10. | +| `ApplyPassageModifier` | 1319 | Shared legacy/op-stack passage post: carves tube and landing/connector air through the idempotent landing primitive, then restores the landing/connector support slab in internal positive-solid density. | +| `VF_ApplyPassageLandingCarving` | — | Idempotent density-independent landing-air threshold shared by the structural pass and the post-disturbance MC backstop; keeps legacy and operator-stack results bit-identical. | +| `ApplyPassageLandingFloorMC` | 1347 | Reasserts the same support slab after MC-space disturbances and before the final XY edge seal. | | `AnyPassageNearBox` | — | Conservative sphere-vs-AABB test of every passage's bound against a voxel box (+carve blend pad). Per TILE (ClassifyTile guard), never per voxel. | +| `AnyPassageLandingFloorNearBox` | — | Conservative room/connector-floor AABB guard. It kills the `AllAir` hypothesis wherever a proved landing floor can occur; the op-stack returns `Both` for the same box. | | `FindSlotIndexForChunkZ` | 427 | Z → layout index. | | `GetStrateAt` / `GetStrateIndex` | 443 / 455 | World-Z queries. | | `GetLayoutVersion` | h:161 (inline) | Layout/passage generation counter (= `PassagesVersion`, bumped by every `Initialize` and by the editor composer override). Hot-path callers key `thread_local` memos on it (strate-index memo in `GetDensityWithParams`, passage shortlist) so live changes never serve stale data. | @@ -597,7 +612,8 @@ verifies it in memory; `VF_LoadVoxelSeasonManifest` plus `VF_RebuildVoxelSeasonS recipe entries rebuild through the mandatory-post builder, while authored `FixedStrates` retain their absolute slot and native vector. The selected-only descent review reuses `VoxelStratePreview`, and all composition/review work is editor/build-box code; parsing and recipe materialisation are runtime. Lateral -region manifests remain rejected because their cross-seam law is only 9/16. The focused +region manifests remain rejected because their post-landing cross-seam law is only **4/16** (the +pre-landing snapshot was 9/16). The focused season test currently reports **24 generated / 19 hard-gate survivors / 6 selected / 18 unselected or rejected**, with **4 vacuous**, **1 primordial-law budget**, **13 policy-not-selected**, and **393,216** round-trip density samples bit-identical. The specified UE 5.7 editor build succeeded and the final @@ -606,6 +622,11 @@ warning-bearing, 0 failed, 0 not run** in **2,523.978 s**. The regenerated showc renders, 2 refusals, and 0 blanks**; all box-verdict violations remained zero. The later focused production-height showcase history is recorded separately below. +The current landing validation is recorded in `VoxelForgeLandingFinal5`: **30 clean successes, 2 +warning-bearing successes, 0 failures, 0 not-run** across 32 tests in **3,607.746 s**. The strict +promotion assertions passed and no assertion was weakened. The landing-specific geometry test, +both connectivity tests, all box verdicts, seals, determinism, and op-stack equivalence tests passed. + **`Public/VoxelSeasonAsset.h` + `Private/VoxelSeasonAsset.cpp`** — `UVoxelSeasonAsset : UPrimaryDataAsset`, the cookable carrier. Its editor button imports the reviewable JSON, stores the exact text plus independently copied metadata/hash, and retains soft references to any authored definitions. @@ -676,13 +697,13 @@ 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. World-scale run: **1,422 applications / 168 distinct fields**; **36/64 survival**, 60 non-vacuous, 58 largest-component, 40 exact-law passes; **1,411 Mixed + 731 AllSolid + 418 AllAir = 1,149 proved boxes**, **1,529,319 lattice voxels checked, 0 violations**, 148.387 s. | | ″ | `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. | -| `VoxelForgeComposerPromotionTest.cpp` | `VoxelForge.Composer.Promotion` | Re-measures the 12 project/default members, simulates five deterministic 24-candidate seasons with normalized measured-metric novelty (`<0.20`), cap 6, cumulative JSON promotion, provenance counts, corpus-hash checks, fresh-load gate verification, spread/survival reporting, and deliberate stale-metric corruption. World-scale run: **16 promoted**, corpus **4/8/16**, survival **45.8/66.7/75.0/66.7/58.3%**, spread **0.677151→0.867010**, **92.390 s**, 0 failures. | +| `VoxelForgeComposerPromotionTest.cpp` | `VoxelForge.Composer.Promotion` | Re-measures the 12 project/default members, simulates five deterministic 24-candidate seasons with normalized measured-metric novelty (`<0.20`), cap 6, cumulative JSON promotion, provenance counts, corpus-hash checks, fresh-load gate verification, spread/survival reporting, and deliberate stale-metric corruption. The final landing validation passed the strict existing gates; no assertion was weakened. | | `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 33 survivors centred on `LargestComponentPoint`, checks exact arrival→departure connectivity, rerolls every recipe/stack for determinism, and brute-forces every uniform box verdict. World-scale run: **33/64 survival (51.6%)**, **64 distinct recipes**, **0 invalid recipes**, **715 proved custom boxes / 951,665 lattice voxels / 0 violations**, blank plan/card **4/33→5/33**, **174.854 s**, **0 refusals**. | -| `VoxelForgeComposerShowcaseTest.cpp` | `VoxelForge.Composer.Showcase` | Exhausts the bounded parameter-roll set (seeds **0, 7331**, indices **0–63**), excludes multi-region rolls while the lateral gate is off, and measures every missing-family candidate in all six interior target slots at step 4 / radius 256 / `MaxCells=8,000,000`. Hard gates are non-vacuous, largest air share ≥ **0.50**, and exact unsnapped arrival→departure connectivity; selection score is floor-area fraction + clearance tie-break + projected-surface tie-break. It asserts roll/manifest determinism, density sign, zero `WorldRadiusVoxels`, bit-identical metric reruns, and writes one alphabetized card per archetype to `Saved/VoxelForge/Showcase/index.html`, with step-1 radius-64 filled/contour plan + vertical ROI images centred on `LargestComponentPoint`. The final production-height run uses **H=8 chunks / 64 m**, emits the full voxel→metre audit, and produced **7 fine renders, 1 refusal, 1 blank**; its hard-gate survivor set was **0/8**. | +| `VoxelForgeComposerShowcaseTest.cpp` | `VoxelForge.Composer.Showcase` | Exhausts the bounded parameter-roll set (seeds **0, 7331**, indices **0–63**), excludes multi-region rolls while the lateral gate is off, and measures every missing-family candidate in all six interior target slots at step 4 / radius 256 / `MaxCells=8,000,000`. Hard gates are non-vacuous, largest air share ≥ **0.50**, and exact unsnapped arrival→departure connectivity; selection score is floor-area fraction + clearance tie-break + projected-surface tie-break. It asserts roll/manifest determinism, density sign, zero `WorldRadiusVoxels`, bit-identical metric reruns, and writes one alphabetized card per archetype to `Saved/VoxelForge/Showcase/index.html`, with step-1 radius-64 filled/contour plan + vertical ROI images centred on `LargestComponentPoint`. The final landing regeneration evaluated **256** rolls (**82** eligible single-region, **174** skipped multi-region), produced **7 fine renders, 1 refusal, 0 blanks**, and retained a diagnostic hard-gate survivor set of **0/8**. | | `VoxelForgeComposerSeasonTest.cpp` | `VoxelForge.Composer.Season` | Existing compose/review/393,216-sample round trip plus schema hash tamper rejection, two independent runtime manager/generator instances, season-authoritative seed/spine/radius, unset-season regression, and brute-force verification of every sampled non-Mixed recipe tile verdict (up to 64). | | `VoxelForgeComposerCorpusFreeTest.cpp` | `VoxelForge.Composer.CorpusFree` | Shares each structure recipe across today's corpus blend, naive independent uniform rolls, and constraint-sampled rolls. The completed equal-arm world-scale run uses **16 candidates per arm**, step 4 / radius 256 / `MaxCells=8,000,000`, fixed passage-law mouths, and 40 box probes per candidate. Result: **10/16, 9/16, 8/16** survival; survivor feature-scale ranges are **8..268**, **8..60**, **8..92** voxels. Box checks: **185/246,235**, **260/346,060**, **299/397,969** proved/voxels, **0 violations** in every arm; constraint parameter and box-stop violations were also 0. | -| `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 both generated mouths against independent source queries. Answered mouths must match the pure player-fit point within the float envelope; the lower mouth also keeps the 16-point ring and source-footing assertions outside the carve/blend band. This is a local-fit/open-space proxy, not a flood-fill proof. Reports answered/refused mouths and archetypes; fails if either side is vacuous. | +| `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, landing descriptors, control geometry, and bounds bit-for-bit. | +| `VoxelForgePassageOpenSpaceTest.cpp` | `VoxelForge.Determinism.PassageLandsInOpenSpace` | Uses the real fixture density path to check both generated mouths against independent source queries, then audits all 14 inter-strate landing ends: body-derived room dimensions, final-density flat-floor slope, explicit root-network join, seal containment, hot-call/rebuild timing, and targeted `ClassifyTile` box soundness. Reports source-fit/refused mouths and per-archetype landing slopes; fails on any floor, join, seal, or box violation. | | `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 uses the bounded 4-chunk density fixture, fits each measurement AABB to the arrival/departure mouth pair plus three shaft spacings (the prescribed local tree fallback), and caps the fitted control at 120,000,000 cells. It reports exact before/after seed-6 cell counts, reruns every negative with the fitted margin doubled, measures tree orphan candidates/path reachability and roughness-bubble proxies, asserts mouths remain inside the deterministic shaft feature core (the exact axis is an unsupported open cylinder), 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 shaft seam test re-evaluates cell-boundary positions after warming distinct neighbouring chunk contexts. | | `VoxelForgeMazeSeamTest.cpp` | `VoxelForge.Generation.MazeSeamFreedom` | Re-evaluates **42** cell-boundary probes after warming six different neighbouring chunk contexts in both legacy and operator-stack paths. Latest result: **0 legacy mismatches, 0 operator mismatches**. The Maze source's 2×2×2 lower-node window is deliberately local; no wide collect is involved. | | `VoxelForgeWorldEdgeSealTest.cpp` | `VoxelForge.Generation.WorldEdgeSeal` | Positive samples outside the radius across every fixture strate, bit-identical interior negative control against radius 0, monotonic smooth-ramp check, radius-0 no-op, radial `ClassifyBox`/T1.d proofs brute-forced for every reported voxel, and synthetic plus generated near-rim passage coverage. Emits one aggregate summary with proved-box and voxel counts. | @@ -711,7 +732,7 @@ Stage order (negative=solid throughout). Each stage's anchor: | 4h — Pinch | 1142 | Passage bottlenecks. | | 5 — Worm tunnels | 1241 | abs(noise1)+abs(noise2), masked by distance-to-network (`WormNetworkRange`: braids hugging rooms/tunnels, no far-field speckle; 0 = legacy unmasked). | | 6 — Boundary seal | 1274 | Solid top/bottom shells (`ApplyBoundarySeal`). | -| 7 — Inter-strate passages | 1281 | Carve passages/elevator (`ApplyPassageCarving`). | +| 7 — Inter-strate passages + landings | `GetDensityWithParams`: 2217; `GetSlabDensity`: 2434+ | Carve passage tubes and deterministic room/connectors, restore their support floors, then leave the final XY seal to the outer MC post (`ApplyPassageModifier` / `ApplyPassageLandingFloorMC`). | ### 4.2 `GetSlabDensity` (FlatPlain / CrystalChamber) — VoxelGenerator.cpp:1306 | Step | Line | What | @@ -720,7 +741,7 @@ Stage order (negative=solid throughout). Each stage's anchor: | 2 — Ceiling surface | 1343 | Formations hang downward (`abs(noise)`). | | 3 — Void→base density | 1379 | Solid outside [floor,ceiling]. | | 4 — Columns | 1399 | World-space hash grid, full-height. | -| 5+6 — Seal + passages | 1461 | Same seal/passage carving as TunnelNetwork. | +| 5+6 — Seal + passages | 1461 | Same seal, passage-tube, landing-room, connector, and support-floor path as TunnelNetwork. | --- @@ -739,7 +760,7 @@ Stage order (negative=solid throughout). Each stage's anchor: | Strate stacking / which strate where | `UVoxelStrateManager::Initialize` :10. | | Publish/use an offline season | Create `UVoxelSeasonAsset`, set `SourceManifestJson`, press `ImportSeasonManifestJson`, then assign it to `UVoxelSettings::Season`. | | Boundary blend between strates | `GetGenerationParams` :515 + `FStrateGenerationParams::Lerp` (expands `VF_STRATE_PARAM_FIELDS`, StrateTypes.h — new fields go in that list). | -| Passages between strates | `GeneratePassages` :146 + `EvaluateModifierSDF` :371 + `ApplyPassageCarving` (Generator.cpp:197). | +| Passages / landing geometry between strates | `GeneratePassages` :146 + `EvaluateModifierSDF` :371 + `ApplyPassageModifier` / `ApplyPassageLandingFloorMC` (StrateManager.cpp) + `ClassifyTile` floor guard (Generator.cpp). | | Player carve/fill | `CarveAtPosition`/`FillAtPosition` VoxelWorld.cpp:691/709 → `UVoxelDiffLayer::ApplyModification` :63. | | Mesh smoothness / normals | Grid-gradient in `GenerateMesh` (`GradAt` lambda), `IsoLevel` (h). | | New slab/flat-world generator | `GetSlabDensity` Generator.cpp:1306 + `FSlabGenerationParams` (StrateTypes.h:1019). | diff --git a/COMPOSER-NOTES.md b/COMPOSER-NOTES.md index 4a2e755..bb9832a 100644 --- a/COMPOSER-NOTES.md +++ b/COMPOSER-NOTES.md @@ -115,7 +115,8 @@ Runtime contains only manifest parsing/verification and recipe evaluation, and ` remains 0. Generated schema-2 slots have no passage/content/visual record: they therefore use deterministic empty/default wider content and zero auto-passages (the origin spine remains). Fixed slots may retain an authored definition path as that wider-content bag. Lateral regions remain unshippable at -the measured 9/16 cross-seam law result. +the measured **4/16** cross-seam law result after the landing pass (the 9/16 figure below is the +pre-landing snapshot). An abbreviated manifest shape is: @@ -268,7 +269,7 @@ through the offline Tier 2 sampler; no runtime generation path calls this API. T test took **76.466 s**. The complete `VoxelForge` namespace then passed **24/24 tests (0 failed, 0 not run)** in **339.797 s**. -### 3.2b Lateral regions — implemented, primordial-law gate failed (2026-09-05) +### 3.2b Lateral regions — implemented, primordial-law gate failed (2026-09-06 post-landing audit) The owner's “tunnel network leading to a big chamber and drops into shafts” question changes the unit of composition: an archetype is a **region inside a strate**, not the strate's label. Tier 4d @@ -284,7 +285,7 @@ different region's stack evaluates too and the two **internal densities** are cr same linear curve used by the vertical transition (split symmetrically at the bisector). Native parameters are never blended: `ShaftSpacing` and a slab's floor/ceiling controls do not share a meaning. Region cores omit structural posts; the parent appends the global spine, vertical seal, -passage carve, and XY edge seal once, in the existing order, after the lateral result. `Density` +passage tube + landing/floor, and XY edge seal once, in the existing order, after the lateral result. `Density` keeps the stack convention positive=solid and is negated only at the MC-facing boundary. The runtime hand-off remains an explicit manifest copy, not a new runtime composer. A worker @@ -310,14 +311,13 @@ if this feature proceeds, a narrower band or a cheaper density junction needs an owner-approved design decision rather than an unreported performance regression. -The primordial law is the blocker. The test deliberately collected **16 actual opposite-region -arrival/departure mouth pairs**, without adding a corridor, changing a threshold, or filtering for -success. The lateral parent connected **9/16 (56.25%)**. The same exact region-zero recipe/vector -and structural settings in a one-region control connected **11/16**, so the underlying structure -roll is itself not universally connected; that control does not excuse the lateral failure. The -same 16 seam cases were **valid 16/16, non-vacuous 16/16, and largest-component share ≥ 0.50 -16/16**; including the unsnapped seam law, full hard-gate survival is therefore **9/16**. The -result is not ready for promotion. A future fix must be a design decision such as a guaranteed +The primordial law is the blocker. The pre-landing audit connected **9/16 (56.25%)**. After the +landing geometry pass, the current same-fixture audit still collected **16 actual opposite-region +arrival/departure mouth pairs** and connected **4/16**; the one-region controls also connected +**4/16**. The same 16 seam cases remain **valid 16/16, non-vacuous 16/16, and largest-component +share ≥ 0.50 16/16**; including the unsnapped seam law, current full hard-gate survival is therefore +**4/16**. The landing itself is geometry-green, but it does not rescue the native region network. +The result is not ready for promotion. A future fix must be a design decision such as a guaranteed cross-seam corridor/landing contract, not a hidden pass threshold or seed selection. The rendered review artifact is @@ -1272,15 +1272,49 @@ Passage placement remains deterministic and cheap, and the slanted control-point Then the measurement pass flood fill is the net: does the upper mouth's air component reach the lower mouth's? If not, re-roll with the attempt folded into the hash. -### 6.5 Findability is mostly DATA, not code +### ✅ 6.5 The passage mouth becomes a LANDING; the landmark remains DATA — BUILT `ELandmarkAnchor::PassageMouth` already exists — landmarks can anchor at passage mouths — and `FStrateLandmark` already carries a Light-Orb block. **A glowing landmark at every passage mouth is -authorable today with no code at all.** Beyond that, cheapest first: light (a glow down a tunnel is the -strongest pull in a cave); sound (works around corners, which light does not — you hear the draft before -you see the hole); and ⭐ **make the mouth a PLACE, not a hole** — the `SuggestLandingPoint` fix already -lands the passage in a room, so make that room distinctive. Cracks in walls are forgettable; rooms are -landmarks, and that is what makes it findable on the *second* visit, which quests need. +authorable today with no code at all.** The geometry half is now built: every end of every inter-strate +passage is a deterministic `FVoxelPassageLanding` — a rounded room, a hard flat support floor, a +doorway tangent to the tube, and a floor-backed connector to the guaranteed `(0,0)` root network. +The content half is deliberately not built here; landmark meshes, lights, sound, and placement +policy belong to the content system. The landing supplies the stable, walkable, deterministic +`PassageMouth` anchor that content placement needs, without inventing landmark geometry here. + +The dimensions derive from the capsule and the existing two-abreast corridor rule: player diameter +`2×0.34/0.25 = 2.72` voxels; floor width `3/0.25 = 12` voxels; capsule height +`2×0.88/0.25 = 7.04` voxels; and 1 m headroom adds 4 voxels. The room uses +`HalfWidth=max(7, MouthRadius+2)`, so its one-voxel-inset support floor is at least 12 voxels / +3 m wide while the authored room is at least 14 voxels / 3.5 m wide. Its height is +`max(12, 7.04+4, 2×MouthRadius+2)`; with the stock 8-voxel mouth, that is 18 voxels / 4.5 m. +The floor is at `StandingPoint.Z−0.5`, three voxels / 0.75 m thick, and the tube centreline meets +the room at `FloorZ+MouthRadius`, so the lower tube tangent is the floor rather than a hole with a +step. The connector is 5 voxels / 1.25 m radius (2.5 m clear width), 12 voxels / 3 m clear height, +with a 4.5-voxel support inset; the room, not the transit leg, owns the turn floor. At the root it +joins a common per-strate annular hub outside the origin spine. A direct ramp is used when it can +stay ≤44°; otherwise the deterministic connector inserts a level dog-leg before its final ramp. +The final-density audit checks every floor normal against the explicit ≤44° walkable-slope limit +(the movement CDO currently reports 44.8°, so this landing contract is conservative). + +The join is guaranteed rather than likely because a source query is only a local capsule proof — it +does not flood-fill the live graph. `GeneratePassages` therefore adds the same deterministic +connector for answered and refused source queries, ending at the common annular hub around `(0,0)`. +The hub is the explicit walkable root network; it is outside the vertical spine, so the spine stays +open while every connector has a floor and a common interior level. It is generated from the +passage seed and is included in the conservative passage bounds. Its structural +post order is `origin spine → vertical seal → passage tube + landing/floor → XY edge seal`; the +landing is clamped inside the vertical seal, the floor backstop runs before the final XY edge seal, +and the edge seal wins at the world rim. `ClassifyTile` and the op-stack both treat a landing floor +as bidirectional geometry, so a proved `AllAir` box cannot erase the support. + +The stable room/floor/door/connector anchor is exactly the hook the landmark content system needs: +it is walkable, known, deterministic, and available through the passage-mouth anchor without +inventing a second placement search. Beyond that, cheapest first: light (a glow down a tunnel is +the strongest pull in a cave) and sound (works around corners, which light does not — you hear the +draft before you see the hole). Rooms are what make the mouth findable on the *second* visit, which +quests need. ### ✅ 6.6 The layout depends on the pool's CONTENTS, not its ORDER (BUILT + TESTS GREEN) @@ -1374,7 +1408,9 @@ refactor, where three weeks of correct work produced a world unchanged by a sing - ✅ **`SuggestLandingPoint` on field sources; passages aim at it** (§6.4) — DONE, **both mouths**. ⚠️ It shipped HALF BUILT for weeks: only the LOWER mouth was ever aimed. See §14.3 — the ring proxy could not see it, and only the Tier 2 connectivity measurement could. -- ⬜ **The passage mouth becomes a room plus a distinctive landmark** (§6.5) — not started. +- ✅ **The passage mouth becomes a room plus a distinctive landmark hook** (§6.5) — BUILT for the + geometry: both ends get deterministic walkable landings, floors, and root-network connectors. + Landmark content itself remains intentionally with the content system. - ⬜ **`SurfaceWorld` still refuses the query** — it needs manager-resolved biome context, which cannot be answered from a pure free function. Open. @@ -1558,7 +1594,7 @@ law has a design-level fix. --- -## 11. Tier 1 status — passages aim at player-fit source space (pure local stencil) +## 11. Tier 1 status — passages aim at player-fit source space (pure local stencil; foundation for §6.5) **Updated 2026-09-06.** `VF_SuggestLandingPoint` (in `VoxelCaveMorphology`) is a **pure free function** of (archetype, params, seed, desired XY, lateral budget) — it touches no operator stack, no @@ -1580,6 +1616,11 @@ large connected component; that question remains in the measurement pass. When n exists inside the bounded source neighbourhood, **the random reach and original XY are preserved unchanged**. +This section is the source-selection contract, not the finished mouth geometry. §6.5 now wraps every +accepted or refused result in a structural landing: the source result chooses a deterministic anchor +when available, while the manager supplies the room, flat floor, doorway, and guaranteed root-network +connector that make the anchor a place to stand. The source query remains pure and manager-free. + ### ⚠️ Coverage is source-dependent and the number is the point The failing pre-fix measurement was: @@ -1632,7 +1673,7 @@ is the only thing standing between a placement change and silent bedrock passage (2026-08-30).** The room-centre correction below remains useful history, but it did not prove a player-sized landing or a large connected component. -### 12.1 2026-09-06 player-fit landing pass +### 12.1 2026-09-06 player-fit source-query pass (historical, before landing geometry) The next correction keeps the pure/re-entrant boundary intact and replaces the query's open-air claim with a local player-pose claim. Both the upper and lower mouths query their own source @@ -1678,7 +1719,7 @@ passed; the full `VoxelForge` namespace finished with **29 successes, 2 known wa 0 not-run** in 2,388.35 s. The warnings remain the gated lateral-region 9/16 law and the known TunnelNetwork op-stack outside-gate sample count; box violations stayed zero. -### 12.2 2026-09-06 Maze spanning-tree correction +### 12.2 2026-09-06 Maze spanning-tree correction (historical, before landing geometry) The Maze lattice no longer uses independent per-edge hash gating. Each non-origin cell chooses one parent from the non-zero coordinate axes and steps one unit toward the origin. The parent reduces @@ -1738,15 +1779,103 @@ rebuild and **0.150 μs/call** for 20,000 hot evaluations; the final namespace r in the thread-local rebuild. The regenerated owner-facing artifact is -`E:/Projet Unreal/VoxelM/Saved/VoxelForge/Showcase/index.html`; the selected Maze card is +`E:/Projet Unreal/VoxelM/Saved/VoxelForge/Showcase/index.html`; the current Maze card is `candidate_1003_plan.png` (with matching `candidate_1003_vertical.png` and fine ROI variants). -The post-fix showcase rendered **6 fine views**, refused **2**, and produced **0 blank** cards. +The landing pass rendered **8 fine views**, refused **0**, and produced **0 blank** cards. The +showcase's diagnostic player-fit gate still found **0/8 hard-gate survivors**; the cards are kept +as walkable geometry previews rather than pretending that unresolved inter-landing law is solved. The final full namespace verification is `Saved/AutomationReports/VoxelForgeFullMazeTreeFinal3/index.json`: **32 tests, 30 clean successes, 2 warning-bearing successes, 0 failures, 0 not-run** in **2,523.978 s**. The two warnings are the pre-existing gated lateral-region result and the known TunnelNetwork outside-gate sample count; neither is a Maze or box-soundness failure. +### 12.3 2026-09-06 §6.5 landing geometry pass + +The mouth is now a place rather than the end of a bore. `GeneratePassages` builds both ends of all +seven inter-strate passages as `FVoxelPassageLanding` descriptors. Each descriptor contains a +rounded room, a three-voxel support slab, a tube doorway at the floor tangent, and an explicit +flat-floor connector to the deterministic `(0,0)` origin-root network. The source query still does +the local archetype selection; the connector is present for both answered and refused queries +because a local capsule proof is not a component proof. + +The body arithmetic is: diameter `2×0.34/0.25 = 2.72` voxels; minimum turn floor `3/0.25 = 12` +voxels; capsule height `2×0.88/0.25 = 7.04` voxels; and one metre of headroom `=4` voxels. +`HalfWidth=max(7,MouthRadius+2)` leaves a one-voxel-inset flat support floor at least 12 voxels / +3 m across and an authored room at least 14 voxels / 3.5 m across. Height is +`max(12,7.04+4,2×MouthRadius+2)`; stock mouth radius 8 therefore produces an 18-voxel / 4.5 m +room. The tube centreline enters at `FloorZ+MouthRadius`, so its lower tangent is the room floor. +The connector is 5 voxels / 1.25 m radius (2.5 m clear width), 12 voxels / 3 m clear height, and +a 4.5-voxel support inset. It joins the common per-strate annular hub outside the origin spine; +the direct ramp is used only when it stays ≤44°, otherwise a deterministic level dog-leg precedes +the final ramp. All 14 landing floors passed the final-density slope bracket; worst measured room +gradient was `0.058998`, below the explicit 44° limit `tan(44°)=0.965689`, and the worst +analytic connector gradient was `0.522406` (walkable limit `0.965689`). The per-end values were recorded by +`VoxelForge.Determinism.PassageLandsInOpenSpace` without per-sample logging. + +The focused landing audit reported **14/14** floor/slope passes, **14/14** explicit root-network +connectors, **0** seal violations, and **0** geometry/floor failures. The targeted tile audit saw +**14** floor boxes, **0** uniform proofs (`AllSolid=0`, `AllAir=0`), and **0** brute-force verdict +violations. The connector itself is a guaranteed walkable branch to the structural root; the +separate player-fit law below correctly remains an empirical test of the native network between +the two landing branches, especially when their floor heights differ. +The same audit measured the passage shortlist at **0.665 μs** per forced rebuild and **0.646 μs** +per hot call on the validation machine; source queries and landing construction remain outside the +voxel loop. + +The support strip stops at the configured origin-spine radius; this preserves the existing vertical +air column rather than capping it with a landing floor. The connector still reaches the spine +boundary, and the full-resolution direct-walk assertion remains enabled. + +Final player-fit component sizes from the fitted-window audit were: + +The table below is the fixed H3 fitted-window identity used by `ScaleDiagnosis`. Separately, the +specific 31.9M-cell whole-strate Maze comparison cited by §6.5 changed from the supplied +**172-cell arrival / 4-cell departure** pocket to **1,235 / 2,938** after the landing. That is a +substantial improvement at both mouths, but it still has a **71.896-voxel** gap and remains +`NotConnectedAtThisResolution`. + +| Archetype | Before: fit / components / largest / arrival / departure / gap / law | After: fit / components / largest / arrival / departure / gap / law | +|---|---|---| +| CrystalChamber | 655 / 197 / 24 / 1 / 1 / 47.529 / NotConnectedAtThisResolution | 1,575 / 90 / 286 / 286 / 274 / 155.042 / NotConnectedAtThisResolution | +| FlatPlain | 1,116 / 45 / 259 / 1 / 1 / 48.104 / NotConnectedAtThisResolution | 2,817 / 11 / 2,130 / 268 / 273 / 155.042 / NotConnectedAtThisResolution | +| FloatingIslands | 4 / 2 / 3 / 1 / 1 / 0.000 / Connected | 72 / 1 / 72 / 72 / 72 / 0.000 / Connected | +| Maze | 2,801 / 309 / 176 / 43 / 81 / 206.630 / NotConnectedAtThisResolution | 12,384 / 340 / 3,210 / 3,210 / 3,210 / 0.000 / Connected | +| SurfaceWorld | 713 / 520 / 6 / 1 / 1 / 110.648 / NotConnectedAtThisResolution | 2,256 / 297 / 286 / 286 / 268 / 164.250 / NotConnectedAtThisResolution | +| TunnelNetwork | 2,748 / 467 / 1,292 / 2 / 1 / 164.405 / NotConnectedAtThisResolution | 6,683 / 227 / 3,841 / 3,841 / 269 / 155.042 / NotConnectedAtThisResolution | +| Underwater | 337 / 86 / 35 / 3 / 11 / 142.499 / NotConnectedAtThisResolution | 5,780 / 117 / 3,838 / 3,838 / 267 / 155.042 / NotConnectedAtThisResolution | +| VerticalShafts | 1,156 / 112 / 96 / 3 / 36 / 94.000 / NotConnectedAtThisResolution | refused: fitted origin-inclusive grid exceeded `MaxCells` | + +Thus the restricted player-fit tally is **1/8 before** and **2/7 after** of the measured archetypes, +with **0** after connections through four cells and **1/8 refused** (VerticalShafts). Maze's +origin-inclusive fitted window is connected, but its independent whole-strate 31,881,528-cell +step-1 window is not: **119,479 fit cells / 2,948 components / largest 11,418 / arrival 1,235 / +departure 2,938 / gap 71.896**, so this is not a cropped-window victory. The hard independent +VerticalShafts air-network refinement is a different, larger check and passed **16/16** fitted +arrival→departure seeds. The remaining player-fit misses are therefore between substantial +landing components and are not being hidden by a relaxed assertion or tuned toward a component +count. + +The final UE 5.7 validation was the requested editor build (4 actions, succeeded; 11.59 s), followed by +`VoxelForgeLandingFinal5`: **32 tests, 30 clean successes, 2 warning-bearing successes, 0 failures, +0 not-run** in **3,607.746 s**. `Composer.Promotion` passed with its strict existing gates; no +assertion was weakened. The two warning-bearing tests are the gated lateral-region audit (**4/16**) +and the known TunnelNetwork op-stack outside-gate sample count; landing, connectivity, seal, +The namespace's showcase child evaluated **256** rolls (**82** eligible single-region, **174** +multi-region skipped while the lateral gate is off) and regenerated +`E:/Projet Unreal/VoxelM/Saved/VoxelForge/Showcase/index.html` with **7 fine renders, 1 refusal, +0 blanks**. + +The landing audit's per-end final-density floor gradients (lower / upper where present) were: +TunnelNetwork **0.044387**; FlatPlain **0.001678 / 0.058998**; CrystalChamber +**0.001434 / 0.006241**; Maze **0.004608 / 0.020844**; SurfaceWorld **0.012390 / 0.012970**; +VerticalShafts **0.000061 / 0.018677**; FloatingIslands **0.005249 / 0.028687**; Underwater +**0.000061 / 0.000061**. All 14 passed against the explicit 44° walkable gradient limit +**0.965689** (`tan(44°)`). +The corresponding analytic connector gradients were **0.183831, 0.149547, 0.522406, 0.285965, +0.037782, 0.113750, 0.069551, 0.215350, 0.189820, 0.201735, 0.132101, 0.157017, 0.011850, +0.028576** (14/14; worst **0.522406**, limit **0.965689**). + ### ⚠️ Correct the record: commit `1ab8c0c` overstated what worked It reported *"3 checked, 48/48 ring samples air"* and read as Tier 1 working. A million-seed sweep later showed the room query left the landing point **OUTSIDE the selected room 92.795% of the time**. The @@ -1961,16 +2090,17 @@ infinite Maze graph is disconnected. | window | exact window / grid | MaxCells | fit cells | components | largest | arrival / departure | gap (voxels) | law | |---|---|---:|---:|---:|---:|---:|---:|---| -| current mouth-AABB + margin | `X[-84.1,168.7) Y[39.5,53.3) Z[-1175,-937)`, `253×14×238`, `842,996` cells, step 1 | 4,000,000 | 1,352 | 50 | 237 | 128 / 1 | 254.492 | `NotConnectedAtThisResolution` | -| origin-inclusive mouth-AABB + margin | `X[-84.1,168.7) Y[0.0,53.3) Z[-1175,-937)`, `253×54×238`, `3,251,556` cells, step 1 | 4,000,000 | 9,598 | 309 | 845 | 120 / 3 | 274.811 | `NotConnectedAtThisResolution` | -| whole-strate centered origin cap window | `X[-183,183) Y[-183,183) Z[-1175,-937)`, `366×366×238`, `31,881,528` cells, step 1 | 32,000,000 | 115,578 | 2,903 | 11,418 | 172 / 4 | 274.811 | `NotConnectedAtThisResolution` | +| current mouth-AABB + margin | `X[-84.1,168.7) Y[39.5,53.3) Z[-1175,-937)`, `253×14×238`, `842,996` cells, step 1 | 4,000,000 | 2,041 | 58 | 510 | 510 / 294 | 254.370 | `NotConnectedAtThisResolution` | +| origin-inclusive mouth-AABB + margin | `X[-84.1,168.7) Y[0.0,53.3) Z[-1175,-937)`, `253×54×238`, `3,251,556` cells, step 1 | 4,000,000 | 12,384 | 340 | 3,210 | 3,210 / 3,210 | 0.000 | `Connected` | +| whole-strate centered origin cap window | `X[-183,183) Y[-183,183) Z[-1175,-937)`, `366×366×238`, `31,881,528` cells, step 1 | 32,000,000 | 119,479 | 2,948 | 11,418 | 1,235 / 2,938 | 71.896 | `NotConnectedAtThisResolution` | -The origin-inclusive box did **not** flip to `Connected`, and the larger centered window did not -either. The hypothesis is therefore **not confirmed** for this candidate. The whole-strate probe -used an explicit 32M cap so it could remain a real step-1 player-fit measurement; the production -fine-law cap remains 4M. A step-2 player-fit probe is not equivalent here: the player-fit API -intentionally refuses every `SampleStep != 1` because the capsule stencil cannot be resolved at -that resolution. +The origin-inclusive box **did** flip this candidate to `Connected`, but the larger centered +whole-strate window did not. The result is therefore a useful local join diagnosis, not a claim that +the native Maze strate is connected end-to-end: the 31.9M-cell measurement is the controlling +whole-strate result. The whole-strate probe used an explicit 32M cap so it could remain a real +step-1 player-fit measurement; the production fine-law cap remains 4M. A step-2 player-fit probe +is not equivalent here: the player-fit API intentionally refuses every `SampleStep != 1` because +the capsule stencil cannot be resolved at that resolution. ### 16.2 Part B — route-aware window policy @@ -1985,10 +2115,10 @@ The policy used by the eight-row law is: - mouth-AABB plus capsule margin: `CrystalChamber`, `FlatPlain`, `SurfaceWorld`, and `FloatingIslands`. -The first group is origin-rooted by its actual topology: Maze parent chains, VerticalShafts +The first group is origin-rooted by its actual native topology: Maze parent chains, VerticalShafts drainage-tree chains, and the origin-flowing room graph shared by TunnelNetwork/Underwater. The -global structural spine exists for every stack, but it is not treated as the promised route for -the four local/independent families. +landing pass also supplies an explicit structural root hub for every archetype, but this diagnostic +keeps that hub separate from the native player-fit law so it cannot hide a broken archetype network. ### 16.3 Part C — corrected eight-row law @@ -1997,24 +2127,23 @@ window column is part of the result, not an omitted implementation detail. | archetype | player-fit window (`X/Y/Z`, grid, cells) | fit cells | components | largest | arrival / departure | gap (voxels) | law | |---|---|---:|---:|---:|---:|---:|---| -| CrystalChamber | mouth-AABB + margin; `X[-178.9,-152.8) Y[-38.2,29.1) Z[-537,-295)`, `27×68×242`, `444,312` | 992 | 102 | 165 | 40 / 6 | 50.070 | `NotConnectedAtThisResolution` | -| FlatPlain | mouth-AABB + margin; `X[-178.9,-152.8) Y[-38.2,29.1) Z[-534,-298)`, `27×68×236`, `433,296` | 2,346 | 9 | 2,337 | 1 / 2,337 | 138.105 | `NotConnectedAtThisResolution` | -| FloatingIslands | mouth-AABB + margin; `X[-143.5,-135.5) Y[121.8,129.8) Z[-537,-295)`, `8×8×242`, `15,488` | 0 | 0 | 0 | 0 / 0 | -1.000 | `StartCellNotPlayerFit` | -| Maze | origin-inclusive mouth-AABB + margin; `X[-84.1,168.7) Y[0.0,53.3) Z[-1175,-937)`, `253×54×238`, `3,251,556` | 9,598 | 309 | 845 | 120 / 3 | 274.811 | `NotConnectedAtThisResolution` | -| SurfaceWorld | mouth-AABB + margin; `X[-133.7,-76.1) Y[-68.8,47.5) Z[-1495,-1257)`, `58×117×238`, `1,615,068` | 1,720 | 248 | 801 | 1 / 3 | 101.454 | `NotConnectedAtThisResolution` | -| TunnelNetwork | origin-inclusive mouth-AABB + margin; `X[-178.9,0.0) Y[-38.2,29.1) Z[-536,-296)`, `179×68×240`, `2,921,280` | 3,684 | 213 | 1,176 | 108 / 82 | 83.361 | `NotConnectedAtThisResolution` | -| Underwater | origin-inclusive mouth-AABB + margin; `X[-178.9,0.0) Y[-38.2,29.1) Z[-535,-297)`, `179×68×238`, `2,896,936` | 1,669 | 79 | 618 | 109 / 109 | 0.000 | `CoarseLiedBudgetExhausted` (**unknown**, not a pass) | +| CrystalChamber | mouth-AABB + margin; `X[-178.9,-152.8) Y[-38.2,29.1) Z[-537,-295)`, `27×68×242`, `444,312` | 1,575 | 90 | 286 | 286 / 274 | 155.042 | `NotConnectedAtThisResolution` | +| FlatPlain | mouth-AABB + margin; `X[-178.9,-152.8) Y[-38.2,29.1) Z[-534,-298)`, `27×68×236`, `433,296` | 2,817 | 11 | 2,130 | 268 / 273 | 155.042 | `NotConnectedAtThisResolution` | +| FloatingIslands | mouth-AABB + margin; `X[-143.5,-135.5) Y[121.8,129.8) Z[-537,-295)`, `8×8×242`, `15,488` | 72 | 1 | 72 | 72 / 72 | 0.000 | `Connected` | +| Maze | origin-inclusive mouth-AABB + margin; `X[-84.1,168.7) Y[0.0,53.3) Z[-1175,-937)`, `253×54×238`, `3,251,556` | 12,384 | 340 | 3,210 | 3,210 / 3,210 | 0.000 | `Connected` | +| SurfaceWorld | mouth-AABB + margin; `X[-133.7,-76.1) Y[-68.8,47.5) Z[-1495,-1257)`, `58×117×238`, `1,615,068` | 2,256 | 297 | 286 | 286 / 268 | 164.250 | `NotConnectedAtThisResolution` | +| TunnelNetwork | origin-inclusive mouth-AABB + margin; `X[-178.9,0.0) Y[-38.2,29.1) Z[-536,-296)`, `179×68×240`, `2,921,280` | 6,683 | 227 | 3,841 | 3,841 / 269 | 155.042 | `NotConnectedAtThisResolution` | +| Underwater | origin-inclusive mouth-AABB + margin; `X[-178.9,0.0) Y[-38.2,29.1) Z[-535,-297)`, `179×68×238`, `2,896,936` | 5,780 | 117 | 3,838 | 3,838 / 267 | 155.042 | `NotConnectedAtThisResolution` | | VerticalShafts | origin-inclusive mouth-AABB + margin; **refused before allocation** because the required grid exceeds `MaxCells=4,000,000` | — | — | — | — | — | `REFUSED(MaxCells)` | -The valid-row tally is **0/7 Connected**, with **1/8 refused** and one `CoarseLiedBudgetExhausted` -unknown. The old one-cell/one-pocket success is not present in this corrected table. +The valid-row tally is **2/7 Connected**, with **1/8 refused** and **0** unknown results. Neither +connected row is a ≤4-cell pocket: `after_connected_through_4_cells=0`. ### 16.4 Plain verdict -The remaining failure is not the cropped measurement window. Maze remains disconnected in player-fit -volume even when the origin is covered and when the full 32M-cell step-1 centered window is measured. -The proven spanning-tree graph and the player-fit volume are different claims: the graph has a route, -but the generated player-fitting corridor/mouth volume does not currently provide a continuous route -from these two passage mouths. The smallest next generation task is therefore at the mouths or their -player-fit join to the corridor network (with the possibility of a fine-floor/stencil break along -the route), not another window, size, density, or topology tuning pass. +The remaining failure is not solved by treating a local origin-inclusive window as the whole strate: +that Maze window is connected, while the full 32M-cell step-1 centered window remains disconnected. +The proven spanning-tree graph and the player-fit volume are different claims. The landing branches +are substantial and explicit-root-connected, but the native player-fitting route between them still +fails at whole-strate scale. That is a genuine between-landings/archetype-network finding, not a +measurement crop, a tiny landing pocket, or a weakened assertion. diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeComposerPromotionTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeComposerPromotionTest.cpp index f0cc45d..73b354c 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeComposerPromotionTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeComposerPromotionTest.cpp @@ -421,13 +421,18 @@ bool FVoxelForgeComposerPromotionTest::RunTest(const FString& Parameters) if (Passage.LowerStrateIndex == CandidateStrateIndex && Passage.UpperStrateIndex + 1 == CandidateStrateIndex) { - ArrivalPoint = Passage.LowerPoint; + // The promotion simulation intentionally remains the legacy coarse air-law probe + // (SampleStep=8), not the Tier-1 fine player-fit law. A floor standing anchor can lie + // in the solid half of a coarse cell after the landing adds its support slab; the + // deterministic door anchor is the actual air position where the tube meets that + // landing and keeps the strict no-snap assertion meaningful. + ArrivalPoint = Passage.LowerLanding.DoorPoint; ++ArrivalCount; } if (Passage.UpperStrateIndex == CandidateStrateIndex && Passage.LowerStrateIndex == CandidateStrateIndex + 1) { - DeparturePoint = Passage.UpperPoint; + DeparturePoint = Passage.UpperLanding.DoorPoint; ++DepartureCount; } } diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeLayoutOrderIndependenceTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeLayoutOrderIndependenceTest.cpp index 2d7caeb..be0c343 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeLayoutOrderIndependenceTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeLayoutOrderIndependenceTest.cpp @@ -136,6 +136,28 @@ namespace && VoxelForgeTest::BitEqual(A.Z, B.Z); } + bool SameLanding(const FVoxelPassageLanding& A, const FVoxelPassageLanding& B) + { + return SameVectorBits(A.StandingPoint, B.StandingPoint) + && SameVectorBits(A.DoorPoint, B.DoorPoint) + && SameVectorBits(A.DoorDirection, B.DoorDirection) + && SameVectorBits(A.ConnectorStart, B.ConnectorStart) + && SameVectorBits(A.ConnectorControl, B.ConnectorControl) + && SameVectorBits(A.ConnectorEnd, B.ConnectorEnd) + && VoxelForgeTest::BitEqual(A.FloorZ, B.FloorZ) + && VoxelForgeTest::BitEqual(A.CeilingZ, B.CeilingZ) + && VoxelForgeTest::BitEqual(A.HalfWidth, B.HalfWidth) + && VoxelForgeTest::BitEqual(A.FloorThickness, B.FloorThickness) + && VoxelForgeTest::BitEqual(A.ConnectorRadius, B.ConnectorRadius) + && VoxelForgeTest::BitEqual(A.ConnectorCeilingZ, B.ConnectorCeilingZ) + && VoxelForgeTest::BitEqual(A.RootFloorZ, B.RootFloorZ) + && VoxelForgeTest::BitEqual(A.RootCeilingZ, B.RootCeilingZ) + && VoxelForgeTest::BitEqual(A.RootSpineRadius, B.RootSpineRadius) + && A.bSourcePlayerFit == B.bSourcePlayerFit + && A.bHasNetworkConnector == B.bHasNetworkConnector + && A.bHasConnectorBend == B.bHasConnectorBend; + } + bool SamePassage(const FVoxelPassage& A, const FVoxelPassage& B, FString& OutMismatch, int32 PassageIndex) { @@ -155,6 +177,14 @@ namespace return false; } + if (!SameLanding(A.UpperLanding, B.UpperLanding) + || !SameLanding(A.LowerLanding, B.LowerLanding)) + { + OutMismatch = FString::Printf( + TEXT("passage %d landing descriptors differ"), PassageIndex); + return false; + } + if (!VoxelForgeTest::BitEqual(A.Radius, B.Radius)) { OutMismatch = FString::Printf( diff --git a/Source/VoxelForge/Private/Tests/VoxelForgePassageOpenSpaceTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgePassageOpenSpaceTest.cpp index b2ddaf9..07ae413 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgePassageOpenSpaceTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgePassageOpenSpaceTest.cpp @@ -4,6 +4,7 @@ #if WITH_DEV_AUTOMATION_TESTS +#include "HAL/PlatformTime.h" #include "Misc/AutomationTest.h" #include "VoxelCaveMorphology.h" @@ -65,6 +66,29 @@ namespace return 0.0f; } } + + float BoundarySealFor(const UVoxelStrateDefinition& Definition) + { + switch (Definition.GeneratorType) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + return Definition.GenerationParams.BoundarySealThickness; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + return Definition.SlabParams.BoundarySealThickness; + case ECaveGeneratorType::Maze: + return Definition.MazeParams.BoundarySealThickness; + case ECaveGeneratorType::SurfaceWorld: + return Definition.SurfaceParams.BoundarySealThickness; + case ECaveGeneratorType::VerticalShafts: + return Definition.VerticalShaftParams.BoundarySealThickness; + case ECaveGeneratorType::FloatingIslands: + return Definition.FloatingIslandParams.BoundarySealThickness; + default: + return 0.0f; + } + } } bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) @@ -104,6 +128,27 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) bool bAllAnswerableFootingsAreValid = true; bool bAllAnswerableEndpointsMatchQuery = true; + int32 NumLandingEnds = 0; + int32 NumLandingFloorsPassed = 0; + int32 NumLandingFloorFailures = 0; + int32 NumLandingNetworkJoins = 0; + int32 NumLandingSealFailures = 0; + int32 NumLandingFloorBoxes = 0; + int32 NumLandingBoxProofs = 0; + int32 NumLandingBoxAllSolid = 0; + int32 NumLandingBoxAllAir = 0; + int32 NumLandingBoxViolations = 0; + int32 NumLandingSourceFit = 0; + int32 NumLandingConnectorSlopesPassed = 0; + int32 NumLandingConnectorSlopeFailures = 0; + float WorstLandingFloorGradient = 0.0f; + float WorstLandingConnectorGradient = 0.0f; + FString FirstLandingFailure; + FString LandingSlopeReport; + constexpr float LandingWalkableAngleDegrees = 44.0f; + const float LandingWalkableSlope = FMath::Tan( + FMath::DegreesToRadians(LandingWalkableAngleDegrees)); + for (const FVoxelPassage& Passage : Passages) { // The surface entry is a same-strate shaft and is not an inter-strate destination query. @@ -279,8 +324,9 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) ++NumChecked; AnsweredArchetypes.Add(static_cast(Definition->GeneratorType)); - // Control-point endpoints are pinned to the complete query result, including any lateral - // snap. A mismatch here means GeneratePassages used stale XY or stale Z downstream. + // Standing endpoints remain pinned to the complete query result, including any lateral + // snap. The tube endpoint is now the landing door, so this intentionally compares the + // public standing anchor rather than the first/last control point. if (!FMath::IsNearlyEqual(Passage.LowerPoint.X, SuggestedPoint.X, 0.01f) || !FMath::IsNearlyEqual(Passage.LowerPoint.Y, SuggestedPoint.Y, 0.01f) || !FMath::IsNearlyEqual(Passage.LowerPoint.Z, SuggestedPoint.Z, 0.01f)) @@ -451,6 +497,337 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) } } + //========================================================================== + // LANDING GEOMETRY — the mouth is now measured as a room, not as a tube. + //========================================================================== + // Use the final production density, including the legacy structural post and any + // disturbance backstop. Five points on every floor give a small numerical walkability + // stencil; the floor writer itself is a plane, but the test must inspect the resulting field. + for (const FVoxelPassage& Passage : Passages) + { + if (Passage.UpperStrateIndex == Passage.LowerStrateIndex) continue; + + const FVoxelPassageLanding* Landings[] = { + &Passage.UpperLanding, &Passage.LowerLanding }; + const int32 StrateIndices[] = { + Passage.UpperStrateIndex, Passage.LowerStrateIndex }; + for (int32 EndIndex = 0; EndIndex < 2; ++EndIndex) + { + ++NumLandingEnds; + const FVoxelPassageLanding& Landing = *Landings[EndIndex]; + const int32 StrateIndex = StrateIndices[EndIndex]; + const UVoxelStrateDefinition* Definition = Layout.IsValidIndex(StrateIndex) + ? Layout[StrateIndex].Definition : nullptr; + + auto RecordFailure = [&](const FString& Message) + { + if (FirstLandingFailure.IsEmpty()) FirstLandingFailure = Message; + }; + + if (Definition == nullptr + || Landing.HalfWidth <= 0.0f + || Landing.CeilingZ <= Landing.FloorZ + || !FMath::IsFinite(Landing.FloorZ) + || !FMath::IsFinite(Landing.CeilingZ) + || !FMath::IsFinite(Landing.HalfWidth)) + { + ++NumLandingFloorFailures; + RecordFailure(FString::Printf( + TEXT("invalid landing descriptor at passage strate end %d/%d"), + Passage.UpperStrateIndex, Passage.LowerStrateIndex)); + continue; + } + + const float StrateTopZ = (float)(Layout[StrateIndex].TopChunkZ + 1) * CHUNK_SIZE; + const float StrateBottomZ = (float)Layout[StrateIndex].BottomChunkZ * CHUNK_SIZE; + const float Seal = BoundarySealFor(*Definition); + if (!(Landing.FloorZ > StrateBottomZ + Seal) + || !(Landing.CeilingZ < StrateTopZ - Seal)) + { + ++NumLandingSealFailures; + RecordFailure(FString::Printf( + TEXT("landing at strate %d reaches its seal (floor %.3f, ceiling %.3f, interior %.3f..%.3f)"), + StrateIndex, Landing.FloorZ, Landing.CeilingZ, + StrateBottomZ + Seal, StrateTopZ - Seal)); + } + + if ((Landing.StandingPoint - (EndIndex == 0 + ? Passage.UpperPoint : Passage.LowerPoint)).SizeSquared() > 0.0001f) + { + ++NumLandingFloorFailures; + RecordFailure(FString::Printf( + TEXT("landing standing anchor does not match passage %s point at strate %d"), + EndIndex == 0 ? TEXT("upper") : TEXT("lower"), StrateIndex)); + } + + const bool bWidthAndHeadroom = Landing.HalfWidth * 2.0f >= 12.0f - 0.01f + && Landing.CeilingZ - Landing.FloorZ >= 12.0f - 0.01f; + const bool bDoorTangent = FMath::IsNearlyEqual( + Landing.DoorPoint.Z - Landing.FloorZ, + Passage.Radius >= 0.0f ? FMath::Max( + Passage.ControlRadii.Num() > 0 + ? Passage.ControlRadii[0] : Passage.Radius, + 1.0f) : 1.0f, + 0.01f); + if (!bWidthAndHeadroom || !bDoorTangent) + { + ++NumLandingFloorFailures; + RecordFailure(FString::Printf( + TEXT("landing at strate %d failed body/door arithmetic (width %.3f, height %.3f, door rise %.3f)"), + StrateIndex, Landing.HalfWidth * 2.0f, + Landing.CeilingZ - Landing.FloorZ, + Landing.DoorPoint.Z - Landing.FloorZ)); + } + + const FVector FloorSamples[] = { + Landing.StandingPoint, + Landing.StandingPoint + FVector(2.0f, 0.0f, 0.0f), + Landing.StandingPoint + FVector(-2.0f, 0.0f, 0.0f), + Landing.StandingPoint + FVector(0.0f, 2.0f, 0.0f), + Landing.StandingPoint + FVector(0.0f, -2.0f, 0.0f) }; + float SurfaceHeights[UE_ARRAY_COUNT(FloorSamples)] = {}; + bool bFloorPassed = true; + float LandingGradient = -1.0f; + for (int32 SampleIndex = 0; SampleIndex < UE_ARRAY_COUNT(FloorSamples); ++SampleIndex) + { + const FVector& Sample = FloorSamples[SampleIndex]; + const float Below = World.Generator->GetDensityAt( + Sample.X, Sample.Y, Landing.FloorZ - 1.0f); + const float Above = World.Generator->GetDensityAt( + Sample.X, Sample.Y, Landing.FloorZ + 1.0f); + const float Denominator = Above - Below; + if (!FMath::IsFinite(Below) || !FMath::IsFinite(Above) + || !(Below < 0.0f) || !(Above >= 0.0f) + || !(Denominator > 0.0f)) + { + bFloorPassed = false; + continue; + } + SurfaceHeights[SampleIndex] = Landing.FloorZ - 1.0f + + FMath::Clamp(-Below / Denominator, 0.0f, 1.0f) * 2.0f; + } + + if (bFloorPassed) + { + float MaxGradient = 0.0f; + for (int32 SampleIndex = 1; SampleIndex < UE_ARRAY_COUNT(FloorSamples); ++SampleIndex) + { + MaxGradient = FMath::Max(MaxGradient, + FMath::Abs(SurfaceHeights[SampleIndex] - SurfaceHeights[0]) / 2.0f); + } + LandingGradient = MaxGradient; + WorstLandingFloorGradient = FMath::Max(WorstLandingFloorGradient, MaxGradient); + const float NormalZ = 1.0f / FMath::Sqrt(1.0f + MaxGradient * MaxGradient); + bFloorPassed = NormalZ + KINDA_SMALL_NUMBER >= FMath::Cos( + FMath::DegreesToRadians(LandingWalkableAngleDegrees)); + } + + float ConnectorGradient = -1.0f; + bool bConnectorSlopePassed = false; + if (Landing.bHasNetworkConnector + && FMath::IsFinite(Landing.ConnectorStart.X) + && FMath::IsFinite(Landing.ConnectorStart.Y) + && FMath::IsFinite(Landing.ConnectorEnd.X) + && FMath::IsFinite(Landing.ConnectorEnd.Y) + && (!Landing.bHasConnectorBend + || (FMath::IsFinite(Landing.ConnectorControl.X) + && FMath::IsFinite(Landing.ConnectorControl.Y))) + && FMath::IsFinite(Landing.RootFloorZ) + && FMath::IsFinite(Landing.FloorZ)) + { + const FVector2D ConnectorStart( + Landing.ConnectorStart.X, Landing.ConnectorStart.Y); + const FVector2D ConnectorEnd( + Landing.ConnectorEnd.X, Landing.ConnectorEnd.Y); + const float DirectLength = (ConnectorEnd - ConnectorStart).Size(); + if (Landing.bHasConnectorBend) + { + const FVector2D ConnectorControl( + Landing.ConnectorControl.X, Landing.ConnectorControl.Y); + const float FinalLegLength = (ConnectorEnd - ConnectorControl).Size(); + const float RampLength = FMath::Max( + FinalLegLength - (Landing.RootSpineRadius > 0.0f ? 2.25f : 0.0f), + KINDA_SMALL_NUMBER); + ConnectorGradient = FMath::Abs( + Landing.RootFloorZ - Landing.FloorZ) / RampLength; + } + else + { + const float RampLength = FMath::Max( + DirectLength - FMath::Max(Landing.HalfWidth - 1.0f, 0.0f) + - (Landing.RootSpineRadius > 0.0f ? 2.25f : 0.0f), + KINDA_SMALL_NUMBER); + ConnectorGradient = FMath::Abs( + Landing.RootFloorZ - Landing.FloorZ) / RampLength; + } + bConnectorSlopePassed = FMath::IsFinite(ConnectorGradient) + && ConnectorGradient <= LandingWalkableSlope + KINDA_SMALL_NUMBER; + } + + if (bConnectorSlopePassed) + { + ++NumLandingConnectorSlopesPassed; + WorstLandingConnectorGradient = FMath::Max( + WorstLandingConnectorGradient, ConnectorGradient); + } + else + { + ++NumLandingConnectorSlopeFailures; + RecordFailure(FString::Printf( + TEXT("landing at strate %d has connector slope %.6f above the %.6f walkable limit"), + StrateIndex, ConnectorGradient, LandingWalkableSlope)); + } + + const bool bLandingSlopePassed = bFloorPassed && bConnectorSlopePassed; + if (bLandingSlopePassed) + { + ++NumLandingFloorsPassed; + } + else + { + ++NumLandingFloorFailures; + RecordFailure(FString::Printf( + TEXT("landing at strate %d failed its final-density flat-floor slope bracket"), + StrateIndex)); + } + + if (Landing.bSourcePlayerFit) + { + ++NumLandingSourceFit; + } + if (Landing.bHasNetworkConnector) + { + ++NumLandingNetworkJoins; + } + else + { + RecordFailure(FString::Printf( + TEXT("landing at strate %d has neither a source fit nor a deterministic network connector"), + StrateIndex)); + } + + // Exercise the same box shortcut that can otherwise erase a support slab. A uniform + // verdict is allowed only if every exact ClassifyTile lattice sample agrees with the + // final density; a floor-bearing landing should normally force Mixed via the + // bidirectional passage effect. This is one aggregate audit, not per-sample logging. + const FIntVector LandingBoxOrigin( + FMath::FloorToInt(Landing.StandingPoint.X) - 4, + FMath::FloorToInt(Landing.StandingPoint.Y) - 4, + FMath::FloorToInt(Landing.FloorZ) - 3); + constexpr int32 LandingBoxCells = 8; + constexpr int32 LandingBoxStep = 1; + const EVoxelTileClass LandingBoxVerdict = World.Generator->ClassifyTile( + LandingBoxOrigin, LandingBoxStep, LandingBoxCells); + ++NumLandingFloorBoxes; + if (LandingBoxVerdict != EVoxelTileClass::Mixed) + { + ++NumLandingBoxProofs; + if (LandingBoxVerdict == EVoxelTileClass::AllSolid) ++NumLandingBoxAllSolid; + if (LandingBoxVerdict == EVoxelTileClass::AllAir) ++NumLandingBoxAllAir; + + bool bBoxAgrees = true; + const bool bClaimsSolid = LandingBoxVerdict == EVoxelTileClass::AllSolid; + for (int32 GZ = -1; GZ <= LandingBoxCells + 1 && bBoxAgrees; ++GZ) + { + for (int32 GY = -1; GY <= LandingBoxCells + 1 && bBoxAgrees; ++GY) + { + for (int32 GX = -1; GX <= LandingBoxCells + 1; ++GX) + { + const float Density = World.Generator->GetDensityAt( + (float)(LandingBoxOrigin.X + GX * LandingBoxStep), + (float)(LandingBoxOrigin.Y + GY * LandingBoxStep), + (float)(LandingBoxOrigin.Z + GZ * LandingBoxStep)); + const bool bMatches = bClaimsSolid + ? Density < 0.0f : Density >= 0.0f; + if (!FMath::IsFinite(Density) || !bMatches) + { + bBoxAgrees = false; + break; + } + } + } + } + if (!bBoxAgrees) + { + ++NumLandingBoxViolations; + if (NumLandingBoxViolations == 1) + { + AddError(FString::Printf( + TEXT("Landing box verdict %d was not sound at (%d,%d,%d): the uniform %s verdict disagreed with final density."), + NumLandingBoxProofs, + LandingBoxOrigin.X, + LandingBoxOrigin.Y, + LandingBoxOrigin.Z, + bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"))); + } + } + } + + const FString LandingArchetype = ArchetypeName(Definition->GeneratorType); + LandingSlopeReport += FString::Printf( + TEXT("%s[%d:%s]=floor:%s(%.6f),connector:%s(%.6f)%s "), + *LandingArchetype, + StrateIndex, + EndIndex == 0 ? TEXT("upper") : TEXT("lower"), + bFloorPassed ? TEXT("PASS") : TEXT("FAIL"), + LandingGradient, + bConnectorSlopePassed ? TEXT("PASS") : TEXT("FAIL"), + ConnectorGradient, + Landing.bHasConnectorBend ? TEXT("[bend]") : TEXT("")); + } + } + + // The passage modifier is called once per density sample, but shortlist construction is + // keyed by (manager, layout version, chunk) in thread_local storage. Measure the hot hit and + // the deliberately alternating two-chunk rebuild separately so the report exposes the cache + // contract without adding a log line for any individual sample. + const FVoxelPassage* TimingPassage = nullptr; + for (const FVoxelPassage& Passage : Passages) + { + if (Passage.UpperStrateIndex != Passage.LowerStrateIndex) + { + TimingPassage = &Passage; + break; + } + } + if (TimingPassage != nullptr) + { + const FVector TimingPoint = TimingPassage->UpperLanding.StandingPoint + + FVector(0.0f, 0.0f, 2.0f); + constexpr int32 HotCalls = 8192; + constexpr int32 RebuildCalls = 96; + volatile float TimingSink = 0.0f; + double StartSeconds = FPlatformTime::Seconds(); + for (int32 Call = 0; Call < HotCalls; ++Call) + { + float Density = 8.0f; + World.StrateManager->ApplyPassageModifier( + Density, TimingPoint.X, TimingPoint.Y, TimingPoint.Z, 8.0f, 0.0f); + TimingSink += Density; + } + const double HotSeconds = FPlatformTime::Seconds() - StartSeconds; + + StartSeconds = FPlatformTime::Seconds(); + for (int32 Call = 0; Call < RebuildCalls; ++Call) + { + const FVector Point = TimingPoint + + ((Call & 1) != 0 ? FVector((float)CHUNK_SIZE, 0.0f, 0.0f) + : FVector::ZeroVector); + float Density = 8.0f; + World.StrateManager->ApplyPassageModifier( + Density, Point.X, Point.Y, Point.Z, 8.0f, 0.0f); + TimingSink += Density; + } + const double RebuildSeconds = FPlatformTime::Seconds() - StartSeconds; + AddInfo(FString::Printf( + TEXT("Passage TLS cache timing: rebuild %.3f us/call over %d alternating chunks; hot %.3f us/call over %d same-chunk calls (sink %.3f)."), + RebuildSeconds * 1000000.0 / (double)RebuildCalls, + RebuildCalls, + HotSeconds * 1000000.0 / (double)HotCalls, + HotCalls, + (float)TimingSink)); + } + AddInfo(FString::Printf( TEXT("Passage player-fit check: %d inter-strate passages, upper %d checked (%d false), lower %d checked (%d footing checks), %d/%d room/slab ring samples air, lower %d query-false (%d unique archetypes; %d unsupported, %d supported-but-no-point)."), NumInterStratePassages, @@ -464,6 +841,36 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) FalseArchetypes.Num(), NumUnsupported, NumSupportedWithoutPoint)); + AddInfo(FString::Printf( + TEXT("Landing geometry: %d/%d ends passed the final-density floor test (worst room gradient %.6f), connector slopes %d/%d (worst %.6f; walkable limit %.6f), source-fit anchors %d/%d, explicit root-network joins %d/%d, seal violations %d, geometry/floor failures %d."), + NumLandingFloorsPassed, + NumLandingEnds, + WorstLandingFloorGradient, + NumLandingConnectorSlopesPassed, + NumLandingEnds, + WorstLandingConnectorGradient, + LandingWalkableSlope, + NumLandingSourceFit, + NumLandingEnds, + NumLandingNetworkJoins, + NumLandingEnds, + NumLandingSealFailures, + NumLandingFloorFailures)); + if (!LandingSlopeReport.IsEmpty()) + { + AddInfo(FString::Printf(TEXT("Landing slope tests per end: %s"), *LandingSlopeReport)); + } + AddInfo(FString::Printf( + TEXT("Landing floor box verdicts: %d boxes probed, %d uniform proofs (AllSolid %d, AllAir %d), %d violations."), + NumLandingFloorBoxes, + NumLandingBoxProofs, + NumLandingBoxAllSolid, + NumLandingBoxAllAir, + NumLandingBoxViolations)); + if (!FirstLandingFailure.IsEmpty()) + { + AddError(FString::Printf(TEXT("First landing geometry failure: %s"), *FirstLandingFailure)); + } // A test that inspected nothing is not evidence of the invariant. Fail loudly in both cases. // Un test qui n'a rien inspecté ne prouve pas l'invariant : échouer explicitement dans les deux cas. @@ -541,6 +948,14 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) } return NumInterStratePassages > 0 + && NumLandingEnds == NumInterStratePassages * 2 + && NumLandingFloorsPassed == NumLandingEnds + && NumLandingConnectorSlopesPassed == NumLandingEnds + && NumLandingConnectorSlopeFailures == 0 + && NumLandingNetworkJoins == NumLandingEnds + && NumLandingSealFailures == 0 + && NumLandingFloorFailures == 0 + && NumLandingBoxViolations == 0 && NumChecked > 0 && NumUpperChecked > 0 && NumFootingChecked > 0 diff --git a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp index 56012d9..df82e13 100644 --- a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp +++ b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp @@ -2056,6 +2056,520 @@ namespace } } +namespace +{ + constexpr float VF_LandingCarveSafetyMargin = 4.0f; + + struct FVoxelPassageLandingConnectorProjection + { + FVector2D Closest = FVector2D::ZeroVector; + float DistanceAlong = 0.0f; + float TotalLength = 0.0f; + float SegmentLength = 0.0f; + float SegmentParameter = 0.0f; + int32 SegmentIndex = 0; + }; + + FVoxelPassageLandingConnectorProjection VF_ProjectPassageLandingConnector( + const FVector& Position, + const FVoxelPassageLanding& Landing) + { + const FVector2D A(Landing.ConnectorStart.X, Landing.ConnectorStart.Y); + const FVector2D Control(Landing.ConnectorControl.X, Landing.ConnectorControl.Y); + const FVector2D B(Landing.ConnectorEnd.X, Landing.ConnectorEnd.Y); + const FVector2D P(Position.X, Position.Y); + const bool bBend = Landing.bHasConnectorBend; + const FVector2D FirstEnd = bBend ? Control : B; + const FVector2D FirstVector = FirstEnd - A; + const FVector2D SecondVector = B - Control; + const float FirstLength = FirstVector.Size(); + const float SecondLength = bBend ? SecondVector.Size() : 0.0f; + + FVoxelPassageLandingConnectorProjection Result; + Result.TotalLength = FirstLength + SecondLength; + Result.Closest = A; + Result.SegmentLength = FirstLength; + if (Result.TotalLength <= KINDA_SMALL_NUMBER) + { + return Result; + } + + float BestDistanceSquared = FLT_MAX; + const auto ProjectSegment = [&](const FVector2D& SegmentStart, + const FVector2D& SegmentEnd, + float SegmentOffset, + int32 SegmentIndex) + { + const FVector2D Segment = SegmentEnd - SegmentStart; + const float LengthSquared = Segment.SizeSquared(); + const float Parameter = LengthSquared > KINDA_SMALL_NUMBER + ? FMath::Clamp(FVector2D::DotProduct(P - SegmentStart, Segment) + / LengthSquared, 0.0f, 1.0f) + : 0.0f; + const FVector2D Candidate = SegmentStart + Segment * Parameter; + const float DistanceSquared = (P - Candidate).SizeSquared(); + if (DistanceSquared < BestDistanceSquared + || (DistanceSquared == BestDistanceSquared + && SegmentIndex < Result.SegmentIndex)) + { + BestDistanceSquared = DistanceSquared; + Result.Closest = Candidate; + Result.DistanceAlong = SegmentOffset + + FMath::Sqrt(FMath::Max(LengthSquared, 0.0f)) * Parameter; + Result.SegmentLength = FMath::Sqrt(FMath::Max(LengthSquared, 0.0f)); + Result.SegmentParameter = Parameter; + Result.SegmentIndex = SegmentIndex; + } + }; + + ProjectSegment(A, FirstEnd, 0.0f, 0); + if (bBend) + { + ProjectSegment(Control, B, FirstLength, 1); + } + return Result; + } + + float VF_PassageLandingConnectorFloorParameter( + const FVoxelPassageLandingConnectorProjection& Projection, + const FVoxelPassageLanding& Landing) + { + if (Projection.TotalLength <= KINDA_SMALL_NUMBER) + { + return 0.0f; + } + + if (Landing.bHasConnectorBend) + { + // The first leg is deliberately level with the room. The second leg carries the + // complete rise and ends with a short level overlap inside the root annulus. + if (Projection.SegmentIndex == 0) + { + return 0.0f; + } + const float RootFlatLength = Landing.RootSpineRadius > 0.0f ? 2.25f : 0.0f; + const float RampLength = FMath::Max( + Projection.SegmentLength - RootFlatLength, KINDA_SMALL_NUMBER); + return FMath::Clamp( + Projection.SegmentParameter * Projection.SegmentLength / RampLength, + 0.0f, 1.0f); + } + + // The direct zero-rise case remains a single segment. Keep the old end aprons for + // descriptors produced by older cooked data, even though new passages use the bend when + // a non-zero rise needs a guaranteed slope. + const float SourceFlatLength = FMath::Max(Landing.HalfWidth - 1.0f, 0.0f); + const float RootFlatLength = Landing.RootSpineRadius > 0.0f ? 2.25f : 0.0f; + const float SourceDistance = FMath::Clamp( + SourceFlatLength, 0.0f, Projection.TotalLength * 0.49f); + const float RootDistance = FMath::Clamp( + Projection.TotalLength - RootFlatLength, + Projection.TotalLength * 0.51f, Projection.TotalLength); + if (RootDistance <= SourceDistance + KINDA_SMALL_NUMBER) + { + return FMath::Clamp( + Projection.DistanceAlong / Projection.TotalLength, 0.0f, 1.0f); + } + return FMath::Clamp( + (Projection.DistanceAlong - SourceDistance) + / (RootDistance - SourceDistance), 0.0f, 1.0f); + } +} + +FVoxelPassageLanding VF_BuildPassageLanding( + const FVector& InStandingPoint, + float MouthRadius, + const FVector& InDoorDirection, + float StrateTopZ, + float StrateBottomZ, + float BoundarySealThickness, + bool bSourcePlayerFit, + bool bHasNetworkConnector, + const FVector& NetworkPoint, + float RootSpineRadius) +{ + FVoxelPassageLanding Landing; + + // Body-derived dimensions at the authored 25 cm voxel scale: + // player diameter = 2 * 34 cm = 0.68 m = 2.72 voxels; + // a 3 m turn floor is 3 / 0.25 = 12 voxels across; + // capsule height = 2 * 88 cm = 1.76 m = 7.04 voxels; + // 1 m of headroom adds 4 voxels, so the minimum room height rounds to 12 voxels (3 m). + // The tube radius adds two voxels of clearance around the doorway. The support writer keeps + // one voxel inside the room wall, so HalfWidth=max(7, MouthRadius+2) gives a guaranteed + // 12-voxel = 3 m flat support floor, while the authored chamber is 14 voxels = 3.5 m wide. + const float SafeMouthRadius = FMath::Max(FMath::Abs(MouthRadius), 1.0f); + const float DesiredHalfWidth = FMath::Max(7.0f, SafeMouthRadius + 2.0f); + const float BodyHeightWithHeadroom = FVoxelPlayerCapsuleConstants::HeightVoxels + 4.0f; + const float DesiredHeight = FMath::Max( + FMath::Max(12.0f, BodyHeightWithHeadroom), + 2.0f * SafeMouthRadius + 2.0f); + const float SafeSeal = FMath::Max(BoundarySealThickness, 0.0f); + + FVector Direction(InDoorDirection.X, InDoorDirection.Y, 0.0f); + if (!Direction.Normalize()) + { + Direction = FVector(1.0f, 0.0f, 0.0f); + } + + Landing.StandingPoint = InStandingPoint; + Landing.FloorZ = InStandingPoint.Z - 0.5f; + Landing.HalfWidth = DesiredHalfWidth; + Landing.CeilingZ = Landing.FloorZ + DesiredHeight; + Landing.FloorThickness = 3.0f; + Landing.DoorDirection = Direction; + Landing.RootSpineRadius = FMath::Max(RootSpineRadius, 0.0f); + Landing.bSourcePlayerFit = bSourcePlayerFit; + Landing.bHasNetworkConnector = false; + Landing.RootFloorZ = Landing.FloorZ; + Landing.RootCeilingZ = Landing.CeilingZ; + + // Keep the complete room and its floor strictly inside the vertical seal. Normal authored + // strates have far more room than this bound; the clamp is a fail-safe for a source pose near + // a seal and never moves a normal interior pose. + const float InnerBottom = StrateBottomZ + SafeSeal; + const float InnerTop = StrateTopZ - SafeSeal; + if (FMath::IsFinite(InnerBottom) && FMath::IsFinite(InnerTop) + && InnerTop > InnerBottom) + { + // Passage carving is intentionally ordered after the vertical seal and fades over its + // four-voxel blend radius. Keep both the underside of the support slab and the top of the + // room beyond that radius from the seal, so the landing cannot use the passage exception + // to open a strate boundary. + const float MinFloor = InnerBottom + Landing.FloorThickness + + VF_LandingCarveSafetyMargin; + const float MaxFloor = InnerTop - DesiredHeight - VF_LandingCarveSafetyMargin; + if (MaxFloor >= MinFloor) + { + Landing.FloorZ = FMath::Clamp(Landing.FloorZ, MinFloor, MaxFloor); + Landing.StandingPoint.Z = Landing.FloorZ + 0.5f; + Landing.CeilingZ = Landing.FloorZ + DesiredHeight; + } + else + { + // A degenerate strate cannot satisfy the full body/headroom contract. Fail closed + // instead of emitting a partial chamber whose blend would approach a seal. + Landing.HalfWidth = 0.0f; + Landing.CeilingZ = Landing.FloorZ; + Landing.bSourcePlayerFit = false; + return Landing; + } + } + + // The tube centreline is tangent to the floor: the tube bottom is exactly FloorZ. The door + // sits one voxel inside the room so the room/tube smooth union cannot leave a lip. + Landing.DoorPoint = Landing.StandingPoint + + Direction * FMath::Max(0.0f, Landing.HalfWidth - 1.0f); + Landing.DoorPoint.Z = Landing.FloorZ + SafeMouthRadius; + + if (bHasNetworkConnector + && FMath::IsFinite(NetworkPoint.X) + && FMath::IsFinite(NetworkPoint.Y) + && FMath::IsFinite(NetworkPoint.Z)) + { + // The connector is a swept rectangular ramp. Its source end keeps the authored landing + // floor; its root end is the common, deterministic interior floor supplied by the + // manager. This is what joins two mouths in one strate when their source floors sit at + // opposite sides of the strate. The manager chooses the common level inside the same + // seal-safe interval as this builder, so no source query or passage order can affect it. + // Five voxels = 1.25 m half-width / 2.5 m clear width. This is the smallest whole-voxel + // annular root corridor that leaves a 1.36-voxel player capsule a two-voxel fit margin on + // the support lattice, while the landing itself remains the 3 m turn floor. Its separate + // 12-voxel (3 m) clear height gives the 1.76 m capsule more than 1 m of headroom while the + // player walks out to the network root. The floor rise is linear along the horizontal + // run; if the direct run is too short, a level dog-leg makes the final ramp satisfy the + // same 44 degree walkable limit by construction. + Landing.ConnectorRadius = 5.0f; + const float ConnectorHeight = FMath::Max( + 12.0f, FVoxelPlayerCapsuleConstants::HeightVoxels + 4.0f); + Landing.ConnectorCeilingZ = Landing.FloorZ + ConnectorHeight; + Landing.RootFloorZ = NetworkPoint.Z; + Landing.RootCeilingZ = Landing.RootFloorZ + ConnectorHeight; + + // Clamp the supplied common level again at the geometry boundary. This is deliberately + // a clamp, not a per-passage repair: all callers derive NetworkPoint.Z from the strate's + // bounds, and malformed tiny strates remain sealed rather than producing a root breach. + if (FMath::IsFinite(InnerBottom) && FMath::IsFinite(InnerTop) + && InnerTop > InnerBottom) + { + const float RootMinFloor = InnerBottom + Landing.FloorThickness + + VF_LandingCarveSafetyMargin; + const float RootMaxFloor = InnerTop - ConnectorHeight + - VF_LandingCarveSafetyMargin; + if (RootMaxFloor >= RootMinFloor) + { + Landing.RootFloorZ = FMath::Clamp( + Landing.RootFloorZ, RootMinFloor, RootMaxFloor); + Landing.RootCeilingZ = Landing.RootFloorZ + ConnectorHeight; + } + else + { + // There is no seal-safe interval for a full landing connector in a malformed + // tiny strate. Fail closed: a partial ramp that reaches a boundary is worse than + // an explicit unanswered landing, and normal authored strates never enter this + // branch. + return Landing; + } + } + else + { + return Landing; + } + + const float ConnectorZ = (Landing.FloorZ + Landing.ConnectorCeilingZ) * 0.5f; + const float RootConnectorZ = (Landing.RootFloorZ + Landing.RootCeilingZ) * 0.5f; + FVector2D RootEntryDirection( + Landing.StandingPoint.X - NetworkPoint.X, + Landing.StandingPoint.Y - NetworkPoint.Y); + if (!RootEntryDirection.Normalize()) + { + RootEntryDirection = FVector2D(Direction.X, Direction.Y); + if (!RootEntryDirection.Normalize()) + { + RootEntryDirection = FVector2D(1.0f, 0.0f); + } + } + + // The structural spine is a vertical air column, so its centre is not a standing + // endpoint. End the ramp in the middle of the support annulus instead: with the 1.36-voxel + // capsule radius, a centre at RootSpineRadius+2.25 sits at least one capsule radius outside + // the spine and at least one capsule radius inside the 4.5-voxel support edge. Every + // passage therefore enters the same continuous, floor-backed annular hub, while the spine + // itself remains open and unsealed. + const float RootEntryRadius = Landing.RootSpineRadius + 2.25f; + const FVector2D RootEntryXY( + NetworkPoint.X + RootEntryDirection.X * RootEntryRadius, + NetworkPoint.Y + RootEntryDirection.Y * RootEntryRadius); + Landing.ConnectorStart = FVector( + Landing.StandingPoint.X, Landing.StandingPoint.Y, ConnectorZ); + Landing.ConnectorEnd = FVector(RootEntryXY.X, RootEntryXY.Y, RootConnectorZ); + + // A direct source-to-root ramp is usually gentle, but its length is not a contract: a + // legal source pose can be close to the spine while its floor is near a strate face. Add a + // deterministic level dog-leg whenever the direct run cannot meet the 44-degree law. + // The final leg is the only rising part, and its extra horizontal run includes the root + // annulus overlap. This makes the slope a construction guarantee for every seed, rather + // than a property observed in today's showcase seeds. WorldRadiusVoxels is deliberately + // zero, so the bounded world rim cannot be approached by this lateral safety run. + constexpr float WalkableSlope = 0.9656888f; // tan(44 degrees) + constexpr float RootFlatLength = 2.25f; + const FVector2D SourceXY(Landing.ConnectorStart.X, Landing.ConnectorStart.Y); + const FVector2D RootXY(Landing.ConnectorEnd.X, Landing.ConnectorEnd.Y); + const FVector2D Direct = RootXY - SourceXY; + const float DirectLength = Direct.Size(); + const float FloorRise = FMath::Abs(Landing.RootFloorZ - Landing.FloorZ); + const float RequiredRampLength = FloorRise / WalkableSlope; + const float SourceFlatLength = FMath::Max(Landing.HalfWidth - 1.0f, 0.0f); + if (FloorRise > KINDA_SMALL_NUMBER + && (DirectLength <= KINDA_SMALL_NUMBER + || DirectLength < RequiredRampLength + RootFlatLength + SourceFlatLength)) + { + FVector2D Along = Direct; + if (!Along.Normalize()) + { + Along = FVector2D(Direction.X, Direction.Y); + if (!Along.Normalize()) + { + Along = FVector2D(1.0f, 0.0f); + } + } + const FVector2D Perpendicular(-Along.Y, Along.X); + const float DogLegLength = RequiredRampLength + RootFlatLength + 1.0f; + const FVector2D ControlXY = RootXY + Perpendicular * DogLegLength; + Landing.ConnectorControl = FVector(ControlXY.X, ControlXY.Y, ConnectorZ); + Landing.bHasConnectorBend = true; + } + else if (FloorRise > KINDA_SMALL_NUMBER) + { + // Keep the same straight geometry when it already satisfies the bound. The explicit + // false flag is part of the descriptor so the evaluator and order-independence test + // cannot infer a different topology from an uninitialised control point. + Landing.ConnectorControl = FVector::ZeroVector; + Landing.bHasConnectorBend = false; + } + else + { + Landing.ConnectorControl = FVector( + (Landing.ConnectorStart.X + Landing.ConnectorEnd.X) * 0.5f, + (Landing.ConnectorStart.Y + Landing.ConnectorEnd.Y) * 0.5f, + ConnectorZ); + Landing.bHasConnectorBend = false; + } + Landing.bHasNetworkConnector = true; + } + + return Landing; +} + +float VF_EvaluatePassageLandingSDF( + const FVector& Position, + const FVoxelPassageLanding& Landing) +{ + if (!FMath::IsFinite(Position.X) || !FMath::IsFinite(Position.Y) + || !FMath::IsFinite(Position.Z) + || !FMath::IsFinite(Landing.StandingPoint.X) + || !FMath::IsFinite(Landing.StandingPoint.Y) + || !FMath::IsFinite(Landing.FloorZ) + || !FMath::IsFinite(Landing.CeilingZ) + || !FMath::IsFinite(Landing.HalfWidth) + || Landing.HalfWidth <= 0.0f + || Landing.CeilingZ <= Landing.FloorZ) + { + return FLT_MAX; + } + + const FVector RoomCenter( + Landing.StandingPoint.X, + Landing.StandingPoint.Y, + (Landing.FloorZ + Landing.CeilingZ) * 0.5f); + constexpr float RoomRounding = 1.5f; + const FVector RoomHalfExtent( + FMath::Max(Landing.HalfWidth - RoomRounding, 0.25f), + FMath::Max(Landing.HalfWidth - RoomRounding, 0.25f), + FMath::Max((Landing.CeilingZ - Landing.FloorZ) * 0.5f - RoomRounding, 0.25f)); + + // max(Room, FloorZ-Z) is the important distinction from a bore: below FloorZ is solid, + // while every horizontal section above it has the same flat floor plane. + float LandingSDF = FMath::Max( + VoxelSDF::RoundedBox(Position, RoomCenter, RoomHalfExtent, RoomRounding), + Landing.FloorZ - Position.Z); + + if (Landing.bHasNetworkConnector && Landing.ConnectorRadius > 0.0f + && FMath::IsFinite(Landing.ConnectorCeilingZ) + && Landing.ConnectorCeilingZ > Landing.FloorZ + && FMath::IsFinite(Landing.RootFloorZ) + && FMath::IsFinite(Landing.RootCeilingZ) + && Landing.RootCeilingZ > Landing.RootFloorZ) + { + const FVoxelPassageLandingConnectorProjection Projection = + VF_ProjectPassageLandingConnector(Position, Landing); + const float FloorT = VF_PassageLandingConnectorFloorParameter(Projection, Landing); + const float HorizontalSDF = (FVector2D(Position.X, Position.Y) + - Projection.Closest).Size() + - Landing.ConnectorRadius; + const float ConnectorFloorZ = FMath::Lerp( + Landing.FloorZ, Landing.RootFloorZ, FloorT); + const float ConnectorCeilingZ = FMath::Lerp( + Landing.ConnectorCeilingZ, Landing.RootCeilingZ, FloorT); + const float VerticalSDF = FMath::Max( + ConnectorFloorZ - Position.Z, + Position.Z - ConnectorCeilingZ); + // A swept box with a flat source leg and a measured-slope final ramp, not a round bore: + // the floor normal is deterministic and bounded, while the fixed twelve-voxel clear height + // follows the route. The source and root ends are exactly the two flat landing floors. + const float ConnectorSDF = FMath::Max(HorizontalSDF, VerticalSDF); + LandingSDF = VoxelSDF::SmoothMin(LandingSDF, ConnectorSDF, 3.0f); + + if (Landing.RootSpineRadius >= 0.0f) + { + // A shared hub turns the guaranteed root join into a walkable network. With the normal + // origin spine it is an annulus outside that shaft; with a disabled spine it safely + // degenerates to a disk, so the landing contract still has a guaranteed root volume. + const float HubRadius = Landing.RootSpineRadius + Landing.ConnectorRadius; + const float RadialDistance = FMath::Sqrt( + FMath::Square(Position.X) + FMath::Square(Position.Y)); + const float HubHorizontalSDF = FMath::Max( + Landing.RootSpineRadius - RadialDistance, + RadialDistance - HubRadius); + const float HubVerticalSDF = FMath::Max( + Landing.RootFloorZ - Position.Z, + Position.Z - Landing.RootCeilingZ); + const float HubSDF = FMath::Max(HubHorizontalSDF, HubVerticalSDF); + LandingSDF = VoxelSDF::SmoothMin(LandingSDF, HubSDF, 3.0f); + } + } + + return LandingSDF; +} + +bool VF_IsPassageLandingFloor( + const FVector& Position, + const FVoxelPassageLanding& Landing) +{ + if (!FMath::IsFinite(Position.X) || !FMath::IsFinite(Position.Y) + || !FMath::IsFinite(Position.Z) || !FMath::IsFinite(Landing.FloorZ) + || !FMath::IsFinite(Landing.FloorThickness) + || Landing.FloorThickness <= 0.0f || Landing.HalfWidth <= 0.0f) + { + return false; + } + + const auto IsInFloorBand = [](float Z, float FloorZ, float Thickness) + { + // The analytic ramp and the sampled density use the same float interpolation, but a + // caller can arrive at the mathematically identical plane through a different fused + // multiply/add sequence. Include one machine epsilon on the upper side so the support + // backstop cannot disappear at an exact floor sample. + return Z <= FloorZ + KINDA_SMALL_NUMBER && Z > FloorZ - Thickness; + }; + const bool bInsideRootSpine = Landing.RootSpineRadius > 0.0f + && FMath::Square(Position.X) + FMath::Square(Position.Y) + <= FMath::Square(Landing.RootSpineRadius); + + // The room floor is a true horizontal landing: it is the only floor that owns the full + // three-metre turning patch. The central root spine is left open. + if (IsInFloorBand(Position.Z, Landing.FloorZ, Landing.FloorThickness) + && !bInsideRootSpine) + { + const float FloorHalfWidth = FMath::Max(Landing.HalfWidth - 1.0f, 0.0f); + if (FMath::Abs(Position.X - Landing.StandingPoint.X) <= FloorHalfWidth + && FMath::Abs(Position.Y - Landing.StandingPoint.Y) <= FloorHalfWidth) + { + return true; + } + } + + if (Landing.bHasNetworkConnector && Landing.ConnectorRadius > 0.0f + && FMath::IsFinite(Landing.RootFloorZ) + && FMath::IsFinite(Landing.RootCeilingZ) + && Landing.RootCeilingZ > Landing.RootFloorZ) + { + const FVoxelPassageLandingConnectorProjection Projection = + VF_ProjectPassageLandingConnector(Position, Landing); + const FVector2D& Closest = Projection.Closest; + const float FloorT = VF_PassageLandingConnectorFloorParameter(Projection, Landing); + const float ConnectorFloorZ = FMath::Lerp( + Landing.FloorZ, Landing.RootFloorZ, FloorT); + if (!bInsideRootSpine + && IsInFloorBand(Position.Z, ConnectorFloorZ, Landing.FloorThickness)) + { + // Keep connector support inset by half a voxel from its clear width. The landing + // chamber—not this narrow transit leg—owns the 3 m turn floor; this inset preserves + // a solid walking strip without capping a neighbouring shaft at its edge. + const float FloorRadius = FMath::Max( + Landing.ConnectorRadius - 0.5f, + FVoxelPlayerCapsuleConstants::RadiusVoxels); + if ((FVector2D(Position.X, Position.Y) - Closest).SizeSquared() + <= FMath::Square(FloorRadius)) + { + return true; + } + } + + if (IsInFloorBand(Position.Z, Landing.RootFloorZ, Landing.FloorThickness)) + { + // The common root hub is a walkable annulus, not a cap over the vertical spine. Its + // 4.5-voxel support inset leaves the same 0.5-voxel air clearance at the outer wall as + // the connector and never writes inside the spine radius. + const float RadialDistanceSquared = FMath::Square(Position.X) + + FMath::Square(Position.Y); + const float HubInnerRadius = Landing.RootSpineRadius; + const float HubOuterSupportRadius = Landing.RootSpineRadius + + FMath::Max(Landing.ConnectorRadius - 0.5f, + FVoxelPlayerCapsuleConstants::RadiusVoxels); + const bool bOutsideSpine = Landing.RootSpineRadius <= 0.0f + || RadialDistanceSquared > FMath::Square(HubInnerRadius); + if (bOutsideSpine + && RadialDistanceSquared <= FMath::Square(HubOuterSupportRadius)) + { + return true; + } + } + } + + return false; +} + bool VF_SuggestLandingPoint( ECaveGeneratorType Archetype, const FStrateGenerationParams& CaveParams, diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index 45b4a50..0ef558e 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -1854,8 +1854,7 @@ namespace void Eval(float X, float Y, float Z, FVoxelOpSample& InOut) const override { if (!Manager) { return; } - const float ModSDF = Manager->EvaluateModifierSDF(X, Y, Z); - VF_ApplyPassageCarving(InOut.Density, ModSDF, Base, Seal); + Manager->ApplyPassageModifier(InOut.Density, X, Y, Z, Base, Seal); } // ≡ la garde `AnyPassageNearBox` écrite à la main dans ClassifyTile — déjà écrite, ici @@ -1863,6 +1862,12 @@ namespace EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext&) const override { if (!Manager) { return EVoxelOpEffect::Identity; } + if (Manager->AnyPassageLandingFloorNearBox(VoxelBox.Min, VoxelBox.Max)) + { + // The room carves air, while its support slab force-writes solid. Both + // hypotheses must therefore be killed for a box touching that slab. + return EVoxelOpEffect::Both; + } return Manager->AnyPassageNearBox(VoxelBox.Min, VoxelBox.Max) ? EVoxelOpEffect::CarveOnly : EVoxelOpEffect::Identity; } @@ -5207,10 +5212,10 @@ void FVoxelOpStack::AppendStructuralPost(float StrateTopWorldZ, float StrateBott const UVoxelStrateManager* StrateManager) { // ORDRE NON NÉGOCIABLE : la spine creuse l'intérieur (et ne touche JAMAIS les bandes de seal), - // le seal vertical re-solidifie ses bandes, les passages percent les seals, puis la limite XY - // gagne sur tout ce qui précède. Ainsi, même un passage placé dans la rampe ou au-delà du rayon - // ne peut pas ouvrir la coque extérieure. Les éditions joueur restent le dernier post de - // GetDensityAt, hors de cette pile, comme avant. + // le seal vertical re-solidifie ses bandes, les passages (tube + landing + support) percent + // les seals, puis la limite XY gagne sur tout ce qui précède. Ainsi, même un passage placé + // dans la rampe ou au-delà du rayon ne peut pas ouvrir la coque extérieure. Les éditions joueur + // restent le dernier post de GetDensityAt, hors de cette pile, comme avant. Add(MakeUnique(StrateTopWorldZ, StrateBottomWorldZ, SealThickness, BaseDensity, SpineRadius)); Add(MakeUnique(StrateTopWorldZ, StrateBottomWorldZ, SealThickness, BaseDensity)); Add(MakeUnique(StrateManager, BaseDensity, SealThickness)); diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index 5a915bf..0c66bfd 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -289,12 +289,6 @@ static FORCEINLINE void ApplyBoundarySeal(float& Density, float WorldZ, VF_ApplyBoundarySeal(Density, WorldZ, StrateTopZ, StrateBottomZ, Thickness, BaseDensity); } -static FORCEINLINE void ApplyPassageCarving(float& Density, float ModSDF, - float BaseDensity, float SealThickness) -{ - VF_ApplyPassageCarving(Density, ModSDF, BaseDensity, SealThickness); -} - static FORCEINLINE void ApplyOriginSpine(float& Density, float WorldX, float WorldY, float WorldZ, float StrateTopZ, float StrateBottomZ, float SealThickness, float BaseDensity, float Radius) { @@ -681,11 +675,12 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co { // SOLID BEDROCK gap between two strates. The auto-carved passages still tunnel // through it, but the (0,0) descent stays solid here so the player digs the gap - // to reach the next layer. No caves, no spine, no vertical seal — just rock + passages; + // to reach the next layer. No caves, no spine, no vertical seal — just rock + passage + // tube/landing; // the global XY edge seal is applied below for bounded worlds. float Density = 8.0f; // bedrock solidity (positive = solid) - const float ModSDF = StrateManager->EvaluateModifierSDF(WorldX, WorldY, WorldZ); - ApplyPassageCarving(Density, ModSDF, 8.0f, 0.0f); + StrateManager->ApplyPassageModifier( + Density, WorldX, WorldY, WorldZ, 8.0f, 0.0f); VF_ApplyXYEdgeSeal(Density, WorldX, WorldY, WorldRadiusVoxels, EdgeSealThickness, 8.0f); Result = -Density; } @@ -996,6 +991,81 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co // Disturbance layer (the "wow" post-process) — cached params, MC convention. ApplyDisturbances(Result, WorldX, WorldY, WorldZ, CP_Dist, (uint32)Seed); + + // A disturbance is allowed to add visual detail, but it must not refill the landing's + // measured air volume or turn its support slab back into a hole. Reassert the landing + // air first, then the structural floor, in MC space before the final XY seal. The two + // calls use the same landing geometry already evaluated by the legacy and op-stack post. + float LandingBaseDensity = CP_Dist.BaseDensity; +#if WITH_EDITOR + // A composer parent can deliberately use a different structural base than the authored + // definition. The landing floor was already applied by PassageCarveOp with that parent + // base; use the same value for this post-disturbance MC backstop or live-vs-direct stack + // evaluation would differ exactly on floor voxels. + if (CP_UseComposerRegions && CP_ComposerRegions.bHasGlobalStructuralParams) + { + LandingBaseDensity = CP_ComposerRegions.BaseDensity; + } + else +#endif + if (CP_UseCustomRecipe) + { + switch (CP_CustomRecipe.StructuralParamBlock) + { + case EVoxelStrateParamBlock::TunnelNetwork: + LandingBaseDensity = CP_CustomParams.TunnelNetworkParams.BaseDensity; + break; + case EVoxelStrateParamBlock::Slab: + LandingBaseDensity = CP_CustomParams.SlabParams.BaseDensity; + break; + case EVoxelStrateParamBlock::Maze: + LandingBaseDensity = CP_CustomParams.MazeParams.BaseDensity; + break; + case EVoxelStrateParamBlock::Surface: + LandingBaseDensity = CP_CustomParams.SurfaceParams.BaseDensity; + break; + case EVoxelStrateParamBlock::VerticalShaft: + LandingBaseDensity = CP_CustomParams.VerticalShaftParams.BaseDensity; + break; + case EVoxelStrateParamBlock::FloatingIsland: + LandingBaseDensity = CP_CustomParams.FloatingIslandParams.BaseDensity; + break; + default: + break; + } + } + else + { + switch (CP_GenType) + { + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + LandingBaseDensity = CP_Slab.BaseDensity; + break; + case ECaveGeneratorType::Maze: + LandingBaseDensity = CP_Maze.BaseDensity; + break; + case ECaveGeneratorType::SurfaceWorld: + LandingBaseDensity = CP_Surface.BaseDensity; + break; + case ECaveGeneratorType::VerticalShafts: + LandingBaseDensity = CP_Vert.BaseDensity; + break; + case ECaveGeneratorType::FloatingIslands: + LandingBaseDensity = CP_Float.BaseDensity; + break; + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + default: + LandingBaseDensity = CP_Tunnel.BaseDensity; + break; + } + } + StrateManager->ApplyPassageLandingAirMC( + Result, WorldX, WorldY, WorldZ, LandingBaseDensity, + CP_Dist.BoundarySealThickness); + StrateManager->ApplyPassageLandingFloorMC( + Result, WorldX, WorldY, WorldZ, LandingBaseDensity); } else { @@ -2151,8 +2221,9 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo //========================================================================= if (StrateManager) { - const float ModSDF = StrateManager->EvaluateModifierSDF(WorldX, WorldY, WorldZ); - ApplyPassageCarving(Density, ModSDF, Params.BaseDensity, Params.BoundarySealThickness); + StrateManager->ApplyPassageModifier( + Density, WorldX, WorldY, WorldZ, + Params.BaseDensity, Params.BoundarySealThickness); } // Fourth structural post: the XY edge wins over a passage near the rim. @@ -2373,8 +2444,9 @@ float UVoxelGenerator::GetSlabDensity(float WorldX, float WorldY, float WorldZ, if (StrateManager) { - const float ModSDF = StrateManager->EvaluateModifierSDF(WorldX, WorldY, WorldZ); - ApplyPassageCarving(Density, ModSDF, Params.BaseDensity, Params.BoundarySealThickness); + StrateManager->ApplyPassageModifier( + Density, WorldX, WorldY, WorldZ, + Params.BaseDensity, Params.BoundarySealThickness); } VF_ApplyXYEdgeSeal(Density, WorldX, WorldY, @@ -2493,8 +2565,9 @@ float UVoxelGenerator::GetMazeDensity(float WorldX, float WorldY, float WorldZ, if (StrateManager) { - const float ModSDF = StrateManager->EvaluateModifierSDF(WorldX, WorldY, WorldZ); - ApplyPassageCarving(Density, ModSDF, Params.BaseDensity, Params.BoundarySealThickness); + StrateManager->ApplyPassageModifier( + Density, WorldX, WorldY, WorldZ, + Params.BaseDensity, Params.BoundarySealThickness); } VF_ApplyXYEdgeSeal(Density, WorldX, WorldY, @@ -2756,8 +2829,9 @@ float UVoxelGenerator::SurfaceDensityFromColumn(float WorldX, float WorldY, floa if (StrateManager) { - const float ModSDF = StrateManager->EvaluateModifierSDF(WorldX, WorldY, WorldZ); - ApplyPassageCarving(Density, ModSDF, S.BaseDensity, S.BoundarySealThickness); + StrateManager->ApplyPassageModifier( + Density, WorldX, WorldY, WorldZ, + S.BaseDensity, S.BoundarySealThickness); } VF_ApplyXYEdgeSeal(Density, WorldX, WorldY, @@ -3139,6 +3213,16 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in { bCanSolid = false; } + // A landing is bidirectional geometry: its chamber kills the AllSolid hypothesis, while its + // guaranteed support slab kills AllAir. Without this second guard a tile covering only the + // floor could be classified uniformly air and the mesher would skip the solid support that + // keeps the player from falling through it. The operator-stack path returns Both for the + // same box, so the hand-written legacy fold must carry the identical hypothesis kill here. + if (StrateManager->AnyPassageLandingFloorNearBox( + FVector(MinX, MinY, MinZ), FVector(MaxX, MaxY, MaxZ))) + { + bCanAir = false; + } if (OriginSpineRadius > 0.0f) { // Spine (0,0) : colonne XY rayon R + blend 3 (cf. ApplyOriginSpine). Test cercle/boîte XY. @@ -4468,8 +4552,9 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float if (StrateManager) { - const float ModSDF = StrateManager->EvaluateModifierSDF(WorldX, WorldY, WorldZ); - ApplyPassageCarving(Density, ModSDF, Params.BaseDensity, Params.BoundarySealThickness); + StrateManager->ApplyPassageModifier( + Density, WorldX, WorldY, WorldZ, + Params.BaseDensity, Params.BoundarySealThickness); } VF_ApplyXYEdgeSeal(Density, WorldX, WorldY, @@ -4628,8 +4713,9 @@ float UVoxelGenerator::GetFloatingIslandDensity(float WorldX, float WorldY, floa if (StrateManager) { - const float ModSDF = StrateManager->EvaluateModifierSDF(WorldX, WorldY, WorldZ); - ApplyPassageCarving(Density, ModSDF, Params.BaseDensity, Params.BoundarySealThickness); + StrateManager->ApplyPassageModifier( + Density, WorldX, WorldY, WorldZ, + Params.BaseDensity, Params.BoundarySealThickness); } VF_ApplyXYEdgeSeal(Density, WorldX, WorldY, diff --git a/Source/VoxelForge/Private/VoxelStrateManager.cpp b/Source/VoxelForge/Private/VoxelStrateManager.cpp index efb7b2c..01c3e61 100644 --- a/Source/VoxelForge/Private/VoxelStrateManager.cpp +++ b/Source/VoxelForge/Private/VoxelStrateManager.cpp @@ -7,6 +7,7 @@ #include "VoxelSeasonAsset.h" #include "VoxelTypes.h" // For CHUNK_SIZE, VOXEL_SIZE, WorldToChunkCoord #include "VoxelCaveMorphology.h" // For VoxelSDF and VoxelHash +#include "VoxelDensityPrimitives.h" // Shared passage carve polarity/strength #include "VoxelTerrainOpDefinition.h" // For UVoxelTerrainOpDefinition::ApplyTo #include "VoxelBiomeDefinition.h" // For UVoxelBiomeDefinition (biome context flatten) #include "UObject/UObjectGlobals.h" @@ -60,6 +61,65 @@ static float PassageFBM(float X, float Seed) return (MaxV > 0.0f) ? (Total / MaxV) : 0.0f; } +namespace +{ + /** + * One shared passage cache for the tube SDF and landing-floor fill. + * + * The source query and all landing dimensions are resolved by GeneratePassages. This cache + * only narrows the immutable passage array once per (manager, version, chunk); neither the + * player-fit stencil nor a topology search can leak into the voxel loop. + */ + struct FPassageEvaluationCache + { + const UVoxelStrateManager* Owner = nullptr; + FIntVector Chunk = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX); + uint32 Version = 0xFFFFFFFFu; + TArray Nearby; + }; + + FPassageEvaluationCache& VF_GetPassageEvaluationCache() + { + thread_local FPassageEvaluationCache Cache; + return Cache; + } + + const TArray& VF_GetNearbyPassages( + const UVoxelStrateManager* Manager, + const FIntVector& ChunkCoord) + { + FPassageEvaluationCache& Cache = VF_GetPassageEvaluationCache(); + const uint32 Version = Manager ? Manager->GetLayoutVersion() : 0u; + if (Cache.Owner == Manager && Cache.Chunk == ChunkCoord && Cache.Version == Version) + { + return Cache.Nearby; + } + + Cache.Owner = Manager; + Cache.Chunk = ChunkCoord; + Cache.Version = Version; + Cache.Nearby.Reset(); + if (!Manager) return Cache.Nearby; + + const FVector ChunkCenter( + (ChunkCoord.X + 0.5f) * (float)CHUNK_SIZE, + (ChunkCoord.Y + 0.5f) * (float)CHUNK_SIZE, + (ChunkCoord.Z + 0.5f) * (float)CHUNK_SIZE); + const float ChunkRadius = (float)CHUNK_SIZE * 0.8660254f + 3.0f; + const TArray& Passages = Manager->GetPassages(); + for (int32 PassageIndex = 0; PassageIndex < Passages.Num(); ++PassageIndex) + { + const FVoxelPassage& Passage = Passages[PassageIndex]; + const float Reach = Passage.BoundRadius + ChunkRadius; + if (FVector::DistSquared(ChunkCenter, Passage.BoundCenter) <= Reach * Reach) + { + Cache.Nearby.Add(PassageIndex); + } + } + return Cache.Nearby; + } +} + bool UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed) { if (!Settings) @@ -601,10 +661,14 @@ void UVoxelStrateManager::GeneratePassages() constexpr uint32 PassageSaltNoiseZ = 0xA1100008u; constexpr uint32 PassageSaltPhase = 0xA1100009u; constexpr uint32 PassageSaltBendFreq = 0xA110000Au; + constexpr uint32 PassageSaltUpperDoor = 0xA110000Bu; + constexpr uint32 PassageSaltLowerDoor = 0xA110000Cu; int32 TotalPassages = 0; int32 NumAimedAtUpperPlayerFit = 0; int32 NumAimedAtLowerPlayerFit = 0; + int32 NumUpperRootConnectors = 0; + int32 NumLowerRootConnectors = 0; TSet NoQueryArchetypes; const auto ArchetypeName = [](ECaveGeneratorType Archetype) @@ -678,6 +742,9 @@ void UVoxelStrateManager::GeneratePassages() case ECaveGeneratorType::Maze: return Definition.MazeParams.BoundarySealThickness; + case ECaveGeneratorType::SurfaceWorld: + return Definition.SurfaceParams.BoundarySealThickness; + case ECaveGeneratorType::VerticalShafts: return Definition.VerticalShaftParams.BoundarySealThickness; @@ -689,6 +756,30 @@ void UVoxelStrateManager::GeneratePassages() } }; + const auto CommonRootFloorFor = [](float StrateTopZ, float StrateBottomZ, + float BoundarySealThickness) -> float + { + // The two mouth queries may legitimately return floors near opposite strate faces. A + // shared root level is therefore derived from the strate bounds, never from passage + // iteration order. Leave the floor slab and the full 12-voxel connector clearance beyond + // the four-voxel passage blend away from both seals; the landing builder repeats this + // clamp as its final safety boundary. + const float Seal = FMath::Max(BoundarySealThickness, 0.0f); + const float InnerBottom = StrateBottomZ + Seal; + const float InnerTop = StrateTopZ - Seal; + const float ConnectorHeight = FMath::Max( + 12.0f, FVoxelPlayerCapsuleConstants::HeightVoxels + 4.0f); + constexpr float PassageBlendSafety = 4.0f; + const float MinimumFloor = InnerBottom + 3.0f + PassageBlendSafety; + const float MaximumFloor = InnerTop - ConnectorHeight - PassageBlendSafety; + if (FMath::IsFinite(MinimumFloor) && FMath::IsFinite(MaximumFloor) + && MaximumFloor >= MinimumFloor) + { + return 0.5f * (MinimumFloor + MaximumFloor); + } + return 0.5f * (InnerBottom + InnerTop); + }; + //========================================================================= // INTER-STRATE PASSAGES: tunnels connecting consecutive strates. // Each passage is randomly assigned one of 5 types, which determines @@ -983,8 +1074,79 @@ void UVoxelStrateManager::GeneratePassages() Passage.ControlRadii.Add(RadiusAt(T)); } - Passage.UpperPoint = Passage.ControlPoints[0]; - Passage.LowerPoint = Passage.ControlPoints.Last(); + // The old control-point endpoints were also the player's standing points. That made + // a sloped/vertical tube open directly under the capsule. Keep the queried points as + // explicit standing anchors, and move only the tube's first/last point to a doorway + // tangent to the landing floor. The endpoint-to-anchor distinction is the geometry + // that turns a mouth into a place. + auto DoorDirectionFrom = [](const FVector& Delta, float Angle) -> FVector + { + FVector Direction(Delta.X, Delta.Y, 0.0f); + if (!Direction.Normalize()) + { + Direction = FVector(FMath::Cos(Angle), FMath::Sin(Angle), 0.0f); + if (!Direction.Normalize()) + { + Direction = FVector(1.0f, 0.0f, 0.0f); + } + } + return Direction; + }; + + const FVector UpperDoorDirection = DoorDirectionFrom( + Passage.ControlPoints.Num() > 1 + ? Passage.ControlPoints[1] - Passage.ControlPoints[0] + : FVector::ZeroVector, + PassageRandom01(PassageSaltUpperDoor) * 2.0f * PI); + const FVector LowerDoorDirection = DoorDirectionFrom( + Passage.ControlPoints.Num() > 1 + ? Passage.ControlPoints[Passage.ControlPoints.Num() - 2] + - Passage.ControlPoints.Last() + : FVector::ZeroVector, + PassageRandom01(PassageSaltLowerDoor) * 2.0f * PI); + + // A source-fit answer proves only a local pose; none of the pure queries performs a + // flood fill over the live strate. Every mouth therefore receives the same explicit + // flat-floor connector to the deterministic root, including answered mouths. This + // is what makes the join guaranteed rather than merely probable. + Passage.UpperLanding = VF_BuildPassageLanding( + FVector(UpperX, UpperY, TopZ), + Cfg.MouthRadius, + UpperDoorDirection, + UpperTopZ, + UpperBottomZ, + UpperDef ? BoundarySealThicknessFor(*UpperDef) : 0.0f, + bAimedUpperAtPlayerFitPoint, + /*bHasNetworkConnector=*/true, + FVector(0.0f, 0.0f, + CommonRootFloorFor( + UpperTopZ, UpperBottomZ, + UpperDef ? BoundarySealThicknessFor(*UpperDef) : 0.0f)), + OriginSpineRadius); + Passage.LowerLanding = VF_BuildPassageLanding( + FVector(LowerX, LowerY, BottomZ), + Cfg.MouthRadius, + LowerDoorDirection, + LowerTopZ, + (float)(Lower.BottomChunkZ) * CHUNK_SIZE, + LowerDef ? BoundarySealThicknessFor(*LowerDef) : 0.0f, + bAimedLowerAtPlayerFitPoint, + /*bHasNetworkConnector=*/true, + FVector(0.0f, 0.0f, + CommonRootFloorFor( + LowerTopZ, (float)(Lower.BottomChunkZ) * CHUNK_SIZE, + LowerDef ? BoundarySealThicknessFor(*LowerDef) : 0.0f)), + OriginSpineRadius); + Passage.UpperPoint = Passage.UpperLanding.StandingPoint; + Passage.LowerPoint = Passage.LowerLanding.StandingPoint; + if (Passage.UpperLanding.bHasNetworkConnector) ++NumUpperRootConnectors; + if (Passage.LowerLanding.bHasNetworkConnector) ++NumLowerRootConnectors; + + if (Passage.ControlPoints.Num() > 0) + { + Passage.ControlPoints[0] = Passage.UpperLanding.DoorPoint; + Passage.ControlPoints.Last() = Passage.LowerLanding.DoorPoint; + } Passage.Radius = 0.0f; for (const float ControlRadius : Passage.ControlRadii) { @@ -992,20 +1154,76 @@ void UVoxelStrateManager::GeneratePassages() } // Fallback / bounds if an invalid authored width produced no positive profile. Passage.Radius = FMath::Max(Passage.Radius, FMath::Max(Cfg.MouthRadius, Cfg.MidRadius)); + Passage.Radius = FMath::Max(Passage.Radius, + FMath::Max(Passage.UpperLanding.ConnectorRadius, + Passage.LowerLanding.ConnectorRadius)); - // Bounding sphere over all control points (+ widest radius + blend) for culling. This - // remains conservative when either mouth snaps laterally: every slanted segment is - // between two control points, and a segment lies inside the sphere containing both - // endpoints. Under-sizing this sphere would make EvaluateModifierSDF cull a real - // passage and leave a sealed pocket. + // Bounding sphere over the tube, both rooms, both floor slabs, and both optional root + // connectors (+ widest radius + blend) for culling. Under-sizing this sphere would + // cull a real landing and leave a sealed pocket, so the room's full box diagonal is + // included rather than treating the standing anchor as a point. { - FVector Center = FVector::ZeroVector; - for (const FVector& CP : Passage.ControlPoints) Center += CP; - Center /= (float)Passage.ControlPoints.Num(); - float MaxDistSq = 0.0f; + FVector BoundsMin(FLT_MAX, FLT_MAX, FLT_MAX); + FVector BoundsMax(-FLT_MAX, -FLT_MAX, -FLT_MAX); + auto IncludePoint = [&BoundsMin, &BoundsMax](const FVector& Point, float Pad) + { + BoundsMin.X = FMath::Min(BoundsMin.X, Point.X - Pad); + BoundsMin.Y = FMath::Min(BoundsMin.Y, Point.Y - Pad); + BoundsMin.Z = FMath::Min(BoundsMin.Z, Point.Z - Pad); + BoundsMax.X = FMath::Max(BoundsMax.X, Point.X + Pad); + BoundsMax.Y = FMath::Max(BoundsMax.Y, Point.Y + Pad); + BoundsMax.Z = FMath::Max(BoundsMax.Z, Point.Z + Pad); + }; for (const FVector& CP : Passage.ControlPoints) - MaxDistSq = FMath::Max(MaxDistSq, (float)FVector::DistSquared(Center, CP)); - const float R = FMath::Sqrt(MaxDistSq) + Passage.Radius + 4.0f; + { + IncludePoint(CP, Passage.Radius + 4.0f); + } + const auto IncludeLanding = [&IncludePoint](const FVoxelPassageLanding& Landing) + { + const float Height = FMath::Max(Landing.CeilingZ - Landing.FloorZ, 0.0f); + const FVector RoomCenter( + Landing.StandingPoint.X, + Landing.StandingPoint.Y, + (Landing.FloorZ + Landing.CeilingZ) * 0.5f); + const float RoomRadius = FMath::Sqrt( + 2.0f * FMath::Square(Landing.HalfWidth) + + 0.25f * FMath::Square(Height)) + + Landing.FloorThickness + 4.0f; + IncludePoint(RoomCenter, RoomRadius); + if (Landing.bHasNetworkConnector) + { + const float HubRadius = Landing.RootSpineRadius + + Landing.ConnectorRadius; + const float HubHalfHeight = 0.5f * FMath::Max( + Landing.RootCeilingZ - Landing.RootFloorZ, 0.0f); + IncludePoint(FVector(0.0f, 0.0f, + (Landing.RootFloorZ + Landing.RootCeilingZ) * 0.5f), + FMath::Sqrt(FMath::Square(HubRadius) + + FMath::Square(HubHalfHeight)) + + Landing.FloorThickness + 4.0f); + const float ConnectorHalfHeight = FMath::Max( + 0.5f * FMath::Max( + Landing.ConnectorCeilingZ - Landing.FloorZ, 0.0f), + Landing.ConnectorRadius); + IncludePoint(Landing.ConnectorStart, + ConnectorHalfHeight + 4.0f); + if (Landing.bHasConnectorBend) + { + IncludePoint(Landing.ConnectorControl, + ConnectorHalfHeight + 4.0f); + } + IncludePoint(Landing.ConnectorEnd, + ConnectorHalfHeight + 4.0f); + } + }; + IncludeLanding(Passage.UpperLanding); + IncludeLanding(Passage.LowerLanding); + + const FVector Center = (BoundsMin + BoundsMax) * 0.5f; + float MaxDistSq = 0.0f; + MaxDistSq = FMath::Max(MaxDistSq, (float)FVector::DistSquared(Center, BoundsMin)); + MaxDistSq = FMath::Max(MaxDistSq, (float)FVector::DistSquared(Center, BoundsMax)); + const float R = FMath::Sqrt(MaxDistSq); Passage.BoundCenter = Center; Passage.BoundRadius = R; Passage.BoundRadiusSq = R * R; @@ -1028,11 +1246,13 @@ void UVoxelStrateManager::GeneratePassages() } UE_LOG(LogTemp, Log, - TEXT("[StrateManager] Passage landing sites: upper %d/%d and lower %d/%d aimed at player-fit source space, %d/%d mouth queries fell back to random reach (archetypes with no query: %s)."), + TEXT("[StrateManager] Passage landings: upper %d/%d and lower %d/%d source-fit; root floor connectors %d/%d; %d/%d mouth queries fell back to random reach (archetypes with no query: %s)."), NumAimedAtUpperPlayerFit, TotalPassages, NumAimedAtLowerPlayerFit, TotalPassages, + NumUpperRootConnectors, + NumLowerRootConnectors, (TotalPassages * 2) - NumAimedAtUpperPlayerFit - NumAimedAtLowerPlayerFit, TotalPassages * 2, *NoQueryList); @@ -1080,52 +1300,16 @@ void UVoxelStrateManager::GeneratePassages() float UVoxelStrateManager::EvaluateModifierSDF(float WorldX, float WorldY, float WorldZ) const { - //========================================================================= - // PER-CHUNK PASSAGE SHORTLIST - //========================================================================= - // This runs PER VOXEL (35³ per tile). The vast majority of chunks are nowhere near a - // descent passage, yet every voxel still walked the WHOLE Passages array just to reject - // each one on a squared-distance test (Passages.Num() × 35³ rejects per tile, all wasted). - // Cache, per chunk, the shortlist of passages whose bounds actually reach this chunk — - // usually EMPTY → instant FLT_MAX return (no carve). Indices (not pointers) + a version - // stamp keep it safe across a GeneratePassages rebuild. Output is bit-identical: the - // shortlist is a conservative superset (chunk bounding sphere vs each passage bound). - thread_local FIntVector SL_Chunk(INT32_MAX, INT32_MAX, INT32_MAX); - thread_local uint32 SL_Version = 0xFFFFFFFFu; - thread_local TArray SL_Nearby; - const FIntVector ChunkCoord( FMath::FloorToInt(WorldX / (float)CHUNK_SIZE), FMath::FloorToInt(WorldY / (float)CHUNK_SIZE), FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE)); - if (ChunkCoord != SL_Chunk || SL_Version != PassagesVersion) - { - SL_Chunk = ChunkCoord; - SL_Version = PassagesVersion; - SL_Nearby.Reset(); + // The shortlist is rebuilt once for a (manager, version, chunk) and is shared by the tube, + // landing, and floor paths. No source-fit stencil or topology search is allowed below it. + const TArray& Nearby = VF_GetNearbyPassages(this, ChunkCoord); - // Chunk bounding sphere (centre + half-diagonal), padded by the blend radius. A passage - // is kept iff its bounding sphere overlaps the chunk's — i.e. some voxel here could be - // inside its per-voxel reject radius. √3/2 · CHUNK_SIZE ≈ 0.866 · size. - const FVector CCenter( - (ChunkCoord.X + 0.5f) * (float)CHUNK_SIZE, - (ChunkCoord.Y + 0.5f) * (float)CHUNK_SIZE, - (ChunkCoord.Z + 0.5f) * (float)CHUNK_SIZE); - const float ChunkR = (float)CHUNK_SIZE * 0.8660254f + 3.0f; // +BlendK - - for (int32 i = 0; i < Passages.Num(); ++i) - { - const FVoxelPassage& P = Passages[i]; - const float Reach = P.BoundRadius + ChunkR; - if (FVector::DistSquared(CCenter, P.BoundCenter) <= Reach * Reach) - { - SL_Nearby.Add(i); - } - } - } - - if (SL_Nearby.Num() == 0) return FLT_MAX; // no passage near this chunk → no carve + if (Nearby.Num() == 0) return FLT_MAX; // no passage near this chunk → no carve float MinSDF = FLT_MAX; const float BlendK = 3.0f; // Smooth blend for passage junctions @@ -1136,7 +1320,7 @@ float UVoxelStrateManager::EvaluateModifierSDF(float WorldX, float WorldY, float // entry is a simple straight tube. A bounding-sphere reject skips far passages. //========================================================================= const FVector Pos(WorldX, WorldY, WorldZ); - for (int32 PIdx : SL_Nearby) + for (int32 PIdx : Nearby) { const FVoxelPassage& P = Passages[PIdx]; // BOUNDING-SPHERE REJECT: skip passages this voxel can't possibly be inside. @@ -1168,11 +1352,125 @@ float UVoxelStrateManager::EvaluateModifierSDF(float WorldX, float WorldY, float const float PassageSDF = VoxelSDF::Capsule(Pos, P.UpperPoint, P.LowerPoint, P.Radius); MinSDF = VoxelSDF::SmoothMin(MinSDF, PassageSDF, BlendK); } + + // A landing is a real room with a hard flat-floor half-space, not a sphere around the + // tube endpoint. The optional connector is the same fixed SDF geometry selected during + // GeneratePassages; it never performs a source query here. + const float UpperLandingSDF = VF_EvaluatePassageLandingSDF(Pos, P.UpperLanding); + const float LowerLandingSDF = VF_EvaluatePassageLandingSDF(Pos, P.LowerLanding); + MinSDF = VoxelSDF::SmoothMin(MinSDF, UpperLandingSDF, BlendK); + MinSDF = VoxelSDF::SmoothMin(MinSDF, LowerLandingSDF, BlendK); } return MinSDF; } +void UVoxelStrateManager::ApplyPassageModifier( + float& Density, float WorldX, float WorldY, float WorldZ, + float BaseDensity, float SealThickness) const +{ + const float ModSDF = EvaluateModifierSDF(WorldX, WorldY, WorldZ); + VF_ApplyPassageCarving(Density, ModSDF, BaseDensity, SealThickness); + ApplyPassageLandingAir(Density, WorldX, WorldY, WorldZ, BaseDensity, SealThickness); + + const FIntVector ChunkCoord( + FMath::FloorToInt(WorldX / (float)CHUNK_SIZE), + FMath::FloorToInt(WorldY / (float)CHUNK_SIZE), + FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE)); + const TArray& Nearby = VF_GetNearbyPassages(this, ChunkCoord); + const FVector Position(WorldX, WorldY, WorldZ); + for (const int32 PassageIndex : Nearby) + { + const FVoxelPassage& Passage = Passages[PassageIndex]; + if (VF_IsPassageLandingFloor(Position, Passage.UpperLanding) + || VF_IsPassageLandingFloor(Position, Passage.LowerLanding)) + { + // This is the one bidirectional part of PassageCarveOp: a floor is a proved solid + // support slab. It is deliberately applied after the air carve so a tube can never + // tunnel through the floor and leave the player over a void. + Density = FMath::Max(Density, BaseDensity); + break; + } + } +} + +void UVoxelStrateManager::ApplyPassageLandingAir( + float& Density, float WorldX, float WorldY, float WorldZ, + float BaseDensity, float SealThickness) const +{ + const FIntVector ChunkCoord( + FMath::FloorToInt(WorldX / (float)CHUNK_SIZE), + FMath::FloorToInt(WorldY / (float)CHUNK_SIZE), + FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE)); + const TArray& Nearby = VF_GetNearbyPassages(this, ChunkCoord); + if (Nearby.Num() == 0) return; + + const FVector Position(WorldX, WorldY, WorldZ); + float MinLandingSDF = FLT_MAX; + for (const int32 PassageIndex : Nearby) + { + const FVoxelPassage& Passage = Passages[PassageIndex]; + if (FVector::DistSquared(Position, Passage.BoundCenter) > Passage.BoundRadiusSq) + { + continue; + } + const FVoxelPassageLanding* Landings[] = { + &Passage.UpperLanding, &Passage.LowerLanding }; + for (const FVoxelPassageLanding* Landing : Landings) + { + const float LandingSDF = VF_EvaluatePassageLandingSDF(Position, *Landing); + if (LandingSDF < MinLandingSDF) + { + MinLandingSDF = LandingSDF; + } + } + } + + if (MinLandingSDF == FLT_MAX) return; + + // Keep the same smooth interior blend used by the passage op. This landing-specific writer is + // idempotent because the MC-facing post may run after the structural op already saw the same + // voxel. The landing's support floor is restored by the floor writer after this MC-facing pass. + VF_ApplyPassageLandingCarving(Density, MinLandingSDF, BaseDensity, SealThickness); +} + +void UVoxelStrateManager::ApplyPassageLandingAirMC( + float& Density, float WorldX, float WorldY, float WorldZ, + float BaseDensity, float SealThickness) const +{ + // ApplyDisturbances is deliberately an MC-space post-process and can add a bridge or ridge + // on top of the structural passage. Reassert only landing air here; the support slab is + // restored by ApplyPassageLandingFloorMC immediately afterwards. This stays on the same + // thread-local passage shortlist as the hot voxel path and performs no source/topology work. + float InternalDensity = -Density; + ApplyPassageLandingAir( + InternalDensity, WorldX, WorldY, WorldZ, BaseDensity, SealThickness); + Density = -InternalDensity; +} + +void UVoxelStrateManager::ApplyPassageLandingFloorMC( + float& Density, float WorldX, float WorldY, float WorldZ, float BaseDensity) const +{ + const FIntVector ChunkCoord( + FMath::FloorToInt(WorldX / (float)CHUNK_SIZE), + FMath::FloorToInt(WorldY / (float)CHUNK_SIZE), + FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE)); + const TArray& Nearby = VF_GetNearbyPassages(this, ChunkCoord); + const FVector Position(WorldX, WorldY, WorldZ); + for (const int32 PassageIndex : Nearby) + { + const FVoxelPassage& Passage = Passages[PassageIndex]; + if (VF_IsPassageLandingFloor(Position, Passage.UpperLanding) + || VF_IsPassageLandingFloor(Position, Passage.LowerLanding)) + { + // Result is in MC convention here (negative = solid). This reassertion is the + // structural floor backstop after the optional MC-space disturbance layer. + Density = FMath::Min(Density, -BaseDensity); + break; + } + } +} + bool UVoxelStrateManager::AnyPassageNearBox(const FVector& MinVoxel, const FVector& MaxVoxel) const { // Le carve d'un passage atteint ModSDF < PASSAGE_BLEND_RADIUS (4, VoxelGenerator.cpp) au-delà de @@ -1194,6 +1492,99 @@ bool UVoxelStrateManager::AnyPassageNearBox(const FVector& MinVoxel, const FVect return false; } +bool UVoxelStrateManager::AnyPassageLandingFloorNearBox( + const FVector& MinVoxel, const FVector& MaxVoxel) const +{ + // A conservative AABB test is enough for the floor's only solid write. False positives cost + // a tile; a false negative could classify an air tile uniformly and remove the player's floor. + for (const FVoxelPassage& Passage : Passages) + { + const FVoxelPassageLanding* Landings[] = { + &Passage.UpperLanding, &Passage.LowerLanding }; + for (const FVoxelPassageLanding* Landing : Landings) + { + if (Landing->HalfWidth <= 0.0f || Landing->FloorThickness <= 0.0f) + { + continue; + } + const float Pad = 1.0f; + const float FloorMinX = Landing->StandingPoint.X - Landing->HalfWidth - Pad; + const float FloorMaxX = Landing->StandingPoint.X + Landing->HalfWidth + Pad; + const float FloorMinY = Landing->StandingPoint.Y - Landing->HalfWidth - Pad; + const float FloorMaxY = Landing->StandingPoint.Y + Landing->HalfWidth + Pad; + const float FloorMinZ = Landing->FloorZ - Landing->FloorThickness - Pad; + const float FloorMaxZ = Landing->FloorZ + Pad; + if (FloorMaxX >= MinVoxel.X && FloorMinX <= MaxVoxel.X + && FloorMaxY >= MinVoxel.Y && FloorMinY <= MaxVoxel.Y + && FloorMaxZ >= MinVoxel.Z && FloorMinZ <= MaxVoxel.Z) + { + return true; + } + + // The fallback connector carries the same support slab away from the room. Its + // segment is not covered by the room AABB above; omitting it would let a tile that + // contains only the connector floor prove AllAir and erase the guaranteed join. + if (Landing->bHasNetworkConnector && Landing->ConnectorRadius > 0.0f + && FMath::IsFinite(Landing->ConnectorStart.X) + && FMath::IsFinite(Landing->ConnectorStart.Y) + && FMath::IsFinite(Landing->ConnectorStart.Z) + && FMath::IsFinite(Landing->ConnectorEnd.X) + && FMath::IsFinite(Landing->ConnectorEnd.Y) + && FMath::IsFinite(Landing->ConnectorEnd.Z)) + { + const float ConnectorPad = Landing->ConnectorRadius + Pad; + float ConnectorMinX = FMath::Min( + Landing->ConnectorStart.X, Landing->ConnectorEnd.X) - ConnectorPad; + float ConnectorMaxX = FMath::Max( + Landing->ConnectorStart.X, Landing->ConnectorEnd.X) + ConnectorPad; + float ConnectorMinY = FMath::Min( + Landing->ConnectorStart.Y, Landing->ConnectorEnd.Y) - ConnectorPad; + float ConnectorMaxY = FMath::Max( + Landing->ConnectorStart.Y, Landing->ConnectorEnd.Y) + ConnectorPad; + if (Landing->bHasConnectorBend) + { + ConnectorMinX = FMath::Min( + ConnectorMinX, Landing->ConnectorControl.X - ConnectorPad); + ConnectorMaxX = FMath::Max( + ConnectorMaxX, Landing->ConnectorControl.X + ConnectorPad); + ConnectorMinY = FMath::Min( + ConnectorMinY, Landing->ConnectorControl.Y - ConnectorPad); + ConnectorMaxY = FMath::Max( + ConnectorMaxY, Landing->ConnectorControl.Y + ConnectorPad); + } + const float ConnectorMinZ = FMath::Min( + Landing->FloorZ, Landing->RootFloorZ) + - Landing->FloorThickness - Pad; + const float ConnectorMaxZ = FMath::Max( + Landing->FloorZ, Landing->RootFloorZ) + Pad; + if (ConnectorMaxX >= MinVoxel.X && ConnectorMinX <= MaxVoxel.X + && ConnectorMaxY >= MinVoxel.Y && ConnectorMinY <= MaxVoxel.Y + && ConnectorMaxZ >= MinVoxel.Z && ConnectorMinZ <= MaxVoxel.Z) + { + return true; + } + + if (Landing->RootSpineRadius >= 0.0f) + { + const float HubPad = Landing->RootSpineRadius + + Landing->ConnectorRadius + Pad; + const float HubMinZ = Landing->RootFloorZ + - Landing->FloorThickness - Pad; + const float HubMaxZ = Landing->RootFloorZ + Pad; + if (HubPad >= 0.0f + && HubPad >= MinVoxel.X && -HubPad <= MaxVoxel.X + && HubPad >= MinVoxel.Y && -HubPad <= MaxVoxel.Y + && HubMaxZ >= MinVoxel.Z && HubMinZ <= MaxVoxel.Z) + { + return true; + } + } + } + } + } + return false; +} + //============================================================================= // QUERIES //============================================================================= diff --git a/Source/VoxelForge/Public/VoxelCaveMorphology.h b/Source/VoxelForge/Public/VoxelCaveMorphology.h index 1925dcd..513e0b0 100644 --- a/Source/VoxelForge/Public/VoxelCaveMorphology.h +++ b/Source/VoxelForge/Public/VoxelCaveMorphology.h @@ -41,6 +41,48 @@ struct FStrateTerrainOpEntry; class UVoxelTerrainOpDefinition; enum class ECaveGeneratorType : uint8; +/** + * The geometry contract for one end of an inter-strate passage. + * + * All values are actor-space voxels. `StandingPoint` is the player-fit anchor: its floor is + * `FloorZ = StandingPoint.Z - 0.5`. The chamber is deliberately separate from the tube endpoint + * so a sloped/vertical tube never presents the player with a hole as its first standing position. + * + * A source query answer selects the landing's XY/Z anchor. Because that pure query does not + * flood-fill the live network, every inter-strate end also gets a deterministic root connector + * (`bHasNetworkConnector`). Its flat floor ramps to the same common interior root level for that + * strate, so both mouths enter one walkable hub even when their authored floors are at opposite + * sides of the strate. The hub is an annular walkable network around (0,0), not the pre-existing + * vertical air shaft. `RootSpineRadius` keeps both the hub air and support slab outside that shaft, + * so the spine remains open. If the direct run would be steeper than 44 degrees, the connector + * uses a deterministic level dog-leg before its final ramp; the bend is geometry, not a per-voxel + * search. + */ +struct VOXELFORGE_API FVoxelPassageLanding +{ + FVector StandingPoint = FVector::ZeroVector; + FVector DoorPoint = FVector::ZeroVector; + FVector DoorDirection = FVector(1.0f, 0.0f, 0.0f); + + FVector ConnectorStart = FVector::ZeroVector; + FVector ConnectorControl = FVector::ZeroVector; + FVector ConnectorEnd = FVector::ZeroVector; + + float FloorZ = 0.0f; + float CeilingZ = 0.0f; + float HalfWidth = 0.0f; + float FloorThickness = 3.0f; + float ConnectorRadius = 0.0f; + float ConnectorCeilingZ = 0.0f; + float RootFloorZ = 0.0f; + float RootCeilingZ = 0.0f; + float RootSpineRadius = 0.0f; + + bool bSourcePlayerFit = false; + bool bHasNetworkConnector = false; + bool bHasConnectorBend = false; +}; + //============================================================================= // SDF PRIMITIVES //============================================================================= @@ -153,6 +195,29 @@ namespace VoxelSDF } } +/** Build the deterministic, body-sized room geometry at one standing anchor. */ +VOXELFORGE_API FVoxelPassageLanding VF_BuildPassageLanding( + const FVector& StandingPoint, + float MouthRadius, + const FVector& DoorDirection, + float StrateTopZ, + float StrateBottomZ, + float BoundarySealThickness, + bool bSourcePlayerFit, + bool bHasNetworkConnector, + const FVector& NetworkPoint, + float RootSpineRadius = 0.0f); + +/** Signed distance of the room + optional root connector. Negative means passage air. */ +VOXELFORGE_API float VF_EvaluatePassageLandingSDF( + const FVector& Position, + const FVoxelPassageLanding& Landing); + +/** True when a point belongs to the guaranteed solid floor slab of the landing/connector. */ +VOXELFORGE_API bool VF_IsPassageLandingFloor( + const FVector& Position, + const FVoxelPassageLanding& Landing); + //============================================================================= // HASH FUNCTIONS //============================================================================= diff --git a/Source/VoxelForge/Public/VoxelDensityOpStack.h b/Source/VoxelForge/Public/VoxelDensityOpStack.h index fccc6c8..57e1e4e 100644 --- a/Source/VoxelForge/Public/VoxelDensityOpStack.h +++ b/Source/VoxelForge/Public/VoxelDensityOpStack.h @@ -176,7 +176,8 @@ public: VF_FoldOp(H, *Entry.Op, VoxelBox, Ctx, (Entry.Writes & VoxelOpChannels::Sdf) != 0); // Do not early-out on a dead hypothesis: a later forcing structural post may - // deliberately overwrite it (the XY edge seal is appended after passage carving). + // deliberately overwrite it (the XY edge seal is appended after passage tube/landing + // carving and support). } return H.Resolve(); } diff --git a/Source/VoxelForge/Public/VoxelDensityPrimitives.h b/Source/VoxelForge/Public/VoxelDensityPrimitives.h index 5286b31..1fe781f 100644 --- a/Source/VoxelForge/Public/VoxelDensityPrimitives.h +++ b/Source/VoxelForge/Public/VoxelDensityPrimitives.h @@ -87,6 +87,28 @@ FORCEINLINE void VF_ApplyPassageCarving(float& Density, float ModSDF, Density = FMath::Min(Density, FMath::Lerp(Density, AirTarget, CarveFactor)); } +// Landing air is also reasserted after the MC-space disturbance post. Unlike the legacy tube +// carve above, this threshold is independent of the incoming density: applying it a second time +// is therefore bit-stable. The structural passage op and the post-disturbance backstop can share +// the same operation without turning an otherwise unchanged live-vs-direct op-stack comparison +// into a second, deeper smooth carve. The tube intentionally retains its old current-density +// interpolation because that is part of the legacy structural path's contract. +FORCEINLINE void VF_ApplyPassageLandingCarving(float& Density, float LandingSDF, + float BaseDensity, float SealThickness) +{ + constexpr float PASSAGE_BLEND_RADIUS = 4.0f; + if (LandingSDF >= PASSAGE_BLEND_RADIUS) return; + + float CarveFactor = FMath::Clamp( + (PASSAGE_BLEND_RADIUS - LandingSDF) / (PASSAGE_BLEND_RADIUS * 2.0f), + 0.0f, 1.0f); + CarveFactor = SmoothStep01(CarveFactor); + + const float AirTarget = -(BaseDensity * 2.0f + SealThickness + 4.0f); + const float LandingThreshold = FMath::Lerp(BaseDensity, AirTarget, CarveFactor); + Density = FMath::Min(Density, LandingThreshold); +} + //============================================================================= // SPINE DE DESCENTE (0,0) / (0,0) DESCENT SPINE //============================================================================= diff --git a/Source/VoxelForge/Public/VoxelStrateManager.h b/Source/VoxelForge/Public/VoxelStrateManager.h index cb65729..0ddcb38 100644 --- a/Source/VoxelForge/Public/VoxelStrateManager.h +++ b/Source/VoxelForge/Public/VoxelStrateManager.h @@ -21,6 +21,7 @@ #include "VoxelStrateTypes.h" #include "VoxelStrateDefinition.h" #include "VoxelSeasonManifest.h" +#include "VoxelCaveMorphology.h" #include "VoxelStrateManager.generated.h" class UVoxelSettings; @@ -84,6 +85,11 @@ struct FVoxelPassage // ControlPoints, the uniform Radius is used as a fallback. TArray ControlRadii; + // Body-sized, flat-floored chambers at both tube ends. UpperPoint/LowerPoint remain the + // standing anchors returned by the source query; the tube enters each room at DoorPoint. + FVoxelPassageLanding UpperLanding; + FVoxelPassageLanding LowerLanding; + // Bounding sphere enclosing the whole passage (+ radius + blend), in voxel coords. // Computed once in GeneratePassages; lets EvaluateModifierSDF reject far voxels with // a single squared-distance test instead of walking every segment per voxel. @@ -348,6 +354,22 @@ public: */ float EvaluateModifierSDF(float WorldX, float WorldY, float WorldZ) const; + /** Apply the passage tube/landing carve and the guaranteed landing floor in internal density. */ + void ApplyPassageModifier(float& Density, float WorldX, float WorldY, float WorldZ, + float BaseDensity, float SealThickness) const; + + /** Reapply the landing-only air carve in internal density (used after visual disturbances). */ + void ApplyPassageLandingAir(float& Density, float WorldX, float WorldY, float WorldZ, + float BaseDensity, float SealThickness) const; + + /** Re-assert landing air after MC-space disturbances, still before the final XY seal. */ + void ApplyPassageLandingAirMC(float& Density, float WorldX, float WorldY, float WorldZ, + float BaseDensity, float SealThickness) const; + + /** Re-assert a landing floor after the landing air pass, still before the final XY seal. */ + void ApplyPassageLandingFloorMC(float& Density, float WorldX, float WorldY, float WorldZ, + float BaseDensity) const; + /** * True si la sphère englobante d'un passage (élargie du rayon de blend de carve) touche la * boîte VOXEL [MinVoxel, MaxVoxel]. Test conservatif O(Passages) — utilisé par ClassifyTile @@ -355,6 +377,9 @@ public: */ bool AnyPassageNearBox(const FVector& MinVoxel, const FVector& MaxVoxel) const; + /** Conservative box guard for the floor fill; unlike a tube it can affect the all-air proof. */ + bool AnyPassageLandingFloorNearBox(const FVector& MinVoxel, const FVector& MaxVoxel) const; + /** Get all generated passages (for debug display). */ const TArray& GetPassages() const { return Passages; }