diff --git a/.gitignore b/.gitignore index d639c709..2fdb5151 100644 --- a/.gitignore +++ b/.gitignore @@ -206,6 +206,17 @@ PublishScripts/ *.snupkg # The packages folder can be ignored because of Package Restore **/[Pp]ackages/* +!/packages/gamecult-geometry-rs/ +!/packages/gamecult-geometry-rs/** +packages/gamecult-geometry-rs/target/ +.tools/ +!/packages/gamecult-geometry-ts/ +!/packages/gamecult-geometry-ts/** +!/unity/org.gamecult.geometry/Samples~/ +!/unity/org.gamecult.geometry/Samples~/** +!/unity/org.gamecult.geometry/Runtime/PlanetaryPatchMeshAdapter.cs.meta +!/unity/org.gamecult.geometry/Runtime/PlanetaryPageUpload.cs.meta +!/src/GameCult.Geometry/Shaders/*.hlsl.meta # except build/, which is used as an MSBuild target. !**/[Pp]ackages/build/ # Uncomment if necessary however generally it will be regenerated when needed @@ -241,6 +252,8 @@ _pkginfo.txt ClientBin/ ~$* *~ +!/unity/org.gamecult.geometry/Samples~/ +!/unity/org.gamecult.geometry/Samples~/** *.dbmdl *.dbproj.schemaview *.jfm diff --git a/CultLib.sln b/CultLib.sln index f6ae25e9..0319071d 100644 --- a/CultLib.sln +++ b/CultLib.sln @@ -29,6 +29,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Geometry", "src\Ga EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Geometry.Tests", "tests\GameCult.Geometry.Tests\GameCult.Geometry.Tests.csproj", "{C95A9A21-34FA-4D82-BE5B-88FBDA836D31}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Geometry.Planetary.Tests", "tests\GameCult.Geometry.Planetary.Tests\GameCult.Geometry.Planetary.Tests.csproj", "{CE447820-B407-48DE-8957-F373E032C4AE}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Mesh.Tests", "tests\GameCult.Mesh.Tests\GameCult.Mesh.Tests.csproj", "{F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}" EndProject Global @@ -173,6 +175,18 @@ Global {C95A9A21-34FA-4D82-BE5B-88FBDA836D31}.Release|x64.Build.0 = Release|Any CPU {C95A9A21-34FA-4D82-BE5B-88FBDA836D31}.Release|x86.ActiveCfg = Release|Any CPU {C95A9A21-34FA-4D82-BE5B-88FBDA836D31}.Release|x86.Build.0 = Release|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Debug|x64.ActiveCfg = Debug|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Debug|x64.Build.0 = Debug|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Debug|x86.ActiveCfg = Debug|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Debug|x86.Build.0 = Debug|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Release|Any CPU.Build.0 = Release|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Release|x64.ActiveCfg = Release|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Release|x64.Build.0 = Release|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Release|x86.ActiveCfg = Release|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Release|x86.Build.0 = Release|Any CPU {F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}.Debug|Any CPU.Build.0 = Debug|Any CPU {F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -201,6 +215,7 @@ Global {EB15435B-7147-4BD9-BB9C-B0A14318D3D5} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} {94C086DA-3359-424B-9688-29A198806481} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} {C95A9A21-34FA-4D82-BE5B-88FBDA836D31} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} + {CE447820-B407-48DE-8957-F373E032C4AE} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} {F8A5060A-FB0B-4B8B-AE06-5147BB4CA127} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} EndGlobalSection EndGlobal diff --git a/Directory.Build.props b/Directory.Build.props index 519e7a93..e38b66bc 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -15,6 +15,8 @@ © Meta Vens and contributors true 1.0.3 + $(MSBuildThisFileDirectory)..\CultMath\.tools\local-feed + $(RestoreAdditionalProjectSources);$(CultMathCandidateFeed) true diff --git a/README.md b/README.md index d6e3778c..80dc90a5 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ The solution includes: - `GameCult.Caching.NewtonsoftJson`: Newtonsoft.Json-backed persistence for the cache - `GameCult.Caching.MessagePack.Generator`: source generator for MessagePack formatters for cache models - `GameCult.Caching.MessagePack.Analyzers`: packaging project that delivers the generator to consuming projects -- `GameCult.Geometry`: CultCache-native geometry domain, selected-cut, and chunk artifact documents for VibeGeometry/Fensalir-style pipelines +- `GameCult.Geometry`: geometry, planetary mapping, CSG, LOD selection, mesh assembly, and CultCache-native geometry documents built on CultMath primitives - `GameCult.Networking`: encrypted login/register/verify flows, schema-v0 contracts, transport adapters, and native RUDP sessions - `GameCult.Mesh`: CultMesh package home for distributed realtime database, shard replication, client prediction, Verse discovery, and mesh witness consensus - `GameCult.Caching.Tests`: NUnit tests for cache and backing-store behavior diff --git a/docs/cultmath-gamecult-geometry-migration-plan.md b/docs/cultmath-gamecult-geometry-migration-plan.md new file mode 100644 index 00000000..e8e961c4 --- /dev/null +++ b/docs/cultmath-gamecult-geometry-migration-plan.md @@ -0,0 +1,552 @@ +# CultMath and GameCult.Geometry Staged Migration Plan + +Status: proposed for execution + +Date: 2026-07-22 + +## Decision + +`GameCult.Geometry` is the sole owner of geometric meaning and geometry +machinery. It builds on CultMath numeric primitives and participates in +CultCache, CultNet, and CultMesh through the normal CultLib integration paths. + +CultMath owns the HLSL-shaped numeric substrate: scalar operations, vectors, +quaternions, and general numeric kernels that have no spatial domain authority. +It does not own regions, topology, projections, surfaces, planetary fields, +patches, geometry residency, meshes, or geometry workflow state. + +VibeGeometry and `vg-csg` are sources to mine. They are not surviving owners or +dependencies. + +Source transfer does not erase provenance. CultMath is MPL-2.0 and CultLib is +MIT-licensed; moved files require an explicit licensing decision and preserved +notices before they enter a differently licensed package. + +## Objective + +Produce one coherent geometry stack in which: + +- consumers use `CultMath.float2`, `float3`, and `float4` as the canonical + floating-point vector primitives; +- `GameCult.Geometry` owns reusable geometry, planetary geometry, CSG, mesh + production, spatial queries, and the typed documents that coordinate those + systems; +- CultMath no longer publishes planetary or other geometry-owned APIs; +- `CultVec2` and `CultVec3` no longer exist; +- VibeGeometry can be archived after its useful implementation, evidence, and + doctrine have been absorbed; +- persisted geometry remains readable through an explicit migration path when + a live persisted corpus requires one. + +## Authority Map + +| Organ | Owns | Does not own | +| --- | --- | --- | +| CultMath | `float2`, `float3`, `float4`, integer/double/bool vector primitives, quaternion algebra, scalar and component-wise intrinsics, coordinate-agnostic numeric kernels, CPU/HLSL parity for those primitives | Rectangles, spheres, topology, projections, planetary fields, geometry pages, meshes, CSG, persistence schemas, worker commands | +| GameCult.Geometry | Spatial value types built from CultMath primitives, spatial queries, topology and projection, planetary field and page machinery, mesh and CSG implementation, geometry schema documents, stable geometry identities, geometry-specific HLSL and cross-runtime fixtures | Competing vector types, generic storage/network ownership, application gameplay consequences | +| CultCache/CultNet/CultMesh | Typed persistence, schema resolution, transport, discovery, publication, commands, and interface projection | Geometry semantics or geometry algorithm ownership | +| VibeGeometry / `vg-csg` | Nothing after migration | Any live runtime, schema, package, or compatibility authority | + +Authority sentence: + +> CultMath owns how numeric primitives behave; GameCult.Geometry owns what those +> primitives mean in space. + +## Current Mechanism + +The live body is split across three places: + +1. CultMath publishes the canonical HLSL-shaped vector types, but also contains + `rect`, planetary topology/projection/field/page/residency systems, spherical + erosion, planetary HLSL, Unity adapters, web fixtures, and planetary tools. +2. `GameCult.Geometry` contains duplicate `CultVec2` and `CultVec3` values, + geometric primitives, and four CultCache document roots. Its document + members still encode many vectors, bounds, transforms, vertices, normals, + and UVs as anonymous `float[]` values. +3. VibeGeometry wraps the `vg-csg` Rust implementation for domain-tree intent, + LOD-aware selected cuts, CSG lowering, and triangle/collider output. + +CultCache's current managed SoA projection only admits primitive scalar members. +It neither preserves arbitrary unmanaged structs as columns nor recursively +splits them. A `float2` or `float3` member is therefore omitted from the SoA +until that storage seam is repaired. + +## Invariants + +The migration must preserve these statements at every published boundary: + +1. There is one canonical vector vocabulary: CultMath's HLSL-shaped primitive + types. +2. Geometry algorithms and geometry state have one owner: `GameCult.Geometry`. +3. CultMath never depends on CultLib, CultCache, MessagePack, or + `GameCult.Geometry`. +4. `GameCult.Geometry` may depend on CultMath and CultLib. +5. Persistence decoration and formatters do not contaminate CultMath primitive + definitions. +6. A geometry schema has one active writer. Legacy readers or offline + migrators may exist temporarily, but they cannot decide live behavior. +7. Record keys and stable fingerprints are identical across supported runtimes + for the same semantic geometry value. +8. SoA storage preserves each authored unmanaged member as one column of its + exact type. It never decomposes `float2`, `float3`, quaternion, or another + value type by reflection. +9. CPU, HLSL, Unity, web, and Rust fixtures compare the same claimed layer of + geometry behavior. +10. VibeGeometry is not retained as a wrapper, submodule, compatibility crate, + or second source of truth. + +## Target Package Shape + +The exact directory names may follow existing CultLib conventions, but the +ownership should lower into these runtime surfaces: + +```text +CultMath + C# and Unity numeric primitives + CultMath.hlsl numeric mirror + coordinate-agnostic native numeric kernels + +CultLib + src/GameCult.Geometry/ + C# geometry API + planetary geometry + CultCache document contracts and formatters + geometry-specific HLSL packaged as content + packages/gamecult-geometry-rs/ + Rust geometry, CSG, and cross-runtime fixtures + packages/gamecult-geometry-ts/ # only if the web runtime remains earned + web projection/tile helpers and parity fixtures + src/GameCult.Unity/Assets/Geometry/ # Unity-owned lowering/adapters + mesh/page upload and presentation adapters + Packages/org.gamecult.geometry/ # final location follows packaging audit + Unity package depending on CultMath and CultLib +``` + +The runtime directories are projections of one geometry authority. They are not +independent products allowed to invent their own vector, topology, schema, or +fingerprint semantics. + +Each stage is a commit/review boundary. Do not begin the next stage until the +current gate passes, and never publish a state in which both repositories claim +the same geometry authority. + +## Source Classification + +### Keep in CultMath + +- HLSL-shaped primitive types such as `float2`, `float3`, and `float4`. +- Integer, double, and boolean vector primitives. +- Quaternion algebra. +- `math` scalar/component-wise operations and the core `CultMath.hlsl` mirror. +- Numeric batch kernels whose contracts are independent of regions, topology, + surfaces, planets, meshes, CSG, rendering policy, and persistence. + +### Move from CultMath to GameCult.Geometry + +The initial move set is: + +- `rect` and other spatial-region values; +- `PlanetaryTopology`; +- `PlanetaryProjection`; +- `PlanetaryQueries`; +- `PlanetaryPatch`; +- `PlanetaryPages`; +- `PlanetaryGpuPages`; +- `PlanetaryResidency`; +- `PlanetaryRadialRefinement`; +- `PlanetaryMapTileEncoding`; +- `PlanetaryField`; +- `AdvancedErosionFilter`; +- `ErosionFrequencyBands`; +- `SphericalErosion`; +- `AdvancedErosionFilter.hlsl`; +- `Planetary.hlsl`; +- spherical-erosion and planetary-radial-refinement shader bodies currently + embedded in `CultMath.hlsl`; +- the versioned `CMPT` planetary tile format and its TypeScript decoder; +- planetary tests, docs, tools, Unity adapters, web fixtures, and samples. + +`AdvancedErosionFilter` and `ErosionFrequencyBands` move with planetary +geometry. Their public contracts encode terrain-field policy, they are direct +dependencies of the planetary field definition, and their HLSL is coupled to +that field. Voronoi tone rendering and `native/cultmath-core` are outside this +migration: the native crate currently owns only that tone kernel and has no +planetary implementation. Audit those separately rather than dragging them +across this boundary because their names look spatial. + +### Rebuild inside GameCult.Geometry + +- Make `CultRect`, `CultCircle`, and `CultSphere` the GameCult.Geometry-owned + spatial values, implemented over CultMath primitives, and delete + `CultMath.rect` at cutover. +- Replace `CultVec2` and `CultVec3` with `float2` and `float3` at every typed + boundary. +- Replace vector-shaped `float[]` document members with typed CultMath values or + intentional packed buffers. +- Keep packed mesh buffers packed when they are genuinely bulk artifact storage; + do not turn every vertex buffer into a forest of object wrappers. +- Put MessagePack/CultCache formatters for CultMath values in the integration + package or generated formatter output, not on the CultMath types. + +### Mine from VibeGeometry and `vg-csg` + +Evaluate and extract, in dependency order: + +- convex plane splitting and brush operations; +- CSG tree and ordered operation semantics; +- domain-tree DSL and lowering; +- dirty-frontier and prefix-checkpoint work; +- selected-cut and LOD planning; +- triangle/collider assembly; +- performance fixtures and parity evidence; +- useful RealtimeCSG-derived behavioral fixtures; +- runtime doctrine that still describes the adopted machine. + +Rewrite extracted code under GameCult.Geometry ownership. Preserve third-party +license notices and source provenance. Do not retain a `vg-csg` forwarding +crate or a VibeGeometry submodule after consumers have moved. + +## Staged Migration + +### Stage 0: Freeze and Baseline + +Purpose: establish the evidence needed to move authority without guessing. + +Work: + +- Freeze new planetary and geometry API additions in CultMath, VibeGeometry, + and the current duplicate primitive surface. +- Do not publish an intermediate release containing duplicate live planetary + implementations. A coordinated working branch may hold source temporarily + for comparison, but only one owner may enter a package feed. +- Inventory public symbols, package versions, HLSL entrypoints, Unity samples, + web fixtures, native exports, persisted schema IDs, record-key algorithms, + and known consumers. +- Inventory exact contract constants: `FieldVersion`, enum ordinals, tile stable + keys, `CMPT` versions and bytes, MessagePack slots, GPU buffer layouts, + integer hashes, topology face/seam/corner rules, and shader include paths. +- Search deployed and checked-in `.cc` stores for the four + `gamecult.geometry.*.v1` schemas. +- Record whether any external consumer relies on CultMath planetary namespaces, + `CultVec*`, `CultMath.rect`, `vg-csg`, or the current HLSL function names. +- Capture passing baselines for CultMath tests, GameCult.Geometry tests, + `vg-csg` tests/clippy, cross-runtime planetary fixtures, and package builds. +- Add representative fixture hashes for projection, topology, field sampling, + patch selection, page encoding, CSG output, and geometry record keys. +- Resolve licensing and provenance for MPL-2.0 CultMath sources and any + third-party-derived `vg-csg` behavior before copying source into CultLib. + +Gate: + +- Every published surface has a named consumer or is marked for deletion. +- The persisted v1 corpus is known rather than assumed. +- Baseline fixtures can be run without booting an entire daemon. + +### Stage 1: Establish the Destination and Dependency Direction + +Purpose: make `GameCult.Geometry` capable of receiving geometry without growing +a second primitive vocabulary. + +Work: + +- Add the explicit `GameCult.Geometry -> CultMath` dependency. +- Define the target namespaces and package content layout for C#, Rust, HLSL, + Unity, and any retained web surface. +- Establish distribution before source transfer: an explicit NuGet dependency + and a dedicated `org.gamecult.geometry` Unity package depending on + `org.gamecult.cultmath` and `org.gamecult.cultlib`, unless package inspection + proves an existing surface already owns that role. +- Retain `net8.0` and `netstandard2.1` targets and Unity 2021.3 compatibility. + The geometry asmdef references CultMath; the Unity lowering assembly + references both. CultMath's asmdef remains reference-free with + `noEngineReferences: true`. +- Add GameCult.Geometry-owned MessagePack formatters for `float2`, `float3`, + `float4`, and quaternion with fixed positional layouts. +- Add cross-runtime byte fixtures for those value encodings. +- Repair CultCache's SoA member discovery so any direct unmanaged member is one + exact typed column. Add `Column` and `Column` tests and a + negative test proving no scalar decomposition occurs. +- Ensure generated schema metadata describes the authored vector member rather + than synthetic x/y/z members. + +Gate: + +- CultMath has no new dependency. +- `GameCult.Geometry` can serialize, deserialize, index, and expose intact + CultMath vectors. +- CultCache hot-path access returns intact vector spans. +- Built NuGet and UPM artifacts contain the expected dependency metadata, + formatters, shaders, and owned runtime assets. + +### Stage 2: Replace Duplicate Primitives and Repair Geometry Schemas + +Purpose: remove the duplicate vocabulary before moving larger geometry systems. + +Work: + +- Rewrite `CultRect`, `CultCircle`, and `CultSphere` with CultMath primitives. +- Delete `CultVec2` and `CultVec3`; do not add aliases or conversion wrappers. +- Replace typed references and opaque native-slice metadata using the obsolete + names. +- Classify every vector-shaped `float[]` in `CultGeometryDocuments.cs`: + - `Translation`, `SupportCenter`, `SupportSize`, `CameraPosition`, + `FrustumMin`, `FrustumMax`, `BoundsMin`, and `BoundsMax` become `float3`; + - `RotationXyzw` becomes quaternion; + - mesh `Positions` and `Normals` become `float3[]`; + - mesh `Uvs` becomes `float2[]`; + - scalar and index buffers remain packed arrays; + - use a named geometry value when bounds/topology carries an invariant. +- Define v2 schemas for every document whose field types change. CultCache + includes CLR type identity in the canonical schema and must reject an in-place + slot type mutation even when no durable v1 corpus is found. +- If live v1 documents exist, add bounded v1 reader DTOs and an offline + v1-to-v2 migrator. Stop all v1 writers at cutover. If no live persisted corpus + exists, omit the migrator and delete v1 runtime support, but still publish the + corrected contract under v2 identity. +- Reject malformed legacy vectors: fixed vectors must have the exact expected + component count, while packed mesh streams must be divisible by their element + width before conversion. +- Recompute and cross-check stable fingerprints and record keys against Rust + fixtures before accepting the new schemas. Preserve an old record key only + when the typed representation reproduces the prior IEEE-bit canonicalization + exactly; otherwise version the identity deliberately. +- Use CultMath's lowercase component names in v2 JSON inspection projections. + JSON is not the runtime transport, and uppercase `X/Y/Z` aliases must not keep + the deleted vector vocabulary alive. Any xenos-facing alternate casing is an + explicit boundary projection. + +Gate: + +- Repository-wide searches find no `CultVec2` or `CultVec3` outside historical + evidence. +- Repository and package searches find no `CultMath.rect`. +- No live writer emits vector-shaped anonymous arrays for semantic single + values. +- The old schema cannot overwrite, repair, or become a second owner of v2 + state. + +### Stage 3: Transfer Planetary Geometry out of CultMath + +Purpose: move planetary authority as one coherent vertical slice. + +Work: + +- Move the classified planetary C# implementation and tests into + `GameCult.Geometry`. +- Move `Planetary.hlsl` and rename its include ownership and public entrypoints + to GameCult.Geometry. It may include CultMath numeric HLSL; CultMath must no + longer include planetary geometry. Remove the current implicit planetary + include from `CultMath.hlsl` only after consumers include the new owner. +- Move `AdvancedErosionFilter.hlsl` plus the spherical-erosion and + planetary-radial-refinement bodies embedded in `CultMath.hlsl`. Publish one + geometry-owned umbrella include that imports CultMath first and geometry + organs afterward. +- Move planetary documentation, fixture tools, web tile code, Unity page/mesh + adapters, and the planetary viewer sample to their consumer-owned runtime + locations. +- Move ownership of the `CMPT` little-endian tile format and TypeScript decoder. + Preserve v1 bytes exactly or introduce a new format version; never reuse a + wire version after changing its bytes. +- Rename public namespaces and symbols to their GameCult.Geometry authority. + Prefer a coordinated breaking release over permanent forwarding types. +- Move spherical/terrain erosion components classified as geometry. Keep only + genuinely coordinate-agnostic numeric kernels in CultMath. +- Run CPU/HLSL and cross-runtime fixtures from the new owner before deleting + the old files. +- Preserve exact fixtures for field versions, enum ordinals, integer hashes, + tile keys/bytes, GPU buffer packing, MessagePack slots, topology seams and + corners, and record hashes. Use explicit tolerances only for floating + CPU/HLSL comparisons. +- Delete the corresponding CultMath sources, package entries, and implicit + includes in the same authority-transfer stage after the destination passes. + Do not defer the actual ownership cut to consumer cleanup. + +Gate: + +- GameCult.Geometry passes the captured planetary baseline and owns the fixture + commands. +- CultMath packages no `Planetary.hlsl`, planetary sample, planetary tool, or + planetary public namespace. +- No source or shader include can select an old CultMath planetary path. +- Package inspection finds no planetary API, Unity adapter, sample, tile + decoder, documentation claim, or implicit shader include in CultMath. + +### Stage 4: Mine and Absorb `vg-csg` + +Purpose: preserve useful CSG machinery without preserving the defunct owner. + +Work: + +- Import the earned Rust implementation into the GameCult.Geometry runtime + surface with source provenance and applicable license notices. +- Replace `bevy_math`-shaped public contracts where they conflict with the + canonical cross-runtime primitive/layout contract. Internal use may remain + where it protects a measured invariant and does not leak a second public + vocabulary. +- Connect domain trees, selected cuts, build requests, and emitted artifacts to + the GameCult.Geometry schema definitions. +- Port useful fixtures before refactoring algorithms so observable behavior has + a witness. +- Preserve the existing Rust v1 tuple MessagePack and stable-hash fixtures as + migration witnesses, then make the GameCult.Geometry-owned Rust package emit + the v2 contract. +- Re-run performance fixtures and retain thresholds only where the fixture + represents a real workload. +- Move surviving doctrine into GameCult.Geometry docs and discard historical + instructions that would recreate VibeGeometry ownership. + +Gate: + +- No production build or test depends on the VibeGeometry repository or + `vg-csg` submodule. +- CSG output satisfies the adopted correctness and performance fixtures from + its new package. +- The Rust runtime speaks the same schema IDs, vector layouts, fingerprints, + and record keys as C#. + +### Stage 5: Integrate Geometry Through CultCache, CultNet, and CultMesh + +Purpose: prove the new owner through the real typed-state path. + +Work: + +- Register the adopted geometry schemas and formatters through the generated + registry path. +- Prove raw envelope interchange for geometry documents across C# and Rust, + adding TypeScript only if an actual web consumer remains. +- Publish geometry worker state and commands through the provider-owned + CultMesh surface. +- Ensure geometry build requests, selected cuts, chunk artifacts, and planetary + page state share one commit/derivation path for direct and programmatic use. +- Add a development probe exposing schema version, owner, source record key, + selected cut/page, content hash, and served package version. + +Gate: + +- A pipeline smoke proves typed handoff from domain input through selected cut + or planetary page selection to a persisted and republished artifact. +- The visible/debug projection and persisted state identify the same owner and + version. +- No ad hoc JSON or parallel status model becomes load-bearing geometry truth. + +### Stage 6: Consumer Cutover and Coordinated Release + +Purpose: switch consumers without maintaining two live authorities. + +Release order: + +1. Build an unpublished CultMath candidate that contains the stable primitive + contract and removes geometry-owned APIs. +2. Build an unpublished GameCult.Geometry candidate against that exact CultMath + version, containing adopted schemas, planetary geometry, and runtime packages. +3. Update and test GameCult services, Unity projects, web consumers, and tools + against a local candidate feed. +4. Publish the CultMath and GameCult.Geometry candidates in one coordinated + release window, with the primitives-only CultMath dependency entering the + feed first. +5. Publish consumer updates after both owning packages are available. +6. Convert persisted v1 geometry stores, if any, and remove migration tooling + after the retained-corpus policy is satisfied. + +Work: + +- Pin compatible package versions across the coordinated release. +- Update examples and developer-facing entrypoints to use + `GameCult.Geometry` plus CultMath primitives directly. +- Cut Fensalir's Engine/Contracts public planetary types and shader includes to + the new assembly before removing CultMath geometry. Its CPU/GPU parity tests + are release gates. +- Add the geometry package to Aetheria/Unity, move its directly compiled + planetary sources to the new assembly, preserve moved Unity `.meta` GUIDs, + and regenerate Unity project files. +- Verify served package, shader, schema, and migration versions in at least one + real consumer for each retained runtime. +- Run the preserved parity corpus through known consumers including Fensalir, + Zyphos, Aetheria/Unity, the web tile decoder when retained, C#, Rust, and HLSL. +- Refuse mixed old/new geometry writers rather than reconciling them later. + +Gate: + +- Every known consumer runs on the new owner. +- No published package graph has `CultMath -> CultLib` or + `CultMath -> GameCult.Geometry`. +- Old and new writers cannot target the same state surface. + +### Stage 7: Delete Obsolete Authorities + +Purpose: make the old ownership structurally unable to return. + +Work: + +- Confirm deletion of all CultMath planetary/geometry sources, tests, shaders, + samples, docs, tools, web surfaces, package content entries, and Unity + adapters; remove any residue found after consumer cutover. +- Delete obsolete GameCult.Geometry duplicate primitive code and stale schema + paths. +- Delete VibeGeometry workspace wiring and the `vg-csg` submodule dependency; + archive the repositories after provenance links and release tags are + recorded. +- Remove compatibility aliases, forwarding namespaces, dual registries, and + temporary migration flags. +- Update source indexes and repository memory so future agents retrieve the new + authority first. + +Gate: + +- Negative searches and package inspection prove the old paths are absent. +- Building CultMath alone cannot access planetary or geometry APIs. +- Building GameCult.Geometry cannot access `CultVec*` or VibeGeometry. +- The old persisted schema path cannot write or override current geometry. + +## Schema Migration Policy + +Do not create a permanent compatibility model pre-emptively. + +- If no durable v1 corpus exists, change the schema once, update fixtures, and + publish the corrected contract as v2, then delete the old form without + building a migrator. +- If a durable v1 corpus exists, create an offline migrator with explicit input + and output schema IDs, content hashes, counts, and a dry-run report. +- A v1 reader may exist only inside migration/inspection tooling. +- The runtime writes only the current schema after cutover. +- Migration completes only when retained stores have receipts and no active + service advertises or writes v1. + +## Verification Matrix + +| Layer | Required evidence | +| --- | --- | +| CultMath primitives | C#/HLSL parity, unmanaged layout/size checks, package inspection | +| Geometry values | Exact `float2`/`float3` field ownership, intersection/query unit tests, MessagePack byte fixtures | +| CultCache SoA | Intact vector columns, no recursive flattening, add/update/remove/snapshot behavior | +| Geometry schemas | Catalog IDs and slots, v1 inventory or migration receipts, stable record-key parity | +| Planetary geometry | Projection round trips, topology adjacency, field/normal samples, radial refinement, page encoding/residency, CPU/HLSL parity | +| CSG | Boolean/convex fixtures, dirty rebuild behavior, selected-cut correctness, triangle/collider receipts, performance fixtures | +| Cross-runtime | C#/Rust payload and fingerprint parity; web parity only for retained web consumers | +| Packaging | CultMath contains no geometry assets; GameCult.Geometry includes its owned HLSL/docs/runtime assets | +| End to end | Domain or planetary input to persisted artifact through CultMesh with visible owner/version evidence | + +## Negative Completion Checks + +The migration is not complete until all are true: + +- `CultVec2` and `CultVec3` cannot appear in newly generated schemas or native + slice metadata. +- CultMath cannot publish or execute planetary geometry through an old entrypoint. +- VibeGeometry and `vg-csg` cannot influence a build, runtime, fixture, or + generated artifact. +- VibeGeometry is absent from project references, package manifests, + submodules, CI, runtime identifiers, and authoritative documentation. +- Legacy v1 state cannot override or repair current geometry after the fact. +- CultCache cannot decompose a vector into independently owned scalar columns. +- Manual, programmatic, imported, persisted, and network-received geometry use + the same schema and commit paths. +- Passing unit tests are accompanied by package inspection and cross-runtime + evidence at the layer users actually consume. + +## Definition of Done + +The work is complete when `GameCult.Geometry` is the only geometry authority, +CultMath is a clean numeric substrate, all retained runtime projections agree on +typed layouts and stable identities, VibeGeometry has no live dependency path, +and the old owners are structurally unable to emit current geometry state. diff --git a/docs/cultmath-gamecult-geometry-migration-progress.md b/docs/cultmath-gamecult-geometry-migration-progress.md new file mode 100644 index 00000000..131f85e1 --- /dev/null +++ b/docs/cultmath-gamecult-geometry-migration-progress.md @@ -0,0 +1,344 @@ +# CultMath and GameCult.Geometry Migration Progress + +Plan: `docs/cultmath-gamecult-geometry-migration-plan.md` (locked) + +Started: 2026-07-22 + +Status: Stages 0-7 complete for source ownership, immutable Git/UPM delivery, +consumer cutover, and verification. NuGet and Cargo registry mirrors remain an +explicit credential-bound publication follow-up; release artifacts are public. + +## Objective + +Implement the locked migration so `GameCult.Geometry` becomes the sole owner of +geometry, planetary geometry, CSG, geometry schemas, and runtime projections, +while CultMath remains the HLSL-shaped numeric substrate and VibeGeometry loses +all live authority. + +## Execution Authority Map + +- Owner: `GameCult.Geometry` owns geometry decisions and geometry state. +- Inputs: CultMath numeric primitives, mined `vg-csg` implementation/evidence, + current geometry v1 documents, and named downstream consumer contracts. +- Outputs: GameCult.Geometry C#/Rust/HLSL/Unity/web surfaces, v2 typed schemas, + persisted artifacts, and CultMesh projections. +- Derived state: JSON inspection, UI/debug projections, caches, migration + receipts, and compatibility reads are non-authoritative. +- Forbidden writers: CultMath planetary APIs, v1 geometry runtime writers, + `CultVec*`, VibeGeometry/`vg-csg`, legacy shader entrypoints, and anonymous + fixed-vector arrays. +- Shared paths: direct API calls, persisted reloads, imports, network envelopes, + worker commands, Unity consumption, web tile reads, and Rust emitters must use + the same current schema/layout/fingerprint rules. +- Deletion line: `CultVec2`, `CultVec3`, `CultMath.rect`, CultMath planetary and + erosion ownership, v1 writers, VibeGeometry wiring/submodule authority, and + stale package/docs/schema paths. + +Demotions: + +- Geometry v1 DTOs are no longer owners; when a durable corpus requires them, + they are bounded read-only migration inputs to v2. +- JSON is no longer an owner; it is an inspection or xenos-boundary projection. +- VibeGeometry is no longer an owner; it is a provenance-bearing source quarry. +- CultMath planetary state is no longer an owner; it is source material pending + transfer and deletion in the same unpublished migration cut. + +## Worktree Safety + +- CultLib began with unrelated modified `.voidbot`, CultMesh, CultCache TS, + CultNet Rust, README, and test files plus scratch PID files. These changes are + outside the migration unless an exact overlap is required and reviewed. +- CultMath began clean on `codex/rust-cultmath-core` at `cb481b4`. +- VibeGeometry began clean on `main` at `85ac60a`. +- The locked plan is not edited during implementation. + +## Stage Ledger + +| Stage | State | Evidence / next gate | +| --- | --- | --- | +| 0. Freeze and baseline | Complete | Source bodies, contracts, consumers, licenses, fixtures, and focused baselines mapped | +| 1. Destination/dependency/formatters/SoA | Complete | NuGet and dedicated UPM artifacts prove dependency direction and owned vector integration | +| 2. Primitive deletion and v2 schemas | Complete | C# and Geometry-owned Rust emit identical v2 bytes and record keys for all four roots | +| 3. Planetary transfer | Complete | Managed, HLSL, CMPT, Unity, web, tool, docs, tests, and package authority moved; CultMath owners deleted | +| 4. Mine `vg-csg` | Complete | Geometry-owned Rust kernel passes correctness, parity, performance, formatting, and warning-denied lint gates | +| 5. CultCache/CultNet/CultMesh integration | Complete | One provider commit path persists, watches, replicates, and probes typed geometry state | +| 6. Consumer cutover/release | Complete | Fensalir and Aetheria green; immutable Git/UPM tags and public release artifacts verified | +| 7. Delete obsolete authorities/audit | Complete | VibeGeometry workspace authority archived; tracked negative audit is clean apart from retained provenance/witnesses | + +## Verification Receipts + +Receipts will be appended here with command, repository, commit, result, and +relevant artifact/version. A passing narrow test is not accepted as evidence for +a broader stage gate. + +### Stage 0 receipts + +- CultLib: `dotnet test tests/GameCult.Geometry.Tests/GameCult.Geometry.Tests.csproj --no-build --no-restore --disable-build-servers` passed 13/13. A full rebuild remains a Stage 1 gate; shared .NET build contention made attached build attempts exceed 60 seconds. +- CultMath: `dotnet test CultMath.slnx --no-restore` passed 72/72. +- CultMath native: `cargo test --locked` passed 1/1. +- CultMath web tile unit: `node --test web/planetary-tile.test.mjs` passed 1/1. +- CultMath pack and the combined web/C# cross-runtime script exceeded the + baseline time window and remain required package-transfer gates. +- VibeGeometry: `cargo test` passed 56 unit and 9 integration tests; doc tests + passed; `cargo clippy --all-targets --all-features` passed. +- Authoritative Rust quarry: VibeGeometry-pinned `vg-csg` submodule `a3197f4`. + Standalone `E:\Projects\vg-csg` is stale and excluded. +- v1 witnesses: current C#/Rust slots and hash algorithms match; Rust expected + record keys include domain `02c9...ed95` and chunk `b94d...21af5`. Exact + checked-in MessagePack byte witnesses still need to be generated before v2. +- Persisted corpus: targeted file inventory covered CultLib, CultMath, + VibeGeometry, Fensalir, and Aetheria. CultLib contains only the two checked-in + `vg-csg-ragnarok` payload fixtures. Binary scanning of Aetheria `.cc`, + `.msgpack`, and `.mpack` artifacts found no `gamecult.geometry.*.v1` catalog + identity. Indexed-source retrieval found definitions/tests/docs but no durable + geometry store. Execution therefore uses v2 plus retained test witnesses, + with no live v1 runtime migrator. + +### Stage 1 receipts + +- CultCache MessagePack now resolves external member values through a + document-owner assembly declaration. Resolver precedence is core CultCache, + owner-declared resolvers, then MessagePack standard fallback; the options are + cached per owner assembly and generated document codecs use that same path. + CultCache does not reference Geometry or CultMath. +- CultCache SoA discovery now admits direct reference-free value types as one + exact typed column. It rejects managed-reference and byref-like values and + does not synthesize component columns from nested struct fields. +- `dotnet test tests/GameCult.Caching.Tests/GameCult.Caching.Tests.csproj + --no-restore --filter + "FullyQualifiedName~DocumentAssemblyResolverTests|FullyQualifiedName~SoaValueColumnTests" + --verbosity minimal -m:1 -nodeReuse:false` passed 4/4 after a full rebuild. +- The initial SoA reflection probe used `Type.IsFunctionPointer`, which is not + available on the library's `netstandard2.1` target. It was removed rather + than papered over; structural managed-reference inspection is the owning + eligibility rule. +- CultMath candidate `0.1.0-geometry-migration.1` was packed from commit + `cb481b4c75444426a693781a0e5b35ade08a8938` with build servers disabled. The + 123,307-byte package SHA-256 is + `07C0CE79453AF9BA29910809F8779916E7B8F92DCA7ACEB5E2B8F9D3EBED3E93`; + a local-feed consumer restored, compiled, and executed `CultMath.float3`. +- GameCult.Geometry consumes that candidate as an explicit NuGet dependency; + its sibling `.tools/local-feed` is only an optional restore source, not a + project-reference authority. Geometry owns strict positional formatters for + `float2`, `float3`, `float4`, and `quaternion`. +- `dotnet test tests/GameCult.Geometry.Tests/GameCult.Geometry.Tests.csproj + --no-restore --filter FullyQualifiedName~CultMathValueIntegrationTests + --verbosity minimal --disable-build-servers -p:UseSharedCompilation=false + -m:1` + passed 6/6. Evidence covers exact bytes, round trips, malformed-width + rejection, actual `Column`/`Column` access, and absence of + synthetic component columns. +- GameCult.Geometry candidate `0.1.0-geometry-migration.1` packed successfully; + its nuspec declares exact candidate dependencies on CultMath, CultCache, and + CultCache.MessagePack. The dedicated UPM artifact remains the open Stage 1 + gate. +- Authoritative `vg-csg` commit `8f070f4` now pins exact v1 MessagePack bytes + for representative domain/chunk tuples plus complete domain, request, + selected-cut, and chunk record keys. Focused Rust tests passed 4/4; formatting + and clippy passed. VibeGeometry commit `82b56db` advances its submodule to + that witness commit. Both migration branches are pushed. +- `scripts/build-geometry-unity-package.ps1` staged + `org.gamecult.geometry@0.1.0` successfully. Its manifest depends explicitly + on `org.gamecult.cultmath@0.1.0` and `org.gamecult.cultlib@1.0.7`; the package + contains only its owned `GameCult.Geometry.dll`/symbols plus its asmdef and + documentation. It neither duplicates CultMath nor buries Geometry inside the + CultLib Unity package. + +## Decisions and Deviations + +- Licensing: no relicensing is assumed. Moved CultMath files retain MPL-2.0 + notices and provenance; the GameCult.Geometry package will carry explicit + mixed-license package documentation rather than falsely stamping those files + MIT. `AdvancedErosionFilter` source and shader notices remain verbatim. +- Serialization: Geometry-owned external value formatters require a declarative + per-document-assembly resolver seam in `GameCult.Caching.MessagePack`. + CultMath remains annotation-free and core caching does not reference Geometry. +- v1 persistence: no durable corpus was found in the named repositories and + artifacts. v1 remains fixture evidence only; it will not survive as a runtime + writer or general compatibility model. +- No deviations from the locked target architecture. +- Stage 2 versions all four root geometry documents as one v2 cohort. Domain's + nested CLR layout changes are invisible to the current root-only schema + fingerprint, and leaving SelectedCut as the lone live v1 writer would retain + a split cohort even though its direct fields are unchanged. +- Typed vectors preserve existing record keys by flattening components in the + same authored order into the existing IEEE-754 bit canonicalization. Wire + payloads still change—especially typed mesh arrays—so byte fixtures and v2 + identities remain mandatory. + +### Stage 2 receipts + +- `CultVec2` and `CultVec3` definitions were deleted; `CultRect`, `CultCircle`, + and `CultSphere` now expose CultMath `float2`/`float3` directly. No alias or + conversion owner remains. +- `dotnet test tests/GameCult.Geometry.Tests/GameCult.Geometry.Tests.csproj + --no-restore --verbosity minimal --disable-build-servers + -p:UseSharedCompilation=false -m:1` passed 19/19 for the primitive cut. +- CultMesh native-slice metadata fixtures now name `CultMath.float2` and + `CultMath.float3` while retaining 8/12-byte element widths. Only those three + overlapping lines were staged from the already-dirty TypeScript test file. +- All four root geometry schemas now form a v2 cohort with their original slot + numbers. Semantic single vectors use `float3`/`quaternion`; mesh vertices, + normals, and UVs use `float3[]`/`float2[]`. The runtime contains no v1 DTO, + reader, writer, or conversion authority. +- Live v2 semantic SchemaIds are pinned as domain `sha256:e335...421d`, build + request `sha256:2de3...21d3`, selected cut `sha256:8644...9ff8`, and chunk + artifact `sha256:2d28...6156`. Full values live in the executable tests. +- Typed stable fingerprints flatten components into the prior ordered IEEE-bit + stream. Domain/chunk round trips preserve record identity, while the checked- + in flat-mesh v1 payload is retained as negative evidence and rejected by the + v2 decoder. The Geometry suite passes 19/19 after the cohort cut. +- `packages/gamecult-geometry-rs` is the Geometry-owned Rust v2 destination. + It has no VibeGeometry, `vg-csg`, `bevy_math`, CultCache, or CultNet + dependency and preserves the MIT provenance of the mined `vg-csg` contract. +- C# and Rust pin identical exact MessagePack bytes and record keys for domain, + build request, selected cut, and chunk roots. C# decodes the Rust witnesses + and re-emits the same bytes. The Geometry suite passes 24/24; Rust passes 4/4, + doc tests, rustfmt, and warning-denied clippy. +- The parity pass exposed an old split identity: C# request scalar floats used + round-trip decimal text while `vg-csg` used IEEE-bit hex. v2 deliberately + standardizes every float on IEEE bits. The request key therefore changes at + the v2 boundary; signed zero, a chosen NaN payload, and infinity are pinned + across MessagePack so this cannot quietly diverge again. + +### Stage 3 receipts + +- Thirteen planetary/erosion C# sources and thirteen xUnit suites now live + under GameCult.Geometry; `CultMath.rect`, its factories, and all original + planetary sources/tests are deleted. Destination planetary tests pass 41/41; + remaining CultMath tests pass 28/28 for both target frameworks. +- Geometry owns five HLSL files behind `GameCult.Geometry.hlsl`; public geometry + symbols use `gamecult_geometry_*`. CultMath's umbrella and NuGet candidate + contain only numeric `CultMath.hlsl`. A DXC SPIR-V smoke compiled the joined + umbrella successfully to a 1,428-byte module. +- The CMPT v1 tool and TypeScript decoder moved intact. The end-to-end fixture + is exactly 1,221 bytes with SHA-256 + `8b2bc46e4123b8ac8936b43f8d08a34e3f2e74f38d0bb3f101cf3728cd201962`; + the C# emitter and TypeScript decoder pass together. +- Unity adapters retain their original `.meta` GUIDs; the Planetary Viewer uses + the Geometry umbrella and owned shader symbols. The staged UPM artifact has + one owned DLL, both adapters, the sample, all five shaders, and explicit + MIT/MPL license and provenance documents. +- Stale candidate cache was made impossible by rotating CultMath and Geometry + to `0.1.0-geometry-migration.2`. CultMath's candidate contains only its DLLs, + README, and numeric shader. Geometry's candidate declares the exact CultMath + `.2` dependency and contains all Geometry shaders/docs/notices under a + mixed-license file declaration rather than the inherited blanket MIT claim. +- Full Geometry tests pass 27/27 after the joined shader/Unity integration; + planetary tests pass 41/41. Negative searches find obsolete CultMath geometry + names only inside tests that assert their absence or ignored generated output. + +### Stage 4 receipts + +- `packages/gamecult-geometry-rs` now owns the mined convex splitting, ordered + brush CSG, tree lowering, dirty frontiers, prefix checkpoints, domain/LOD + selection, feature lowering, and triangle/collider assembly kernels. No build + or runtime dependency points at VibeGeometry or `vg-csg`. +- The public `GeometryBrushAssembler` facade accepts Geometry-owned + `Float3` values and returns `GeometryTriangleMesh`; `bevy_math` remains a + private implementation dependency and is not re-exported. +- The duplicate quarry persistence authority (`DomainSpecDocument`, + `DomainNodeDocument`, `FeatureClaimDocument`, and its local schema version) + was deleted. Private specs are in-memory builders; the v2 `Geometry*` + documents remain the sole wire authority. +- Nine RealtimeCSG behavioral fixtures and a bounded 512-distant-cutter + performance fixture moved under Geometry ownership. `cargo test --locked` + passes 67/67 plus doc tests; rustfmt and warning-denied Clippy pass. +- The authority transfer commits are CultLib `756f5a33`, CultMath `6a16260`, + and the post-transfer label cleanup `99c893bd`. The Rust absorption commit is + CultLib `6b44f969`. + +### Stage 5 receipts + +- `CultGeometryWorkerProvider` owns the build invocation and output commit + sequence. Direct calls and the `gamecult.geometry.worker.build` CultMesh + operation share `BuildAsync`; compute is injected through the persistence-free + `ICultGeometryBuildPipeline` port. +- Domain/request reads and selected-cut, artifact, and observational worker-state + writes use `CultNetDatabase` typed APIs. Watches and raw MessagePack envelopes + are consequences of that commit path rather than parallel publishers. +- The development probe derives owner, v2 schema, request/cut keys, selected + nodes, artifact identities, content hashes, and served assembly version from + persisted documents. Mutating worker display state cannot overwrite geometry + outputs or forge the served version. +- Worker state registers through the generated CultDocument registry. Focused + provider tests pass 4/4 and the full GameCult.Geometry suite passes 31/31. +- Rust raw-envelope duplication was deliberately omitted: CultNet owns that + protocol. Existing C#/Rust exact v2 document bytes and record keys remain the + cross-runtime geometry witness. + +### Stage 6 receipts + +- Coordinated local candidates use + `scripts/build-geometry-candidate.ps1`. CultMath `.2` is primitives-only; + Geometry `.2` contains its managed assembly, notices/docs, and all five owned + shaders with exact support-package and CultMath candidate dependencies. A + clean net10 consumer restored and built; the Unity package staged one owned + assembly plus adapters, sample, and shaders. The Rust crate passed an + independent `cargo package --allow-dirty --no-verify` dry run. +- Fensalir cutover commit `42778c1` adds direct Geometry dependencies, migrates + planetary C# consumers, compiler include roots, shaders, and parity harnesses + while retaining CultMath for numeric primitives. The solution builds with no + warnings/errors; fractal tests pass 170/170, erosion parity 1/1, topology + parity 19/19, and planetary page tests 16/16. +- Aetheria's coordinated cutover ends at commit `3ec6222f`. It replaces + `CultVec*` fixtures with CultMath vectors under an exact legacy MessagePack + byte witness, moves spatial rectangles to `GameCult.Geometry.CultRect`, and + pins CultMath, CultLib, and Geometry to immutable Git/UPM tags. Geometry is an + explicit Unity testable dependency. The Freeze build and State verifier pass; + the verifier fingerprint is + `664afd7e...48b`, and the legacy float2/float3 bytes remain exact. +- A disposable Aetheria import using only the published tags passed the owned + EditMode suite 3/3: primitive ownership, CultMath visibility, and planetary + Unity mesh projection. The resolved commits are CultMath `b43cf49`, Geometry + and CultLib `b65619ff`, and Eve `e5556c3`. No compatibility compiler errors + remain. Normal Unity project generation confirmed the core packages are + precompiled and require no generated CultMath or Geometry core project. + Aetheria commit `9c983a5e` deletes the stale generated `CultMath.csproj` and + its solution/project references; generated project metadata contains no + deleted `rect.cs` or planetary source paths. +- Legacy D3DCompiler exposed that macro-expanded HLSL includes were not + portable. CultLib commit `cdeea474` gives the Geometry umbrella direct default + and Unity include branches; Geometry shader ownership tests pass 3/3 and the + downstream D3D12 corpus passes. +- The coordinated release body is CultLib commit `b65619ff`, merging Geometry + ownership with the CultMesh reliability line. Caching passes 36/36, Mesh + 210/210, and Geometry 31/31. Its Unity package contains 24 managed assemblies, + owned binaries at file version `1.0.44.0`, the resolver declaration and body + read-lease API, and the native QUIC payload. +- CultMath `0.1.0` and Geometry `0.1.0` are published as immutable Git/UPM tags. + Public GitHub releases carry the NuGet and Cargo artifacts at + `https://github.com/GameCult/CultMath/releases/tag/cultmath-v0.1.0` and + `https://github.com/GameCult/CultLib/releases/tag/gamecult-geometry-v0.1.0`. + The CultMath NuGet SHA-256 is `90dc30c0...d070`; Geometry NuGet is + `fdce63a3...6835`; the Rust crates are `a346dca1...d68` and + `ac0c45cb...9e0e` respectively. +- NuGet and Cargo registry mirroring was not attempted: the authenticated GitHub + token lacks package scopes, no NuGet API key or Cargo registry token exists, + and GitHub Packages is not a Cargo registry. This is a distribution mirror, + not surviving source or package authority; the exact public artifacts and + immutable consumer paths above are the completed release contract. + +### Stage 7 receipts + +- Tracked negative audits across CultLib, CultMath, Fensalir, Aetheria, and + VibeGeometry find no live `CultVec*`, `CultMath.rect`, CultMath planetary + implementation, old planetary shader vocabulary, geometry v1 writer, or + VibeGeometry/`vg-csg` build dependency. Frozen fixtures, source attribution, + and negative ownership tests remain deliberately. +- VibeGeometry commit `bc9604a` removes its tracked Cargo workspace, lockfile, + submodule declaration/gitlink, scratch/build wiring, and live mission claims. + Its remaining documents point to source witness `8f070f4f` and Geometry + absorption commit `6b44f969`; repository history preserves provenance. +- CultLib commits `8a7eee71` and `b9f20a64` remove manual-registry guidance and + update the root package map. Generated CultDocument metadata remains the only + registration owner. + +## Publication Follow-up + +- Mirror the already-published NuGet artifacts to the chosen package feed when + a credential with package write scope is available. +- Mirror the two Rust crates to crates.io or another real Cargo registry when a + registry token and registry choice are available. +- These mirrors must publish the recorded bytes. Rebuilding them would create a + different release and requires a new version. diff --git a/package.json b/package.json index f1b995a2..a6803b6b 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "workspaces": [ "packages/cultcache-ts", "packages/cultnet-ts", - "packages/cultmesh-ts" + "packages/cultmesh-ts", + "packages/gamecult-geometry-ts" ], "scripts": { "build:ts": "npm --workspaces run build", diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index c61dfee1..687f9b8a 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -4088,11 +4088,25 @@ export interface CultMeshRudpDocumentPut { }; } +export interface CultMeshRudpDocumentReceipt { + binding: CultNetDocumentBinding; + recordKey: string; + value: CultCacheDocumentValue; + messageId?: string; + sourceRuntimeId?: string; + sourceAgentId?: string; + sourceRole?: string; + tags?: string[]; +} + export interface CultMeshRudpDocumentServerOptions extends CultMeshRudpSocketOptions { documents: CultNetDocumentRegistry; getCache?: () => Promise | CultCache; onError?: (error: Error) => void; - onDocumentPutRaw?: (document: CultMeshRudpDocumentPut) => void | Promise; + onDocumentPutRaw?: (document: CultMeshRudpDocumentPut) => + | CultMeshRudpDocumentReceipt + | void + | Promise; wireContract?: CultNetWireContract; sessionTimeoutMs?: number; } @@ -4112,6 +4126,10 @@ export interface CultMeshRudpDocumentPublishOptions extends CultMeshRudpPeerOpti flushTimeoutMs?: number; } +export interface CultMeshRudpDocumentReceiptOptions extends CultMeshRudpDocumentPublishOptions { + receiptTimeoutMs?: number; +} + export interface CultMeshRudpEndpoint { host: string; port: number; @@ -5638,7 +5656,18 @@ export class CultMesh { return; case "cultnet.document_put_raw.v0": if (options.onDocumentPutRaw) { - await options.onDocumentPutRaw(normalizeRudpDocumentPut(message, record.remote)); + const receipt = await options.onDocumentPutRaw(normalizeRudpDocumentPut(message, record.remote)); + if (receipt) { + sendSchemaMessage(record, options.documents.createRawDocumentPutMessage( + receipt.binding, receipt.messageId ?? message.messageId, receipt.recordKey, receipt.value, + { + sourceRuntimeId: receipt.sourceRuntimeId ?? runtimeId, + sourceAgentId: receipt.sourceAgentId, + sourceRole: receipt.sourceRole ?? "provider", + tags: receipt.tags ?? ["receipt"], + }, + )); + } } return; default: @@ -5753,6 +5782,36 @@ export class CultMesh { } } + public static async publishRudpDocumentAndWaitForReceipt( + runtimeId: string, connectionId: number, endpoint: string | CultMeshRudpEndpoint, + binding: CultNetDocumentBinding, recordKey: string, value: CultCacheDocumentValue, + receiptBinding: CultNetDocumentBinding, options: CultMeshRudpDocumentReceiptOptions = {}, + ): Promise> { + requireNonEmpty(runtimeId, "runtimeId"); + requireNonEmpty(recordKey, "recordKey"); + const messageId = options.messageId ?? `${runtimeId}:${binding.definition.type}:${recordKey}`; + const peer = await CultMesh.createRudpPeer(runtimeId, connectionId, endpoint, options); + try { + const receipt = new Promise>((resolve, reject) => { + const timeout = setTimeout(() => { cleanup(); reject(new Error(`Timed out waiting for RUDP application receipt for ${recordKey}.`)); }, options.receiptTimeoutMs ?? 5_000); + const onMessage = (message: CultNetMessage): void => { + if (message.schemaVersion !== "cultnet.document_put_raw.v0" || message.messageId !== messageId || message.document.schemaId !== receiptBinding.definition.schemaId) return; + try { + const decoded = decode(new Uint8Array(message.document.payload)); + cleanup(); + resolve(receiptBinding.definition.schema.parse(decoded) as CultCacheDocumentValue); + } catch (error) { cleanup(); reject(error); } + }; + const cleanup = (): void => { clearTimeout(timeout); peer.off("message", onMessage); }; + peer.on("message", onMessage); + }); + peer.send(new CultNetDocumentRegistry([binding]).createRawDocumentPutMessage(binding, messageId, recordKey, value, { + sourceRuntimeId: options.sourceRuntimeId ?? runtimeId, sourceAgentId: options.sourceAgentId, sourceRole: options.sourceRole, tags: options.tags, + })); + return await receipt; + } finally { peer.close(); } + } + public static async createRudpServer( runtimeId: string, connectionId: number, diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index f4e8f9cc..d7f5e267 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -34,6 +34,13 @@ const noteAliasDocument = defineDocumentType({ name: "noteId", }); +const noteReceiptDocument = defineDocumentType({ + type: "cultmesh.note_receipt", + schemaId: "cultmesh.note_receipt.v0", + schema: z.object({ commandId: z.string(), state: z.literal("applied"), body: z.string() }), + name: "commandId", +}); + const foreignNoteDocument = defineDocumentType({ type: "runtime.generated.cultmesh.note", schemaId: "runtime.generated.cultmesh.note.ui.42", @@ -1447,7 +1454,7 @@ test("CultMesh TS describes typed surface catalogs for tools and generated bindi "aetheria.zone.render", "gamecult.aetheria.render_body.v1", 128, - [CultMesh.nativeSliceColumn("position", "CultVec2", 8)], + [CultMesh.nativeSliceColumn("position", "CultMath.float2", 8)], { route: routeHint }, ); @@ -2143,7 +2150,7 @@ test("CultMesh TS exposes native slice descriptors", () => { "gamecult.aetheria.zone_render.v1", 12, [ - CultMesh.nativeSliceColumn("position", "CultVec3", 12), + CultMesh.nativeSliceColumn("position", "CultMath.float3", 12), CultMesh.nativeSliceColumn("rotationRadians", "float32", 4), CultMesh.nativeSliceColumn("renderGroupId", "uint32", 4), ], @@ -2156,7 +2163,7 @@ test("CultMesh TS exposes native slice descriptors", () => { assert.equal(CultMesh.denseRowStrideBytes(view), 20); assert.deepEqual(CultMesh.findNativeSliceColumn(view, "position"), { name: "position", - valueType: "CultVec3", + valueType: "CultMath.float3", elementSizeBytes: 12, }); assert.equal(CultMesh.findNativeSliceColumn(view, "missing"), undefined); @@ -2568,6 +2575,29 @@ test("CultMesh TS publishes one registered document to a RUDP catalog", async () } }); +test("CultMesh TS returns a correlated provider receipt after a RUDP document mutation", async () => { + const connectionId = 0x10203050; + const commandBinding = defineCultNetDocumentBinding({ definition: noteDocument }); + const receiptBinding = defineCultNetDocumentBinding({ definition: noteReceiptDocument }); + const server = CultMesh.createRudpDocumentServer("cultmesh-ts-receipt-server", connectionId, { + documents: new CultNetDocumentRegistry([commandBinding, receiptBinding]), bindHost: "127.0.0.1", bindPort: 0, + resendDelayMs: 25, resendPollMs: 5, + onDocumentPutRaw: async (document) => ({ + binding: receiptBinding, recordKey: document.recordKey, + value: { commandId: document.recordKey, state: "applied", body: (document.payload as { body: string }).body }, + }), + }); + try { + await server.start(); + const receipt = await CultMesh.publishRudpDocumentAndWaitForReceipt( + "cultmesh-ts-receipt-client", connectionId, `rudp://127.0.0.1:${server.bind.port}`, + commandBinding, "command:note", { noteId: "command:note", body: "provider applied this" }, receiptBinding, + { messageId: "command:note", receiptTimeoutMs: 1_000, resendPollMs: 5 }, + ); + assert.deepEqual(receipt, { commandId: "command:note", state: "applied", body: "provider applied this" }); + } finally { server.close(); } +}); + test("CultMesh TS reads remote RUDP snapshots through document handles", async () => { const connectionId = 0x10203048; const node = await CultMesh.startNode( diff --git a/packages/gamecult-geometry-rs/Cargo.lock b/packages/gamecult-geometry-rs/Cargo.lock new file mode 100644 index 00000000..986160f0 --- /dev/null +++ b/packages/gamecult-geometry-rs/Cargo.lock @@ -0,0 +1,969 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "assert_type_match" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f548ad2c4031f2902e3edc1f29c29e835829437de49562d8eb5dc5584d3a1043" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bevy_macro_utils" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62d984f9f8bd0f0d9fb020492a955e641e30e7a425f3588bf346cb3e61fec3c3" +dependencies = [ + "parking_lot", + "proc-macro2", + "quote", + "syn 2.0.119", + "toml_edit", +] + +[[package]] +name = "bevy_math" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa74ae5d968749cc073da991757d3c7e3504ac6dbaac5f8c2a54b9d19b0b7ed" +dependencies = [ + "approx", + "bevy_reflect", + "derive_more", + "glam", + "itertools", + "rand", + "rand_distr", + "serde", + "smallvec", + "thiserror", + "variadics_please", +] + +[[package]] +name = "bevy_platform" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4691af6d7cfd1b5deb2fc926a43a180a546cdc3fe1e5a013fcee60db9bb2c81f" +dependencies = [ + "critical-section", + "foldhash", + "futures-channel", + "hashbrown 0.16.1", + "js-sys", + "portable-atomic", + "portable-atomic-util", + "serde", + "spin", + "wasm-bindgen", + "wasm-bindgen-futures", +] + +[[package]] +name = "bevy_ptr" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17d24d7906c7de556033168b3485de36c59049fbaef0c2c44c715a23e0329b10" + +[[package]] +name = "bevy_reflect" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5472b91928c0f3e4e3988c0d036b00719f19520f53a0c3f8c2af72f00e693c5" +dependencies = [ + "assert_type_match", + "bevy_platform", + "bevy_ptr", + "bevy_reflect_derive", + "bevy_utils", + "derive_more", + "disqualified", + "downcast-rs", + "erased-serde", + "foldhash", + "glam", + "serde", + "smol_str", + "thiserror", + "uuid", + "variadics_please", + "wgpu-types", +] + +[[package]] +name = "bevy_reflect_derive" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "083784255162fa39960aa3cf3c23af0e515db2daa7f2e796ae34df993f4d3f6c" +dependencies = [ + "bevy_macro_utils", + "indexmap", + "proc-macro2", + "quote", + "syn 2.0.119", + "uuid", +] + +[[package]] +name = "bevy_utils" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "789d04f88c764877a4552e07745b402dbc45f5d0545e6d102558f2f1752a1d89" +dependencies = [ + "bevy_platform", + "disqualified", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "disqualified" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9c272297e804878a2a4b707cfcfc6d2328b5bb936944613b4fdf2b9269afdfd" + +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gamecult-geometry" +version = "0.1.0" +dependencies = [ + "approx", + "bevy_math", + "rmp-serde", + "serde", + "sha2", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "glam" +version = "0.30.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9" +dependencies = [ + "bytemuck", + "rand", + "serde_core", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "equivalent", + "serde", + "serde_core", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_distr" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" +dependencies = [ + "num-traits", + "rand", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow 0.7.15", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "variadics_please" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b6d82be61465f97d42bd1d15bf20f3b0a3a0905018f38f9d6f6962055b0b5c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wgpu-types" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca7a8d8af57c18f57d393601a1fb159ace8b2328f1b6b5f80893f7d672c9ae2" +dependencies = [ + "bitflags", + "bytemuck", + "js-sys", + "log", + "serde", + "thiserror", + "web-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/packages/gamecult-geometry-rs/Cargo.toml b/packages/gamecult-geometry-rs/Cargo.toml new file mode 100644 index 00000000..b05c0246 --- /dev/null +++ b/packages/gamecult-geometry-rs/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "gamecult-geometry" +version = "0.1.0" +edition = "2024" +description = "GameCult.Geometry typed contracts and geometry runtime for Rust" +license = "MIT" +repository = "https://github.com/GameCult/CultLib" +readme = "README.md" + +[dependencies] +bevy_math = "0.17" +rmp-serde = "1" +serde = { version = "1", features = ["derive"] } +sha2 = "0.10" + +[dev-dependencies] +approx = "0.5" diff --git a/packages/gamecult-geometry-rs/LICENSE b/packages/gamecult-geometry-rs/LICENSE new file mode 100644 index 00000000..a431c3ab --- /dev/null +++ b/packages/gamecult-geometry-rs/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2026 GameCult + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/packages/gamecult-geometry-rs/README.md b/packages/gamecult-geometry-rs/README.md new file mode 100644 index 00000000..0b3ec053 --- /dev/null +++ b/packages/gamecult-geometry-rs/README.md @@ -0,0 +1,11 @@ +# GameCult.Geometry for Rust + +Rust projection of the GameCult.Geometry v2 document contract. It owns the +typed MessagePack layouts and stable geometry record identities used at the +Rust boundary; it does not depend on VibeGeometry or `vg-csg`. + +The initial contract and stable-hash implementation were mined from the +MIT-licensed GameCult `vg-csg` crate at commit `8f070f4`. The implementation +was rewritten here under GameCult.Geometry ownership. The original copyright +and MIT terms are preserved in [LICENSE](LICENSE). + diff --git a/packages/gamecult-geometry-rs/src/assembler.rs b/packages/gamecult-geometry-rs/src/assembler.rs new file mode 100644 index 00000000..078a07b5 --- /dev/null +++ b/packages/gamecult-geometry-rs/src/assembler.rs @@ -0,0 +1,1509 @@ +#![allow(dead_code)] +use std::cell::RefCell; + +use bevy_math::Vec3; + +use crate::{ + Aabb, Brush, BrushId, BrushOp, ConvexPolygon, ConvexSolid, DirtyDemandFrontier, MaterialId, + PolygonRouteScratch, Primitive, TriangleMesh, append_cylinder_z, append_dome_cap_z, + append_floret_arm, + primitives::{DomeCapZSpec, FloretArmSpec}, +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BuildWarning { + SubtractIgnoredForNonBox { brush: String }, + IntersectIgnoredForNonBox { brush: String }, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct BuildReport { + pub input_brushes: usize, + pub emitted_convex_fragments: usize, + pub operator_brushes: usize, + pub candidate_pairs: usize, + pub rejected_pairs: usize, + pub reused_mesh: bool, + pub used_checkpoint: bool, + pub checkpoint_rebuild_from: Option, + pub direct_dirty_rebuild: bool, + pub warnings: Vec, +} + +#[derive(Clone, Debug)] +pub struct BuildOutput { + pub mesh: TriangleMesh, + pub report: BuildReport, + pub generation: u64, +} + +#[derive(Clone, Debug, Default)] +pub struct Assembler { + brushes: Vec, + compiled: Vec, + cache: RefCell>, + box_cache: RefCell>, + general_cache: RefCell>, + first_dirty_index: RefCell>, + next_id: u32, + generation: u64, +} + +#[derive(Clone, Debug)] +struct CompiledBrush { + op: BrushOp, + material: MaterialId, + bounds: Aabb, + geometry: CompiledGeometry, + name: String, +} + +#[derive(Clone, Debug)] +enum CompiledGeometry { + Box(Aabb), + Convex(ConvexSolid), + CylinderZ { + center: Vec3, + radius: f32, + depth: f32, + segments: usize, + }, + DomeCapZ { + center: Vec3, + radius: f32, + height: f32, + rings: usize, + segments: usize, + }, + FloretArm { + anchor: Vec3, + direction: Vec3, + length: f32, + root_width: f32, + tip_width: f32, + thickness: f32, + tip_lift: f32, + }, +} + +#[derive(Clone, Debug, Default)] +struct BoxBuildCache { + generation: u64, + valid_checkpoint_index: usize, + checkpoints: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq)] +struct BoxBuildState { + boxes: Vec<(Aabb, MaterialId)>, + report: BuildReport, +} + +#[derive(Clone, Debug, Default)] +struct GeneralBuildCache { + generation: u64, + valid_checkpoint_index: usize, + checkpoints: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq)] +struct GeneralBuildState { + mesh: TriangleMesh, + solids: Vec, + report: BuildReport, +} + +impl Assembler { + pub fn new() -> Self { + Self::default() + } + + pub fn brushes(&self) -> &[Brush] { + &self.brushes + } + + pub fn generation(&self) -> u64 { + self.generation + } + + pub fn brush_index(&self, id: BrushId) -> Option { + self.brushes.iter().position(|brush| brush.id == id) + } + + pub fn dirty_frontier_for_indices( + &self, + dirty_indices: impl IntoIterator, + ) -> DirtyDemandFrontier { + DirtyDemandFrontier::from_dirty_indices(&self.brushes, dirty_indices) + } + + pub fn dirty_frontier_for_brushes( + &self, + dirty_brushes: impl IntoIterator, + ) -> DirtyDemandFrontier { + let indices = dirty_brushes + .into_iter() + .filter_map(|id| self.brush_index(id)) + .collect::>(); + self.dirty_frontier_for_indices(indices) + } + + pub fn add_brush( + &mut self, + name: impl Into, + op: BrushOp, + primitive: Primitive, + material: MaterialId, + ) -> BrushId { + let id = BrushId(self.next_id); + self.next_id += 1; + let index = self.brushes.len(); + let brush = Brush::new(id, name, op, primitive, material); + self.compiled.push(CompiledBrush::from_brush(&brush)); + self.brushes.push(brush); + self.generation = self.generation.wrapping_add(1); + self.mark_dirty_index(index); + id + } + + pub fn set_brush_primitive(&mut self, id: BrushId, primitive: Primitive) -> bool { + let Some(index) = self.brush_index(id) else { + return false; + }; + self.brushes[index].primitive = primitive; + self.brushes[index].set_dirty(); + self.compiled[index] = CompiledBrush::from_brush(&self.brushes[index]); + self.generation = self.generation.wrapping_add(1); + self.mark_dirty_index(index); + true + } + + pub fn set_brush_op(&mut self, id: BrushId, op: BrushOp) -> bool { + let Some(index) = self.brush_index(id) else { + return false; + }; + self.brushes[index].op = op; + self.brushes[index].set_dirty(); + self.compiled[index] = CompiledBrush::from_brush(&self.brushes[index]); + self.generation = self.generation.wrapping_add(1); + self.mark_dirty_index(index); + true + } + + pub fn solid_box( + &mut self, + name: impl Into, + bounds: Aabb, + material: MaterialId, + ) -> BrushId { + self.add_brush(name, BrushOp::Add, Primitive::Box { bounds }, material) + } + + pub fn cut_box(&mut self, name: impl Into, bounds: Aabb) -> BrushId { + self.add_brush( + name, + BrushOp::Subtract, + Primitive::Box { bounds }, + MaterialId::default(), + ) + } + + pub fn solid_oriented_box( + &mut self, + name: impl Into, + center: Vec3, + size: Vec3, + rotation: bevy_math::Quat, + material: MaterialId, + ) -> BrushId { + self.add_brush( + name, + BrushOp::Add, + Primitive::OrientedBox { + center, + size, + rotation, + }, + material, + ) + } + + pub fn cut_oriented_box( + &mut self, + name: impl Into, + center: Vec3, + size: Vec3, + rotation: bevy_math::Quat, + ) -> BrushId { + self.add_brush( + name, + BrushOp::Subtract, + Primitive::OrientedBox { + center, + size, + rotation, + }, + MaterialId::default(), + ) + } + + pub fn build(&self) -> BuildOutput { + if let Some(output) = self + .cache + .borrow() + .as_ref() + .filter(|output| output.generation == self.generation) + { + return output.clone(); + } + + let output = self.build_with_checkpoints(); + *self.cache.borrow_mut() = Some(output.clone()); + *self.first_dirty_index.borrow_mut() = None; + output + } + + pub fn build_incremental(&self) -> BuildOutput { + if let Some(output) = self + .cache + .borrow() + .as_ref() + .filter(|output| output.generation == self.generation) + { + return output.clone(); + } + + let first_dirty_index = *self.first_dirty_index.borrow(); + let output = self + .try_build_axis_aligned_boxes_from_cache(first_dirty_index) + .unwrap_or_else(|| self.build_general_from_cache(first_dirty_index)); + *self.cache.borrow_mut() = Some(output.clone()); + *self.first_dirty_index.borrow_mut() = None; + output + } + + pub fn rebuild_incremental_for_indices( + &self, + dirty_indices: impl IntoIterator, + ) -> BuildOutput { + let first_dirty_index = self + .dirty_frontier_for_indices(dirty_indices) + .first_dirty_index; + self.try_build_axis_aligned_boxes_from_cache(first_dirty_index) + .unwrap_or_else(|| self.build_general_from_cache(first_dirty_index)) + } + + pub fn rebuild_incremental_for_brushes( + &self, + dirty_brushes: impl IntoIterator, + ) -> BuildOutput { + let indices = dirty_brushes + .into_iter() + .filter_map(|id| self.brush_index(id)) + .collect::>(); + self.rebuild_incremental_for_indices(indices) + } + + pub fn rebuild(&self) -> BuildOutput { + self.build_uncached() + } + + pub fn rebuild_routed_surfaces(&self) -> BuildOutput { + self.try_build_routed_surfaces() + .unwrap_or_else(|| self.build_uncached()) + } + + pub fn supports_routed_surfaces(&self) -> bool { + self.supports_routed_surface_subset() + } + + fn mark_dirty_index(&self, index: usize) { + let mut first_dirty_index = self.first_dirty_index.borrow_mut(); + *first_dirty_index = Some(first_dirty_index.map_or(index, |dirty| dirty.min(index))); + } + + fn try_build_routed_surfaces(&self) -> Option { + if !self.supports_routed_surface_subset() { + return None; + } + + let mut report = BuildReport { + input_brushes: self.brushes.len(), + ..BuildReport::default() + }; + let mut source = None::; + let mut surfaces = Vec::::new(); + let mut previous_cutters = Vec::::new(); + let mut scratch = PolygonRouteScratch::default(); + + for brush in &self.compiled { + match brush.op { + BrushOp::Add => { + if source.is_some() { + return None; + } + let solid = brush.convex_cutter()?; + surfaces = solid.polygons.clone(); + source = Some(solid); + } + BrushOp::Subtract => { + report.operator_brushes += 1; + let source_solid = source.as_ref()?; + if !source_solid.bounds.intersects(brush.bounds) { + report.rejected_pairs += 1; + continue; + } + let cutter = brush.convex_cutter()?; + report.candidate_pairs += 1; + surfaces = scratch.route_outside_of(surfaces, &cutter); + + let mut caps = scratch.route_inside_of(cutter.polygons.clone(), source_solid); + for previous in &previous_cutters { + caps = scratch.route_outside_of(caps, previous); + } + for cap in &mut caps { + cap.material = source_solid.material; + cap.reversed = !cap.reversed; + } + surfaces.extend(caps); + previous_cutters.push(cutter); + } + BrushOp::Intersect => return None, + } + } + + let mut mesh = TriangleMesh::new(); + ConvexSolid::append_polygons_to_mesh(&surfaces, &mut mesh); + report.emitted_convex_fragments = surfaces.len(); + + Some(BuildOutput { + mesh, + report, + generation: self.generation, + }) + } + + fn supports_routed_surface_subset(&self) -> bool { + let mut source = None::; + let mut candidate_subtracts = 0usize; + + for brush in &self.compiled { + match brush.op { + BrushOp::Add => { + if source.is_some() || brush.convex_cutter().is_none() { + return false; + } + source = Some(brush.bounds); + } + BrushOp::Subtract => { + let Some(source_bounds) = source else { + return false; + }; + if brush.convex_cutter().is_none() { + return false; + } + if source_bounds.intersects(brush.bounds) { + candidate_subtracts += 1; + if candidate_subtracts > 1 { + return false; + } + } + } + BrushOp::Intersect => return false, + } + } + + source.is_some() && candidate_subtracts == 1 + } + + fn build_uncached(&self) -> BuildOutput { + if self + .compiled + .iter() + .all(|brush| matches!(brush.geometry, CompiledGeometry::Box(_))) + { + return self.build_axis_aligned_boxes_direct(); + } + + self.build_general_direct() + } + + fn build_with_checkpoints(&self) -> BuildOutput { + self.try_build_axis_aligned_boxes_from_cache(Some(0)) + .unwrap_or_else(|| self.build_general_from_cache(Some(0))) + } + + fn build_general_direct(&self) -> BuildOutput { + let mut state = GeneralBuildState::default(); + for brush in &self.compiled { + state.apply_compiled_brush(brush); + } + state.into_output(self.brushes.len(), self.generation) + } + + fn build_axis_aligned_boxes_direct(&self) -> BuildOutput { + let mut state = BoxBuildState::default(); + for brush in &self.compiled { + state.apply_compiled_box(brush); + } + state.report.direct_dirty_rebuild = true; + state.into_output_with_mesh(self.brushes.len(), self.generation, None) + } + + fn try_build_axis_aligned_boxes_from_cache( + &self, + first_dirty_index: Option, + ) -> Option { + if !self + .compiled + .iter() + .all(|brush| matches!(brush.geometry, CompiledGeometry::Box(_))) + { + return None; + } + + let brush_count = self.compiled.len(); + let previous_output = self.cache.borrow().as_ref().cloned(); + if let Some(first_dirty_index) = first_dirty_index + && first_dirty_index > 0 + { + return Some(self.build_axis_aligned_boxes_direct()); + } + let rebuild_from = match first_dirty_index { + Some(index) => index.min(brush_count), + None => self + .box_cache + .borrow() + .as_ref() + .filter(|cache| { + cache.generation == self.generation + && cache.valid_checkpoint_index == brush_count + && cache.checkpoints.len() == brush_count + 1 + }) + .map_or(0, |_| brush_count), + }; + let cached_state = self + .box_cache + .borrow() + .as_ref() + .filter(|cache| rebuild_from <= cache.valid_checkpoint_index) + .and_then(|cache| cache.checkpoints.get(rebuild_from).cloned()); + let (mut state, start_index) = if let Some(state) = cached_state { + (state, rebuild_from) + } else { + (BoxBuildState::default(), 0) + }; + if start_index > 0 { + state.report.used_checkpoint = true; + state.report.checkpoint_rebuild_from = Some(start_index); + } + + let mut checkpoints = (start_index == 0).then(|| vec![BoxBuildState::default()]); + + let mut changed_geometry = start_index == 0; + for brush in &self.compiled[start_index..] { + changed_geometry |= state.apply_compiled_box(brush); + if let Some(checkpoints) = &mut checkpoints { + checkpoints.push(state.clone()); + } + } + + if let Some(checkpoints) = checkpoints { + *self.box_cache.borrow_mut() = Some(BoxBuildCache { + generation: self.generation, + valid_checkpoint_index: brush_count, + checkpoints, + }); + } else if let Some(cache) = self.box_cache.borrow_mut().as_mut() { + cache.generation = self.generation; + cache.valid_checkpoint_index = cache.valid_checkpoint_index.min(start_index); + } + + let reused_mesh = (!changed_geometry) + .then_some(previous_output) + .flatten() + .map(|output| output.mesh); + Some(state.into_output_with_mesh(self.brushes.len(), self.generation, reused_mesh)) + } + + fn build_general_from_cache(&self, first_dirty_index: Option) -> BuildOutput { + let brush_count = self.compiled.len(); + let previous_output = self.cache.borrow().as_ref().cloned(); + let rebuild_from = match first_dirty_index { + Some(index) => index.min(brush_count), + None => self + .general_cache + .borrow() + .as_ref() + .filter(|cache| { + cache.generation == self.generation + && cache.valid_checkpoint_index == brush_count + && cache.checkpoints.len() == brush_count + 1 + }) + .map_or(0, |_| brush_count), + }; + let cached_state = self + .general_cache + .borrow() + .as_ref() + .filter(|cache| rebuild_from <= cache.valid_checkpoint_index) + .and_then(|cache| cache.checkpoints.get(rebuild_from).cloned()); + let (mut state, start_index) = if let Some(state) = cached_state { + (state, rebuild_from) + } else { + (GeneralBuildState::default(), 0) + }; + if start_index > 0 { + state.report.used_checkpoint = true; + state.report.checkpoint_rebuild_from = Some(start_index); + } + + let mut checkpoints = (start_index == 0).then(|| vec![GeneralBuildState::default()]); + + let mut changed_geometry = start_index == 0; + for brush in &self.compiled[start_index..] { + changed_geometry |= state.apply_compiled_brush(brush); + if let Some(checkpoints) = &mut checkpoints { + checkpoints.push(state.clone()); + } + } + + if let Some(checkpoints) = checkpoints { + *self.general_cache.borrow_mut() = Some(GeneralBuildCache { + generation: self.generation, + valid_checkpoint_index: brush_count, + checkpoints, + }); + } else if let Some(cache) = self.general_cache.borrow_mut().as_mut() { + cache.generation = self.generation; + cache.valid_checkpoint_index = cache.valid_checkpoint_index.min(start_index); + } + + let reused_mesh = (!changed_geometry) + .then_some(previous_output) + .flatten() + .map(|output| output.mesh); + state.into_output_with_mesh(self.brushes.len(), self.generation, reused_mesh) + } + + pub fn sample_room_with_door() -> Self { + let wall = MaterialId(1); + let floor = MaterialId(2); + let mut asm = Self::new(); + asm.solid_box( + "floor", + Aabb::from_center_size(Vec3::new(0.0, 0.0, -0.1), Vec3::new(8.0, 8.0, 0.2)), + floor, + ); + asm.solid_box( + "north wall", + Aabb::from_center_size(Vec3::new(0.0, 4.0, 1.5), Vec3::new(8.0, 0.25, 3.0)), + wall, + ); + asm.cut_box( + "door void", + Aabb::from_center_size(Vec3::new(0.0, 4.0, 1.0), Vec3::new(1.2, 0.5, 2.0)), + ); + asm + } +} + +impl CompiledBrush { + fn from_brush(brush: &Brush) -> Self { + let bounds = brush.bounds(); + let geometry = match &brush.primitive { + Primitive::Box { bounds } => CompiledGeometry::Box(*bounds), + Primitive::OrientedBox { + center, + size, + rotation, + } => CompiledGeometry::Convex(ConvexSolid::box_from_center_size( + *center, + *size, + *rotation, + brush.material, + )), + Primitive::CylinderZ { + center, + radius, + depth, + segments, + } => CompiledGeometry::CylinderZ { + center: *center, + radius: *radius, + depth: *depth, + segments: *segments, + }, + Primitive::DomeCapZ { + center, + radius, + height, + rings, + segments, + } => CompiledGeometry::DomeCapZ { + center: *center, + radius: *radius, + height: *height, + rings: *rings, + segments: *segments, + }, + Primitive::FloretArm { + anchor, + direction, + length, + root_width, + tip_width, + thickness, + tip_lift, + } => CompiledGeometry::FloretArm { + anchor: *anchor, + direction: *direction, + length: *length, + root_width: *root_width, + tip_width: *tip_width, + thickness: *thickness, + tip_lift: *tip_lift, + }, + }; + + Self { + op: brush.op, + material: brush.material, + bounds, + geometry, + name: brush.name.clone(), + } + } + + fn convex_cutter(&self) -> Option { + match &self.geometry { + CompiledGeometry::Box(bounds) => Some(ConvexSolid::from_aabb(*bounds, self.material)), + CompiledGeometry::Convex(solid) => Some(solid.clone()), + _ => None, + } + } +} + +impl BoxBuildState { + fn apply_compiled_box(&mut self, brush: &CompiledBrush) -> bool { + let CompiledGeometry::Box(bounds) = brush.geometry else { + unreachable!("box build state received a non-box brush"); + }; + + match brush.op { + BrushOp::Add => { + self.boxes.push((bounds, brush.material)); + true + } + BrushOp::Subtract => { + self.report.operator_brushes += 1; + if !bounds.intersects_any(self.boxes.iter().map(|(bounds, _)| *bounds)) { + self.report.rejected_pairs += self.boxes.len(); + return false; + } + + let mut out = Vec::with_capacity(self.boxes.len() + 4); + for (solid, material) in self.boxes.drain(..) { + if solid.intersects(bounds) { + self.report.candidate_pairs += 1; + out.extend( + solid + .subtract_box(bounds) + .into_iter() + .map(|piece| (piece, material)), + ); + } else { + self.report.rejected_pairs += 1; + out.push((solid, material)); + } + } + self.boxes = out; + true + } + BrushOp::Intersect => { + self.report.operator_brushes += 1; + if !bounds.intersects_any(self.boxes.iter().map(|(bounds, _)| *bounds)) { + let changed = !self.boxes.is_empty(); + self.report.rejected_pairs += self.boxes.len(); + self.boxes.clear(); + return changed; + } + + self.boxes = self + .boxes + .drain(..) + .filter_map(|(solid, material)| { + if let Some(hit) = solid.intersection(bounds) { + self.report.candidate_pairs += 1; + Some((hit, material)) + } else { + self.report.rejected_pairs += 1; + None + } + }) + .collect(); + true + } + } + } + + fn into_output_with_mesh( + mut self, + input_brushes: usize, + generation: u64, + reused_mesh: Option, + ) -> BuildOutput { + let mut mesh = TriangleMesh::new(); + self.report.input_brushes = input_brushes; + self.report.emitted_convex_fragments = self.boxes.len(); + if let Some(reused_mesh) = reused_mesh { + self.report.reused_mesh = true; + mesh = reused_mesh; + } else { + for (bounds, material) in self.boxes { + mesh.append_box(bounds, material); + } + } + + BuildOutput { + mesh, + report: self.report, + generation, + } + } +} + +impl GeneralBuildState { + fn apply_compiled_brush(&mut self, brush: &CompiledBrush) -> bool { + match brush.op { + BrushOp::Add => { + self.add_brush_geometry(brush); + true + } + BrushOp::Subtract => { + self.report.operator_brushes += 1; + if !brush + .bounds + .intersects_any(self.solids.iter().map(|solid| solid.bounds)) + { + self.report.rejected_pairs += self.solids.len(); + return false; + } + if let Some(cutter) = brush.convex_cutter() { + self.solids = subtract_from_solids( + std::mem::take(&mut self.solids), + &cutter, + &mut self.report, + ); + true + } else { + let has_candidate = + record_solid_pairs(&self.solids, brush.bounds, &mut self.report); + if has_candidate { + self.report + .warnings + .push(BuildWarning::SubtractIgnoredForNonBox { + brush: brush.name.clone(), + }); + } + false + } + } + BrushOp::Intersect => { + self.report.operator_brushes += 1; + if !brush + .bounds + .intersects_any(self.solids.iter().map(|solid| solid.bounds)) + { + let changed = !self.solids.is_empty(); + self.report.rejected_pairs += self.solids.len(); + self.solids.clear(); + return changed; + } + if let Some(cutter) = brush.convex_cutter() { + self.solids = intersect_solids( + std::mem::take(&mut self.solids), + &cutter, + &mut self.report, + ); + true + } else { + let has_candidate = + record_solid_pairs(&self.solids, brush.bounds, &mut self.report); + let changed = !self.solids.is_empty(); + self.solids.clear(); + if has_candidate { + self.report + .warnings + .push(BuildWarning::IntersectIgnoredForNonBox { + brush: brush.name.clone(), + }); + } + changed + } + } + } + } + + fn add_brush_geometry(&mut self, brush: &CompiledBrush) { + match &brush.geometry { + CompiledGeometry::Box(bounds) => { + self.solids + .push(ConvexSolid::from_aabb(*bounds, brush.material)); + } + CompiledGeometry::Convex(solid) => self.solids.push(solid.clone()), + CompiledGeometry::CylinderZ { + center, + radius, + depth, + segments, + } => append_cylinder_z( + &mut self.mesh, + *center, + *radius, + *depth, + *segments, + brush.material, + ), + CompiledGeometry::DomeCapZ { + center, + radius, + height, + rings, + segments, + } => append_dome_cap_z( + &mut self.mesh, + DomeCapZSpec { + center: *center, + radius: *radius, + height: *height, + rings: *rings, + segments: *segments, + material: brush.material, + }, + ), + CompiledGeometry::FloretArm { + anchor, + direction, + length, + root_width, + tip_width, + thickness, + tip_lift, + } => append_floret_arm( + &mut self.mesh, + FloretArmSpec { + anchor: *anchor, + direction: *direction, + length: *length, + root_width: *root_width, + tip_width: *tip_width, + thickness: *thickness, + tip_lift: *tip_lift, + material: brush.material, + }, + ), + } + } + + fn into_output(self, input_brushes: usize, generation: u64) -> BuildOutput { + self.into_output_with_mesh(input_brushes, generation, None) + } + + fn into_output_with_mesh( + mut self, + input_brushes: usize, + generation: u64, + reused_mesh: Option, + ) -> BuildOutput { + self.report.input_brushes = input_brushes; + self.report.emitted_convex_fragments = self.solids.len(); + if let Some(reused_mesh) = reused_mesh { + self.report.reused_mesh = true; + self.mesh = reused_mesh; + } else { + for solid in self.solids { + solid.append_to_mesh(&mut self.mesh); + } + } + + BuildOutput { + mesh: self.mesh, + report: self.report, + generation, + } + } +} + +fn subtract_from_solids( + solids: Vec, + cutter: &ConvexSolid, + report: &mut BuildReport, +) -> Vec { + let mut out = Vec::with_capacity(solids.len() + 4); + for solid in solids { + if solid.bounds.intersects(cutter.bounds) { + report.candidate_pairs += 1; + out.extend(solid.subtract_convex_owned(cutter)); + } else { + report.rejected_pairs += 1; + out.push(solid); + } + } + out +} + +fn intersect_solids( + solids: Vec, + cutter: &ConvexSolid, + report: &mut BuildReport, +) -> Vec { + let mut out = Vec::with_capacity(solids.len()); + for solid in solids { + if !solid.bounds.intersects(cutter.bounds) { + report.rejected_pairs += 1; + continue; + } + report.candidate_pairs += 1; + if let Some(fragment) = solid.intersect_convex_owned(cutter) { + out.push(fragment); + } + } + out +} + +fn record_solid_pairs(solids: &[ConvexSolid], bounds: Aabb, report: &mut BuildReport) -> bool { + let mut has_candidate = false; + for solid in solids { + if solid.bounds.intersects(bounds) { + report.candidate_pairs += 1; + has_candidate = true; + } else { + report.rejected_pairs += 1; + } + } + has_candidate +} + +#[cfg(test)] +mod tests { + use bevy_math::Vec3; + + use super::*; + + #[test] + fn centered_subtract_splits_box_into_six_fragments() { + let source = Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)); + let cutter = Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)); + let pieces = source.subtract_box(cutter); + + assert_eq!(pieces.len(), 6); + assert!(pieces.iter().all(|piece| piece.is_valid())); + assert!(pieces.iter().all(|piece| !piece.intersects(cutter))); + } + + #[test] + fn door_cut_leaves_no_box_fragment_inside_void() { + let output = Assembler::sample_room_with_door().build(); + let cutter = Aabb::from_center_size(Vec3::new(0.0, 4.0, 1.0), Vec3::new(1.2, 0.5, 2.0)); + + assert!(output.report.emitted_convex_fragments > 2); + for tri in output.mesh.indices.chunks_exact(3) { + let center = (output.mesh.positions[tri[0] as usize] + + output.mesh.positions[tri[1] as usize] + + output.mesh.positions[tri[2] as usize]) + / 3.0; + assert!(!cutter.contains_point_strict(center, 1.0e-4)); + } + } + + #[test] + fn rotated_door_cut_produces_diagonal_portal_faces() { + let mut asm = Assembler::new(); + asm.solid_box( + "wall", + Aabb::from_center_size(Vec3::new(0.0, 0.0, 1.5), Vec3::new(5.0, 0.5, 3.0)), + MaterialId(1), + ); + asm.cut_oriented_box( + "angled void", + Vec3::new(0.0, 0.0, 1.5), + Vec3::new(1.5, 2.0, 2.0), + bevy_math::Quat::from_rotation_z(std::f32::consts::FRAC_PI_4), + ); + + let output = asm.build(); + assert!(output.report.emitted_convex_fragments > 2); + assert!(output.mesh.normals.iter().any(|normal| { + normal.x.abs() > 0.2 && normal.y.abs() > 0.2 && normal.z.abs() < 0.1 + })); + } + + #[test] + fn mesh_box_emits_twelve_triangles() { + let mut mesh = TriangleMesh::new(); + mesh.append_box(Aabb::from_center_size(Vec3::ZERO, Vec3::ONE), MaterialId(7)); + + assert_eq!(mesh.triangle_count(), 12); + assert_eq!(mesh.triangle_materials.len(), 12); + assert_eq!(mesh.triangle_materials[0], MaterialId(7)); + } + + #[test] + fn distant_subtract_skips_convex_split_and_keeps_source_solid() { + let mut asm = Assembler::new(); + asm.solid_box( + "source", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)), + MaterialId(1), + ); + asm.cut_box( + "far void", + Aabb::from_center_size(Vec3::splat(10.0), Vec3::splat(1.0)), + ); + + let output = asm.build(); + assert_eq!(output.report.emitted_convex_fragments, 1); + assert_eq!(output.report.operator_brushes, 1); + assert_eq!(output.report.candidate_pairs, 0); + assert_eq!(output.report.rejected_pairs, 1); + assert_eq!(output.mesh.triangle_count(), 12); + } + + #[test] + fn intersecting_box_keeps_common_convex_region() { + let mut asm = Assembler::new(); + asm.solid_box( + "a", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + MaterialId(1), + ); + asm.add_brush( + "b", + BrushOp::Intersect, + Primitive::Box { + bounds: Aabb::from_center_size(Vec3::X, Vec3::splat(4.0)), + }, + MaterialId(1), + ); + + let output = asm.build(); + assert_eq!(output.report.emitted_convex_fragments, 1); + assert_eq!(output.report.operator_brushes, 1); + assert_eq!(output.report.candidate_pairs, 1); + assert_eq!(output.report.rejected_pairs, 0); + assert_eq!(output.report.warnings.len(), 0); + assert_eq!(output.mesh.triangle_count(), 12); + } + + #[test] + fn cached_build_and_dirty_rebuild_emit_same_output() { + let mut asm = Assembler::new(); + asm.solid_box( + "slab", + Aabb::from_center_size(Vec3::ZERO, Vec3::new(8.0, 8.0, 2.0)), + MaterialId(1), + ); + asm.cut_box( + "center void", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)), + ); + asm.cut_oriented_box( + "angled void", + Vec3::new(1.0, 1.0, 0.0), + Vec3::new(1.25, 4.0, 2.5), + bevy_math::Quat::from_rotation_z(0.35), + ); + + let stable = asm.build(); + let cached = asm.build(); + let dirty = asm.rebuild(); + + assert_eq!( + stable.report.emitted_convex_fragments, + dirty.report.emitted_convex_fragments + ); + assert_eq!( + stable.report.operator_brushes, + dirty.report.operator_brushes + ); + assert_eq!(stable.report.candidate_pairs, dirty.report.candidate_pairs); + assert_eq!(stable.report.rejected_pairs, dirty.report.rejected_pairs); + assert_eq!(stable.report.warnings, dirty.report.warnings); + assert_eq!(stable.mesh.triangle_count(), dirty.mesh.triangle_count()); + assert_eq!(stable.mesh.vertex_count(), dirty.mesh.vertex_count()); + assert_eq!(stable.mesh.positions, dirty.mesh.positions); + assert_eq!(stable.mesh.indices, dirty.mesh.indices); + assert_eq!(cached.mesh.positions, stable.mesh.positions); + assert_eq!(cached.mesh.indices, stable.mesh.indices); + } + + #[test] + fn routed_surface_rebuild_emits_clean_center_cut_boundary() { + let mut asm = Assembler::new(); + let cutter = Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)); + asm.solid_box( + "source", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + MaterialId(1), + ); + asm.cut_box("void", cutter); + + let routed = asm.rebuild_routed_surfaces(); + + assert_eq!(routed.report.operator_brushes, 1); + assert_eq!(routed.report.candidate_pairs, 1); + assert_eq!(routed.report.rejected_pairs, 0); + assert_eq!(routed.report.emitted_convex_fragments, 24); + assert!(routed.mesh.triangle_count() < asm.rebuild().mesh.triangle_count()); + for tri in routed.mesh.indices.chunks_exact(3) { + let center = (routed.mesh.positions[tri[0] as usize] + + routed.mesh.positions[tri[1] as usize] + + routed.mesh.positions[tri[2] as usize]) + / 3.0; + assert!(!cutter.contains_point_strict(center, 1.0e-4)); + } + } + + #[test] + fn routed_surface_rebuild_falls_back_for_intersections() { + let mut asm = Assembler::new(); + asm.solid_box( + "source", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + MaterialId(1), + ); + asm.add_brush( + "common", + BrushOp::Intersect, + Primitive::Box { + bounds: Aabb::from_center_size(Vec3::X, Vec3::splat(4.0)), + }, + MaterialId(1), + ); + + let stable = asm.rebuild(); + let routed = asm.rebuild_routed_surfaces(); + + assert_eq!( + routed.report.emitted_convex_fragments, + stable.report.emitted_convex_fragments + ); + assert_eq!(routed.mesh.triangle_count(), stable.mesh.triangle_count()); + assert_eq!(routed.mesh.positions, stable.mesh.positions); + assert_eq!(routed.mesh.indices, stable.mesh.indices); + } + + #[test] + fn routed_surface_rebuild_falls_back_for_multiple_candidate_cutters() { + let mut asm = Assembler::new(); + asm.solid_box( + "source", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + MaterialId(1), + ); + asm.cut_oriented_box( + "a", + Vec3::new(-0.5, 0.0, 0.0), + Vec3::new(1.0, 5.0, 5.0), + bevy_math::Quat::from_rotation_z(0.25), + ); + asm.cut_oriented_box( + "b", + Vec3::new(0.5, 0.0, 0.0), + Vec3::new(1.0, 5.0, 5.0), + bevy_math::Quat::from_rotation_z(-0.25), + ); + + let stable = asm.rebuild(); + let routed = asm.rebuild_routed_surfaces(); + + assert_eq!( + routed.report.emitted_convex_fragments, + stable.report.emitted_convex_fragments + ); + assert_eq!(routed.mesh.triangle_count(), stable.mesh.triangle_count()); + assert_eq!(routed.mesh.positions, stable.mesh.positions); + assert_eq!(routed.mesh.indices, stable.mesh.indices); + } + + #[test] + fn routed_surface_rebuild_falls_back_when_no_cutter_touches_source() { + let mut asm = Assembler::new(); + asm.solid_box( + "source", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + MaterialId(1), + ); + asm.cut_box( + "far", + Aabb::from_center_size(Vec3::splat(20.0), Vec3::splat(2.0)), + ); + + let stable = asm.rebuild(); + let routed = asm.rebuild_routed_surfaces(); + + assert_eq!( + routed.report.emitted_convex_fragments, + stable.report.emitted_convex_fragments + ); + assert_eq!(routed.mesh.triangle_count(), stable.mesh.triangle_count()); + assert_eq!(routed.mesh.positions, stable.mesh.positions); + assert_eq!(routed.mesh.indices, stable.mesh.indices); + } + + #[test] + fn mutating_brush_primitive_invalidates_cache_and_exposes_dirty_frontier() { + let mut asm = Assembler::new(); + let source = asm.solid_box( + "source", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + MaterialId(1), + ); + let cutter = asm.cut_box("void", Aabb::from_center_size(Vec3::ZERO, Vec3::splat(1.0))); + let before = asm.build(); + + assert!(asm.set_brush_primitive( + cutter, + Primitive::Box { + bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)), + }, + )); + let frontier = asm.dirty_frontier_for_brushes([cutter]); + let after = asm.build(); + + assert_eq!(frontier.first_dirty_index, Some(1)); + assert_eq!(frontier.operator_brushes, 1); + assert_eq!(frontier.candidate_pairs(), 1); + assert_eq!(frontier.rejected_pairs, 0); + assert!(after.generation > before.generation); + assert_ne!(after.mesh.positions, before.mesh.positions); + assert_eq!(asm.brush_index(source), Some(0)); + assert_eq!(asm.brush_index(cutter), Some(1)); + } + + #[test] + fn incremental_box_rebuild_reuses_prefix_and_matches_full_rebuild() { + let mut asm = Assembler::new(); + asm.solid_box( + "source", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(8.0)), + MaterialId(1), + ); + asm.cut_box( + "early", + Aabb::from_center_size(Vec3::new(-2.0, 0.0, 0.0), Vec3::splat(2.0)), + ); + let dirty = asm.cut_box( + "dirty", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)), + ); + asm.cut_box( + "late", + Aabb::from_center_size(Vec3::new(2.0, 0.0, 0.0), Vec3::splat(2.0)), + ); + let before = asm.build(); + + assert!(asm.set_brush_primitive( + dirty, + Primitive::Box { + bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(3.0)), + }, + )); + let incremental = asm.build_incremental(); + let full = asm.rebuild(); + + assert!(!incremental.report.used_checkpoint); + assert!(incremental.report.direct_dirty_rebuild); + assert_eq!(incremental.mesh.positions, full.mesh.positions); + assert_eq!(incremental.mesh.indices, full.mesh.indices); + assert_eq!( + incremental.report.candidate_pairs, + full.report.candidate_pairs + ); + assert_eq!( + incremental.report.rejected_pairs, + full.report.rejected_pairs + ); + assert_eq!(incremental.generation, full.generation); + assert_ne!(incremental.mesh.positions, before.mesh.positions); + } + + #[test] + fn explicit_incremental_box_rebuild_matches_full_rebuild() { + let mut asm = Assembler::new(); + asm.solid_box( + "source", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(8.0)), + MaterialId(1), + ); + asm.cut_box( + "early", + Aabb::from_center_size(Vec3::new(-2.0, 0.0, 0.0), Vec3::splat(2.0)), + ); + let dirty = asm.cut_box( + "dirty", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)), + ); + asm.cut_box( + "late", + Aabb::from_center_size(Vec3::new(2.0, 0.0, 0.0), Vec3::splat(2.0)), + ); + asm.build_incremental(); + + assert!(asm.set_brush_primitive( + dirty, + Primitive::Box { + bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(3.0)), + }, + )); + let incremental = asm.rebuild_incremental_for_brushes([dirty]); + let full = asm.rebuild(); + + assert_eq!(incremental.mesh.positions, full.mesh.positions); + assert_eq!(incremental.mesh.indices, full.mesh.indices); + assert_eq!( + incremental.report.candidate_pairs, + full.report.candidate_pairs + ); + assert_eq!( + incremental.report.rejected_pairs, + full.report.rejected_pairs + ); + } + + #[test] + fn incremental_distant_tail_edit_reuses_previous_mesh() { + let mut asm = Assembler::new(); + asm.solid_box( + "source", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(8.0)), + MaterialId(1), + ); + let far = asm.cut_oriented_box( + "far", + Vec3::splat(100.0), + Vec3::splat(1.0), + bevy_math::Quat::from_rotation_z(0.2), + ); + let before = asm.build_incremental(); + + assert!(asm.set_brush_primitive( + far, + Primitive::OrientedBox { + center: Vec3::splat(120.0), + size: Vec3::splat(1.0), + rotation: bevy_math::Quat::from_rotation_z(0.2), + }, + )); + let after = asm.build_incremental(); + + assert_eq!(after.mesh, before.mesh); + assert!(after.report.reused_mesh); + assert!(after.report.used_checkpoint); + assert_eq!(after.report.checkpoint_rebuild_from, Some(1)); + assert!(!after.report.direct_dirty_rebuild); + assert_eq!(after.report.candidate_pairs, 0); + assert_eq!(after.report.rejected_pairs, 1); + } + + #[test] + fn incremental_convex_rebuild_matches_full_rebuild() { + let mut asm = Assembler::new(); + asm.solid_box( + "slab", + Aabb::from_center_size(Vec3::ZERO, Vec3::new(16.0, 16.0, 4.0)), + MaterialId(1), + ); + + let mut tail = BrushId(0); + for index in 0..12 { + let angle = index as f32 * 0.217; + let center = Vec3::new(angle.cos() * 4.0, angle.sin() * 4.0, 0.0); + tail = asm.cut_oriented_box( + format!("rotated_void_{index}"), + center, + Vec3::new(1.0, 6.0, 5.0), + bevy_math::Quat::from_rotation_z(angle), + ); + } + asm.build_incremental(); + + assert!(asm.set_brush_primitive( + tail, + Primitive::OrientedBox { + center: Vec3::new(3.0, 0.0, 0.0), + size: Vec3::new(1.25, 6.0, 5.0), + rotation: bevy_math::Quat::from_rotation_z(0.5), + }, + )); + let incremental = asm.build_incremental(); + let full = asm.rebuild(); + + assert!(incremental.report.used_checkpoint); + assert_eq!(incremental.report.checkpoint_rebuild_from, Some(12)); + assert!(!incremental.report.direct_dirty_rebuild); + assert_eq!(incremental.mesh.positions, full.mesh.positions); + assert_eq!(incremental.mesh.indices, full.mesh.indices); + assert_eq!( + incremental.report.candidate_pairs, + full.report.candidate_pairs + ); + assert_eq!( + incremental.report.rejected_pairs, + full.report.rejected_pairs + ); + } + + #[test] + fn mutating_brush_operation_changes_ordered_output() { + let mut asm = Assembler::new(); + let _source = asm.solid_box( + "source", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + MaterialId(1), + ); + let other = asm.solid_box( + "other", + Aabb::from_center_size(Vec3::new(0.5, 0.0, 0.0), Vec3::splat(3.0)), + MaterialId(1), + ); + let before = asm.rebuild(); + + assert!(asm.set_brush_op(other, BrushOp::Intersect)); + let after = asm.rebuild(); + + assert_eq!(after.report.operator_brushes, 1); + assert_eq!(after.report.candidate_pairs, 1); + assert!(after.mesh.triangle_count() < before.mesh.triangle_count()); + } + + #[test] + fn rotated_cut_stack_regression_preserves_ordered_output_shape() { + let mut asm = Assembler::new(); + asm.solid_box( + "slab", + Aabb::from_center_size(Vec3::ZERO, Vec3::new(32.0, 32.0, 4.0)), + MaterialId(1), + ); + + for index in 0..64 { + let angle = index as f32 * 0.173; + let radius = 11.0 + (index % 7) as f32 * 0.35; + let center = Vec3::new(angle.cos() * radius, angle.sin() * radius, 0.0); + asm.cut_oriented_box( + format!("rotated_void_{index}"), + center, + Vec3::new(1.0 + (index % 3) as f32 * 0.3, 8.0, 5.0), + bevy_math::Quat::from_rotation_z(angle), + ); + } + + let output = asm.rebuild(); + + assert_eq!(output.report.emitted_convex_fragments, 280); + assert_eq!(output.mesh.triangle_count(), 3404); + assert_eq!(output.report.candidate_pairs, 804); + assert_eq!(output.report.rejected_pairs, 9417); + } +} diff --git a/packages/gamecult-geometry-rs/src/brush.rs b/packages/gamecult-geometry-rs/src/brush.rs new file mode 100644 index 00000000..314c84d0 --- /dev/null +++ b/packages/gamecult-geometry-rs/src/brush.rs @@ -0,0 +1,370 @@ +#![allow(dead_code)] +use bevy_math::{Quat, Vec3}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct BrushId(pub u32); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] +pub struct MaterialId(pub u32); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BrushOp { + Add, + Subtract, + Intersect, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum PolygonCategory { + Inside, + Outside, + Aligned, + ReverseAligned, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Aabb { + pub min: Vec3, + pub max: Vec3, +} + +impl Aabb { + pub fn empty() -> Self { + Self { + min: Vec3::splat(f32::INFINITY), + max: Vec3::splat(f32::NEG_INFINITY), + } + } + + pub fn from_center_size(center: Vec3, size: Vec3) -> Self { + let half = size * 0.5; + Self { + min: center - half, + max: center + half, + } + } + + pub fn new(min: Vec3, max: Vec3) -> Self { + Self { min, max } + } + + pub fn from_points(points: &[Vec3]) -> Self { + let mut bounds = Self::empty(); + for point in points { + bounds.include_point(*point); + } + bounds + } + + pub fn include_point(&mut self, point: Vec3) { + self.min = self.min.min(point); + self.max = self.max.max(point); + } + + pub fn union(self, other: Self) -> Self { + if !self.has_finite_extents() { + return other; + } + if !other.has_finite_extents() { + return self; + } + Self { + min: self.min.min(other.min), + max: self.max.max(other.max), + } + } + + pub fn size(self) -> Vec3 { + self.max - self.min + } + + pub fn center(self) -> Vec3 { + (self.min + self.max) * 0.5 + } + + pub fn is_valid(self) -> bool { + self.max.x > self.min.x && self.max.y > self.min.y && self.max.z > self.min.z + } + + pub fn has_finite_extents(self) -> bool { + self.min.is_finite() && self.max.is_finite() + } + + pub fn intersects(self, other: Self) -> bool { + self.min.x < other.max.x + && self.max.x > other.min.x + && self.min.y < other.max.y + && self.max.y > other.min.y + && self.min.z < other.max.z + && self.max.z > other.min.z + } + + pub fn intersects_any(self, others: impl IntoIterator) -> bool { + others.into_iter().any(|other| self.intersects(other)) + } + + pub fn intersection(self, other: Self) -> Option { + if !self.intersects(other) { + return None; + } + + let min = self.min.max(other.min); + let max = self.max.min(other.max); + let hit = Self { min, max }; + hit.is_valid().then_some(hit) + } + + pub fn contains_point(self, point: Vec3) -> bool { + point.x >= self.min.x + && point.x <= self.max.x + && point.y >= self.min.y + && point.y <= self.max.y + && point.z >= self.min.z + && point.z <= self.max.z + } + + pub fn contains_point_strict(self, point: Vec3, epsilon: f32) -> bool { + point.x > self.min.x + epsilon + && point.x < self.max.x - epsilon + && point.y > self.min.y + epsilon + && point.y < self.max.y - epsilon + && point.z > self.min.z + epsilon + && point.z < self.max.z - epsilon + } + + pub fn subtract_box(self, cutter: Self) -> Vec { + let Some(hit) = self.intersection(cutter) else { + return vec![self]; + }; + + let mut pieces = Vec::with_capacity(6); + + push_valid( + &mut pieces, + Self::new(self.min, Vec3::new(hit.min.x, self.max.y, self.max.z)), + ); + push_valid( + &mut pieces, + Self::new(Vec3::new(hit.max.x, self.min.y, self.min.z), self.max), + ); + + let x_min = self.min.x.max(hit.min.x); + let x_max = self.max.x.min(hit.max.x); + + push_valid( + &mut pieces, + Self::new( + Vec3::new(x_min, self.min.y, self.min.z), + Vec3::new(x_max, hit.min.y, self.max.z), + ), + ); + push_valid( + &mut pieces, + Self::new( + Vec3::new(x_min, hit.max.y, self.min.z), + Vec3::new(x_max, self.max.y, self.max.z), + ), + ); + + let y_min = self.min.y.max(hit.min.y); + let y_max = self.max.y.min(hit.max.y); + + push_valid( + &mut pieces, + Self::new( + Vec3::new(x_min, y_min, self.min.z), + Vec3::new(x_max, y_max, hit.min.z), + ), + ); + push_valid( + &mut pieces, + Self::new( + Vec3::new(x_min, y_min, hit.max.z), + Vec3::new(x_max, y_max, self.max.z), + ), + ); + + pieces + } +} + +fn push_valid(pieces: &mut Vec, bounds: Aabb) { + if bounds.is_valid() { + pieces.push(bounds); + } +} + +#[derive(Clone, Debug)] +pub enum Primitive { + Box { + bounds: Aabb, + }, + OrientedBox { + center: Vec3, + size: Vec3, + rotation: Quat, + }, + CylinderZ { + center: Vec3, + radius: f32, + depth: f32, + segments: usize, + }, + DomeCapZ { + center: Vec3, + radius: f32, + height: f32, + rings: usize, + segments: usize, + }, + FloretArm { + anchor: Vec3, + direction: Vec3, + length: f32, + root_width: f32, + tip_width: f32, + thickness: f32, + tip_lift: f32, + }, +} + +#[derive(Clone, Debug)] +pub struct Brush { + pub id: BrushId, + pub name: String, + pub op: BrushOp, + pub primitive: Primitive, + pub material: MaterialId, + pub rotation: Quat, + pub generation: u64, +} + +impl Brush { + pub fn new( + id: BrushId, + name: impl Into, + op: BrushOp, + primitive: Primitive, + material: MaterialId, + ) -> Self { + Self { + id, + name: name.into(), + op, + primitive, + material, + rotation: Quat::IDENTITY, + generation: 0, + } + } + + pub fn set_dirty(&mut self) { + self.generation = self.generation.wrapping_add(1); + } + + pub fn as_box(&self) -> Option { + match self.primitive { + Primitive::Box { bounds } => Some(bounds), + _ => None, + } + } + + pub fn bounds(&self) -> Aabb { + self.primitive.bounds() + } +} + +impl Primitive { + pub fn bounds(&self) -> Aabb { + match *self { + Self::Box { bounds } => bounds, + Self::OrientedBox { + center, + size, + rotation, + } => oriented_box_bounds(center, size, rotation), + Self::CylinderZ { + center, + radius, + depth, + .. + } => Aabb::new( + center - Vec3::new(radius, radius, depth * 0.5), + center + Vec3::new(radius, radius, depth * 0.5), + ), + Self::DomeCapZ { + center, + radius, + height, + .. + } => Aabb::new( + center - Vec3::new(radius, radius, 0.0), + center + Vec3::new(radius, radius, height), + ), + Self::FloretArm { + anchor, + direction, + length, + root_width, + tip_width, + thickness, + tip_lift, + } => floret_bounds( + anchor, direction, length, root_width, tip_width, thickness, tip_lift, + ), + } + } +} + +fn oriented_box_bounds(center: Vec3, size: Vec3, rotation: Quat) -> Aabb { + let half = size * 0.5; + let local_corners = [ + Vec3::new(-half.x, -half.y, -half.z), + Vec3::new(half.x, -half.y, -half.z), + Vec3::new(half.x, half.y, -half.z), + Vec3::new(-half.x, half.y, -half.z), + Vec3::new(-half.x, -half.y, half.z), + Vec3::new(half.x, -half.y, half.z), + Vec3::new(half.x, half.y, half.z), + Vec3::new(-half.x, half.y, half.z), + ]; + let corners = local_corners.map(|point| center + rotation * point); + Aabb::from_points(&corners) +} + +fn floret_bounds( + anchor: Vec3, + direction: Vec3, + length: f32, + root_width: f32, + tip_width: f32, + thickness: f32, + tip_lift: f32, +) -> Aabb { + let forward = direction.normalize_or_zero(); + let forward = if forward.length_squared() == 0.0 { + Vec3::X + } else { + forward + }; + let mut side = forward.cross(Vec3::Z).normalize_or_zero(); + if side.length_squared() == 0.0 { + side = Vec3::Y; + } + + let root_center = anchor; + let tip_center = anchor + forward * length + Vec3::Z * tip_lift; + let z = Vec3::Z * (thickness * 0.5); + let r = side * (root_width * 0.5); + let t = side * (tip_width * 0.5); + Aabb::from_points(&[ + root_center - r - z, + root_center + r - z, + root_center - r + z, + root_center + r + z, + tip_center - t - z, + tip_center + t - z, + tip_center - t + z, + tip_center + t + z, + ]) +} diff --git a/packages/gamecult-geometry-rs/src/convex.rs b/packages/gamecult-geometry-rs/src/convex.rs new file mode 100644 index 00000000..7c8d78b6 --- /dev/null +++ b/packages/gamecult-geometry-rs/src/convex.rs @@ -0,0 +1,905 @@ +#![allow(dead_code)] +use bevy_math::{Quat, Vec2, Vec3}; + +use crate::{Aabb, MaterialId, PolygonCategory, TriangleMesh}; + +const EPSILON: f32 = 1.0e-5; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Plane { + pub normal: Vec3, + pub distance: f32, +} + +impl Plane { + pub fn from_point_normal(point: Vec3, normal: Vec3) -> Self { + let normal = normal.normalize_or_zero(); + Self { + normal, + distance: -normal.dot(point), + } + } + + pub fn signed_distance(self, point: Vec3) -> f32 { + self.normal.dot(point) + self.distance + } + + pub fn is_coplanar_with(self, other: Self) -> bool { + self.normal.dot(other.normal).abs() > 1.0 - EPSILON + && (self.distance - other.distance).abs() <= EPSILON + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ConvexPolygon { + pub vertices: Vec, + pub normal: Vec3, + pub material: MaterialId, + pub category: PolygonCategory, + pub visible: bool, + pub reversed: bool, + pub bounds: Aabb, +} + +impl ConvexPolygon { + pub fn new(vertices: Vec, normal: Vec3, material: MaterialId) -> Self { + Self { + bounds: Aabb::from_points(&vertices), + vertices, + normal: normal.normalize_or_zero(), + material, + category: PolygonCategory::Aligned, + visible: true, + reversed: false, + } + } + + pub fn centroid(&self) -> Vec3 { + if self.vertices.is_empty() { + return Vec3::ZERO; + } + self.vertices.iter().copied().sum::() / self.vertices.len() as f32 + } + + fn with_vertices(&self, vertices: Vec) -> Self { + Self { + bounds: Aabb::from_points(&vertices), + vertices, + normal: self.normal, + material: self.material, + category: self.category, + visible: self.visible, + reversed: self.reversed, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ConvexSolid { + pub polygons: Vec, + pub clip_planes: Vec, + pub material: MaterialId, + pub bounds: Aabb, +} + +#[derive(Clone, Debug, Default)] +pub struct CategorizedPolygons { + pub inside: Vec, + pub outside: Vec, + pub aligned: Vec, + pub reverse_aligned: Vec, +} + +#[derive(Default)] +pub struct PolygonRouteScratch { + pieces: Vec, + inside_pieces: Vec, + cap_points: Vec, +} + +impl ConvexSolid { + pub fn from_aabb(bounds: Aabb, material: MaterialId) -> Self { + Self::box_from_center_size(bounds.center(), bounds.size(), Quat::IDENTITY, material) + } + + pub fn box_from_center_size( + center: Vec3, + size: Vec3, + rotation: Quat, + material: MaterialId, + ) -> Self { + let half = size * 0.5; + let local_corners = [ + Vec3::new(-half.x, -half.y, -half.z), + Vec3::new(half.x, -half.y, -half.z), + Vec3::new(half.x, half.y, -half.z), + Vec3::new(-half.x, half.y, -half.z), + Vec3::new(-half.x, -half.y, half.z), + Vec3::new(half.x, -half.y, half.z), + Vec3::new(half.x, half.y, half.z), + Vec3::new(-half.x, half.y, half.z), + ]; + let corners = local_corners.map(|point| center + rotation * point); + let faces = [ + ([0, 3, 2, 1], Vec3::NEG_Z), + ([4, 5, 6, 7], Vec3::Z), + ([0, 1, 5, 4], Vec3::NEG_Y), + ([3, 7, 6, 2], Vec3::Y), + ([0, 4, 7, 3], Vec3::NEG_X), + ([1, 2, 6, 5], Vec3::X), + ]; + + let mut polygons = Vec::with_capacity(6); + let mut clip_planes = Vec::with_capacity(6); + for (indices, local_normal) in faces { + let normal = rotation * local_normal; + let vertices = indices.map(|index| corners[index]).to_vec(); + clip_planes.push(Plane::from_point_normal(vertices[0], normal)); + polygons.push(ConvexPolygon::new(vertices, normal, material)); + } + + Self { + bounds: Aabb::from_points(&corners), + polygons, + clip_planes, + material, + } + } + + pub fn subtract_convex(&self, cutter: &Self) -> Vec { + self.clone().subtract_convex_owned(cutter) + } + + pub fn subtract_convex_owned(self, cutter: &Self) -> Vec { + let mut fragments = Vec::new(); + let mut remainder = Some(self); + + for plane in &cutter.clip_planes { + let Some(current) = remainder.take() else { + break; + }; + + match current.split_owned(*plane) { + SplitResult::Front(front) => { + fragments.push(front); + break; + } + SplitResult::Back(back) => { + remainder = Some(back); + } + SplitResult::Both { front, back } => { + fragments.push(front); + remainder = Some(back); + } + SplitResult::Coplanar(solid) => { + remainder = Some(solid); + } + } + } + + fragments + } + + pub fn intersect_convex(&self, cutter: &Self) -> Option { + self.clone().intersect_convex_owned(cutter) + } + + pub fn intersect_convex_owned(self, cutter: &Self) -> Option { + if cutter.contains_solid(&self) { + return Some(self); + } + if self.contains_solid(cutter) { + return Some(cutter.clone_with_material(self.material)); + } + + let mut remainder = self; + + for plane in &cutter.clip_planes { + remainder = match remainder.split_owned(*plane) { + SplitResult::Front(_) => return None, + SplitResult::Back(back) | SplitResult::Coplanar(back) => back, + SplitResult::Both { back, .. } => back, + }; + } + + Some(remainder) + } + + pub fn contains_point(&self, point: Vec3) -> bool { + self.clip_planes + .iter() + .all(|plane| plane.signed_distance(point) <= EPSILON) + } + + pub fn contains_solid(&self, other: &Self) -> bool { + other + .polygons + .iter() + .flat_map(|polygon| polygon.vertices.iter()) + .all(|point| self.contains_point(*point)) + } + + pub fn clone_with_material(&self, material: MaterialId) -> Self { + let mut solid = self.clone(); + solid.material = material; + for polygon in &mut solid.polygons { + polygon.material = material; + } + solid + } + + pub fn append_to_mesh(&self, mesh: &mut TriangleMesh) { + for polygon in &self.polygons { + if !polygon.visible || polygon.vertices.len() < 3 { + continue; + } + let base = polygon.vertices[0]; + for i in 1..polygon.vertices.len() - 1 { + let triangle = if polygon.reversed { + [base, polygon.vertices[i + 1], polygon.vertices[i]] + } else { + [base, polygon.vertices[i], polygon.vertices[i + 1]] + }; + let normal = if polygon.reversed { + -polygon.normal + } else { + polygon.normal + }; + mesh.append_triangle( + triangle, + normal, + [Vec2::ZERO, Vec2::X, Vec2::Y], + polygon.material, + ); + } + } + } + + pub fn categorize_whole_polygons_against(&mut self, cutter: &Self) { + for polygon in &mut self.polygons { + if let Some(category) = whole_polygon_category_against(polygon, cutter) { + polygon.category = category; + } + } + } + + pub fn categorize_polygons_against(&self, cutter: &Self) -> CategorizedPolygons { + PolygonRouteScratch::default().categorize_against(&self.polygons, cutter) + } + + pub fn route_polygons_outside_of( + polygons: Vec, + cutter: &Self, + ) -> Vec { + let categorized = PolygonRouteScratch::default().categorize_against(&polygons, cutter); + categorized.outside + } + + pub fn route_polygons_inside_of( + polygons: Vec, + cutter: &Self, + ) -> Vec { + let categorized = PolygonRouteScratch::default().categorize_against(&polygons, cutter); + let mut inside = categorized.inside; + inside.extend(categorized.aligned); + inside.extend(categorized.reverse_aligned); + inside + } + + pub fn subtract_convex_routed_polygons(&self, cutter: &Self) -> Vec { + let mut polygons = Vec::new(); + let source = self.categorize_polygons_against(cutter); + polygons.extend(source.outside); + + let cutter_inside_source = cutter.categorize_polygons_against(self); + for mut polygon in cutter_inside_source.inside { + polygon.reversed = !polygon.reversed; + polygons.push(polygon); + } + for mut polygon in cutter_inside_source.aligned { + polygon.reversed = !polygon.reversed; + polygons.push(polygon); + } + for mut polygon in cutter_inside_source.reverse_aligned { + polygon.reversed = !polygon.reversed; + polygons.push(polygon); + } + + polygons + } + + pub fn append_polygons_to_mesh(polygons: &[ConvexPolygon], mesh: &mut TriangleMesh) { + for polygon in polygons { + if !polygon.visible || polygon.vertices.len() < 3 { + continue; + } + let base = polygon.vertices[0]; + for i in 1..polygon.vertices.len() - 1 { + let triangle = if polygon.reversed { + [base, polygon.vertices[i + 1], polygon.vertices[i]] + } else { + [base, polygon.vertices[i], polygon.vertices[i + 1]] + }; + let normal = if polygon.reversed { + -polygon.normal + } else { + polygon.normal + }; + mesh.append_triangle( + triangle, + normal, + [Vec2::ZERO, Vec2::X, Vec2::Y], + polygon.material, + ); + } + } + } + + fn split_owned(self, plane: Plane) -> SplitResult { + let mut saw_front = false; + let mut saw_back = false; + for polygon in &self.polygons { + for vertex in &polygon.vertices { + let distance = plane.signed_distance(*vertex); + saw_front |= distance > EPSILON; + saw_back |= distance < -EPSILON; + } + } + + match (saw_front, saw_back) { + (true, false) => return SplitResult::Front(self), + (false, true) => return SplitResult::Back(self), + (false, false) => return SplitResult::Coplanar(self), + (true, true) => {} + } + + let mut front_polygons = Vec::with_capacity(self.polygons.len() + 1); + let mut back_polygons = Vec::with_capacity(self.polygons.len() + 1); + let mut cap_points = Vec::with_capacity(self.polygons.len() * 2); + + for polygon in &self.polygons { + let front = clip_polygon(&polygon.vertices, plane, KeepSide::Front, &mut cap_points); + if front.len() >= 3 { + front_polygons.push(polygon.with_vertices(front)); + } + + let back = clip_polygon(&polygon.vertices, plane, KeepSide::Back, &mut cap_points); + if back.len() >= 3 { + back_polygons.push(polygon.with_vertices(back)); + } + } + + let cap_points = unique_points_on_plane(cap_points); + if cap_points.len() >= 3 { + front_polygons.push(make_cap_polygon(&cap_points, -plane.normal, self.material)); + back_polygons.push(make_cap_polygon(&cap_points, plane.normal, self.material)); + } + + SplitResult::Both { + front: Self { + bounds: bounds_from_polygons(&front_polygons), + clip_planes: planes_from_polygons(&front_polygons), + polygons: front_polygons, + material: self.material, + }, + back: Self { + bounds: bounds_from_polygons(&back_polygons), + clip_planes: planes_from_polygons(&back_polygons), + polygons: back_polygons, + material: self.material, + }, + } + } +} + +impl PolygonRouteScratch { + pub fn categorize_against( + &mut self, + polygons: &[ConvexPolygon], + cutter: &ConvexSolid, + ) -> CategorizedPolygons { + let mut categorized = CategorizedPolygons::default(); + + for polygon in polygons { + self.pieces.clear(); + self.pieces.push(CategorizationPiece { + polygon: polygon.clone(), + aligned_category: None, + }); + + for plane in &cutter.clip_planes { + self.inside_pieces.clear(); + for piece in self.pieces.drain(..) { + classify_piece_against_plane( + piece, + *plane, + &mut self.inside_pieces, + &mut categorized, + &mut self.cap_points, + ); + } + std::mem::swap(&mut self.pieces, &mut self.inside_pieces); + } + + for piece in self.pieces.drain(..) { + match piece.aligned_category { + Some(PolygonCategory::Aligned) => categorized.aligned.push(piece.polygon), + Some(PolygonCategory::ReverseAligned) => { + categorized.reverse_aligned.push(piece.polygon); + } + _ => categorized.inside.push(piece.polygon), + } + } + } + + categorized + } + + pub fn route_outside_of( + &mut self, + polygons: Vec, + cutter: &ConvexSolid, + ) -> Vec { + self.categorize_against(&polygons, cutter).outside + } + + pub fn route_inside_of( + &mut self, + polygons: Vec, + cutter: &ConvexSolid, + ) -> Vec { + let categorized = self.categorize_against(&polygons, cutter); + let mut inside = categorized.inside; + inside.extend(categorized.aligned); + inside.extend(categorized.reverse_aligned); + inside + } +} + +enum SplitResult { + Front(ConvexSolid), + Back(ConvexSolid), + Both { + front: ConvexSolid, + back: ConvexSolid, + }, + Coplanar(ConvexSolid), +} + +struct CategorizationPiece { + polygon: ConvexPolygon, + aligned_category: Option, +} + +#[derive(Clone, Copy)] +enum KeepSide { + Front, + Back, +} + +fn classify_piece_against_plane( + mut piece: CategorizationPiece, + plane: Plane, + inside_pieces: &mut Vec, + categorized: &mut CategorizedPolygons, + cap_points: &mut Vec, +) { + let piece_plane = piece + .polygon + .vertices + .first() + .map(|point| Plane::from_point_normal(*point, piece.polygon.normal)); + + if piece_plane.is_some_and(|piece_plane| piece_plane.is_coplanar_with(plane)) { + piece.aligned_category = Some(if piece.polygon.normal.dot(plane.normal) >= 0.0 { + PolygonCategory::Aligned + } else { + PolygonCategory::ReverseAligned + }); + inside_pieces.push(piece); + return; + } + + let mut saw_front = false; + let mut saw_back = false; + for vertex in &piece.polygon.vertices { + let distance = plane.signed_distance(*vertex); + saw_front |= distance > EPSILON; + saw_back |= distance < -EPSILON; + } + + match (saw_front, saw_back) { + (true, false) => { + let mut polygon = piece.polygon; + polygon.category = PolygonCategory::Outside; + categorized.outside.push(polygon); + } + (false, true) | (false, false) => inside_pieces.push(piece), + (true, true) => { + cap_points.clear(); + let outside = clip_polygon(&piece.polygon.vertices, plane, KeepSide::Front, cap_points); + if outside.len() >= 3 { + let mut outside_polygon = piece.polygon.with_vertices(outside); + outside_polygon.category = PolygonCategory::Outside; + categorized.outside.push(outside_polygon); + } + + let inside = clip_polygon(&piece.polygon.vertices, plane, KeepSide::Back, cap_points); + if inside.len() >= 3 { + inside_pieces.push(CategorizationPiece { + polygon: piece.polygon.with_vertices(inside), + aligned_category: piece.aligned_category, + }); + } + } + } +} + +fn clip_polygon( + vertices: &[Vec3], + plane: Plane, + keep: KeepSide, + cap_points: &mut Vec, +) -> Vec { + let mut output = Vec::with_capacity(vertices.len() + 1); + if vertices.is_empty() { + return output; + } + + let mut previous = *vertices.last().expect("checked non-empty"); + let mut previous_distance = plane.signed_distance(previous); + let mut previous_inside = inside(previous_distance, keep); + + for ¤t in vertices { + let current_distance = plane.signed_distance(current); + let current_inside = inside(current_distance, keep); + + if current_inside != previous_inside { + let t = previous_distance / (previous_distance - current_distance); + let intersection = previous + (current - previous) * t; + output.push(intersection); + cap_points.push(intersection); + } + + if current_inside { + output.push(current); + } + + previous = current; + previous_distance = current_distance; + previous_inside = current_inside; + } + + output +} + +fn inside(distance: f32, keep: KeepSide) -> bool { + match keep { + KeepSide::Front => distance >= -EPSILON, + KeepSide::Back => distance <= EPSILON, + } +} + +fn make_cap_polygon(points: &[Vec3], normal: Vec3, material: MaterialId) -> ConvexPolygon { + let center = points.iter().copied().sum::() / points.len() as f32; + let axis = if normal.z.abs() < 0.9 { + Vec3::Z + } else { + Vec3::Y + }; + let tangent = normal.cross(axis).normalize_or_zero(); + let bitangent = normal.cross(tangent).normalize_or_zero(); + let mut vertices = points.to_vec(); + + vertices.sort_by(|a, b| { + let da = *a - center; + let db = *b - center; + let aa = da.dot(bitangent).atan2(da.dot(tangent)); + let ab = db.dot(bitangent).atan2(db.dot(tangent)); + aa.total_cmp(&ab) + }); + + if polygon_normal(&vertices).dot(normal) < 0.0 { + vertices.reverse(); + } + + ConvexPolygon::new(vertices, normal, material) +} + +fn unique_points_on_plane(points: Vec) -> Vec { + let mut unique = Vec::new(); + 'outer: for point in points { + for seen in &unique { + let delta: Vec3 = point - *seen; + if delta.length_squared() < EPSILON * EPSILON { + continue 'outer; + } + } + unique.push(point); + } + unique +} + +fn planes_from_polygons(polygons: &[ConvexPolygon]) -> Vec { + polygons + .iter() + .filter_map(|polygon| { + polygon + .vertices + .first() + .map(|point| Plane::from_point_normal(*point, polygon.normal)) + }) + .collect() +} + +fn bounds_from_polygons(polygons: &[ConvexPolygon]) -> Aabb { + polygons.iter().fold(Aabb::empty(), |bounds, polygon| { + bounds.union(polygon.bounds) + }) +} + +fn whole_polygon_category_against( + polygon: &ConvexPolygon, + cutter: &ConvexSolid, +) -> Option { + let polygon_plane = polygon + .vertices + .first() + .map(|point| Plane::from_point_normal(*point, polygon.normal))?; + + for cutter_plane in &cutter.clip_planes { + if polygon_plane.is_coplanar_with(*cutter_plane) + && polygon + .vertices + .iter() + .all(|vertex| cutter_plane.signed_distance(*vertex).abs() <= EPSILON) + { + return Some(if polygon.normal.dot(cutter_plane.normal) >= 0.0 { + PolygonCategory::Aligned + } else { + PolygonCategory::ReverseAligned + }); + } + } + + let mut fully_inside = true; + for cutter_plane in &cutter.clip_planes { + let mut all_outside_this_plane = true; + for vertex in &polygon.vertices { + let distance = cutter_plane.signed_distance(*vertex); + if distance > EPSILON { + fully_inside = false; + } else { + all_outside_this_plane = false; + } + } + if all_outside_this_plane { + return Some(PolygonCategory::Outside); + } + } + + fully_inside.then_some(PolygonCategory::Inside) +} + +fn polygon_normal(vertices: &[Vec3]) -> Vec3 { + if vertices.len() < 3 { + return Vec3::ZERO; + } + (vertices[1] - vertices[0]) + .cross(vertices[2] - vertices[0]) + .normalize_or_zero() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_box_parity(solid: &ConvexSolid, material: MaterialId) { + assert_eq!(solid.polygons.len(), 6); + assert_eq!(solid.clip_planes.len(), 6); + assert!( + solid + .polygons + .iter() + .all(|polygon| polygon.vertices.len() == 4) + ); + assert!( + solid + .polygons + .iter() + .all(|polygon| polygon.material == material) + ); + } + + #[test] + fn parity_box_brush_from_planes_has_six_quad_polygons() { + let solid = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(2.0), + Quat::IDENTITY, + MaterialId(9), + ); + + assert_box_parity(&solid, MaterialId(9)); + } + + #[test] + fn subtract_center_box_emits_six_convex_fragments() { + let source = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(4.0), + Quat::IDENTITY, + MaterialId(1), + ); + let cutter = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(2.0), + Quat::IDENTITY, + MaterialId(0), + ); + + let fragments = source.subtract_convex(&cutter); + assert_eq!(fragments.len(), 6); + assert!( + fragments + .iter() + .all(|fragment| !fragment.polygons.is_empty()) + ); + } + + #[test] + fn intersect_overlapping_boxes_emits_single_common_solid() { + let source = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(4.0), + Quat::IDENTITY, + MaterialId(1), + ); + let cutter = ConvexSolid::box_from_center_size( + Vec3::X, + Vec3::splat(4.0), + Quat::IDENTITY, + MaterialId(0), + ); + + let common = source.intersect_convex(&cutter).expect("intersection"); + + assert!(common.bounds.is_valid()); + assert_eq!(common.polygons.len(), 6); + assert!(common.bounds.contains_point(Vec3::ZERO)); + assert!(common.bounds.contains_point(Vec3::new(2.0, 0.0, 0.0))); + assert!(!common.bounds.contains_point(Vec3::new(-2.0, 0.0, 0.0))); + } + + #[test] + fn categorize_crossing_coplanar_face_splits_visible_categories() { + let source = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(4.0), + Quat::IDENTITY, + MaterialId(1), + ); + let cutter = ConvexSolid::box_from_center_size( + Vec3::new(0.0, 0.0, 1.0), + Vec3::new(2.0, 2.0, 2.0), + Quat::IDENTITY, + MaterialId(0), + ); + + let categorized = source.categorize_polygons_against(&cutter); + + assert_eq!(categorized.aligned.len(), 1); + assert!(categorized.outside.len() > 6); + assert!(categorized.inside.is_empty()); + assert!(categorized.reverse_aligned.is_empty()); + assert!( + categorized + .aligned + .iter() + .all(|polygon| polygon.category == PolygonCategory::Aligned) + ); + assert!( + categorized + .outside + .iter() + .all(|polygon| polygon.category == PolygonCategory::Outside) + ); + } + + #[test] + fn subtract_rotated_box_creates_non_axis_aligned_faces() { + let source = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(4.0), + Quat::IDENTITY, + MaterialId(1), + ); + let cutter = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::new(2.0, 5.0, 2.0), + Quat::from_rotation_z(std::f32::consts::FRAC_PI_4), + MaterialId(0), + ); + + let fragments = source.subtract_convex(&cutter); + assert!(fragments.len() > 2); + assert!(fragments.iter().any(|fragment| { + fragment + .polygons + .iter() + .any(|polygon| polygon.normal.x.abs() > 0.01 && polygon.normal.y.abs() > 0.01) + })); + } + + #[test] + fn routed_subtraction_emits_source_shell_and_reversed_cutter_shell() { + let source = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(4.0), + Quat::IDENTITY, + MaterialId(1), + ); + let cutter = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(2.0), + Quat::IDENTITY, + MaterialId(0), + ); + + let routed = source.subtract_convex_routed_polygons(&cutter); + + assert_eq!(routed.len(), 24); + assert_eq!( + routed + .iter() + .filter(|polygon| polygon.material == MaterialId(1)) + .count(), + 18 + ); + assert_eq!( + routed + .iter() + .filter(|polygon| polygon.material == MaterialId(0)) + .count(), + 6 + ); + assert_eq!( + routed + .iter() + .filter(|polygon| polygon.material == MaterialId(0) && polygon.reversed) + .count(), + 6 + ); + } + + #[test] + fn routed_subtraction_clips_crossing_cutter_surfaces_to_source() { + let source = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(4.0), + Quat::IDENTITY, + MaterialId(1), + ); + let cutter = ConvexSolid::box_from_center_size( + Vec3::new(1.5, 0.0, 0.0), + Vec3::splat(2.0), + Quat::IDENTITY, + MaterialId(0), + ); + + let routed = source.subtract_convex_routed_polygons(&cutter); + + assert!(!routed.is_empty()); + assert!( + routed + .iter() + .flat_map(|polygon| polygon.vertices.iter()) + .all(|vertex| source.bounds.contains_point(*vertex)) + ); + assert!( + routed + .iter() + .any(|polygon| polygon.material == MaterialId(0) && polygon.reversed) + ); + } +} diff --git a/packages/gamecult-geometry-rs/src/domain.rs b/packages/gamecult-geometry-rs/src/domain.rs new file mode 100644 index 00000000..a946305e --- /dev/null +++ b/packages/gamecult-geometry-rs/src/domain.rs @@ -0,0 +1,1560 @@ +#![allow(dead_code)] +use bevy_math::{Quat, Vec3}; + +use serde::{Deserialize, Serialize}; + +use crate::{ + Aabb, BrushOp, BuildReport, CsgBranchOp, CsgNodeId, CsgOperationType, CsgTreeArena, MaterialId, + Primitive, TriangleMesh, +}; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct DomainKey(pub String); + +impl DomainKey { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn child(&self, name: impl AsRef) -> Self { + Self(format!("{}/{}", self.0, name.as_ref())) + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DomainFrame { + pub translation: Vec3, + pub rotation: Quat, +} + +impl DomainFrame { + pub const IDENTITY: Self = Self { + translation: Vec3::ZERO, + rotation: Quat::IDENTITY, + }; + + pub fn translated(translation: Vec3) -> Self { + Self { + translation, + rotation: Quat::IDENTITY, + } + } + + pub fn rotated_z(translation: Vec3, radians: f32) -> Self { + Self { + translation, + rotation: Quat::from_rotation_z(radians), + } + } + + pub fn transform_point(self, point: Vec3) -> Vec3 { + self.translation + self.rotation * point + } + + pub fn transform_bounds(self, bounds: Aabb) -> Aabb { + let min = bounds.min; + let max = bounds.max; + let corners = [ + Vec3::new(min.x, min.y, min.z), + Vec3::new(max.x, min.y, min.z), + Vec3::new(max.x, max.y, min.z), + Vec3::new(min.x, max.y, min.z), + Vec3::new(min.x, min.y, max.z), + Vec3::new(max.x, min.y, max.z), + Vec3::new(max.x, max.y, max.z), + Vec3::new(min.x, max.y, max.z), + ]; + let world = corners.map(|corner| self.transform_point(corner)); + Aabb::from_points(&world) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum DomainKind { + Root, + Column, + AltitudeBand, + RoadSpine, + BranchRoad, + Roundabout, + SupportMass, + ClearanceVolume, + Chunk, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum FieldLayer { + Form, + Appearance, + Transport, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum FieldEncoding { + Mesh, + Sdf3d, + Occupancy, + Material, + Confidence, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum FeatureClaimKind { + SolidBrush, + VoidBrush, + RoadSurfaceSlab, + ClearanceVolume, + SupportShell, + ColliderProxy, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum FeatureLoweringPolicy { + RenderOnly, + ColliderOnly, + RenderAndCollider, + BooleanOperator, +} + +impl FeatureLoweringPolicy { + pub fn default_for_kind(kind: FeatureClaimKind) -> Self { + match kind { + FeatureClaimKind::VoidBrush | FeatureClaimKind::ClearanceVolume => { + Self::BooleanOperator + } + FeatureClaimKind::ColliderProxy => Self::ColliderOnly, + FeatureClaimKind::SolidBrush + | FeatureClaimKind::RoadSurfaceSlab + | FeatureClaimKind::SupportShell => Self::RenderAndCollider, + } + } + + pub fn emits_to(self, target: ClaimLoweringTarget) -> bool { + match self { + Self::RenderOnly => target == ClaimLoweringTarget::Render, + Self::ColliderOnly => target == ClaimLoweringTarget::Collider, + Self::RenderAndCollider | Self::BooleanOperator => true, + } + } +} + +impl FeatureClaimKind { + pub fn field_layer(self) -> FieldLayer { + match self { + Self::SolidBrush + | Self::VoidBrush + | Self::RoadSurfaceSlab + | Self::ClearanceVolume + | Self::SupportShell + | Self::ColliderProxy => FieldLayer::Form, + } + } + + pub fn field_encoding(self) -> FieldEncoding { + match self { + Self::VoidBrush | Self::ClearanceVolume => FieldEncoding::Sdf3d, + Self::ColliderProxy => FieldEncoding::Occupancy, + _ => FieldEncoding::Mesh, + } + } + + pub fn brush_op(self) -> BrushOp { + match self { + Self::VoidBrush | Self::ClearanceVolume => BrushOp::Subtract, + _ => BrushOp::Add, + } + } + + pub fn emits_render(self) -> bool { + !matches!(self, Self::ColliderProxy | Self::ClearanceVolume) + } + + pub fn emits_collider(self) -> bool { + matches!( + self, + Self::SolidBrush | Self::RoadSurfaceSlab | Self::SupportShell | Self::ColliderProxy + ) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct FeatureClaim { + pub key: String, + pub domain_key: DomainKey, + pub frame: DomainFrame, + pub support: Aabb, + pub kind: FeatureClaimKind, + pub material: MaterialId, + pub lowering: FeatureLoweringPolicy, +} + +impl FeatureClaim { + pub fn world_bounds(&self) -> Aabb { + self.frame.transform_bounds(self.support) + } + + pub fn primitive(&self) -> Primitive { + Primitive::OrientedBox { + center: self.frame.transform_point(self.support.center()), + size: self.support.size(), + rotation: self.frame.rotation, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct DomainSummary { + pub bounds: Aabb, + pub estimated_triangle_cost: usize, + pub estimated_brush_cost: usize, + pub projected_error: f32, + pub contribution_weight: f32, + pub has_children: bool, + pub fallback_claims: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct DomainNode { + pub key: DomainKey, + pub parent: Option, + pub kind: DomainKind, + pub frame: DomainFrame, + pub bounds: Aabb, + pub seed: u64, + pub summary: DomainSummary, + pub claims: Vec, + pub children: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct FeatureClaimSpec { + pub name: String, + pub frame: DomainFrame, + pub support: Aabb, + pub kind: FeatureClaimKind, + pub material: MaterialId, + pub lowering: FeatureLoweringPolicy, +} + +impl FeatureClaimSpec { + pub fn new( + name: impl Into, + frame: DomainFrame, + support: Aabb, + kind: FeatureClaimKind, + material: MaterialId, + ) -> Self { + Self { + name: name.into(), + frame, + support, + kind, + material, + lowering: FeatureLoweringPolicy::default_for_kind(kind), + } + } + + pub fn with_lowering_policy(mut self, lowering: FeatureLoweringPolicy) -> Self { + self.lowering = lowering; + self + } + + pub fn compile(&self, domain_key: &DomainKey) -> FeatureClaim { + FeatureClaim { + key: format!("{}/claim/{}", domain_key.0, self.name), + domain_key: domain_key.clone(), + frame: self.frame, + support: self.support, + kind: self.kind, + material: self.material, + lowering: self.lowering, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct DomainNodeSpec { + pub name: String, + pub kind: DomainKind, + pub frame: DomainFrame, + pub seed: u64, + pub claims: Vec, + pub children: Vec, +} + +impl DomainNodeSpec { + pub fn new(name: impl Into, kind: DomainKind, frame: DomainFrame, seed: u64) -> Self { + Self { + name: name.into(), + kind, + frame, + seed, + claims: Vec::new(), + children: Vec::new(), + } + } + + pub fn with_claim(mut self, claim: FeatureClaimSpec) -> Self { + self.claims.push(claim); + self + } + + pub fn with_child(mut self, child: DomainNodeSpec) -> Self { + self.children.push(child); + self + } + + pub fn compile_root(&self) -> DomainNode { + self.compile(DomainKey::new(self.name.clone()), None) + } + + fn compile(&self, key: DomainKey, parent: Option) -> DomainNode { + let claims = self + .claims + .iter() + .map(|claim| claim.compile(&key)) + .collect::>(); + let children = self + .children + .iter() + .map(|child| child.compile(key.child(&child.name), Some(key.clone()))) + .collect::>(); + DomainNode::new( + key, parent, self.kind, self.frame, self.seed, claims, children, + ) + } +} + +impl DomainNode { + pub fn new( + key: DomainKey, + parent: Option, + kind: DomainKind, + frame: DomainFrame, + seed: u64, + claims: Vec, + children: Vec, + ) -> Self { + let claim_bounds = claims + .iter() + .map(FeatureClaim::world_bounds) + .fold(Aabb::empty(), Aabb::union); + let child_bounds = children + .iter() + .map(|child| child.summary.bounds) + .fold(Aabb::empty(), Aabb::union); + let bounds = claim_bounds.union(child_bounds); + let has_children = !children.is_empty(); + let estimated_brush_cost = claims.len() + + children + .iter() + .map(|child| child.summary.estimated_brush_cost) + .sum::(); + let estimated_triangle_cost = claims.len().max(1) * 12 + + children + .iter() + .map(|child| child.summary.estimated_triangle_cost) + .sum::(); + let contribution_weight = domain_priority(kind) * bounds.size().length().max(1.0); + let fallback_claims = if claims.is_empty() { + fallback_box_claim(&key, kind, frame, bounds) + } else { + claims.clone() + }; + + Self { + key, + parent, + kind, + frame, + bounds, + seed, + summary: DomainSummary { + bounds, + estimated_triangle_cost, + estimated_brush_cost, + projected_error: bounds.size().length().max(1.0), + contribution_weight, + has_children, + fallback_claims, + }, + claims, + children, + } + } + + pub fn find(&self, key: &DomainKey) -> Option<&Self> { + if &self.key == key { + return Some(self); + } + self.children.iter().find_map(|child| child.find(key)) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct DomainQuery { + pub camera_position: Vec3, + pub frustum: Aabb, + pub viewport_height_px: f32, + pub vertical_fov_radians: f32, + pub target_error: f32, + pub triangle_budget: usize, + pub collider_budget: usize, + pub semantic_filter: Vec, + pub requested_chunk_keys: Vec, + pub dirty_domain_keys: Vec, +} + +impl DomainQuery { + pub fn accepts_kind(&self, kind: DomainKind) -> bool { + self.semantic_filter.is_empty() || self.semantic_filter.contains(&kind) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ContributionRow { + pub domain_key: DomainKey, + pub kind: DomainKind, + pub contribution: f32, + pub projected_screen_error: f32, + pub semantic_priority: f32, + pub estimated_triangle_cost: usize, + pub child_cost: usize, + pub remaining_triangle_budget: usize, + pub requested: bool, + pub dirty: bool, + pub selected: bool, + pub used_fallback: bool, + pub deferred_by_budget: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SelectedCut { + pub id: String, + pub selected_nodes: Vec, + pub emitted_claims: Vec, + pub deferred_children: Vec, + pub fallback_nodes: Vec, + pub diagnostics: Vec, +} + +#[derive(Clone, Debug)] +pub struct DomainChunkBuild { + pub cut: SelectedCut, + pub chunks: Vec, + pub manifest: SelectedCutManifest, +} + +impl DomainChunkBuild {} + +#[derive(Clone, Debug)] +pub struct TriangleChunk { + pub key: DomainKey, + pub selected_cut_id: String, + pub bounds: Aabb, + pub mesh: TriangleMesh, + pub collider_mesh: Option, + pub source_domain_keys: Vec, + pub source_claim_keys: Vec, + pub report: BuildReport, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TriangleChunkManifest { + pub key: String, + pub selected_cut_id: String, + pub bounds_min: [f32; 3], + pub bounds_max: [f32; 3], + pub render_vertices: usize, + pub render_triangles: usize, + pub collider_triangles: usize, + pub source_domain_keys: Vec, + pub source_claim_keys: Vec, + pub input_brushes: usize, + pub candidate_pairs: usize, + pub rejected_pairs: usize, + pub transition_hint: ChunkTransitionHint, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SelectedCutManifest { + pub id: String, + pub selected_nodes: Vec, + pub deferred_child_requests: Vec, + pub parent_fallback_nodes: Vec, + pub diagnostics: Vec, + pub chunks: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ContributionManifest { + pub domain_key: String, + pub kind: DomainKind, + pub contribution: f32, + pub projected_screen_error: f32, + pub semantic_priority: f32, + pub estimated_triangle_cost: usize, + pub child_cost: usize, + pub remaining_triangle_budget: usize, + pub requested: bool, + pub dirty: bool, + pub selected: bool, + pub used_fallback: bool, + pub deferred_by_budget: bool, +} + +impl SelectedCut { + pub fn manifest(&self, chunks: &[TriangleChunk]) -> SelectedCutManifest { + SelectedCutManifest { + id: self.id.clone(), + selected_nodes: self + .selected_nodes + .iter() + .map(|key| key.0.clone()) + .collect(), + deferred_child_requests: self + .deferred_children + .iter() + .map(|key| key.0.clone()) + .collect(), + parent_fallback_nodes: self + .fallback_nodes + .iter() + .map(|key| key.0.clone()) + .collect(), + diagnostics: self + .diagnostics + .iter() + .map(ContributionManifest::from_row) + .collect(), + chunks: chunks.iter().map(TriangleChunk::manifest).collect(), + } + } +} + +impl ContributionManifest { + pub fn from_row(row: &ContributionRow) -> Self { + Self { + domain_key: row.domain_key.0.clone(), + kind: row.kind, + contribution: row.contribution, + projected_screen_error: row.projected_screen_error, + semantic_priority: row.semantic_priority, + estimated_triangle_cost: row.estimated_triangle_cost, + child_cost: row.child_cost, + remaining_triangle_budget: row.remaining_triangle_budget, + requested: row.requested, + dirty: row.dirty, + selected: row.selected, + used_fallback: row.used_fallback, + deferred_by_budget: row.deferred_by_budget, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChunkTransitionHint { + pub stable_clip_seed: u64, + pub supports_parent_child_coexistence: bool, +} + +impl TriangleChunk { + pub fn manifest(&self) -> TriangleChunkManifest { + TriangleChunkManifest { + key: self.key.0.clone(), + selected_cut_id: self.selected_cut_id.clone(), + bounds_min: vec3_to_array(self.bounds.min), + bounds_max: vec3_to_array(self.bounds.max), + render_vertices: self.mesh.vertex_count(), + render_triangles: self.mesh.triangle_count(), + collider_triangles: self + .collider_mesh + .as_ref() + .map_or(0, TriangleMesh::triangle_count), + source_domain_keys: self + .source_domain_keys + .iter() + .map(|key| key.0.clone()) + .collect(), + source_claim_keys: self.source_claim_keys.clone(), + input_brushes: self.report.input_brushes, + candidate_pairs: self.report.candidate_pairs, + rejected_pairs: self.report.rejected_pairs, + transition_hint: ChunkTransitionHint { + stable_clip_seed: stable_str_hash(&self.key.0), + supports_parent_child_coexistence: true, + }, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ClaimLoweringTarget { + Render, + Collider, +} + +#[derive(Clone, Debug)] +pub struct CsgClaimLowering { + pub arena: CsgTreeArena, + pub root: Option, + pub bounds: Aabb, + pub source_domain_keys: Vec, + pub source_claim_keys: Vec, +} + +pub fn select_domain_cut(root: &DomainNode, query: &DomainQuery) -> SelectedCut { + let mut state = CutState { + query, + selected: Vec::new(), + claims: Vec::new(), + deferred: Vec::new(), + fallback: Vec::new(), + diagnostics: Vec::new(), + remaining_triangles: query.triangle_budget, + remaining_colliders: query.collider_budget, + }; + state.visit(root, true); + SelectedCut { + id: stable_cut_id(&state.selected), + selected_nodes: state.selected, + emitted_claims: state.claims, + deferred_children: state.deferred, + fallback_nodes: state.fallback, + diagnostics: state.diagnostics, + } +} + +pub fn build_domain_chunks(root: &DomainNode, query: &DomainQuery) -> DomainChunkBuild { + let cut = select_domain_cut(root, query); + let chunks = lower_selected_cut_chunks(&cut); + let manifest = cut.manifest(&chunks); + DomainChunkBuild { + cut, + chunks, + manifest, + } +} + +pub fn lower_selected_cut(cut: &SelectedCut) -> TriangleChunk { + lower_claims_to_chunk( + DomainKey::new(format!("chunk/{}", cut.id)), + &cut.id, + &cut.emitted_claims, + ) +} + +pub fn lower_selected_cut_chunks(cut: &SelectedCut) -> Vec { + let mut chunks = Vec::new(); + for domain_key in &cut.selected_nodes { + let claims = cut + .emitted_claims + .iter() + .filter(|claim| &claim.domain_key == domain_key) + .cloned() + .collect::>(); + if claims.is_empty() { + continue; + } + chunks.push(lower_claims_to_chunk( + DomainKey::new(format!("chunk/{}/{}", cut.id, domain_key.0)), + &cut.id, + &claims, + )); + } + chunks +} + +pub fn lower_feature_claims_to_csg_tree( + claims: &[FeatureClaim], + target: ClaimLoweringTarget, +) -> CsgClaimLowering { + let mut arena = CsgTreeArena::new(); + let mut additive_nodes = Vec::new(); + let mut subtractive_nodes = Vec::new(); + let mut source_domain_keys = Vec::::new(); + let mut source_claim_keys = Vec::::new(); + let mut bounds = Aabb::empty(); + + for claim in claims.iter().filter(|claim| claim_in_target(claim, target)) { + source_claim_keys.push(claim.key.clone()); + if !source_domain_keys.contains(&claim.domain_key) { + source_domain_keys.push(claim.domain_key.clone()); + } + bounds = bounds.union(claim.world_bounds()); + let brush = arena.generate_brush( + claim.key.clone(), + CsgOperationType::Additive, + claim.primitive(), + claim.material, + ); + match claim.kind.brush_op() { + BrushOp::Add => additive_nodes.push(brush.node), + BrushOp::Subtract => subtractive_nodes.push(brush.node), + BrushOp::Intersect => additive_nodes.push(brush.node), + } + } + + let root = build_claim_tree(&mut arena, additive_nodes, subtractive_nodes); + CsgClaimLowering { + arena, + root, + bounds, + source_domain_keys, + source_claim_keys, + } +} + +fn lower_claims_to_chunk( + key: DomainKey, + selected_cut_id: &str, + claims: &[FeatureClaim], +) -> TriangleChunk { + let render = lower_feature_claims_to_csg_tree(claims, ClaimLoweringTarget::Render); + let collider = lower_feature_claims_to_csg_tree(claims, ClaimLoweringTarget::Collider); + let (source_domain_keys, source_claim_keys) = claim_metadata(claims); + + let output = render + .root + .map(|root| { + render + .arena + .compile_tree_to_assembler(render.arena.generate_tree(root)) + .build() + }) + .unwrap_or_else(|| crate::Assembler::new().build()); + let collider_output = collider.root.map(|root| { + collider + .arena + .compile_tree_to_assembler(collider.arena.generate_tree(root)) + .build() + }); + + TriangleChunk { + key, + selected_cut_id: selected_cut_id.to_owned(), + bounds: render.bounds.union(collider.bounds), + mesh: output.mesh, + collider_mesh: collider_output.map(|output| output.mesh), + source_domain_keys, + source_claim_keys, + report: output.report, + } +} + +fn claim_metadata(claims: &[FeatureClaim]) -> (Vec, Vec) { + let mut source_domain_keys = Vec::::new(); + let mut source_claim_keys = Vec::::new(); + for claim in claims { + source_claim_keys.push(claim.key.clone()); + if !source_domain_keys.contains(&claim.domain_key) { + source_domain_keys.push(claim.domain_key.clone()); + } + } + (source_domain_keys, source_claim_keys) +} + +fn claim_in_target(claim: &FeatureClaim, target: ClaimLoweringTarget) -> bool { + claim.lowering.emits_to(target) +} + +fn build_claim_tree( + arena: &mut CsgTreeArena, + additive_nodes: Vec, + subtractive_nodes: Vec, +) -> Option { + let additive_root = match additive_nodes.len() { + 0 => None, + 1 => additive_nodes.first().copied(), + _ => Some( + arena + .generate_branch( + "domain-additive-claims", + CsgBranchOp::Addition, + additive_nodes, + ) + .node, + ), + }?; + + if subtractive_nodes.is_empty() { + return Some(additive_root); + } + + let subtractive_root = match subtractive_nodes.len() { + 0 => unreachable!(), + 1 => subtractive_nodes[0], + _ => { + arena + .generate_branch( + "domain-subtractive-claims", + CsgBranchOp::Addition, + subtractive_nodes, + ) + .node + } + }; + + Some( + arena + .generate_branch( + "domain-claims", + CsgBranchOp::Subtraction, + [additive_root, subtractive_root], + ) + .node, + ) +} + +pub fn ragnarok_column_fixture() -> DomainNode { + ragnarok_column_spec().compile_root() +} + +pub fn ragnarok_column_spec() -> DomainNodeSpec { + let mut column = DomainNodeSpec::new( + "stellarator-column-00", + DomainKind::Column, + DomainFrame::IDENTITY, + 0xC011_0000, + ) + .with_claim(claim_spec( + "column-support-shell", + DomainFrame::IDENTITY, + Aabb::from_center_size(Vec3::new(0.0, 0.0, 45.0), Vec3::new(18.0, 18.0, 96.0)), + FeatureClaimKind::SupportShell, + MaterialId(10), + )) + .with_claim(claim_spec( + "column-core-clearance", + DomainFrame::IDENTITY, + Aabb::from_center_size(Vec3::new(0.0, 0.0, 45.0), Vec3::new(7.0, 7.0, 100.0)), + FeatureClaimKind::ClearanceVolume, + MaterialId(0), + )); + for index in 0..3 { + column = column.with_child(ragnarok_band(index)); + } + + DomainNodeSpec::new( + "ragnarok-column", + DomainKind::Root, + DomainFrame::IDENTITY, + 0x5EED, + ) + .with_child(column) +} + +fn ragnarok_band(index: usize) -> DomainNodeSpec { + let z = 15.0 + index as f32 * 30.0; + let frame = DomainFrame::translated(Vec3::new(0.0, 0.0, z)); + let mut band = DomainNodeSpec::new( + format!("altitude-band-{index}"), + DomainKind::AltitudeBand, + frame, + 0xBADD_0000 + index as u64, + ) + .with_claim(claim_spec( + "coarse-ring-road", + frame, + Aabb::from_center_size(Vec3::ZERO, Vec3::new(34.0, 5.0, 1.0)), + FeatureClaimKind::RoadSurfaceSlab, + MaterialId(20 + index as u32), + )); + for lane in 0..2 { + band = band.with_child(ragnarok_branch(index, lane)); + } + band.with_child(ragnarok_roundabout(index)) +} + +fn ragnarok_branch(band: usize, lane: usize) -> DomainNodeSpec { + let angle = band as f32 * 0.41 + lane as f32 * std::f32::consts::PI; + let radius = 16.0 + lane as f32 * 5.0; + let frame = DomainFrame::rotated_z( + Vec3::new(angle.cos() * radius, angle.sin() * radius, 0.0), + angle, + ); + let mut branch = DomainNodeSpec::new( + format!("branch-road-{lane}"), + DomainKind::BranchRoad, + frame, + 0xA11E_0000 + (band * 10 + lane) as u64, + ) + .with_claim(claim_spec( + "road-slab", + frame, + Aabb::from_center_size(Vec3::new(7.0, 0.0, 0.0), Vec3::new(18.0, 4.0, 0.8)), + FeatureClaimKind::RoadSurfaceSlab, + MaterialId(40 + band as u32), + )) + .with_claim(claim_spec( + "hover-clearance", + frame, + Aabb::from_center_size(Vec3::new(7.0, 0.0, 2.2), Vec3::new(17.0, 3.0, 2.0)), + FeatureClaimKind::ClearanceVolume, + MaterialId(0), + )) + .with_claim(claim_spec( + "coarse-support-rib", + frame, + Aabb::from_center_size(Vec3::new(7.0, 0.0, -1.0), Vec3::new(18.5, 1.0, 1.5)), + FeatureClaimKind::SupportShell, + MaterialId(12), + )); + for segment in 0..3 { + branch = branch.with_child(ragnarok_road_chunk(band, lane, segment, frame)); + } + branch +} + +fn ragnarok_roundabout(band: usize) -> DomainNodeSpec { + let frame = DomainFrame::translated(Vec3::ZERO); + let mut roundabout = DomainNodeSpec::new( + "roundabout", + DomainKind::Roundabout, + frame, + 0xF00D_0000 + band as u64, + ) + .with_claim(claim_spec( + "roundabout-east-west", + frame, + Aabb::from_center_size(Vec3::ZERO, Vec3::new(26.0, 4.5, 0.9)), + FeatureClaimKind::RoadSurfaceSlab, + MaterialId(60 + band as u32), + )) + .with_claim(claim_spec( + "roundabout-north-south", + DomainFrame::rotated_z(Vec3::ZERO, std::f32::consts::FRAC_PI_2), + Aabb::from_center_size(Vec3::ZERO, Vec3::new(26.0, 4.5, 0.9)), + FeatureClaimKind::RoadSurfaceSlab, + MaterialId(60 + band as u32), + )); + for quadrant in 0..4 { + roundabout = roundabout.with_child(ragnarok_roundabout_chunk(band, quadrant)); + } + roundabout +} + +fn ragnarok_road_chunk( + band: usize, + lane: usize, + segment: usize, + frame: DomainFrame, +) -> DomainNodeSpec { + let x = 1.5 + segment as f32 * 5.5; + DomainNodeSpec::new( + format!("chunk-{segment}"), + DomainKind::Chunk, + frame, + 0xC40B_0000 + (band * 100 + lane * 10 + segment) as u64, + ) + .with_claim(claim_spec( + "road-slab", + frame, + Aabb::from_center_size(Vec3::new(x, 0.0, 0.0), Vec3::new(6.5, 4.0, 0.75)), + FeatureClaimKind::RoadSurfaceSlab, + MaterialId(80 + band as u32), + )) + .with_claim(claim_spec( + "hover-clearance", + frame, + Aabb::from_center_size(Vec3::new(x, 0.0, 2.1), Vec3::new(6.0, 3.0, 1.9)), + FeatureClaimKind::ClearanceVolume, + MaterialId(0), + )) + .with_claim(claim_spec( + "support-rib", + frame, + Aabb::from_center_size(Vec3::new(x, 0.0, -1.05), Vec3::new(6.8, 0.9, 1.4)), + FeatureClaimKind::SupportShell, + MaterialId(90 + lane as u32), + )) + .with_claim(claim_spec( + "collider-proxy", + frame, + Aabb::from_center_size(Vec3::new(x, 0.0, 0.35), Vec3::new(6.8, 4.2, 0.35)), + FeatureClaimKind::ColliderProxy, + MaterialId(0), + )) +} + +fn ragnarok_roundabout_chunk(band: usize, quadrant: usize) -> DomainNodeSpec { + let angle = quadrant as f32 * std::f32::consts::FRAC_PI_2; + let frame = DomainFrame::rotated_z(Vec3::ZERO, angle); + DomainNodeSpec::new( + format!("chunk-{quadrant}"), + DomainKind::Chunk, + frame, + 0xC10C_0000 + (band * 10 + quadrant) as u64, + ) + .with_claim(claim_spec( + "arc-road-slab", + frame, + Aabb::from_center_size(Vec3::new(5.0, 5.0, 0.0), Vec3::new(12.0, 4.0, 0.8)), + FeatureClaimKind::RoadSurfaceSlab, + MaterialId(100 + band as u32), + )) + .with_claim(claim_spec( + "arc-clearance", + frame, + Aabb::from_center_size(Vec3::new(5.0, 5.0, 2.1), Vec3::new(11.0, 3.0, 1.8)), + FeatureClaimKind::ClearanceVolume, + MaterialId(0), + )) + .with_claim(claim_spec( + "arc-support", + frame, + Aabb::from_center_size(Vec3::new(5.0, 5.0, -1.0), Vec3::new(12.5, 0.8, 1.2)), + FeatureClaimKind::SupportShell, + MaterialId(90), + )) +} + +fn claim_spec( + name: &str, + frame: DomainFrame, + support: Aabb, + kind: FeatureClaimKind, + material: MaterialId, +) -> FeatureClaimSpec { + FeatureClaimSpec::new(name, frame, support, kind, material) +} + +fn vec3_to_array(value: Vec3) -> [f32; 3] { + [value.x, value.y, value.z] +} + +fn array_to_vec3(value: [f32; 3]) -> Vec3 { + Vec3::new(value[0], value[1], value[2]) +} + +fn quat_to_array(value: Quat) -> [f32; 4] { + [value.x, value.y, value.z, value.w] +} + +fn array_to_quat(value: [f32; 4]) -> Quat { + Quat::from_xyzw(value[0], value[1], value[2], value[3]) +} + +fn fallback_box_claim( + key: &DomainKey, + kind: DomainKind, + frame: DomainFrame, + bounds: Aabb, +) -> Vec { + if !bounds.is_valid() { + return Vec::new(); + } + let kind = match kind { + DomainKind::ClearanceVolume => FeatureClaimKind::ClearanceVolume, + DomainKind::RoadSpine | DomainKind::BranchRoad | DomainKind::Roundabout => { + FeatureClaimKind::RoadSurfaceSlab + } + _ => FeatureClaimKind::SupportShell, + }; + vec![FeatureClaim { + key: format!("{}/claim/fallback-summary", key.0), + domain_key: key.clone(), + frame, + support: Aabb::from_center_size(Vec3::ZERO, bounds.size()), + kind, + material: MaterialId(1), + lowering: FeatureLoweringPolicy::default_for_kind(kind), + }] +} + +fn domain_priority(kind: DomainKind) -> f32 { + match kind { + DomainKind::Root => 0.5, + DomainKind::Column => 1.0, + DomainKind::AltitudeBand => 1.4, + DomainKind::RoadSpine | DomainKind::BranchRoad | DomainKind::Roundabout => 2.0, + DomainKind::SupportMass => 1.1, + DomainKind::ClearanceVolume => 1.8, + DomainKind::Chunk => 1.0, + } +} + +fn contribution(node: &DomainNode, query: &DomainQuery) -> f32 { + let projected_error = projected_screen_error(node, query); + let stale_multiplier = if query.dirty_domain_keys.contains(&node.key) { + 1.5 + } else { + 1.0 + }; + let cost_pressure = selection_triangle_cost(node).max(1) as f32; + (projected_error * domain_priority(node.kind) * stale_multiplier) / cost_pressure.sqrt() +} + +fn projected_screen_error(node: &DomainNode, query: &DomainQuery) -> f32 { + let center = node.summary.bounds.center(); + let distance = center.distance(query.camera_position).max(1.0); + let radius = node.summary.bounds.size().length() * 0.5; + let focal_pixels = query.viewport_height_px.max(1.0) + / (2.0 * (query.vertical_fov_radians.max(0.01) * 0.5).tan()); + (radius / distance) * focal_pixels +} + +fn stable_cut_id(keys: &[DomainKey]) -> String { + let mut hash = FNV_OFFSET_BASIS; + for key in keys { + hash = stable_hash_bytes(hash, key.0.as_bytes()); + } + format!("cut-{hash:016x}") +} + +const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; +const FNV_PRIME: u64 = 0x1000_0000_01b3; + +fn stable_str_hash(value: &str) -> u64 { + stable_hash_bytes(FNV_OFFSET_BASIS, value.as_bytes()) +} + +fn stable_hash_bytes(mut hash: u64, bytes: &[u8]) -> u64 { + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(FNV_PRIME); + } + hash +} + +struct CutState<'a> { + query: &'a DomainQuery, + selected: Vec, + claims: Vec, + deferred: Vec, + fallback: Vec, + diagnostics: Vec, + remaining_triangles: usize, + remaining_colliders: usize, +} + +impl CutState<'_> { + fn visit(&mut self, node: &DomainNode, force_visit: bool) { + let requested_self = self.query.requested_chunk_keys.contains(&node.key); + let requested_descendant = has_requested_descendant(node, &self.query.requested_chunk_keys); + let dirty_descendant = has_requested_descendant(node, &self.query.dirty_domain_keys); + if !force_visit + && !requested_self + && !requested_descendant + && !dirty_descendant + && !node.summary.bounds.intersects(self.query.frustum) + { + return; + } + let score = contribution(node, self.query); + let projected_error = projected_screen_error(node, self.query); + let semantic_priority = domain_priority(node.kind); + let dirty = self.query.dirty_domain_keys.contains(&node.key); + let child_cost = node + .children + .iter() + .map(selection_triangle_cost) + .sum::(); + let child_collider_cost = node + .children + .iter() + .map(selection_collider_cost) + .sum::(); + let budget_allows_children = child_cost <= self.remaining_triangles + && child_collider_cost <= self.remaining_colliders; + let descend_for_detail = score >= self.query.target_error && budget_allows_children; + let descend_for_request = requested_descendant && !requested_self; + let descend_for_dirty = dirty_descendant && budget_allows_children; + let should_descend = node.summary.has_children + && !requested_self + && (descend_for_request || descend_for_dirty || descend_for_detail); + self.diagnostics.push(ContributionRow { + domain_key: node.key.clone(), + kind: node.kind, + contribution: score, + projected_screen_error: projected_error, + semantic_priority, + estimated_triangle_cost: node.summary.estimated_triangle_cost, + child_cost, + remaining_triangle_budget: self.remaining_triangles, + requested: requested_self, + dirty, + selected: !should_descend && self.query.accepts_kind(node.kind), + used_fallback: !should_descend && node.summary.has_children, + deferred_by_budget: node.summary.has_children + && !requested_self + && score >= self.query.target_error + && !budget_allows_children, + }); + + if should_descend { + if budget_allows_children { + self.remaining_triangles = self.remaining_triangles.saturating_sub(child_cost); + self.remaining_colliders = + self.remaining_colliders.saturating_sub(child_collider_cost); + } + for child in &node.children { + let child_requested = self.query.requested_chunk_keys.contains(&child.key) + || has_requested_descendant(child, &self.query.requested_chunk_keys); + let child_dirty = self.query.dirty_domain_keys.contains(&child.key) + || has_requested_descendant(child, &self.query.dirty_domain_keys); + self.visit(child, child_requested || child_dirty); + } + return; + } + + for child in &node.children { + self.deferred.push(child.key.clone()); + } + if self.query.accepts_kind(node.kind) { + self.selected.push(node.key.clone()); + let claims = if node.summary.has_children { + self.fallback.push(node.key.clone()); + &node.summary.fallback_claims + } else { + &node.claims + }; + self.claims.extend(claims.iter().cloned()); + } + } +} + +fn selection_triangle_cost(node: &DomainNode) -> usize { + let claim_count = if node.summary.has_children { + node.summary.fallback_claims.len() + } else { + node.claims.len() + }; + claim_count.max(1) * 12 +} + +fn selection_collider_cost(node: &DomainNode) -> usize { + let claims = if node.summary.has_children { + &node.summary.fallback_claims + } else { + &node.claims + }; + claims + .iter() + .filter(|claim| claim.lowering.emits_to(ClaimLoweringTarget::Collider)) + .count() + .max(1) + * 12 +} + +fn has_requested_descendant(node: &DomainNode, requested: &[DomainKey]) -> bool { + node.children + .iter() + .any(|child| requested.contains(&child.key) || has_requested_descendant(child, requested)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn query(target_error: f32, triangle_budget: usize) -> DomainQuery { + DomainQuery { + camera_position: Vec3::new(36.0, -42.0, 30.0), + frustum: Aabb::from_center_size(Vec3::new(0.0, 0.0, 45.0), Vec3::splat(150.0)), + viewport_height_px: 1080.0, + vertical_fov_radians: std::f32::consts::FRAC_PI_3, + target_error, + triangle_budget, + collider_budget: triangle_budget, + semantic_filter: Vec::new(), + requested_chunk_keys: Vec::new(), + dirty_domain_keys: Vec::new(), + } + } + + #[test] + fn ragnarok_domain_keys_are_stable() { + let a = ragnarok_column_fixture(); + let b = ragnarok_column_fixture(); + assert_eq!(a.key, b.key); + assert_eq!(a.children[0].children[1].key, b.children[0].children[1].key); + } + + #[test] + fn domain_tree_spec_compiles_stable_parent_claim_identity() { + let spec = DomainNodeSpec::new("root", DomainKind::Root, DomainFrame::IDENTITY, 1) + .with_child( + DomainNodeSpec::new("road", DomainKind::BranchRoad, DomainFrame::IDENTITY, 2) + .with_claim(FeatureClaimSpec::new( + "slab", + DomainFrame::IDENTITY, + Aabb::from_center_size(Vec3::ZERO, Vec3::ONE), + FeatureClaimKind::RoadSurfaceSlab, + MaterialId(1), + )), + ); + let domain = spec.compile_root(); + let road = &domain.children[0]; + assert_eq!(road.key, DomainKey::new("root/road")); + assert_eq!(road.parent, Some(domain.key.clone())); + assert_eq!(road.claims[0].key, "root/road/claim/slab"); + assert_eq!(road.claims[0].domain_key, road.key); + assert_eq!( + road.claims[0].lowering, + FeatureLoweringPolicy::RenderAndCollider + ); + } + + #[test] + fn domain_summary_contains_child_bounds() { + let fixture = ragnarok_column_fixture(); + let column = &fixture.children[0]; + for band in &column.children { + assert!( + column + .summary + .bounds + .contains_point(band.summary.bounds.center()) + ); + } + } + + #[test] + fn selected_cut_chooses_parent_under_tight_budget_and_children_when_relaxed() { + let fixture = ragnarok_column_fixture(); + let tight = select_domain_cut(&fixture, &query(10_000.0, 100)); + let relaxed = select_domain_cut(&fixture, &query(0.01, 10_000)); + assert!(tight.selected_nodes.len() < relaxed.selected_nodes.len()); + assert!( + tight + .fallback_nodes + .iter() + .any(|key| key.0.contains("ragnarok-column")) + ); + assert!( + relaxed + .selected_nodes + .iter() + .any(|key| key.0.contains("branch-road")) + ); + } + + #[test] + fn selected_cut_honors_collider_budget() { + let fixture = ragnarok_column_fixture(); + let mut collider_tight = query(0.01, 10_000); + collider_tight.collider_budget = 1; + let cut = select_domain_cut(&fixture, &collider_tight); + assert_eq!(cut.selected_nodes, vec![fixture.key.clone()]); + assert!(cut.diagnostics[0].deferred_by_budget); + } + + #[test] + fn selected_cut_reports_projected_error_and_dirty_pressure() { + let fixture = ragnarok_column_fixture(); + let dirty_key = fixture.key.clone(); + let mut request = query(10_000.0, 100); + request.dirty_domain_keys.push(dirty_key.clone()); + let cut = select_domain_cut(&fixture, &request); + let row = cut + .diagnostics + .iter() + .find(|row| row.domain_key == dirty_key) + .unwrap(); + assert!(row.projected_screen_error > 0.0); + assert_eq!(row.semantic_priority, domain_priority(row.kind)); + assert!(row.dirty); + } + + #[test] + fn dirty_descendant_pulls_selection_down_when_budget_allows() { + let fixture = ragnarok_column_fixture(); + let dirty_key = fixture.children[0].children[0].children[0].children[0] + .key + .clone(); + let mut request = query(10_000.0, 10_000); + request.dirty_domain_keys.push(dirty_key.clone()); + let cut = select_domain_cut(&fixture, &request); + assert!(cut.selected_nodes.contains(&dirty_key)); + assert!( + cut.diagnostics + .iter() + .any(|row| row.domain_key == dirty_key && row.dirty) + ); + } + + #[test] + fn missing_children_degrade_to_parent_fallback_claims() { + let fixture = ragnarok_column_fixture(); + let cut = select_domain_cut(&fixture, &query(10_000.0, 100)); + assert!(!cut.fallback_nodes.is_empty()); + assert!(!cut.emitted_claims.is_empty()); + } + + #[test] + fn feature_claims_lower_deterministically_to_triangle_chunks() { + let fixture = ragnarok_column_fixture(); + let cut = select_domain_cut(&fixture, &query(0.01, 10_000)); + let a = lower_selected_cut(&cut); + let b = lower_selected_cut(&cut); + assert_eq!(a.source_claim_keys, b.source_claim_keys); + assert_eq!(a.mesh.positions, b.mesh.positions); + assert_eq!(a.mesh.indices, b.mesh.indices); + } + + #[test] + fn triangle_chunks_preserve_source_domain_and_claim_ids() { + let fixture = ragnarok_column_fixture(); + let cut = select_domain_cut(&fixture, &query(0.01, 10_000)); + let chunk = lower_selected_cut(&cut); + assert!(!chunk.source_domain_keys.is_empty()); + assert!(!chunk.source_claim_keys.is_empty()); + assert!(chunk.collider_mesh.is_some()); + } + + #[test] + fn feature_claims_lower_through_csg_tree_branches() { + let fixture = ragnarok_column_fixture(); + let cut = select_domain_cut(&fixture, &query(0.01, 10_000)); + let lowering = + lower_feature_claims_to_csg_tree(&cut.emitted_claims, ClaimLoweringTarget::Render); + assert!(lowering.root.is_some()); + assert!(lowering.arena.brush_count() > 0); + assert!(lowering.arena.branch_count() > 0); + } + + #[test] + fn lowering_policy_controls_render_and_collider_streams() { + let fixture = ragnarok_column_fixture(); + let cut = select_domain_cut(&fixture, &query(0.01, 10_000)); + let render = + lower_feature_claims_to_csg_tree(&cut.emitted_claims, ClaimLoweringTarget::Render); + let collider = + lower_feature_claims_to_csg_tree(&cut.emitted_claims, ClaimLoweringTarget::Collider); + assert!( + !render + .source_claim_keys + .iter() + .any(|key| key.ends_with("/claim/collider-proxy")) + ); + assert!( + collider + .source_claim_keys + .iter() + .any(|key| key.ends_with("/claim/collider-proxy")) + ); + assert!( + render + .source_claim_keys + .iter() + .any(|key| key.ends_with("/claim/hover-clearance")) + ); + } + + #[test] + fn selected_cut_chunks_emit_per_selected_domain() { + let fixture = ragnarok_column_fixture(); + let cut = select_domain_cut(&fixture, &query(0.01, 10_000)); + let chunks = lower_selected_cut_chunks(&cut); + assert_eq!(chunks.len(), cut.selected_nodes.len()); + for chunk in chunks { + assert_eq!(chunk.source_domain_keys.len(), 1); + assert!(cut.selected_nodes.contains(&chunk.source_domain_keys[0])); + assert!(chunk.mesh.triangle_count() > 0); + } + } + + #[test] + fn triangle_chunk_manifest_preserves_streaming_metadata() { + let fixture = ragnarok_column_fixture(); + let cut = select_domain_cut(&fixture, &query(0.01, 10_000)); + let chunks = lower_selected_cut_chunks(&cut); + let manifest = chunks[0].manifest(); + assert_eq!(manifest.selected_cut_id, cut.id); + assert!(manifest.render_triangles > 0); + assert!(!manifest.source_domain_keys.is_empty()); + assert!(manifest.transition_hint.supports_parent_child_coexistence); + assert_ne!(manifest.transition_hint.stable_clip_seed, 0); + } + + #[test] + fn selected_cut_manifest_carries_worker_artifact_context() { + let fixture = ragnarok_column_fixture(); + let cut = select_domain_cut(&fixture, &query(0.01, 10_000)); + let chunks = lower_selected_cut_chunks(&cut); + let manifest = cut.manifest(&chunks); + assert_eq!(manifest.id, cut.id); + assert_eq!(manifest.chunks.len(), chunks.len()); + assert_eq!(manifest.selected_nodes.len(), cut.selected_nodes.len()); + assert!(!manifest.diagnostics.is_empty()); + } + + #[test] + fn build_domain_chunks_runs_full_runtime_pipeline() { + let fixture = ragnarok_column_fixture(); + let build = build_domain_chunks(&fixture, &query(0.01, 10_000)); + assert_eq!(build.manifest.id, build.cut.id); + assert_eq!(build.manifest.chunks.len(), build.chunks.len()); + assert!(!build.chunks.is_empty()); + assert!( + build + .chunks + .iter() + .all(|chunk| !chunk.mesh.indices.is_empty()) + ); + } + + #[test] + fn requested_chunk_bypasses_frustum_and_budget() { + let fixture = ragnarok_column_fixture(); + let requested = fixture.children[0].children[2].children[0].children[1] + .key + .clone(); + let mut request = query(0.01, 1); + request.frustum = + Aabb::from_center_size(Vec3::new(10_000.0, 10_000.0, 10_000.0), Vec3::splat(1.0)); + request.requested_chunk_keys.push(requested.clone()); + let cut = select_domain_cut(&fixture, &request); + assert_eq!(cut.selected_nodes, vec![requested.clone()]); + assert!( + cut.diagnostics + .iter() + .any(|row| row.domain_key == requested && row.requested) + ); + } + + #[test] + fn ragnarok_fixture_emits_lod_chunks_and_preserves_parent_for_transition() { + let fixture = ragnarok_column_fixture(); + let tight_cut = select_domain_cut(&fixture, &query(10_000.0, 100)); + let medium_cut = select_domain_cut(&fixture, &query(0.01, 560)); + let relaxed_cut = select_domain_cut(&fixture, &query(0.01, 10_000)); + let tight = lower_selected_cut(&tight_cut); + let medium = lower_selected_cut(&medium_cut); + let relaxed = lower_selected_cut(&relaxed_cut); + assert!(tight.mesh.triangle_count() > 0); + assert!(medium.mesh.triangle_count() > tight.mesh.triangle_count()); + assert!(relaxed.mesh.triangle_count() > medium.mesh.triangle_count()); + assert!(medium_cut.selected_nodes.len() > tight_cut.selected_nodes.len()); + assert!(relaxed_cut.selected_nodes.len() > medium_cut.selected_nodes.len()); + assert!(!tight_cut.fallback_nodes.is_empty()); + assert!(!relaxed_cut.selected_nodes.is_empty()); + assert!( + relaxed_cut + .selected_nodes + .iter() + .any(|key| key.0.contains("/chunk-")) + ); + } + + #[test] + fn clearance_voids_leave_no_triangle_centers_inside_strict_bounds() { + let fixture = ragnarok_column_fixture(); + let cut = select_domain_cut(&fixture, &query(0.01, 10_000)); + let chunk = lower_selected_cut(&cut); + for clearance in cut + .emitted_claims + .iter() + .filter(|claim| claim.kind == FeatureClaimKind::ClearanceVolume) + { + let bounds = clearance.world_bounds(); + for tri in chunk.mesh.indices.chunks_exact(3) { + let center = (chunk.mesh.positions[tri[0] as usize] + + chunk.mesh.positions[tri[1] as usize] + + chunk.mesh.positions[tri[2] as usize]) + / 3.0; + assert!(!bounds.contains_point_strict(center, 1.0e-4)); + } + } + } +} diff --git a/packages/gamecult-geometry-rs/src/dsl.rs b/packages/gamecult-geometry-rs/src/dsl.rs new file mode 100644 index 00000000..77e31e68 --- /dev/null +++ b/packages/gamecult-geometry-rs/src/dsl.rs @@ -0,0 +1,164 @@ +#![allow(dead_code)] +use bevy_math::{Quat, Vec3}; + +use crate::{ + Aabb, Assembler, BrushId, BrushOp, BuildOutput, DomeCapZSpec, FloretArmSpec, MaterialId, + Primitive, +}; + +#[derive(Clone, Debug, Default)] +pub struct LevelDsl { + assembler: Assembler, +} + +impl LevelDsl { + pub fn new() -> Self { + Self::default() + } + + pub fn solid_box( + &mut self, + name: impl Into, + center: Vec3, + size: Vec3, + material: MaterialId, + ) -> BrushId { + self.assembler + .solid_box(name, Aabb::from_center_size(center, size), material) + } + + pub fn cut_box(&mut self, name: impl Into, center: Vec3, size: Vec3) -> BrushId { + self.assembler + .cut_box(name, Aabb::from_center_size(center, size)) + } + + pub fn solid_oriented_box( + &mut self, + name: impl Into, + center: Vec3, + size: Vec3, + rotation: Quat, + material: MaterialId, + ) -> BrushId { + self.assembler + .solid_oriented_box(name, center, size, rotation, material) + } + + pub fn cut_oriented_box( + &mut self, + name: impl Into, + center: Vec3, + size: Vec3, + rotation: Quat, + ) -> BrushId { + self.assembler + .cut_oriented_box(name, center, size, rotation) + } + + pub fn cylinder_z( + &mut self, + name: impl Into, + center: Vec3, + radius: f32, + depth: f32, + segments: usize, + material: MaterialId, + ) -> BrushId { + self.assembler.add_brush( + name, + BrushOp::Add, + Primitive::CylinderZ { + center, + radius, + depth, + segments, + }, + material, + ) + } + + pub fn dome_cap_z(&mut self, name: impl Into, spec: DomeCapZSpec) -> BrushId { + self.assembler.add_brush( + name, + BrushOp::Add, + Primitive::DomeCapZ { + center: spec.center, + radius: spec.radius, + height: spec.height, + rings: spec.rings, + segments: spec.segments, + }, + spec.material, + ) + } + + pub fn floret_arm(&mut self, name: impl Into, spec: FloretArmSpec) -> BrushId { + self.assembler.add_brush( + name, + BrushOp::Add, + Primitive::FloretArm { + anchor: spec.anchor, + direction: spec.direction, + length: spec.length, + root_width: spec.root_width, + tip_width: spec.tip_width, + thickness: spec.thickness, + tip_lift: spec.tip_lift, + }, + spec.material, + ) + } + + pub fn assemble(&self) -> BuildOutput { + self.assembler.build() + } + + pub fn into_assembler(self) -> Assembler { + self.assembler + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dsl_builds_room_and_habitat_primitives() { + let mut dsl = LevelDsl::new(); + dsl.solid_box( + "wall", + Vec3::new(0.0, 0.0, 1.5), + Vec3::new(5.0, 0.25, 3.0), + MaterialId(1), + ); + dsl.cut_box("door", Vec3::new(0.0, 0.0, 1.0), Vec3::new(1.0, 0.5, 2.0)); + dsl.dome_cap_z( + "city dome", + DomeCapZSpec { + center: Vec3::new(0.0, 0.0, 0.0), + radius: 3.0, + height: 1.5, + rings: 4, + segments: 16, + material: MaterialId(2), + }, + ); + dsl.floret_arm( + "solar petal", + FloretArmSpec { + anchor: Vec3::new(3.0, 0.0, 0.0), + direction: Vec3::X, + length: 12.0, + root_width: 1.2, + tip_width: 3.0, + thickness: 0.08, + tip_lift: 0.6, + material: MaterialId(3), + }, + ); + + let output = dsl.assemble(); + assert!(output.mesh.triangle_count() > 12); + assert!(output.report.warnings.is_empty()); + } +} diff --git a/packages/gamecult-geometry-rs/src/frontier.rs b/packages/gamecult-geometry-rs/src/frontier.rs new file mode 100644 index 00000000..e48af14f --- /dev/null +++ b/packages/gamecult-geometry-rs/src/frontier.rs @@ -0,0 +1,321 @@ +#![allow(dead_code)] +use crate::{Brush, BrushOp}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DemandPair { + pub source_index: usize, + pub operator_index: usize, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct DemandFrontier { + pub pairs: Vec, + pub rejected_pairs: usize, + pub operator_brushes: usize, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct DirtyDemandFrontier { + pub first_dirty_index: Option, + pub pairs: Vec, + pub rejected_pairs: usize, + pub operator_brushes: usize, + pub skipped_prefix_pairs: usize, +} + +impl DemandFrontier { + pub fn from_ordered_brushes(brushes: &[Brush]) -> Self { + let mut frontier = Self::default(); + let mut active_sources = Vec::::new(); + + for (operator_index, brush) in brushes.iter().enumerate() { + match brush.op { + BrushOp::Add => active_sources.push(operator_index), + BrushOp::Subtract => { + frontier.operator_brushes += 1; + frontier.collect_pairs(brushes, &active_sources, operator_index); + } + BrushOp::Intersect => { + frontier.operator_brushes += 1; + let retained = frontier.collect_pairs(brushes, &active_sources, operator_index); + active_sources = retained; + } + } + } + + frontier + } + + pub fn candidate_pairs(&self) -> usize { + self.pairs.len() + } + + fn collect_pairs( + &mut self, + brushes: &[Brush], + active_sources: &[usize], + operator_index: usize, + ) -> Vec { + let operator_bounds = brushes[operator_index].bounds(); + let mut retained = Vec::new(); + + for &source_index in active_sources { + if brushes[source_index].bounds().intersects(operator_bounds) { + self.pairs.push(DemandPair { + source_index, + operator_index, + }); + retained.push(source_index); + } else { + self.rejected_pairs += 1; + } + } + + retained + } +} + +impl DirtyDemandFrontier { + pub fn from_dirty_indices( + brushes: &[Brush], + dirty_indices: impl IntoIterator, + ) -> Self { + let first_dirty_index = dirty_indices + .into_iter() + .filter(|index| *index < brushes.len()) + .min(); + let Some(first_dirty_index) = first_dirty_index else { + return Self::default(); + }; + + let mut frontier = Self { + first_dirty_index: Some(first_dirty_index), + ..Self::default() + }; + let mut active_sources = Vec::::new(); + + for (operator_index, brush) in brushes.iter().enumerate() { + match brush.op { + BrushOp::Add => active_sources.push(operator_index), + BrushOp::Subtract => { + frontier.operator_brushes += usize::from(operator_index >= first_dirty_index); + frontier.collect_pairs( + brushes, + &active_sources, + operator_index, + first_dirty_index, + ); + } + BrushOp::Intersect => { + frontier.operator_brushes += usize::from(operator_index >= first_dirty_index); + let retained = frontier.collect_pairs( + brushes, + &active_sources, + operator_index, + first_dirty_index, + ); + active_sources = retained; + } + } + } + + frontier + } + + pub fn candidate_pairs(&self) -> usize { + self.pairs.len() + } + + fn collect_pairs( + &mut self, + brushes: &[Brush], + active_sources: &[usize], + operator_index: usize, + first_dirty_index: usize, + ) -> Vec { + let operator_bounds = brushes[operator_index].bounds(); + let mut retained = Vec::new(); + + for &source_index in active_sources { + if brushes[source_index].bounds().intersects(operator_bounds) { + if operator_index >= first_dirty_index { + self.pairs.push(DemandPair { + source_index, + operator_index, + }); + } else { + self.skipped_prefix_pairs += 1; + } + retained.push(source_index); + } else if operator_index >= first_dirty_index { + self.rejected_pairs += 1; + } else { + self.skipped_prefix_pairs += 1; + } + } + + retained + } +} + +#[cfg(test)] +mod tests { + use bevy_math::{Quat, Vec3}; + + use super::*; + use crate::{Aabb, Brush, MaterialId, Primitive}; + + fn brush(name: &str, op: BrushOp, bounds: Aabb) -> Brush { + Brush::new( + crate::BrushId(0), + name, + op, + Primitive::Box { bounds }, + MaterialId(1), + ) + } + + #[test] + fn frontier_keeps_only_touching_ordered_pairs() { + let brushes = vec![ + brush( + "source", + BrushOp::Add, + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + ), + brush( + "far cutter", + BrushOp::Subtract, + Aabb::from_center_size(Vec3::splat(20.0), Vec3::ONE), + ), + brush( + "near cutter", + BrushOp::Subtract, + Aabb::from_center_size(Vec3::ZERO, Vec3::ONE), + ), + ]; + + let frontier = DemandFrontier::from_ordered_brushes(&brushes); + + assert_eq!(frontier.operator_brushes, 2); + assert_eq!(frontier.rejected_pairs, 1); + assert_eq!( + frontier.pairs, + vec![DemandPair { + source_index: 0, + operator_index: 2, + }] + ); + } + + #[test] + fn intersection_frontier_reduces_active_sources() { + let brushes = vec![ + brush( + "a", + BrushOp::Add, + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)), + ), + brush( + "b", + BrushOp::Add, + Aabb::from_center_size(Vec3::new(20.0, 0.0, 0.0), Vec3::splat(2.0)), + ), + Brush::new( + crate::BrushId(2), + "common", + BrushOp::Intersect, + Primitive::OrientedBox { + center: Vec3::ZERO, + size: Vec3::splat(3.0), + rotation: Quat::IDENTITY, + }, + MaterialId(1), + ), + brush( + "cut", + BrushOp::Subtract, + Aabb::from_center_size(Vec3::ZERO, Vec3::ONE), + ), + ]; + + let frontier = DemandFrontier::from_ordered_brushes(&brushes); + + assert_eq!(frontier.operator_brushes, 2); + assert_eq!(frontier.rejected_pairs, 1); + assert_eq!( + frontier.pairs, + vec![ + DemandPair { + source_index: 0, + operator_index: 2, + }, + DemandPair { + source_index: 0, + operator_index: 3, + }, + ] + ); + } + + #[test] + fn dirty_frontier_preserves_prefix_before_first_dirty_brush() { + let brushes = vec![ + brush( + "source", + BrushOp::Add, + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(8.0)), + ), + brush( + "early", + BrushOp::Subtract, + Aabb::from_center_size(Vec3::new(-2.0, 0.0, 0.0), Vec3::splat(2.0)), + ), + brush( + "dirty", + BrushOp::Subtract, + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)), + ), + brush( + "later", + BrushOp::Subtract, + Aabb::from_center_size(Vec3::new(2.0, 0.0, 0.0), Vec3::splat(2.0)), + ), + ]; + + let frontier = DirtyDemandFrontier::from_dirty_indices(&brushes, [2]); + + assert_eq!(frontier.first_dirty_index, Some(2)); + assert_eq!(frontier.operator_brushes, 2); + assert_eq!(frontier.skipped_prefix_pairs, 1); + assert_eq!(frontier.candidate_pairs(), 2); + assert_eq!( + frontier.pairs, + vec![ + DemandPair { + source_index: 0, + operator_index: 2, + }, + DemandPair { + source_index: 0, + operator_index: 3, + }, + ] + ); + } + + #[test] + fn dirty_frontier_ignores_out_of_range_dirty_indices() { + let brushes = vec![brush( + "source", + BrushOp::Add, + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)), + )]; + + let frontier = DirtyDemandFrontier::from_dirty_indices(&brushes, [99]); + + assert_eq!(frontier.first_dirty_index, None); + assert_eq!(frontier.candidate_pairs(), 0); + assert_eq!(frontier.operator_brushes, 0); + } +} diff --git a/packages/gamecult-geometry-rs/src/lib.rs b/packages/gamecult-geometry-rs/src/lib.rs new file mode 100644 index 00000000..04727ed1 --- /dev/null +++ b/packages/gamecult-geometry-rs/src/lib.rs @@ -0,0 +1,695 @@ +//! GameCult.Geometry's Rust contract and runtime surface. +//! +//! The v2 contract was mined from the MIT-licensed GameCult `vg-csg` crate at +//! commit `8f070f4`, then rewritten around typed vector values. This crate has +//! no runtime or build dependency on VibeGeometry or `vg-csg`. + +mod assembler; +mod brush; +mod convex; +mod domain; +mod dsl; +mod frontier; +mod mesh; +mod primitives; +#[cfg(test)] +mod realtime_csg_parity; +mod tree; + +#[allow(unused_imports)] +pub(crate) use assembler::{Assembler, BuildOutput, BuildReport, BuildWarning}; +#[allow(unused_imports)] +pub(crate) use brush::{Aabb, Brush, BrushId, BrushOp, MaterialId, PolygonCategory, Primitive}; +#[allow(unused_imports)] +pub(crate) use convex::{ + CategorizedPolygons, ConvexPolygon, ConvexSolid, Plane, PolygonRouteScratch, +}; +#[allow(unused_imports)] +pub(crate) use domain::{ + ClaimLoweringTarget, ContributionManifest, ContributionRow, CsgClaimLowering, DomainChunkBuild, + DomainFrame, DomainKey, DomainKind, DomainNode, DomainNodeSpec, DomainQuery, DomainSummary, + FeatureClaim, FeatureClaimKind, FeatureClaimSpec, FeatureLoweringPolicy, FieldEncoding, + FieldLayer, SelectedCut, SelectedCutManifest, TriangleChunk, TriangleChunkManifest, + build_domain_chunks, lower_feature_claims_to_csg_tree, lower_selected_cut, + lower_selected_cut_chunks, ragnarok_column_fixture, ragnarok_column_spec, select_domain_cut, +}; +#[allow(unused_imports)] +pub(crate) use dsl::LevelDsl; +#[allow(unused_imports)] +pub(crate) use frontier::{DemandFrontier, DemandPair, DirtyDemandFrontier}; +pub(crate) use mesh::TriangleMesh; +#[allow(unused_imports)] +pub(crate) use primitives::{ + DomeCapZSpec, FloretArmSpec, append_cylinder_z, append_dome_cap_z, append_floret_arm, +}; +#[allow(unused_imports)] +pub(crate) use tree::{ + CsgBranchOp, CsgNode, CsgNodeId, CsgOperationType, CsgTree, CsgTreeArena, CsgTreeBranch, + CsgTreeBrush, +}; + +use std::fmt::Write; + +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use sha2::{Digest, Sha256}; + +pub const GEOMETRY_DOMAIN_SCHEMA: &str = "gamecult.geometry.domain.v2"; +pub const GEOMETRY_BUILD_REQUEST_SCHEMA: &str = "gamecult.geometry.build_request.v2"; +pub const GEOMETRY_SELECTED_CUT_SCHEMA: &str = "gamecult.geometry.selected_cut.v2"; +pub const GEOMETRY_CHUNK_ARTIFACT_SCHEMA: &str = "gamecult.geometry.chunk_artifact.v2"; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Float2(pub f32, pub f32); + +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Float3(pub f32, pub f32, pub f32); + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Quaternion(pub f32, pub f32, pub f32, pub f32); + +impl Default for Quaternion { + fn default() -> Self { + Self(0.0, 0.0, 0.0, 1.0) + } +} + +pub trait GeometryDocument: Serialize + DeserializeOwned + Sized { + const SCHEMA_VERSION: &'static str; + fn record_key(&self) -> String; + fn to_msgpack(&self) -> Result, rmp_serde::encode::Error> { + rmp_serde::to_vec(self) + } + fn from_msgpack(payload: &[u8]) -> Result { + rmp_serde::from_slice(payload) + } +} + +/// Geometry-owned CSG facade. The implementation uses an internal math kernel, +/// but no `bevy_math` type crosses this public boundary. +#[derive(Default)] +pub struct GeometryBrushAssembler(Assembler); + +impl GeometryBrushAssembler { + pub fn new() -> Self { + Self(Assembler::new()) + } + pub fn add_box( + &mut self, + name: impl Into, + center: Float3, + size: Float3, + material: u32, + ) { + self.0 + .solid_box(name, internal_aabb(center, size), MaterialId(material)); + } + pub fn subtract_box(&mut self, name: impl Into, center: Float3, size: Float3) { + self.0.cut_box(name, internal_aabb(center, size)); + } + pub fn build(&self) -> GeometryTriangleMesh { + internal_mesh(&self.0.build().mesh) + } +} + +fn internal_aabb(center: Float3, size: Float3) -> Aabb { + use bevy_math::Vec3; + Aabb::from_center_size( + Vec3::new(center.0, center.1, center.2), + Vec3::new(size.0, size.1, size.2), + ) +} + +fn internal_mesh(mesh: &TriangleMesh) -> GeometryTriangleMesh { + GeometryTriangleMesh( + mesh.positions + .iter() + .map(|v| Float3(v.x, v.y, v.z)) + .collect(), + mesh.normals.iter().map(|v| Float3(v.x, v.y, v.z)).collect(), + mesh.uvs.iter().map(|v| Float2(v.x, v.y)).collect(), + mesh.indices.clone(), + mesh.triangle_materials.iter().map(|v| v.0).collect(), + ) +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GeometryDomainDocument( + pub String, + pub String, + pub String, + pub GeometryDomainNode, + pub String, +); + +impl GeometryDocument for GeometryDomainDocument { + const SCHEMA_VERSION: &'static str = GEOMETRY_DOMAIN_SCHEMA; + fn record_key(&self) -> String { + format!( + "geometry:domain:{}", + stable_hash(&[&self.1, &self.2, &self.3.stable_fingerprint()]) + ) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GeometryDomainNode( + pub String, + pub String, + pub Float3, + pub Quaternion, + pub u64, + pub Vec, + pub Vec, +); + +impl GeometryDomainNode { + pub fn stable_fingerprint(&self) -> String { + let mut parts = vec![ + self.0.clone(), + self.1.clone(), + stable_float3(self.2), + stable_quaternion(self.3), + self.4.to_string(), + ]; + parts.extend(self.5.iter().map(GeometryFeatureClaim::stable_fingerprint)); + parts.extend(self.6.iter().map(GeometryDomainNode::stable_fingerprint)); + stable_join(&parts) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GeometryFeatureClaim( + pub String, + pub Float3, + pub Quaternion, + pub Float3, + pub Float3, + pub String, + pub u32, + pub String, +); + +impl GeometryFeatureClaim { + pub fn stable_fingerprint(&self) -> String { + stable_join(&[ + self.0.clone(), + stable_float3(self.1), + stable_quaternion(self.2), + stable_float3(self.3), + stable_float3(self.4), + self.5.clone(), + self.6.to_string(), + self.7.clone(), + ]) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GeometryBuildRequest( + pub String, + pub String, + pub String, + pub Float3, + pub Float3, + pub Float3, + pub f32, + pub f32, + pub f32, + pub i32, + pub i32, + pub Vec, + pub Vec, + pub Vec, + pub String, +); + +impl GeometryDocument for GeometryBuildRequest { + const SCHEMA_VERSION: &'static str = GEOMETRY_BUILD_REQUEST_SCHEMA; + fn record_key(&self) -> String { + format!( + "geometry:request:{}", + stable_hash(&[ + &self.1, + &self.2, + &stable_float3(self.3), + &stable_float3(self.4), + &stable_float3(self.5), + &stable_f32(self.6), + &stable_f32(self.7), + &stable_f32(self.8), + &self.9.to_string(), + &self.10.to_string(), + &stable_join(&self.11), + &stable_join(&self.12), + &stable_join(&self.13), + ]) + ) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GeometrySelectedCutManifest( + pub String, + pub String, + pub Vec, + pub Vec, + pub Vec, + pub Vec, +); + +impl GeometryDocument for GeometrySelectedCutManifest { + const SCHEMA_VERSION: &'static str = GEOMETRY_SELECTED_CUT_SCHEMA; + fn record_key(&self) -> String { + format!( + "geometry:cut:{}", + stable_hash(&[ + &self.1, + &self.0, + &stable_join(&self.2), + &stable_join(&self.3), + &stable_join(&self.4), + ]) + ) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GeometryContributionRow( + pub String, + pub String, + pub f32, + pub f32, + pub f32, + pub i32, + pub i32, + pub i32, + pub bool, + pub bool, + pub bool, + pub bool, + pub bool, +); + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GeometryChunkArtifact( + pub String, + pub String, + pub String, + pub Float3, + pub Float3, + pub Vec, + pub Vec, + pub GeometryTriangleMesh, + pub Option, + pub i32, + pub i32, + pub i32, + pub u64, + pub bool, +); + +impl GeometryDocument for GeometryChunkArtifact { + const SCHEMA_VERSION: &'static str = GEOMETRY_CHUNK_ARTIFACT_SCHEMA; + fn record_key(&self) -> String { + format!( + "geometry:chunk:{}", + stable_hash(&[ + &self.1, + &self.0, + &self.2, + &stable_join(&self.5), + &stable_join(&self.6), + &self.7.stable_fingerprint(), + self.8 + .as_ref() + .map(GeometryTriangleMesh::stable_fingerprint) + .as_deref() + .unwrap_or(""), + ]) + ) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct GeometryTriangleMesh( + pub Vec, + pub Vec, + pub Vec, + pub Vec, + pub Vec, +); + +impl GeometryTriangleMesh { + pub fn triangle_count(&self) -> usize { + self.3.len() / 3 + } + pub fn stable_fingerprint(&self) -> String { + stable_hash(&[ + &stable_float3_array(&self.0), + &stable_float3_array(&self.1), + &stable_float2_array(&self.2), + &stable_u32_array(&self.3), + &stable_u32_array(&self.4), + ]) + } +} + +fn stable_hash(parts: &[&str]) -> String { + let mut hasher = Sha256::new(); + hasher.update(parts.join("\u{1f}").as_bytes()); + let mut output = String::with_capacity(64); + for byte in hasher.finalize() { + write!(&mut output, "{byte:02x}").expect("string write"); + } + output +} + +fn stable_join(parts: &[String]) -> String { + parts.join("\u{1e}") +} +fn stable_f32(value: f32) -> String { + format!("{:08x}", value.to_bits()) +} +fn stable_float2(value: Float2) -> String { + format!("{},{}", stable_f32(value.0), stable_f32(value.1)) +} +fn stable_float3(value: Float3) -> String { + format!( + "{},{},{}", + stable_f32(value.0), + stable_f32(value.1), + stable_f32(value.2) + ) +} +fn stable_quaternion(value: Quaternion) -> String { + format!( + "{},{},{},{}", + stable_f32(value.0), + stable_f32(value.1), + stable_f32(value.2), + stable_f32(value.3) + ) +} +fn stable_float2_array(values: &[Float2]) -> String { + values + .iter() + .map(|v| stable_float2(*v)) + .collect::>() + .join(",") +} +fn stable_float3_array(values: &[Float3]) -> String { + values + .iter() + .map(|v| stable_float3(*v)) + .collect::>() + .join(",") +} +fn stable_u32_array(values: &[u32]) -> String { + values + .iter() + .map(u32::to_string) + .collect::>() + .join(",") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn domain() -> GeometryDomainDocument { + GeometryDomainDocument( + "fixture-domain".into(), + "fixture/root".into(), + "gamecult-geometry-rs".into(), + GeometryDomainNode( + "root".into(), + "Root".into(), + Float3(1.0, -2.5, 0.0), + Quaternion::default(), + 42, + vec![], + vec![], + ), + "2026-07-22T00:00:00Z".into(), + ) + } + + fn chunk() -> GeometryChunkArtifact { + GeometryChunkArtifact( + "fixture/chunk".into(), + "geometry:cut:fixture".into(), + "cut-fixture".into(), + Float3(-1.0, -2.0, -3.0), + Float3(1.0, 2.0, 3.0), + vec!["fixture/root".into()], + vec!["fixture/root/claim".into()], + GeometryTriangleMesh( + vec![ + Float3(0.0, 0.0, 0.0), + Float3(1.0, 0.0, 0.0), + Float3(0.0, 1.0, 0.0), + ], + vec![Float3(0.0, 0.0, 1.0); 3], + vec![Float2(0.0, 0.0), Float2(1.0, 0.0), Float2(0.0, 1.0)], + vec![0, 1, 2], + vec![7], + ), + None, + 1, + 0, + 0, + 0x0123_4567_89ab_cdef, + true, + ) + } + + fn request() -> GeometryBuildRequest { + GeometryBuildRequest( + "request-fixture".into(), + domain().record_key(), + "workers".into(), + Float3(1.0, 2.0, 3.0), + Float3(-4.0, -5.0, -6.0), + Float3(4.0, 5.0, 6.0), + 1080.0, + 1.0, + 0.25, + 100, + 50, + vec!["Root".into()], + vec![], + vec![], + "2026-07-22T00:00:01Z".into(), + ) + } + + fn cut() -> GeometrySelectedCutManifest { + GeometrySelectedCutManifest( + "cut-fixture".into(), + "geometry:request:fixture".into(), + vec!["fixture/root".into()], + vec![], + vec![], + vec![], + ) + } + + #[test] + fn schema_versions_are_v2() { + assert_eq!( + GeometryDomainDocument::SCHEMA_VERSION, + "gamecult.geometry.domain.v2" + ); + assert_eq!( + GeometryBuildRequest::SCHEMA_VERSION, + "gamecult.geometry.build_request.v2" + ); + assert_eq!( + GeometrySelectedCutManifest::SCHEMA_VERSION, + "gamecult.geometry.selected_cut.v2" + ); + assert_eq!( + GeometryChunkArtifact::SCHEMA_VERSION, + "gamecult.geometry.chunk_artifact.v2" + ); + } + + #[test] + fn domain_record_key_matches_csharp_v2_fixture() { + let document = GeometryDomainDocument( + "ragnarok-column".into(), + "ragnarok-column".into(), + "vg-csg".into(), + GeometryDomainNode( + "ragnarok-column".into(), + "Root".into(), + Float3::default(), + Quaternion::default(), + 0x5eed, + vec![], + vec![GeometryDomainNode( + "stellarator-column-00".into(), + "Column".into(), + Float3::default(), + Quaternion::default(), + 0xc011_0000, + vec![GeometryFeatureClaim( + "column-support-shell".into(), + Float3::default(), + Quaternion::default(), + Float3(0.0, 0.0, 45.0), + Float3(18.0, 18.0, 96.0), + "SupportShell".into(), + 10, + "RenderAndCollider".into(), + )], + vec![], + )], + ), + "2026-05-29T00:00:00.0000000Z".into(), + ); + + assert_eq!( + document.record_key(), + "geometry:domain:175899ea97548da0599e12bcaccd07fa1d6009ed450fadb3de229d47bab04431" + ); + } + + #[test] + fn mined_csg_performance_fixture_keeps_large_distant_cutters_bounded() { + use bevy_math::Vec3; + use std::time::{Duration, Instant}; + + let mut assembler = Assembler::new(); + assembler.solid_box( + "source", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + MaterialId(1), + ); + for index in 0..512 { + assembler.cut_box( + format!("distant-{index}"), + Aabb::from_center_size(Vec3::new(100.0 + index as f32 * 2.0, 0.0, 0.0), Vec3::ONE), + ); + } + let started = Instant::now(); + let output = assembler.build(); + assert_eq!(output.mesh.triangle_count(), 12); + assert_eq!(output.report.candidate_pairs, 0); + assert_eq!(output.report.rejected_pairs, 512); + assert!(started.elapsed() < Duration::from_secs(2)); + } + + #[test] + fn exact_v2_messagepack_and_record_key_witnesses() { + let domain = domain(); + let chunk = chunk(); + let request = request(); + let cut = cut(); + assert_eq!( + domain.record_key(), + "geometry:domain:505c94e7393580a4e5b048b169aa4da955da606ffd28c054b766a4b94e50502e" + ); + assert_eq!( + request.record_key(), + "geometry:request:36e25b80c04a6c54c8b3137abb4a8c961468a9b1c0e7dd4d5632cf5a6f847966" + ); + assert_eq!( + cut.record_key(), + "geometry:cut:5614eae3fe2a17dca651d1e32f401c50e1c3b9cf941f5789655990325da64fb3" + ); + assert_eq!( + chunk.record_key(), + "geometry:chunk:ca2874d3638800179c906087ec817ea715c43b70b99ead04edccce2a1b3d6ebb" + ); + assert_eq!( + domain.to_msgpack().unwrap(), + [ + 149, 174, 102, 105, 120, 116, 117, 114, 101, 45, 100, 111, 109, 97, 105, 110, 172, + 102, 105, 120, 116, 117, 114, 101, 47, 114, 111, 111, 116, 180, 103, 97, 109, 101, + 99, 117, 108, 116, 45, 103, 101, 111, 109, 101, 116, 114, 121, 45, 114, 115, 151, + 164, 114, 111, 111, 116, 164, 82, 111, 111, 116, 147, 202, 63, 128, 0, 0, 202, 192, + 32, 0, 0, 202, 0, 0, 0, 0, 148, 202, 0, 0, 0, 0, 202, 0, 0, 0, 0, 202, 0, 0, 0, 0, + 202, 63, 128, 0, 0, 42, 144, 144, 180, 50, 48, 50, 54, 45, 48, 55, 45, 50, 50, 84, + 48, 48, 58, 48, 48, 58, 48, 48, 90 + ] + ); + assert_eq!( + request.to_msgpack().unwrap(), + [ + 159, 175, 114, 101, 113, 117, 101, 115, 116, 45, 102, 105, 120, 116, 117, 114, 101, + 217, 80, 103, 101, 111, 109, 101, 116, 114, 121, 58, 100, 111, 109, 97, 105, 110, + 58, 53, 48, 53, 99, 57, 52, 101, 55, 51, 57, 51, 53, 56, 48, 97, 52, 101, 53, 98, + 48, 52, 56, 98, 49, 54, 57, 97, 97, 52, 100, 97, 57, 53, 53, 100, 97, 54, 48, 54, + 102, 102, 100, 50, 56, 99, 48, 53, 52, 98, 55, 54, 54, 97, 52, 98, 57, 52, 101, 53, + 48, 53, 48, 50, 101, 167, 119, 111, 114, 107, 101, 114, 115, 147, 202, 63, 128, 0, + 0, 202, 64, 0, 0, 0, 202, 64, 64, 0, 0, 147, 202, 192, 128, 0, 0, 202, 192, 160, 0, + 0, 202, 192, 192, 0, 0, 147, 202, 64, 128, 0, 0, 202, 64, 160, 0, 0, 202, 64, 192, + 0, 0, 202, 68, 135, 0, 0, 202, 63, 128, 0, 0, 202, 62, 128, 0, 0, 100, 50, 145, + 164, 82, 111, 111, 116, 144, 144, 180, 50, 48, 50, 54, 45, 48, 55, 45, 50, 50, 84, + 48, 48, 58, 48, 48, 58, 48, 49, 90 + ] + ); + assert_eq!( + cut.to_msgpack().unwrap(), + [ + 150, 171, 99, 117, 116, 45, 102, 105, 120, 116, 117, 114, 101, 184, 103, 101, 111, + 109, 101, 116, 114, 121, 58, 114, 101, 113, 117, 101, 115, 116, 58, 102, 105, 120, + 116, 117, 114, 101, 145, 172, 102, 105, 120, 116, 117, 114, 101, 47, 114, 111, 111, + 116, 144, 144, 144 + ] + ); + assert_eq!( + chunk.to_msgpack().unwrap(), + [ + 158, 173, 102, 105, 120, 116, 117, 114, 101, 47, 99, 104, 117, 110, 107, 180, 103, + 101, 111, 109, 101, 116, 114, 121, 58, 99, 117, 116, 58, 102, 105, 120, 116, 117, + 114, 101, 171, 99, 117, 116, 45, 102, 105, 120, 116, 117, 114, 101, 147, 202, 191, + 128, 0, 0, 202, 192, 0, 0, 0, 202, 192, 64, 0, 0, 147, 202, 63, 128, 0, 0, 202, 64, + 0, 0, 0, 202, 64, 64, 0, 0, 145, 172, 102, 105, 120, 116, 117, 114, 101, 47, 114, + 111, 111, 116, 145, 178, 102, 105, 120, 116, 117, 114, 101, 47, 114, 111, 111, 116, + 47, 99, 108, 97, 105, 109, 149, 147, 147, 202, 0, 0, 0, 0, 202, 0, 0, 0, 0, 202, 0, + 0, 0, 0, 147, 202, 63, 128, 0, 0, 202, 0, 0, 0, 0, 202, 0, 0, 0, 0, 147, 202, 0, 0, + 0, 0, 202, 63, 128, 0, 0, 202, 0, 0, 0, 0, 147, 147, 202, 0, 0, 0, 0, 202, 0, 0, 0, + 0, 202, 63, 128, 0, 0, 147, 202, 0, 0, 0, 0, 202, 0, 0, 0, 0, 202, 63, 128, 0, 0, + 147, 202, 0, 0, 0, 0, 202, 0, 0, 0, 0, 202, 63, 128, 0, 0, 147, 146, 202, 0, 0, 0, + 0, 202, 0, 0, 0, 0, 146, 202, 63, 128, 0, 0, 202, 0, 0, 0, 0, 146, 202, 0, 0, 0, 0, + 202, 63, 128, 0, 0, 147, 0, 1, 2, 145, 7, 192, 1, 0, 0, 207, 1, 35, 69, 103, 137, + 171, 205, 239, 195 + ] + ); + assert_eq!( + GeometryDomainDocument::from_msgpack(&domain.to_msgpack().unwrap()).unwrap(), + domain + ); + assert_eq!( + GeometryChunkArtifact::from_msgpack(&chunk.to_msgpack().unwrap()).unwrap(), + chunk + ); + assert_eq!( + GeometryBuildRequest::from_msgpack(&request.to_msgpack().unwrap()).unwrap(), + request + ); + assert_eq!( + GeometrySelectedCutManifest::from_msgpack(&cut.to_msgpack().unwrap()).unwrap(), + cut + ); + } + + #[test] + fn scalar_bit_identity_is_stable_through_messagepack() { + let mut signed_zero = request(); + signed_zero.6 = -0.0; + signed_zero.7 = f32::from_bits(0x7fc0_1234); + signed_zero.8 = f32::NEG_INFINITY; + assert_eq!( + signed_zero.record_key(), + "geometry:request:978dd0913fc3e12c04766e065320d485d172d60d61be920a9a32637d1c439c81" + ); + let decoded = + GeometryBuildRequest::from_msgpack(&signed_zero.to_msgpack().unwrap()).unwrap(); + assert_eq!(decoded.6.to_bits(), 0x8000_0000); + assert_eq!(decoded.7.to_bits(), 0x7fc0_1234); + assert_eq!(decoded.8.to_bits(), f32::NEG_INFINITY.to_bits()); + } +} diff --git a/packages/gamecult-geometry-rs/src/mesh.rs b/packages/gamecult-geometry-rs/src/mesh.rs new file mode 100644 index 00000000..9e23b7ab --- /dev/null +++ b/packages/gamecult-geometry-rs/src/mesh.rs @@ -0,0 +1,103 @@ +#![allow(dead_code)] +use bevy_math::{Vec2, Vec3}; + +use crate::{Aabb, MaterialId}; + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct TriangleMesh { + pub positions: Vec, + pub normals: Vec, + pub uvs: Vec, + pub indices: Vec, + pub triangle_materials: Vec, +} + +impl TriangleMesh { + pub fn new() -> Self { + Self::default() + } + + pub fn triangle_count(&self) -> usize { + self.indices.len() / 3 + } + + pub fn vertex_count(&self) -> usize { + self.positions.len() + } + + pub fn append_box(&mut self, bounds: Aabb, material: MaterialId) { + let min = bounds.min; + let max = bounds.max; + let corners = [ + Vec3::new(min.x, min.y, min.z), + Vec3::new(max.x, min.y, min.z), + Vec3::new(max.x, max.y, min.z), + Vec3::new(min.x, max.y, min.z), + Vec3::new(min.x, min.y, max.z), + Vec3::new(max.x, min.y, max.z), + Vec3::new(max.x, max.y, max.z), + Vec3::new(min.x, max.y, max.z), + ]; + + self.append_quad( + [corners[0], corners[3], corners[2], corners[1]], + Vec3::NEG_Z, + material, + ); + self.append_quad( + [corners[4], corners[5], corners[6], corners[7]], + Vec3::Z, + material, + ); + self.append_quad( + [corners[0], corners[1], corners[5], corners[4]], + Vec3::NEG_Y, + material, + ); + self.append_quad( + [corners[3], corners[7], corners[6], corners[2]], + Vec3::Y, + material, + ); + self.append_quad( + [corners[0], corners[4], corners[7], corners[3]], + Vec3::NEG_X, + material, + ); + self.append_quad( + [corners[1], corners[2], corners[6], corners[5]], + Vec3::X, + material, + ); + } + + pub fn append_quad(&mut self, points: [Vec3; 4], normal: Vec3, material: MaterialId) { + let base = self.positions.len() as u32; + self.positions.extend(points); + self.normals.extend([normal; 4]); + self.uvs.extend([ + Vec2::new(0.0, 0.0), + Vec2::new(1.0, 0.0), + Vec2::new(1.0, 1.0), + Vec2::new(0.0, 1.0), + ]); + self.indices + .extend([base, base + 1, base + 2, base, base + 2, base + 3]); + self.triangle_materials.extend([material; 2]); + } + + pub fn append_triangle( + &mut self, + points: [Vec3; 3], + normal: Vec3, + uvs: [Vec2; 3], + material: MaterialId, + ) { + let base = self.positions.len() as u32; + self.positions.extend(points); + self.normals.extend([normal; 3]); + self.uvs.extend(uvs); + self.indices.extend([base, base + 1, base + 2]); + self.triangle_materials.push(material); + } +} diff --git a/packages/gamecult-geometry-rs/src/primitives.rs b/packages/gamecult-geometry-rs/src/primitives.rs new file mode 100644 index 00000000..bc100e59 --- /dev/null +++ b/packages/gamecult-geometry-rs/src/primitives.rs @@ -0,0 +1,173 @@ +#![allow(dead_code)] +use bevy_math::{Vec2, Vec3}; + +use crate::{MaterialId, TriangleMesh}; + +#[derive(Clone, Copy, Debug)] +pub struct DomeCapZSpec { + pub center: Vec3, + pub radius: f32, + pub height: f32, + pub rings: usize, + pub segments: usize, + pub material: MaterialId, +} + +#[derive(Clone, Copy, Debug)] +pub struct FloretArmSpec { + pub anchor: Vec3, + pub direction: Vec3, + pub length: f32, + pub root_width: f32, + pub tip_width: f32, + pub thickness: f32, + pub tip_lift: f32, + pub material: MaterialId, +} + +pub fn append_cylinder_z( + mesh: &mut TriangleMesh, + center: Vec3, + radius: f32, + depth: f32, + segments: usize, + material: MaterialId, +) { + let segments = segments.max(3); + let half = depth * 0.5; + let bottom_center = center - Vec3::Z * half; + let top_center = center + Vec3::Z * half; + + for i in 0..segments { + let a0 = ring_angle(i, segments); + let a1 = ring_angle(i + 1, segments); + let p0 = center + Vec3::new(a0.cos() * radius, a0.sin() * radius, -half); + let p1 = center + Vec3::new(a1.cos() * radius, a1.sin() * radius, -half); + let p2 = center + Vec3::new(a1.cos() * radius, a1.sin() * radius, half); + let p3 = center + Vec3::new(a0.cos() * radius, a0.sin() * radius, half); + let normal = Vec3::new(((a0 + a1) * 0.5).cos(), ((a0 + a1) * 0.5).sin(), 0.0); + mesh.append_quad([p0, p1, p2, p3], normal, material); + mesh.append_triangle( + [bottom_center, p1, p0], + Vec3::NEG_Z, + [Vec2::ZERO, Vec2::X, Vec2::Y], + material, + ); + mesh.append_triangle( + [top_center, p3, p2], + Vec3::Z, + [Vec2::ZERO, Vec2::X, Vec2::Y], + material, + ); + } +} + +pub fn append_dome_cap_z(mesh: &mut TriangleMesh, spec: DomeCapZSpec) { + let rings = spec.rings.max(2); + let segments = spec.segments.max(6); + let top = spec.center + Vec3::Z * spec.height; + + for ring in 0..rings { + let t0 = ring as f32 / rings as f32; + let t1 = (ring + 1) as f32 / rings as f32; + let r0 = spec.radius * (1.0 - t0 * t0).sqrt(); + let r1 = spec.radius * (1.0 - t1 * t1).sqrt(); + let z0 = spec.center.z + spec.height * t0; + let z1 = spec.center.z + spec.height * t1; + + for seg in 0..segments { + let a0 = ring_angle(seg, segments); + let a1 = ring_angle(seg + 1, segments); + let p0 = Vec3::new( + spec.center.x + a0.cos() * r0, + spec.center.y + a0.sin() * r0, + z0, + ); + let p1 = Vec3::new( + spec.center.x + a1.cos() * r0, + spec.center.y + a1.sin() * r0, + z0, + ); + + if ring + 1 == rings { + let normal = (p0 - spec.center) + .cross(top - spec.center) + .normalize_or_zero(); + mesh.append_triangle( + [p0, p1, top], + normal, + [Vec2::ZERO, Vec2::X, Vec2::Y], + spec.material, + ); + } else { + let p2 = Vec3::new( + spec.center.x + a1.cos() * r1, + spec.center.y + a1.sin() * r1, + z1, + ); + let p3 = Vec3::new( + spec.center.x + a0.cos() * r1, + spec.center.y + a0.sin() * r1, + z1, + ); + let normal = ((p0 + p1 + p2 + p3) * 0.25 - spec.center).normalize_or_zero(); + mesh.append_quad([p0, p1, p2, p3], normal, spec.material); + } + } + } +} + +pub fn append_floret_arm(mesh: &mut TriangleMesh, spec: FloretArmSpec) { + let forward = spec.direction.normalize_or_zero(); + let forward = if forward.length_squared() == 0.0 { + Vec3::X + } else { + forward + }; + let mut side = forward.cross(Vec3::Z).normalize_or_zero(); + if side.length_squared() == 0.0 { + side = Vec3::Y; + } + + let root_center = spec.anchor; + let tip_center = spec.anchor + forward * spec.length + Vec3::Z * spec.tip_lift; + let z = Vec3::Z * (spec.thickness * 0.5); + let r = side * (spec.root_width * 0.5); + let t = side * (spec.tip_width * 0.5); + + let bottom = [ + root_center - r - z, + root_center + r - z, + tip_center + t - z, + tip_center - t - z, + ]; + let top = [ + root_center - r + z, + root_center + r + z, + tip_center + t + z, + tip_center - t + z, + ]; + + mesh.append_quad( + [bottom[0], bottom[3], bottom[2], bottom[1]], + Vec3::NEG_Z, + spec.material, + ); + mesh.append_quad([top[0], top[1], top[2], top[3]], Vec3::Z, spec.material); + mesh.append_quad( + [bottom[0], bottom[1], top[1], top[0]], + -forward, + spec.material, + ); + mesh.append_quad( + [bottom[3], top[3], top[2], bottom[2]], + forward, + spec.material, + ); + mesh.append_quad([bottom[0], top[0], top[3], bottom[3]], -side, spec.material); + mesh.append_quad([bottom[1], bottom[2], top[2], top[1]], side, spec.material); +} + +fn ring_angle(index: usize, segments: usize) -> f32 { + index as f32 * std::f32::consts::TAU / segments as f32 +} diff --git a/packages/gamecult-geometry-rs/src/realtime_csg_parity.rs b/packages/gamecult-geometry-rs/src/realtime_csg_parity.rs new file mode 100644 index 00000000..fbb1ac70 --- /dev/null +++ b/packages/gamecult-geometry-rs/src/realtime_csg_parity.rs @@ -0,0 +1,166 @@ +use crate::{ + Aabb, Assembler, ConvexSolid, CsgBranchOp, CsgOperationType, CsgTreeArena, MaterialId, + PolygonCategory, Primitive, +}; +use bevy_math::{Quat, Vec3}; + +#[test] +fn parity_polygon_category_vocabulary_matches_public_demo() { + let categories = [ + PolygonCategory::Inside, + PolygonCategory::Outside, + PolygonCategory::Aligned, + PolygonCategory::ReverseAligned, + ]; + + assert_eq!(categories.len(), 4); +} + +#[test] +fn parity_operation_type_ordinals_match_public_api() { + assert_eq!(CsgOperationType::Additive as u8, 0); + assert_eq!(CsgOperationType::Subtractive as u8, 1); + assert_eq!(CsgOperationType::Intersecting as u8, 2); +} + +#[test] +fn parity_demo_branch_vocabulary_matches_public_demo() { + let branches = [ + CsgBranchOp::Addition, + CsgBranchOp::Subtraction, + CsgBranchOp::Common, + ]; + + assert_eq!(branches.len(), 3); +} + +#[test] +fn parity_tree_api_exposes_brush_branch_tree_handles() { + let mut arena = CsgTreeArena::new(); + let brush = arena.generate_brush( + "brush", + CsgOperationType::Additive, + Primitive::Box { + bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::ONE), + }, + MaterialId(1), + ); + let branch = arena.generate_branch("branch", CsgBranchOp::Addition, [brush.node]); + let tree = arena.generate_tree(branch.node); + + assert_eq!(arena.brush_count(), 1); + assert_eq!(arena.branch_count(), 1); + assert_eq!(arena.child_nodes(branch).expect("branch"), &[brush.node]); + assert_eq!(tree.root, branch.node); +} + +#[test] +fn parity_box_brush_observable_counts_match_realtime_csg_demo() { + let solid = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(2.0), + Quat::IDENTITY, + MaterialId(3), + ); + + assert_eq!(solid.clip_planes.len(), 6); + assert_eq!(solid.polygons.len(), 6); + assert_eq!( + solid + .polygons + .iter() + .map(|polygon| polygon.vertices.len()) + .sum::(), + 24 + ); +} + +#[test] +fn parity_center_box_subtraction_has_six_surviving_regions() { + let mut asm = Assembler::new(); + asm.solid_box( + "source", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + MaterialId(1), + ); + asm.cut_box("void", Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0))); + + let output = asm.build(); + assert_eq!(output.report.emitted_convex_fragments, 6); + assert_eq!(output.report.warnings.len(), 0); +} + +#[test] +fn parity_identical_box_polygons_classify_as_aligned() { + let cutter = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(2.0), + Quat::IDENTITY, + MaterialId(0), + ); + let mut source = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(2.0), + Quat::IDENTITY, + MaterialId(1), + ); + + source.categorize_whole_polygons_against(&cutter); + + assert_eq!(source.polygons.len(), 6); + assert!( + source + .polygons + .iter() + .all(|polygon| polygon.category == PolygonCategory::Aligned) + ); +} + +#[test] +fn parity_outer_box_polygons_classify_as_outside_inner_box() { + let cutter = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(2.0), + Quat::IDENTITY, + MaterialId(0), + ); + let mut source = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(4.0), + Quat::IDENTITY, + MaterialId(1), + ); + + source.categorize_whole_polygons_against(&cutter); + + assert_eq!(source.polygons.len(), 6); + assert!( + source + .polygons + .iter() + .all(|polygon| polygon.category == PolygonCategory::Outside) + ); +} + +#[test] +fn parity_crossing_polygons_split_before_final_category() { + let source = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(4.0), + Quat::IDENTITY, + MaterialId(1), + ); + let cutter = ConvexSolid::box_from_center_size( + Vec3::new(0.0, 0.0, 1.0), + Vec3::new(2.0, 2.0, 2.0), + Quat::IDENTITY, + MaterialId(0), + ); + + let categorized = source.categorize_polygons_against(&cutter); + + assert_eq!(categorized.aligned.len(), 1); + assert!(categorized.outside.len() > source.polygons.len()); + assert_eq!(categorized.inside.len(), 0); + assert_eq!(categorized.reverse_aligned.len(), 0); +} diff --git a/packages/gamecult-geometry-rs/src/tree.rs b/packages/gamecult-geometry-rs/src/tree.rs new file mode 100644 index 00000000..14bdc226 --- /dev/null +++ b/packages/gamecult-geometry-rs/src/tree.rs @@ -0,0 +1,402 @@ +#![allow(dead_code)] +use crate::{Aabb, Assembler, Brush, BrushId, BrushOp, MaterialId, Primitive}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct CsgNodeId(pub u32); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct CsgTreeBrush { + pub node: CsgNodeId, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct CsgTreeBranch { + pub node: CsgNodeId, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct CsgTree { + pub root: CsgNodeId, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum CsgOperationType { + Additive = 0, + Subtractive = 1, + Intersecting = 2, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum CsgBranchOp { + Addition, + Subtraction, + Common, +} + +#[derive(Clone, Debug)] +pub enum CsgNode { + Brush(Brush), + Branch { + name: String, + op: CsgBranchOp, + children: Vec, + }, +} + +#[derive(Clone, Debug, Default)] +pub struct CsgTreeArena { + nodes: Vec, +} + +impl CsgTreeArena { + pub fn new() -> Self { + Self::default() + } + + pub fn nodes(&self) -> &[CsgNode] { + &self.nodes + } + + pub fn node(&self, id: CsgNodeId) -> Option<&CsgNode> { + self.nodes.get(id.0 as usize) + } + + pub fn node_mut(&mut self, id: CsgNodeId) -> Option<&mut CsgNode> { + self.nodes.get_mut(id.0 as usize) + } + + pub fn brush_count(&self) -> usize { + self.nodes + .iter() + .filter(|node| matches!(node, CsgNode::Brush(_))) + .count() + } + + pub fn branch_count(&self) -> usize { + self.nodes + .iter() + .filter(|node| matches!(node, CsgNode::Branch { .. })) + .count() + } + + pub fn generate_brush( + &mut self, + name: impl Into, + operation: CsgOperationType, + primitive: Primitive, + material: MaterialId, + ) -> CsgTreeBrush { + let id = CsgNodeId(self.nodes.len() as u32); + let brush = Brush::new( + BrushId(id.0), + name, + operation.into_brush_op(), + primitive, + material, + ); + self.nodes.push(CsgNode::Brush(brush)); + CsgTreeBrush { node: id } + } + + pub fn generate_branch( + &mut self, + name: impl Into, + op: CsgBranchOp, + children: impl IntoIterator, + ) -> CsgTreeBranch { + let id = CsgNodeId(self.nodes.len() as u32); + self.nodes.push(CsgNode::Branch { + name: name.into(), + op, + children: children.into_iter().collect(), + }); + CsgTreeBranch { node: id } + } + + pub fn generate_tree(&self, root: CsgNodeId) -> CsgTree { + CsgTree { root } + } + + pub fn set_brush_operation_type( + &mut self, + brush: CsgTreeBrush, + operation: CsgOperationType, + ) -> bool { + let Some(CsgNode::Brush(node)) = self.node_mut(brush.node) else { + return false; + }; + node.op = operation.into_brush_op(); + node.set_dirty(); + true + } + + pub fn set_branch_operation_type(&mut self, branch: CsgTreeBranch, op: CsgBranchOp) -> bool { + let Some(CsgNode::Branch { op: current_op, .. }) = self.node_mut(branch.node) else { + return false; + }; + *current_op = op; + true + } + + pub fn set_child_nodes( + &mut self, + branch: CsgTreeBranch, + children: impl IntoIterator, + ) -> bool { + let Some(CsgNode::Branch { + children: current_children, + .. + }) = self.node_mut(branch.node) + else { + return false; + }; + *current_children = children.into_iter().collect(); + true + } + + pub fn child_nodes(&self, branch: CsgTreeBranch) -> Option<&[CsgNodeId]> { + let Some(CsgNode::Branch { children, .. }) = self.node(branch.node) else { + return None; + }; + Some(children) + } + + pub fn node_bounds(&self, id: CsgNodeId) -> Option { + match self.node(id)? { + CsgNode::Brush(brush) => Some(brush.bounds()), + CsgNode::Branch { op, children, .. } => self.branch_bounds(*op, children), + } + } + + pub fn compile_tree_to_assembler(&self, tree: CsgTree) -> Assembler { + let mut assembler = Assembler::new(); + self.compile_node(tree.root, &mut assembler, None); + assembler + } + + fn compile_node( + &self, + id: CsgNodeId, + assembler: &mut Assembler, + operation_override: Option, + ) { + let Some(node) = self.node(id) else { + return; + }; + + match node { + CsgNode::Brush(brush) => { + let op = operation_override + .map(CsgOperationType::into_brush_op) + .unwrap_or(brush.op); + assembler.add_brush( + brush.name.clone(), + op, + brush.primitive.clone(), + brush.material, + ); + } + CsgNode::Branch { op, children, .. } => match op { + CsgBranchOp::Addition => { + for child in children { + self.compile_node(*child, assembler, operation_override); + } + } + CsgBranchOp::Subtraction => { + if let Some((first, rest)) = children.split_first() { + self.compile_node(*first, assembler, operation_override); + for child in rest { + self.compile_node( + *child, + assembler, + Some(CsgOperationType::Subtractive), + ); + } + } + } + CsgBranchOp::Common => { + if let Some((first, rest)) = children.split_first() { + self.compile_node(*first, assembler, operation_override); + for child in rest { + self.compile_node( + *child, + assembler, + Some(CsgOperationType::Intersecting), + ); + } + } + } + }, + } + } + + fn branch_bounds(&self, op: CsgBranchOp, children: &[CsgNodeId]) -> Option { + let mut child_bounds = children.iter().filter_map(|child| self.node_bounds(*child)); + match op { + CsgBranchOp::Addition => child_bounds.reduce(Aabb::union), + CsgBranchOp::Subtraction => child_bounds.next(), + CsgBranchOp::Common => { + let first = child_bounds.next()?; + child_bounds.try_fold(first, |bounds, child| bounds.intersection(child)) + } + } + } +} + +impl CsgOperationType { + pub fn into_brush_op(self) -> BrushOp { + match self { + Self::Additive => BrushOp::Add, + Self::Subtractive => BrushOp::Subtract, + Self::Intersecting => BrushOp::Intersect, + } + } +} + +impl From for CsgOperationType { + fn from(value: BrushOp) -> Self { + match value { + BrushOp::Add => Self::Additive, + BrushOp::Subtract => Self::Subtractive, + BrushOp::Intersect => Self::Intersecting, + } + } +} + +#[cfg(test)] +mod tests { + use bevy_math::Vec3; + + use super::*; + use crate::{Aabb, Primitive}; + + #[test] + fn tree_subtraction_compiles_to_ordered_brush_stream() { + let mut arena = CsgTreeArena::new(); + let solid = arena.generate_brush( + "solid", + CsgOperationType::Additive, + Primitive::Box { + bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + }, + MaterialId(1), + ); + let void = arena.generate_brush( + "void", + CsgOperationType::Additive, + Primitive::Box { + bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)), + }, + MaterialId(0), + ); + let branch = arena.generate_branch( + "solid minus void", + CsgBranchOp::Subtraction, + [solid.node, void.node], + ); + let assembler = arena.compile_tree_to_assembler(arena.generate_tree(branch.node)); + + assert_eq!(assembler.brushes().len(), 2); + assert_eq!(assembler.brushes()[0].op, BrushOp::Add); + assert_eq!(assembler.brushes()[1].op, BrushOp::Subtract); + assert_eq!(assembler.build().report.emitted_convex_fragments, 6); + } + + #[test] + fn tree_edit_api_replaces_children_and_operations() { + let mut arena = CsgTreeArena::new(); + let a = arena.generate_brush( + "a", + CsgOperationType::Additive, + Primitive::Box { + bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)), + }, + MaterialId(1), + ); + let b = arena.generate_brush( + "b", + CsgOperationType::Additive, + Primitive::Box { + bounds: Aabb::from_center_size(Vec3::X, Vec3::splat(1.0)), + }, + MaterialId(1), + ); + let branch = arena.generate_branch("branch", CsgBranchOp::Addition, [a.node]); + + assert!(arena.set_child_nodes(branch, [a.node, b.node])); + assert_eq!(arena.child_nodes(branch).expect("branch").len(), 2); + assert!(arena.set_branch_operation_type(branch, CsgBranchOp::Subtraction)); + assert!(arena.set_brush_operation_type(b, CsgOperationType::Subtractive)); + assert_eq!(arena.brush_count(), 2); + assert_eq!(arena.branch_count(), 1); + } + + #[test] + fn tree_bounds_follow_boolean_branch_shape() { + let mut arena = CsgTreeArena::new(); + let a = arena.generate_brush( + "a", + CsgOperationType::Additive, + Primitive::Box { + bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + }, + MaterialId(1), + ); + let b = arena.generate_brush( + "b", + CsgOperationType::Additive, + Primitive::Box { + bounds: Aabb::from_center_size(Vec3::X * 2.5, Vec3::splat(2.0)), + }, + MaterialId(1), + ); + let union = arena.generate_branch("union", CsgBranchOp::Addition, [a.node, b.node]); + let common = arena.generate_branch("common", CsgBranchOp::Common, [a.node, b.node]); + let subtract = + arena.generate_branch("subtract", CsgBranchOp::Subtraction, [a.node, b.node]); + + assert_eq!( + arena.node_bounds(union.node).expect("union"), + Aabb::new(Vec3::splat(-2.0), Vec3::new(3.5, 2.0, 2.0)) + ); + assert_eq!( + arena.node_bounds(common.node).expect("common"), + Aabb::new(Vec3::new(1.5, -1.0, -1.0), Vec3::new(2.0, 1.0, 1.0)) + ); + assert_eq!( + arena.node_bounds(subtract.node).expect("subtract"), + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)) + ); + } + + #[test] + fn tree_common_compiles_to_additive_source_then_intersections() { + let mut arena = CsgTreeArena::new(); + let a = arena.generate_brush( + "a", + CsgOperationType::Additive, + Primitive::Box { + bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + }, + MaterialId(1), + ); + let b = arena.generate_brush( + "b", + CsgOperationType::Additive, + Primitive::Box { + bounds: Aabb::from_center_size(Vec3::X, Vec3::splat(4.0)), + }, + MaterialId(1), + ); + let branch = arena.generate_branch("common", CsgBranchOp::Common, [a.node, b.node]); + + let assembler = arena.compile_tree_to_assembler(arena.generate_tree(branch.node)); + let output = assembler.build(); + + assert_eq!(assembler.brushes()[0].op, BrushOp::Add); + assert_eq!(assembler.brushes()[1].op, BrushOp::Intersect); + assert_eq!(output.report.emitted_convex_fragments, 1); + assert_eq!(output.report.warnings.len(), 0); + assert_eq!(output.mesh.triangle_count(), 12); + } +} diff --git a/packages/gamecult-geometry-ts/package.json b/packages/gamecult-geometry-ts/package.json new file mode 100644 index 00000000..ea7f8a32 --- /dev/null +++ b/packages/gamecult-geometry-ts/package.json @@ -0,0 +1,10 @@ +{ + "name": "@gamecult/geometry", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "node --no-warnings --experimental-strip-types --check src/planetary-tile.ts", + "test": "node --no-warnings --experimental-strip-types test/planetary-tile.test.mjs" + } +} diff --git a/packages/gamecult-geometry-ts/src/planetary-tile.ts b/packages/gamecult-geometry-ts/src/planetary-tile.ts new file mode 100644 index 00000000..82849c82 --- /dev/null +++ b/packages/gamecult-geometry-ts/src/planetary-tile.ts @@ -0,0 +1,47 @@ +export const GAMECULT_GEOMETRY_PLANETARY_TILE_VERSION = 1; + +export interface PlanetaryTileHeader { + fieldVersion: bigint; projectionVersion: number; layerId: number; projectionKind: number; + centerLongitude: number; centerLatitude: number; projectionScale: number; + level: number; x: number; y: number; interiorSize: number; borderSize: number; + footprintMeters: number; maximumUnresolvedHeight: number; +} +export interface PlanetaryTileSample { + direction: readonly [number, number, number]; radius: number; radialDisplacement: number; + tangentGradient: readonly [number, number, number]; surfaceNormal: readonly [number, number, number]; + slope: number; ridge: number; gully: number; finestResolvedWavelength: number; unresolvedHeightBound: number; +} +export interface PlanetarySurfaceTile { header: PlanetaryTileHeader; storageSize: number; samples: readonly (PlanetaryTileSample | null)[]; } + +export function decodePlanetarySurfaceTile(bytes: ArrayBuffer): PlanetarySurfaceTile { + const view = new DataView(bytes); let offset = 0; + const u8 = () => view.getUint8(offset++); + const u32 = () => { const value = view.getUint32(offset, true); offset += 4; return value; }; + const i32 = () => { const value = view.getInt32(offset, true); offset += 4; return value; }; + const f32 = () => { const value = view.getFloat32(offset, true); offset += 4; return value; }; + const f64 = () => { const value = view.getFloat64(offset, true); offset += 8; return value; }; + const u64 = () => { const value = view.getBigUint64(offset, true); offset += 8; return value; }; + const vector3 = (): [number, number, number] => [f32(), f32(), f32()]; + const requireRemaining = (count: number) => { if (offset + count > view.byteLength) throw new Error("Truncated GameCult.Geometry planetary tile"); }; + requireRemaining(8); + if (String.fromCharCode(u8(), u8(), u8(), u8()) !== "CMPT") throw new Error("Not a GameCult.Geometry planetary tile"); + const version = u32(); + if (version !== GAMECULT_GEOMETRY_PLANETARY_TILE_VERSION) throw new Error(`Unsupported GameCult.Geometry planetary tile version ${version}`); + requireRemaining(76); + const header: PlanetaryTileHeader = { + fieldVersion: u64(), projectionVersion: u32(), layerId: u32(), projectionKind: i32(), + centerLongitude: f64(), centerLatitude: f64(), projectionScale: f64(), + level: i32(), x: i32(), y: i32(), interiorSize: i32(), borderSize: i32(), + footprintMeters: f32(), maximumUnresolvedHeight: f32(), + }; + const count = i32(); const storageSize = header.interiorSize + header.borderSize * 2; + if (count !== storageSize * storageSize) throw new Error("GameCult.Geometry planetary tile sample count does not match layout"); + const samples: (PlanetaryTileSample | null)[] = new Array(count); + for (let index = 0; index < count; index++) { + requireRemaining(1); if (u8() === 0) { samples[index] = null; continue; } + requireRemaining(64); + samples[index] = { direction: vector3(), radius: f32(), radialDisplacement: f32(), tangentGradient: vector3(), surfaceNormal: vector3(), slope: f32(), ridge: f32(), gully: f32(), finestResolvedWavelength: f32(), unresolvedHeightBound: f32() }; + } + if (offset !== view.byteLength) throw new Error("GameCult.Geometry planetary tile has trailing bytes"); + return { header, storageSize, samples }; +} diff --git a/packages/gamecult-geometry-ts/test/planetary-tile.cross-runtime.test.mjs b/packages/gamecult-geometry-ts/test/planetary-tile.cross-runtime.test.mjs new file mode 100644 index 00000000..04243ce8 --- /dev/null +++ b/packages/gamecult-geometry-ts/test/planetary-tile.cross-runtime.test.mjs @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { decodePlanetarySurfaceTile } from "../src/planetary-tile.ts"; +if (process.argv.length !== 3) throw new Error("Expected a generated CMPT fixture path"); +const bytes = await readFile(process.argv[2]); +assert.equal(bytes.length, 1221); +assert.equal(createHash("sha256").update(bytes).digest("hex"), "8b2bc46e4123b8ac8936b43f8d08a34e3f2e74f38d0bb3f101cf3728cd201962"); +const tile = decodePlanetarySurfaceTile(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)); +assert.equal(tile.header.projectionVersion, 3); assert.equal(tile.header.layerId, 29); +assert.equal(tile.header.projectionKind, 2); assert.equal(tile.header.interiorSize, 5); +assert.equal(tile.header.borderSize, 1); assert.equal(tile.storageSize, 7); assert.equal(tile.samples.length, 49); +assert.ok(tile.samples.some(sample => sample !== null)); +for (const sample of tile.samples) if (sample !== null) { + assert.ok(Number.isFinite(sample.radialDisplacement)); assert.ok(Number.isFinite(sample.unresolvedHeightBound)); +} diff --git a/packages/gamecult-geometry-ts/test/planetary-tile.test.mjs b/packages/gamecult-geometry-ts/test/planetary-tile.test.mjs new file mode 100644 index 00000000..c5c27027 --- /dev/null +++ b/packages/gamecult-geometry-ts/test/planetary-tile.test.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import { decodePlanetarySurfaceTile } from "../src/planetary-tile.ts"; +const bytes = new ArrayBuffer(152); const view = new DataView(bytes); let offset = 0; +const u8 = value => view.setUint8(offset++, value); +const u32 = value => { view.setUint32(offset, value, true); offset += 4; }; +const i32 = value => { view.setInt32(offset, value, true); offset += 4; }; +const u64 = value => { view.setBigUint64(offset, value, true); offset += 8; }; +const f32 = value => { view.setFloat32(offset, value, true); offset += 4; }; +const f64 = value => { view.setFloat64(offset, value, true); offset += 8; }; +for (const character of "CMPT") u8(character.charCodeAt(0)); +u32(1); u64(0x1234n); u32(2); u32(17); i32(0); f64(0); f64(0); f64(1); +i32(0); i32(0); i32(0); i32(2); i32(0); f32(10); f32(0.5); i32(4); u8(1); +for (const value of [1, 0, 0]) f32(value); f32(8.4); f32(0.25); +for (const value of [0, 0.1, 0]) f32(value); +for (const value of [0.99, -0.1, 0]) f32(value); +for (const value of [0.1, 0.8, 0.2, 2.5, 0.05]) f32(value); +u8(0); u8(0); u8(0); assert.equal(offset, bytes.byteLength); +const tile = decodePlanetarySurfaceTile(bytes); +assert.equal(tile.header.fieldVersion, 0x1234n); assert.equal(tile.header.layerId, 17); +assert.equal(tile.storageSize, 2); assert.equal(tile.samples.length, 4); +assert.equal(tile.samples[0]?.radialDisplacement, 0.25); assert.equal(tile.samples[1], null); +assert.throws(() => decodePlanetarySurfaceTile(bytes.slice(0, -1)), /Truncated/); +const trailing = new Uint8Array(bytes.byteLength + 1); trailing.set(new Uint8Array(bytes)); +assert.throws(() => decodePlanetarySurfaceTile(trailing.buffer), /trailing/); diff --git a/scripts/build-geometry-candidate.ps1 b/scripts/build-geometry-candidate.ps1 new file mode 100644 index 00000000..0643dd8b --- /dev/null +++ b/scripts/build-geometry-candidate.ps1 @@ -0,0 +1,37 @@ +param( + [string] $GeometryVersion = "0.1.0", + [string] $SupportVersion = "1.0.3", + [string] $CultMathVersion = "0.1.0", + [string] $OutputDirectory = "artifacts\candidate-feed", + [string] $CultMathCandidateFeed = "..\CultMath\.tools\local-feed" +) +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +$project = Join-Path $root "src\GameCult.Geometry\GameCult.Geometry.csproj" +$output = Join-Path $root $OutputDirectory +$feed = [IO.Path]::GetFullPath((Join-Path $root $CultMathCandidateFeed)) +New-Item -ItemType Directory -Force -Path $output | Out-Null +$cultMathPackage = Join-Path $feed "CultMath.$CultMathVersion.nupkg" +if (-not (Test-Path -LiteralPath $cultMathPackage)) { + throw "CultMath $CultMathVersion is missing from $feed. Pack the final CultMath artifact first." +} +$supportProjects = @( + "src\GameCult.Logging\GameCult.Logging.csproj", + "src\GameCult.Caching\GameCult.Caching.csproj", + "src\GameCult.Caching.MessagePack.Analyzers\GameCult.Caching.MessagePack.Analyzers.csproj", + "src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj", + "src\GameCult.Networking\GameCult.Networking.csproj", + "src\GameCult.Mesh\GameCult.Mesh.csproj" +) +foreach ($relativeProject in $supportProjects) { + $supportProject = Join-Path $root $relativeProject + dotnet pack $supportProject -c Release -o $output --disable-build-servers -p:PackageVersion=$SupportVersion -p:UseSharedCompilation=false -m:1 + if ($LASTEXITCODE -ne 0) { throw "Candidate support pack failed: $relativeProject" } +} +dotnet restore $project --disable-build-servers -p:CultMathCandidateFeed=$feed -p:CultMathVersion=$CultMathVersion -p:UseSharedCompilation=false -m:1 +if ($LASTEXITCODE -ne 0) { throw "Geometry candidate restore failed." } +dotnet pack $project -c Release -o $output --no-restore --disable-build-servers -p:CultMathVersion=$CultMathVersion -p:CultMathCandidateFeed=$feed -p:UseSharedCompilation=false -m:1 +if ($LASTEXITCODE -ne 0) { throw "Geometry candidate pack failed." } +$package = Join-Path $output "GameCult.Geometry.$GeometryVersion.nupkg" +if (-not (Test-Path -LiteralPath $package)) { throw "Geometry candidate package was not produced." } +Write-Host $package diff --git a/scripts/build-geometry-unity-package.ps1 b/scripts/build-geometry-unity-package.ps1 new file mode 100644 index 00000000..27f3c567 --- /dev/null +++ b/scripts/build-geometry-unity-package.ps1 @@ -0,0 +1,117 @@ +param( + [string] $Configuration = "Release", + [string] $OutputDirectory = "artifacts\unity\org.gamecult.geometry", + [string] $CultMathCandidateFeed = "..\CultMath\.tools\local-feed", + [string] $CultMathVersion = "0.1.0" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +$projectPath = Join-Path $repoRoot "src\GameCult.Geometry\GameCult.Geometry.csproj" +$templateRoot = Join-Path $repoRoot "unity\org.gamecult.geometry" +$publishRoot = Join-Path $repoRoot "artifacts\geometry-unity-publish" +$outputRoot = if ([System.IO.Path]::IsPathRooted($OutputDirectory)) { + $OutputDirectory +} else { + Join-Path $repoRoot $OutputDirectory +} +$candidateFeed = if ([System.IO.Path]::IsPathRooted($CultMathCandidateFeed)) { + $CultMathCandidateFeed +} else { + Join-Path $repoRoot $CultMathCandidateFeed +} +$pluginRoot = Join-Path $outputRoot "Runtime\Plugins" +$templatePluginRoot = Join-Path $templateRoot "Runtime\Plugins" + +if (-not (Test-Path -LiteralPath (Join-Path $candidateFeed "CultMath.$CultMathVersion.nupkg"))) { + throw "CultMath $CultMathVersion is missing from $candidateFeed. Pack CultMath before staging Geometry." +} + +if (Test-Path -LiteralPath $publishRoot) { + Remove-Item -LiteralPath $publishRoot -Recurse -Force +} +if (Test-Path -LiteralPath $outputRoot) { + Remove-Item -LiteralPath $outputRoot -Recurse -Force +} + +dotnet restore $projectPath --disable-build-servers -p:CultMathCandidateFeed=$candidateFeed -p:CultMathVersion=$CultMathVersion -p:UseSharedCompilation=false -m:1 +if ($LASTEXITCODE -ne 0) { + throw "GameCult.Geometry restore failed with exit code $LASTEXITCODE" +} + +dotnet publish $projectPath -c $Configuration -o $publishRoot --no-restore --disable-build-servers -p:UseSharedCompilation=false -m:1 +if ($LASTEXITCODE -ne 0) { + throw "GameCult.Geometry publish failed with exit code $LASTEXITCODE" +} + +$geometryAssembly = Join-Path $publishRoot "GameCult.Geometry.dll" +if (-not (Test-Path -LiteralPath $geometryAssembly)) { + throw "Geometry Unity package is missing GameCult.Geometry.dll" +} +$geometrySymbols = [System.IO.Path]::ChangeExtension($geometryAssembly, ".pdb") + +$requiredTemplateFiles = @( + "LICENSES.md", + "THIRD-PARTY-NOTICES.md", + "Runtime\Plugins\GameCult.Geometry.dll", + "Runtime\Plugins\GameCult.Geometry.dll.meta", + "Runtime\Plugins\GameCult.Geometry.pdb", + "Runtime\Plugins\GameCult.Geometry.pdb.meta", + "Runtime\Unity\GameCult.Geometry.Unity.asmdef", + "Runtime\Unity\PlanetaryPatchMeshAdapter.cs", + "Runtime\Unity\PlanetaryPageUpload.cs", + "Tests\Editor\GameCult.Geometry.Tests.Editor.asmdef", + "Tests\Editor\GeometryPackageTests.cs", + "Shaders\AdvancedErosionFilter.hlsl", + "Shaders\GameCult.Geometry.hlsl", + "Shaders\Planetary.hlsl", + "Shaders\PlanetaryRadialRefinement.hlsl", + "Shaders\SphericalErosion.hlsl" +) +foreach ($relativePath in $requiredTemplateFiles) { + if (-not (Test-Path -LiteralPath (Join-Path $templateRoot $relativePath))) { + throw "Tracked Geometry Unity package is incomplete: missing $relativePath" + } +} + +$unityAssetFiles = @(Get-ChildItem -LiteralPath $templateRoot -Recurse -File | Where-Object { + $_.Extension -in @(".asmdef", ".cs", ".dll", ".pdb", ".shader", ".hlsl") +}) +$missingAssetMetas = @($unityAssetFiles | Where-Object { -not (Test-Path -LiteralPath ($_.FullName + ".meta")) }) +$missingDirectoryMetas = @(Get-ChildItem -LiteralPath $templateRoot -Recurse -Directory | Where-Object { + -not (Test-Path -LiteralPath ($_.FullName + ".meta")) +}) +if ($missingAssetMetas.Count -ne 0 -or $missingDirectoryMetas.Count -ne 0) { + $missing = @($missingAssetMetas.FullName) + @($missingDirectoryMetas.FullName) + throw "Tracked Geometry Unity package has assets without stable .meta files: $($missing -join ', ')" +} + +$assemblyDirectories = @($unityAssetFiles | Where-Object { $_.Extension -eq ".asmdef" } | Group-Object DirectoryName) +$duplicateAssemblyDirectories = @($assemblyDirectories | Where-Object { $_.Count -ne 1 }) +if ($duplicateAssemblyDirectories.Count -ne 0) { + throw "Each Geometry Unity assembly directory must contain exactly one asmdef: $($duplicateAssemblyDirectories.Name -join ', ')" +} + +$templateManifest = Get-Content -LiteralPath (Join-Path $templateRoot "package.json") -Raw | ConvertFrom-Json +$expectedFileVersion = "$($templateManifest.version).0" +$trackedAssembly = Join-Path $templatePluginRoot "GameCult.Geometry.dll" +$trackedFileVersion = [Diagnostics.FileVersionInfo]::GetVersionInfo($trackedAssembly).FileVersion +$publishedFileVersion = [Diagnostics.FileVersionInfo]::GetVersionInfo($geometryAssembly).FileVersion +if ($trackedFileVersion -ne $expectedFileVersion -or $publishedFileVersion -ne $expectedFileVersion) { + throw "Geometry Unity assemblies must match package version $expectedFileVersion; tracked=$trackedFileVersion published=$publishedFileVersion" +} + +New-Item -ItemType Directory -Force -Path $outputRoot | Out-Null +Copy-Item -Path (Join-Path $templateRoot "*") -Destination $outputRoot -Recurse -Force + +$manifest = Get-Content -LiteralPath (Join-Path $outputRoot "package.json") -Raw | ConvertFrom-Json +$stagedAssemblies = @(Get-ChildItem -LiteralPath $pluginRoot -Filter "*.dll") +if ($stagedAssemblies.Count -ne 1 -or $stagedAssemblies[0].Name -ne "GameCult.Geometry.dll") { + throw "Geometry package must stage only its owned assembly; found: $($stagedAssemblies.Name -join ', ')" +} + +Write-Host "GameCult.Geometry Unity package: $outputRoot" +Write-Host "Package: $($manifest.name)@$($manifest.version)" +Write-Host "Dependencies: $($manifest.dependencies.PSObject.Properties.Name -join ', ')" +Write-Host "Managed assemblies: $($stagedAssemblies.Count)" diff --git a/scripts/build-unity-package.ps1 b/scripts/build-unity-package.ps1 index e5cc82aa..6ba396e8 100644 --- a/scripts/build-unity-package.ps1 +++ b/scripts/build-unity-package.ps1 @@ -15,22 +15,7 @@ $outputRoot = if ([System.IO.Path]::IsPathRooted($OutputDirectory)) { Join-Path $repoRoot $OutputDirectory } $pluginRoot = Join-Path $outputRoot "Runtime\Plugins" - -if (Test-Path -LiteralPath $publishRoot) { - Remove-Item -LiteralPath $publishRoot -Recurse -Force -} -if (Test-Path -LiteralPath $outputRoot) { - Remove-Item -LiteralPath $outputRoot -Recurse -Force -} - -dotnet publish $projectPath -c $Configuration -o $publishRoot -if ($LASTEXITCODE -ne 0) { - throw "CultLib publish failed with exit code $LASTEXITCODE" -} - -New-Item -ItemType Directory -Force -Path $pluginRoot | Out-Null -Copy-Item -LiteralPath (Join-Path $templateRoot "package.json") -Destination $outputRoot -Copy-Item -LiteralPath (Join-Path $templateRoot "README.md") -Destination $outputRoot +$manifest = Get-Content -LiteralPath (Join-Path $templateRoot "package.json") -Raw | ConvertFrom-Json $expectedAssemblies = @( "ConcurrentCollections.dll", @@ -57,6 +42,68 @@ $expectedAssemblies = @( "System.Text.Json.dll", "System.Threading.Channels.dll" ) +$ownedAssemblies = @( + "GameCult.Caching.dll", + "GameCult.Caching.MessagePack.dll", + "GameCult.Logging.dll", + "GameCult.Mesh.dll", + "GameCult.Networking.dll" +) +$expectedPdbs = $ownedAssemblies | ForEach-Object { [System.IO.Path]::ChangeExtension($_, ".pdb") } + +function Assert-SameNames([string[]] $Actual, [string[]] $Expected, [string] $Description) { + $difference = Compare-Object ($Expected | Sort-Object) ($Actual | Sort-Object) + if ($difference) { + throw "$Description differs from the expected package contract:`n$($difference | Out-String)" + } +} + +function Assert-Meta([string] $Path) { + if (-not (Test-Path -LiteralPath "$Path.meta")) { + throw "Unity metadata is missing for: $Path" + } +} + +if ($manifest.name -ne "org.gamecult.cultlib" -or $manifest.version -notmatch '^\d+\.\d+\.\d+$') { + throw "CultLib package manifest has an invalid name or semantic version." +} + +$templatePluginRoot = Join-Path $templateRoot "Runtime\Plugins" +Assert-SameNames ` + (Get-ChildItem -LiteralPath $templatePluginRoot -Filter "*.dll" | Select-Object -ExpandProperty Name) ` + $expectedAssemblies ` + "Tracked Unity DLL set" +Assert-SameNames ` + (Get-ChildItem -LiteralPath $templatePluginRoot -Filter "*.pdb" | Select-Object -ExpandProperty Name) ` + $expectedPdbs ` + "Tracked Unity PDB set" + +@( + (Join-Path $templateRoot "package.json"), + (Join-Path $templateRoot "README.md"), + (Join-Path $templateRoot "Runtime"), + (Join-Path $templateRoot "Runtime\GameCult.CultLib.asmdef"), + $templatePluginRoot +) + ($expectedAssemblies | ForEach-Object { Join-Path $templatePluginRoot $_ }) + + ($expectedPdbs | ForEach-Object { Join-Path $templatePluginRoot $_ }) | + ForEach-Object { Assert-Meta $_ } + +$asmdef = Get-Content -LiteralPath (Join-Path $templateRoot "Runtime\GameCult.CultLib.asmdef") -Raw | ConvertFrom-Json +Assert-SameNames $asmdef.precompiledReferences $expectedAssemblies "asmdef precompiled reference set" + +if (Test-Path -LiteralPath $publishRoot) { + Remove-Item -LiteralPath $publishRoot -Recurse -Force +} +if (Test-Path -LiteralPath $outputRoot) { + Remove-Item -LiteralPath $outputRoot -Recurse -Force +} + +dotnet publish $projectPath -c $Configuration -o $publishRoot --disable-build-servers ` + -p:UseSharedCompilation=false -m:1 -p:Version=$($manifest.version) +if ($LASTEXITCODE -ne 0) { + throw "CultLib publish failed with exit code $LASTEXITCODE" +} + $publishedByName = @{} foreach ($assembly in Get-ChildItem -LiteralPath $publishRoot -Filter "*.dll") { $publishedByName[$assembly.Name] = $assembly @@ -67,18 +114,50 @@ foreach ($assemblyName in $expectedAssemblies) { } } -Copy-Item -LiteralPath (Join-Path $templateRoot "Runtime\GameCult.CultLib.asmdef") ` - -Destination (Join-Path $outputRoot "Runtime") +Copy-Item -LiteralPath $templateRoot -Destination $outputRoot -Recurse foreach ($assemblyName in $expectedAssemblies) { - $assembly = $publishedByName[$assemblyName] - Copy-Item -LiteralPath $assembly.FullName -Destination $pluginRoot - $pdb = [System.IO.Path]::ChangeExtension($assembly.FullName, ".pdb") - if (Test-Path -LiteralPath $pdb) { - Copy-Item -LiteralPath $pdb -Destination $pluginRoot + Copy-Item -LiteralPath $publishedByName[$assemblyName].FullName -Destination $pluginRoot -Force +} +foreach ($pdbName in $expectedPdbs) { + $pdbPath = Join-Path $publishRoot $pdbName + if (-not (Test-Path -LiteralPath $pdbPath)) { + throw "CultLib Unity package is missing expected symbols: $pdbName" + } + Copy-Item -LiteralPath $pdbPath -Destination $pluginRoot -Force +} + +Assert-SameNames ` + (Get-ChildItem -LiteralPath $pluginRoot -Filter "*.dll" | Select-Object -ExpandProperty Name) ` + $expectedAssemblies ` + "Staged Unity DLL set" +Assert-SameNames ` + (Get-ChildItem -LiteralPath $pluginRoot -Filter "*.pdb" | Select-Object -ExpandProperty Name) ` + $expectedPdbs ` + "Staged Unity PDB set" + +$expectedFileVersion = "$($manifest.version).0" +foreach ($assemblyName in $ownedAssemblies) { + $assemblyPath = Join-Path $pluginRoot $assemblyName + $fileVersion = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($assemblyPath).FileVersion + if ($fileVersion -ne $expectedFileVersion) { + throw "$assemblyName has file version $fileVersion; expected $expectedFileVersion." + } + $freshHash = (Get-FileHash -LiteralPath (Join-Path $publishRoot $assemblyName) -Algorithm SHA256).Hash + $stagedHash = (Get-FileHash -LiteralPath $assemblyPath -Algorithm SHA256).Hash + if ($freshHash -ne $stagedHash) { + throw "$assemblyName differs between the fresh publish and staged package." } } -$manifest = Get-Content -LiteralPath (Join-Path $outputRoot "package.json") -Raw | ConvertFrom-Json +$resolverAssemblyPath = Join-Path $pluginRoot "GameCult.Caching.MessagePack.dll" +$resolverAssemblyMetadata = [System.Text.Encoding]::UTF8.GetString( + [System.IO.File]::ReadAllBytes($resolverAssemblyPath)) +if (-not $resolverAssemblyMetadata.Contains("CultDocumentMessagePackResolversAttribute")) { + throw "GameCult.Caching.MessagePack.dll lacks CultDocumentMessagePackResolversAttribute." +} + Write-Host "CultLib Unity package: $outputRoot" Write-Host "Package: $($manifest.name)@$($manifest.version)" Write-Host "Managed assemblies: $($expectedAssemblies.Count)" +Write-Host "Verified owned assembly version: $expectedFileVersion" +Write-Host "Verified Stage 1 MessagePack resolver attribute." diff --git a/scripts/test-geometry-planetary-tile.ps1 b/scripts/test-geometry-planetary-tile.ps1 new file mode 100644 index 00000000..59cc859b --- /dev/null +++ b/scripts/test-geometry-planetary-tile.ps1 @@ -0,0 +1,9 @@ +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +$fixture = Join-Path $root ".tools\planetary-tile\fixture.cmpt" +dotnet run --project (Join-Path $root "tools\GameCult.Geometry.PlanetaryTileFixture\GameCult.Geometry.PlanetaryTileFixture.csproj") -- $fixture +if ($LASTEXITCODE -ne 0) { throw "Planetary tile fixture generation failed." } +node --no-warnings --experimental-strip-types (Join-Path $root "packages\gamecult-geometry-ts\test\planetary-tile.cross-runtime.test.mjs") $fixture +if ($LASTEXITCODE -ne 0) { throw "Planetary tile web decoding failed." } +node --no-warnings --experimental-strip-types (Join-Path $root "packages\gamecult-geometry-ts\test\planetary-tile.test.mjs") +if ($LASTEXITCODE -ne 0) { throw "Planetary tile decoder unit test failed." } diff --git a/src/GameCult.Caching.MessagePack.Generator/CultDocumentMessagePackGenerator.cs b/src/GameCult.Caching.MessagePack.Generator/CultDocumentMessagePackGenerator.cs index eeb09475..2948837b 100644 --- a/src/GameCult.Caching.MessagePack.Generator/CultDocumentMessagePackGenerator.cs +++ b/src/GameCult.Caching.MessagePack.Generator/CultDocumentMessagePackGenerator.cs @@ -274,7 +274,7 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator builder.AppendLine($" private static byte[] {document.PayloadSerializerMethodName}(object document)"); builder.AppendLine(" {"); builder.AppendLine($" var typed = ({document.DocumentTypeName})document;"); - builder.AppendLine(" var options = global::GameCult.Caching.MessagePack.CultDocumentMessagePackSerialization.Options;"); + builder.AppendLine($" var options = global::GameCult.Caching.MessagePack.CultDocumentMessagePackSerialization.OptionsFor(typeof({document.DocumentTypeName}));"); builder.AppendLine(" var buffer = new global::System.Buffers.ArrayBufferWriter();"); builder.AppendLine(" var writer = new global::MessagePack.MessagePackWriter(buffer);"); builder.AppendLine($" writer.WriteArrayHeader({document.Members.Length});"); @@ -292,7 +292,7 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator { builder.AppendLine($" private static object {document.PayloadDeserializerMethodName}(byte[] payload)"); builder.AppendLine(" {"); - builder.AppendLine(" var options = global::GameCult.Caching.MessagePack.CultDocumentMessagePackSerialization.Options;"); + builder.AppendLine($" var options = global::GameCult.Caching.MessagePack.CultDocumentMessagePackSerialization.OptionsFor(typeof({document.DocumentTypeName}));"); builder.AppendLine(" var reader = new global::MessagePack.MessagePackReader(payload);"); builder.AppendLine(" var count = reader.ReadArrayHeader();"); builder.AppendLine($" var value = new {document.DocumentTypeName}();"); diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs index 21278fa8..688d23f6 100644 --- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs +++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs @@ -1,5 +1,8 @@ using System; using System.Buffers; +using System.Collections.Concurrent; +using System.Linq; +using System.Reflection; using GameCult.Caching; using MessagePack; using MessagePack.Formatters; @@ -7,6 +10,27 @@ using MessagePack.Resolvers; namespace GameCult.Caching.MessagePack; +/// +/// Declares MessagePack resolvers owned by the assembly that defines a Cult document. +/// +[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] +public sealed class CultDocumentMessagePackResolversAttribute : Attribute +{ + /// Creates a resolver declaration in precedence order. + public CultDocumentMessagePackResolversAttribute(params Type[] resolverTypes) + { + if (resolverTypes == null || resolverTypes.Length == 0) + { + throw new ArgumentException("At least one resolver type is required.", nameof(resolverTypes)); + } + + ResolverTypes = resolverTypes; + } + + /// Gets the declared resolver types in precedence order. + public Type[] ResolverTypes { get; } +} + /// /// MessagePack resolver for CultCache-specific value types. /// @@ -43,6 +67,7 @@ public static class CultDocumentMessagePackSerialization private const int SchemaCatalogEntryFieldCount = 7; private const int SchemaCatalogMemberFieldCount = 8; private const int StoreSnapshotFieldCount = 3; + private static readonly ConcurrentDictionary DocumentAssemblyOptions = new(); /// /// Gets the shared MessagePack serializer options for CultCache payloads. @@ -54,12 +79,29 @@ public static class CultDocumentMessagePackSerialization StandardResolver.Instance)) .WithSecurity(MessagePackSecurity.UntrustedData); + /// + /// Gets serializer options for documents owned by an assembly. CultCache formatters + /// take precedence, followed by assembly-declared resolvers and the standard resolver. + /// + public static MessagePackSerializerOptions OptionsFor(Assembly documentAssembly) + { + if (documentAssembly == null) throw new ArgumentNullException(nameof(documentAssembly)); + return DocumentAssemblyOptions.GetOrAdd(documentAssembly, CreateDocumentAssemblyOptions); + } + + /// Gets serializer options for the assembly that owns a document type. + public static MessagePackSerializerOptions OptionsFor(Type documentType) + { + if (documentType == null) throw new ArgumentNullException(nameof(documentType)); + return OptionsFor(documentType.Assembly); + } + /// /// Serializes a typed value with the CultCache MessagePack options. /// public static byte[] Serialize(T value) { - return MessagePackSerializer.Serialize(value, Options); + return MessagePackSerializer.Serialize(value, OptionsFor(typeof(T))); } /// @@ -67,7 +109,7 @@ public static class CultDocumentMessagePackSerialization /// public static T Deserialize(byte[] payload) { - return MessagePackSerializer.Deserialize(payload, Options); + return MessagePackSerializer.Deserialize(payload, OptionsFor(typeof(T))); } /// @@ -84,7 +126,7 @@ public static class CultDocumentMessagePackSerialization } } - return MessagePackSerializer.Serialize(type, value, Options); + return MessagePackSerializer.Serialize(type, value, OptionsFor(type)); } /// @@ -98,10 +140,43 @@ public static class CultDocumentMessagePackSerialization return descriptor.GeneratedPayloadDeserializer(payload); } - return MessagePackSerializer.Deserialize(type, payload, Options) + return MessagePackSerializer.Deserialize(type, payload, OptionsFor(type)) ?? throw new InvalidOperationException($"MessagePack returned null for Cult document type {type.FullName}."); } + private static MessagePackSerializerOptions CreateDocumentAssemblyOptions(Assembly documentAssembly) + { + var ownerResolvers = documentAssembly + .GetCustomAttributes() + .SelectMany(attribute => attribute.ResolverTypes) + .Select(CreateOwnerResolver) + .ToArray(); + + if (ownerResolvers.Length == 0) + { + return Options; + } + + var resolvers = new IFormatterResolver[ownerResolvers.Length + 2]; + resolvers[0] = CultDocumentResolver.Instance; + Array.Copy(ownerResolvers, 0, resolvers, 1, ownerResolvers.Length); + resolvers[resolvers.Length - 1] = StandardResolver.Instance; + return Options.WithResolver(CompositeResolver.Create(resolvers)); + } + + private static IFormatterResolver CreateOwnerResolver(Type resolverType) + { + if (resolverType == null || !typeof(IFormatterResolver).IsAssignableFrom(resolverType)) + { + throw new InvalidOperationException( + $"Cult document MessagePack resolver '{resolverType?.FullName ?? ""}' must implement {typeof(IFormatterResolver).FullName}."); + } + + return Activator.CreateInstance(resolverType) as IFormatterResolver + ?? throw new InvalidOperationException( + $"Cult document MessagePack resolver '{resolverType.FullName}' must have a public parameterless constructor."); + } + /// /// Serializes one persisted store record. /// diff --git a/src/GameCult.Caching/CultManagedDocument.cs b/src/GameCult.Caching/CultManagedDocument.cs index 100cb628..1919ea88 100644 --- a/src/GameCult.Caching/CultManagedDocument.cs +++ b/src/GameCult.Caching/CultManagedDocument.cs @@ -398,18 +398,30 @@ namespace GameCult.Caching private static bool IsColumnType(Type type) { - return type.IsEnum || - type == typeof(byte) || - type == typeof(sbyte) || - type == typeof(short) || - type == typeof(ushort) || - type == typeof(int) || - type == typeof(uint) || - type == typeof(long) || - type == typeof(ulong) || - type == typeof(float) || - type == typeof(double) || - type == typeof(bool); + return type.IsValueType && + !type.IsByRefLike && + !ContainsManagedReferences(type, new HashSet()); + } + + private static bool ContainsManagedReferences(Type type, HashSet visited) + { + if (type.IsPointer) + { + return false; + } + + if (!type.IsValueType) + { + return true; + } + + if (type.IsPrimitive || type.IsEnum || !visited.Add(type)) + { + return false; + } + + const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + return type.GetFields(flags).Any(field => ContainsManagedReferences(field.FieldType, visited)); } } } diff --git a/src/GameCult.Geometry/CultGeometryDocuments.cs b/src/GameCult.Geometry/CultGeometryDocuments.cs index 306b5849..afb6d305 100644 --- a/src/GameCult.Geometry/CultGeometryDocuments.cs +++ b/src/GameCult.Geometry/CultGeometryDocuments.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Security.Cryptography; using System.Text; using GameCult.Caching; +using CultMath; using MessagePack; using static GameCult.Geometry.CultGeometryDocuments; @@ -14,10 +15,10 @@ namespace GameCult.Geometry /// public static class CultGeometrySchemaVersions { - public const string Domain = "gamecult.geometry.domain.v1"; - public const string BuildRequest = "gamecult.geometry.build_request.v1"; - public const string SelectedCut = "gamecult.geometry.selected_cut.v1"; - public const string ChunkArtifact = "gamecult.geometry.chunk_artifact.v1"; + public const string Domain = "gamecult.geometry.domain.v2"; + public const string BuildRequest = "gamecult.geometry.build_request.v2"; + public const string SelectedCut = "gamecult.geometry.selected_cut.v2"; + public const string ChunkArtifact = "gamecult.geometry.chunk_artifact.v2"; } /// @@ -67,10 +68,10 @@ namespace GameCult.Geometry public string Kind { get; set; } = string.Empty; [Key(2)] - public float[] Translation { get; set; } = Array.Empty(); + public float3 Translation { get; set; } [Key(3)] - public float[] RotationXyzw { get; set; } = Array.Empty(); + public quaternion Rotation { get; set; } = quaternion.identity; [Key(4)] public ulong Seed { get; set; } @@ -88,7 +89,7 @@ namespace GameCult.Geometry Name, Kind, StableVector(Translation), - StableVector(RotationXyzw), + StableVector(Rotation), Seed.ToString(CultureInfo.InvariantCulture) } .Concat(Claims.Select(claim => claim.StableFingerprint())) @@ -109,16 +110,16 @@ namespace GameCult.Geometry public string Name { get; set; } = string.Empty; [Key(1)] - public float[] Translation { get; set; } = Array.Empty(); + public float3 Translation { get; set; } [Key(2)] - public float[] RotationXyzw { get; set; } = Array.Empty(); + public quaternion Rotation { get; set; } = quaternion.identity; [Key(3)] - public float[] SupportCenter { get; set; } = Array.Empty(); + public float3 SupportCenter { get; set; } [Key(4)] - public float[] SupportSize { get; set; } = Array.Empty(); + public float3 SupportSize { get; set; } [Key(5)] public string Kind { get; set; } = string.Empty; @@ -132,7 +133,7 @@ namespace GameCult.Geometry public string StableFingerprint() => StableArray([ Name, StableVector(Translation), - StableVector(RotationXyzw), + StableVector(Rotation), StableVector(SupportCenter), StableVector(SupportSize), Kind, @@ -161,13 +162,13 @@ namespace GameCult.Geometry public string WorkerGroup { get; set; } = string.Empty; [Key(3)] - public float[] CameraPosition { get; set; } = Array.Empty(); + public float3 CameraPosition { get; set; } [Key(4)] - public float[] FrustumMin { get; set; } = Array.Empty(); + public float3 FrustumMin { get; set; } [Key(5)] - public float[] FrustumMax { get; set; } = Array.Empty(); + public float3 FrustumMax { get; set; } [Key(6)] public float ViewportHeightPixels { get; set; } @@ -205,9 +206,9 @@ namespace GameCult.Geometry StableVector(request.CameraPosition), StableVector(request.FrustumMin), StableVector(request.FrustumMax), - request.ViewportHeightPixels.ToString("R", CultureInfo.InvariantCulture), - request.VerticalFovRadians.ToString("R", CultureInfo.InvariantCulture), - request.TargetError.ToString("R", CultureInfo.InvariantCulture), + StableFloat(request.ViewportHeightPixels), + StableFloat(request.VerticalFovRadians), + StableFloat(request.TargetError), request.TriangleBudget.ToString(CultureInfo.InvariantCulture), request.ColliderBudget.ToString(CultureInfo.InvariantCulture), StableArray(request.SemanticFilter), @@ -321,10 +322,10 @@ namespace GameCult.Geometry public string SelectedCutId { get; set; } = string.Empty; [Key(3)] - public float[] BoundsMin { get; set; } = Array.Empty(); + public float3 BoundsMin { get; set; } [Key(4)] - public float[] BoundsMax { get; set; } = Array.Empty(); + public float3 BoundsMax { get; set; } [Key(5)] public string[] SourceDomainKeys { get; set; } = Array.Empty(); @@ -374,13 +375,13 @@ namespace GameCult.Geometry public sealed class CultGeometryTriangleMesh { [Key(0)] - public float[] Positions { get; set; } = Array.Empty(); + public float3[] Positions { get; set; } = Array.Empty(); [Key(1)] - public float[] Normals { get; set; } = Array.Empty(); + public float3[] Normals { get; set; } = Array.Empty(); [Key(2)] - public float[] Uvs { get; set; } = Array.Empty(); + public float2[] Uvs { get; set; } = Array.Empty(); [Key(3)] public uint[] Indices { get; set; } = Array.Empty(); @@ -409,10 +410,20 @@ namespace GameCult.Geometry return string.Concat(bytes.Select(value => value.ToString("x2"))); } - public static string StableVector(float[] values) - { - return string.Join(",", values.Select(StableFloat)); - } + public static string StableVector(float2 value) => + string.Join(",", StableFloat(value.x), StableFloat(value.y)); + + public static string StableVector(float3 value) => + string.Join(",", StableFloat(value.x), StableFloat(value.y), StableFloat(value.z)); + + public static string StableVector(quaternion value) => + string.Join(",", StableFloat(value.x), StableFloat(value.y), StableFloat(value.z), StableFloat(value.w)); + + public static string StableVector(float2[] values) => + string.Join(",", values.SelectMany(value => new[] { value.x, value.y }).Select(StableFloat)); + + public static string StableVector(float3[] values) => + string.Join(",", values.SelectMany(value => new[] { value.x, value.y, value.z }).Select(StableFloat)); public static string StableFloat(float value) { diff --git a/src/GameCult.Geometry/CultGeometryPrimitives.cs b/src/GameCult.Geometry/CultGeometryPrimitives.cs index 99587b8e..4636a3bc 100644 --- a/src/GameCult.Geometry/CultGeometryPrimitives.cs +++ b/src/GameCult.Geometry/CultGeometryPrimitives.cs @@ -1,126 +1,10 @@ using System; using System.Text.Json.Serialization; +using CultMath; using MessagePack; namespace GameCult.Geometry { - /// - /// Deterministic two-dimensional float vector for cross-runtime geometry and query contracts. - /// - [MessagePackObject] - public readonly struct CultVec2 : IEquatable - { - /// Creates a two-dimensional vector. - public CultVec2(float x, float y) - { - X = x; - Y = y; - } - - /// Gets the x component. - [Key(0)] - public float X { get; } - - /// Gets the y component. - [Key(1)] - public float Y { get; } - - /// Gets the zero vector. - [IgnoreMember] - [JsonIgnore] - public static CultVec2 Zero => new(0f, 0f); - - /// Gets the squared vector length. - [IgnoreMember] - [JsonIgnore] - public float LengthSquared => (X * X) + (Y * Y); - - /// Returns whether this vector is equal to another vector. - public bool Equals(CultVec2 other) => X.Equals(other.X) && Y.Equals(other.Y); - - /// - public override bool Equals(object? obj) => obj is CultVec2 other && Equals(other); - - /// - public override int GetHashCode() => HashCode.Combine(X, Y); - - /// - public override string ToString() => $"({X}, {Y})"; - - public static CultVec2 operator +(CultVec2 left, CultVec2 right) => new(left.X + right.X, left.Y + right.Y); - public static CultVec2 operator -(CultVec2 left, CultVec2 right) => new(left.X - right.X, left.Y - right.Y); - public static CultVec2 operator *(CultVec2 value, float scalar) => new(value.X * scalar, value.Y * scalar); - public static CultVec2 operator /(CultVec2 value, float scalar) => new(value.X / scalar, value.Y / scalar); - public static bool operator ==(CultVec2 left, CultVec2 right) => left.Equals(right); - public static bool operator !=(CultVec2 left, CultVec2 right) => !left.Equals(right); - } - - /// - /// Deterministic three-dimensional float vector for cross-runtime geometry and query contracts. - /// - [MessagePackObject] - public readonly struct CultVec3 : IEquatable - { - /// Creates a three-dimensional vector. - public CultVec3(float x, float y, float z) - { - X = x; - Y = y; - Z = z; - } - - /// Gets the x component. - [Key(0)] - public float X { get; } - - /// Gets the y component. - [Key(1)] - public float Y { get; } - - /// Gets the z component. - [Key(2)] - public float Z { get; } - - /// Gets the zero vector. - [IgnoreMember] - [JsonIgnore] - public static CultVec3 Zero => new(0f, 0f, 0f); - - /// Gets the XY plane projection. - [IgnoreMember] - [JsonIgnore] - public CultVec2 Xy => new(X, Y); - - /// Gets the XZ plane projection for Unity compatibility adapters. - [IgnoreMember] - [JsonIgnore] - public CultVec2 Xz => new(X, Z); - - /// Gets the squared vector length. - [IgnoreMember] - [JsonIgnore] - public float LengthSquared => (X * X) + (Y * Y) + (Z * Z); - - /// Returns whether this vector is equal to another vector. - public bool Equals(CultVec3 other) => X.Equals(other.X) && Y.Equals(other.Y) && Z.Equals(other.Z); - - /// - public override bool Equals(object? obj) => obj is CultVec3 other && Equals(other); - - /// - public override int GetHashCode() => HashCode.Combine(X, Y, Z); - - /// - public override string ToString() => $"({X}, {Y}, {Z})"; - - public static CultVec3 operator +(CultVec3 left, CultVec3 right) => new(left.X + right.X, left.Y + right.Y, left.Z + right.Z); - public static CultVec3 operator -(CultVec3 left, CultVec3 right) => new(left.X - right.X, left.Y - right.Y, left.Z - right.Z); - public static CultVec3 operator *(CultVec3 value, float scalar) => new(value.X * scalar, value.Y * scalar, value.Z * scalar); - public static CultVec3 operator /(CultVec3 value, float scalar) => new(value.X / scalar, value.Y / scalar, value.Z / scalar); - public static bool operator ==(CultVec3 left, CultVec3 right) => left.Equals(right); - public static bool operator !=(CultVec3 left, CultVec3 right) => !left.Equals(right); - } - /// /// Axis-aligned two-dimensional rectangle represented by canonical min and max corners. /// @@ -128,52 +12,52 @@ namespace GameCult.Geometry public readonly struct CultRect : IEquatable { /// Creates a rectangle and canonicalizes min/max corners. - public CultRect(CultVec2 min, CultVec2 max) + public CultRect(float2 min, float2 max) { - Min = new CultVec2(Math.Min(min.X, max.X), Math.Min(min.Y, max.Y)); - Max = new CultVec2(Math.Max(min.X, max.X), Math.Max(min.Y, max.Y)); + Min = new float2(Math.Min(min.x, max.x), Math.Min(min.y, max.y)); + Max = new float2(Math.Max(min.x, max.x), Math.Max(min.y, max.y)); } /// Creates a rectangle and canonicalizes min/max corners. public CultRect(float minX, float minY, float maxX, float maxY) - : this(new CultVec2(minX, minY), new CultVec2(maxX, maxY)) + : this(new float2(minX, minY), new float2(maxX, maxY)) { } /// Gets the canonical minimum corner. [Key(0)] - public CultVec2 Min { get; } + public float2 Min { get; } /// Gets the canonical maximum corner. [Key(1)] - public CultVec2 Max { get; } + public float2 Max { get; } /// Gets the rectangle size. [IgnoreMember] [JsonIgnore] - public CultVec2 Size => Max - Min; + public float2 Size => Max - Min; /// Gets the rectangle center. [IgnoreMember] [JsonIgnore] - public CultVec2 Center => (Min + Max) * 0.5f; + public float2 Center => (Min + Max) * 0.5f; /// Returns whether the point lies inside or on the rectangle edge. - public bool Contains(CultVec2 point) + public bool Contains(float2 point) { - return point.X >= Min.X && - point.X <= Max.X && - point.Y >= Min.Y && - point.Y <= Max.Y; + return point.x >= Min.x && + point.x <= Max.x && + point.y >= Min.y && + point.y <= Max.y; } /// Returns whether this rectangle intersects another rectangle. public bool Intersects(CultRect other) { - return Min.X <= other.Max.X && - Max.X >= other.Min.X && - Min.Y <= other.Max.Y && - Max.Y >= other.Min.Y; + return Min.x <= other.Max.x && + Max.x >= other.Min.x && + Min.y <= other.Max.y && + Max.y >= other.Min.y; } /// Returns whether this rectangle is equal to another rectangle. @@ -199,7 +83,7 @@ namespace GameCult.Geometry public readonly struct CultCircle : IEquatable { /// Creates a circle. - public CultCircle(CultVec2 center, float radius) + public CultCircle(float2 center, float radius) { if (radius < 0f) { @@ -212,7 +96,7 @@ namespace GameCult.Geometry /// Gets the circle center. [Key(0)] - public CultVec2 Center { get; } + public float2 Center { get; } /// Gets the circle radius. [Key(1)] @@ -222,24 +106,24 @@ namespace GameCult.Geometry [IgnoreMember] [JsonIgnore] public CultRect Bounds => new( - Center.X - Radius, - Center.Y - Radius, - Center.X + Radius, - Center.Y + Radius); + Center.x - Radius, + Center.y - Radius, + Center.x + Radius, + Center.y + Radius); /// Returns whether the point lies inside or on the circle edge. - public bool Contains(CultVec2 point) + public bool Contains(float2 point) { var delta = point - Center; - return delta.LengthSquared <= Radius * Radius; + return ((delta.x * delta.x) + (delta.y * delta.y)) <= Radius * Radius; } /// Returns whether this circle intersects a rectangle. public bool Intersects(CultRect rect) { - var clampedX = Math.Max(rect.Min.X, Math.Min(Center.X, rect.Max.X)); - var clampedY = Math.Max(rect.Min.Y, Math.Min(Center.Y, rect.Max.Y)); - return Contains(new CultVec2(clampedX, clampedY)); + var clampedX = Math.Max(rect.Min.x, Math.Min(Center.x, rect.Max.x)); + var clampedY = Math.Max(rect.Min.y, Math.Min(Center.y, rect.Max.y)); + return Contains(new float2(clampedX, clampedY)); } /// Returns whether this circle is equal to another circle. @@ -262,7 +146,7 @@ namespace GameCult.Geometry public readonly struct CultSphere : IEquatable { /// Creates a sphere. - public CultSphere(CultVec3 center, float radius) + public CultSphere(float3 center, float radius) { if (radius < 0f) { @@ -275,7 +159,7 @@ namespace GameCult.Geometry /// Gets the sphere center. [Key(0)] - public CultVec3 Center { get; } + public float3 Center { get; } /// Gets the sphere radius. [Key(1)] @@ -284,13 +168,13 @@ namespace GameCult.Geometry /// Gets the XY circle projection. [IgnoreMember] [JsonIgnore] - public CultCircle XyCircle => new(Center.Xy, Radius); + public CultCircle XyCircle => new(Center.xy, Radius); /// Returns whether the point lies inside or on the sphere surface. - public bool Contains(CultVec3 point) + public bool Contains(float3 point) { var delta = point - Center; - return delta.LengthSquared <= Radius * Radius; + return ((delta.x * delta.x) + (delta.y * delta.y) + (delta.z * delta.z)) <= Radius * Radius; } /// Returns whether this sphere is equal to another sphere. diff --git a/src/GameCult.Geometry/CultGeometryWorkerDocuments.cs b/src/GameCult.Geometry/CultGeometryWorkerDocuments.cs new file mode 100644 index 00000000..280f7c4f --- /dev/null +++ b/src/GameCult.Geometry/CultGeometryWorkerDocuments.cs @@ -0,0 +1,87 @@ +using System; +using GameCult.Caching; +using MessagePack; + +namespace GameCult.Geometry +{ + public static class CultGeometryWorkerSchemaVersions + { + public const string State = "gamecult.geometry.worker_state.v1"; + } + + [MessagePackObject] + [CultDocument("gamecult.geometry.worker_state", CultGeometryWorkerSchemaVersions.State)] + public sealed class CultGeometryWorkerState + { + [Key(0), CultName] + public string WorkerId { get; set; } = string.Empty; + + [Key(1)] + public string Phase { get; set; } = string.Empty; + + [Key(2), CultReference(typeof(CultGeometryBuildRequest))] + public string ActiveRequestKey { get; set; } = string.Empty; + + [Key(3), CultReference(typeof(CultGeometrySelectedCutManifest))] + public string LastSelectedCutKey { get; set; } = string.Empty; + + [Key(4)] + public string[] LastArtifactKeys { get; set; } = Array.Empty(); + + [Key(5)] + public string LastError { get; set; } = string.Empty; + + [Key(6)] + public string UpdatedAt { get; set; } = string.Empty; + + [Key(7)] + public string ServedPackageVersion { get; set; } = string.Empty; + + public static CultRecordKey CreateRecordKey(string workerId) + { + if (string.IsNullOrWhiteSpace(workerId)) throw new ArgumentException("Value must be non-empty.", nameof(workerId)); + return new CultRecordKey("geometry:worker:" + workerId); + } + } + + [MessagePackObject] + public sealed class CultGeometryBuildCommand + { + [Key(0)] + public string RequestKey { get; set; } = string.Empty; + } + + [MessagePackObject] + public sealed class CultGeometryBuildReceipt + { + [Key(0)] + public string RequestKey { get; set; } = string.Empty; + + [Key(1)] + public string SelectedCutKey { get; set; } = string.Empty; + + [Key(2)] + public string[] ArtifactKeys { get; set; } = Array.Empty(); + + [Key(3)] + public string[] ContentHashes { get; set; } = Array.Empty(); + } + + public sealed class CultGeometryBuildOutput + { + public CultGeometrySelectedCutManifest SelectedCut { get; init; } = new(); + public CultGeometryChunkArtifact[] Artifacts { get; init; } = Array.Empty(); + } + + public sealed class CultGeometryDevelopmentProbe + { + public string Owner { get; init; } = string.Empty; + public string SchemaVersion { get; init; } = string.Empty; + public string SourceRecordKey { get; init; } = string.Empty; + public string SelectedCutKey { get; init; } = string.Empty; + public string[] SelectedNodes { get; init; } = Array.Empty(); + public string[] ArtifactKeys { get; init; } = Array.Empty(); + public string[] ContentHashes { get; init; } = Array.Empty(); + public string ServedPackageVersion { get; init; } = string.Empty; + } +} diff --git a/src/GameCult.Geometry/CultGeometryWorkerProvider.cs b/src/GameCult.Geometry/CultGeometryWorkerProvider.cs new file mode 100644 index 00000000..745da5a1 --- /dev/null +++ b/src/GameCult.Geometry/CultGeometryWorkerProvider.cs @@ -0,0 +1,124 @@ +using System; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using GameCult.Caching; +using GameCult.Mesh; +using GameCult.Networking; + +namespace GameCult.Geometry +{ + public interface ICultGeometryBuildPipeline + { + Task BuildAsync( + CultGeometryDomainDocument domain, + CultGeometryBuildRequest request); + } + + /// Owns geometry worker commands and the authoritative output commit path. + public sealed class CultGeometryWorkerProvider + { + public const string Owner = "GameCult.Geometry"; + public const string BuildOperationId = "gamecult.geometry.worker.build"; + + private readonly string _workerId; + private readonly CultNetDatabase _database; + private readonly ICultGeometryBuildPipeline _pipeline; + + public CultGeometryWorkerProvider( + string workerId, + CultNetDatabase database, + ICultGeometryBuildPipeline pipeline) + { + _workerId = string.IsNullOrWhiteSpace(workerId) + ? throw new ArgumentException("Value must be non-empty.", nameof(workerId)) + : workerId; + _database = database ?? throw new ArgumentNullException(nameof(database)); + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); + BuildOperation = new CultMeshOperationHandle( + BuildOperationId, + (command, _) => BuildAsync(command)); + } + + public CultMeshOperationHandle BuildOperation { get; } + + public CultRecordKey WorkerStateKey => CultGeometryWorkerState.CreateRecordKey(_workerId); + + public async Task BuildAsync(CultGeometryBuildCommand command) + { + if (command == null) throw new ArgumentNullException(nameof(command)); + if (string.IsNullOrWhiteSpace(command.RequestKey)) + throw new ArgumentException("Request key must be non-empty.", nameof(command)); + + var requestKey = new CultRecordKey(command.RequestKey); + var request = await RequireAsync(requestKey).ConfigureAwait(false); + var domain = await RequireAsync(new CultRecordKey(request.DomainKey)).ConfigureAwait(false); + var output = await _pipeline.BuildAsync(domain, request).ConfigureAwait(false) + ?? throw new InvalidOperationException("Geometry build pipeline returned no output."); + + output.SelectedCut.RequestKey = requestKey.Value; + var cutKey = CultGeometrySelectedCutManifest.CreateRecordKey(output.SelectedCut); + await _database.PutAsync(cutKey, output.SelectedCut).ConfigureAwait(false); + + var artifactKeys = new string[output.Artifacts.Length]; + for (var index = 0; index < output.Artifacts.Length; index++) + { + var artifact = output.Artifacts[index]; + artifact.CutKey = cutKey.Value; + artifact.SelectedCutId = output.SelectedCut.CutId; + var artifactKey = CultGeometryChunkArtifact.CreateRecordKey(artifact); + await _database.PutAsync(artifactKey, artifact).ConfigureAwait(false); + artifactKeys[index] = artifactKey.Value; + } + + var receipt = new CultGeometryBuildReceipt + { + RequestKey = requestKey.Value, + SelectedCutKey = cutKey.Value, + ArtifactKeys = artifactKeys, + ContentHashes = artifactKeys.Select(ContentHashFromKey).ToArray() + }; + await _database.PutAsync(WorkerStateKey, new CultGeometryWorkerState + { + WorkerId = _workerId, + Phase = "completed", + ActiveRequestKey = requestKey.Value, + LastSelectedCutKey = cutKey.Value, + LastArtifactKeys = artifactKeys, + UpdatedAt = DateTimeOffset.UtcNow.ToString("O"), + ServedPackageVersion = PackageVersion + }).ConfigureAwait(false); + return receipt; + } + + public async Task ProbeAsync() + { + var state = await RequireAsync(WorkerStateKey).ConfigureAwait(false); + var cut = await RequireAsync(new CultRecordKey(state.LastSelectedCutKey)).ConfigureAwait(false); + return new CultGeometryDevelopmentProbe + { + Owner = Owner, + SchemaVersion = CultGeometrySchemaVersions.SelectedCut, + SourceRecordKey = state.ActiveRequestKey, + SelectedCutKey = state.LastSelectedCutKey, + SelectedNodes = cut.SelectedNodes, + ArtifactKeys = state.LastArtifactKeys, + ContentHashes = state.LastArtifactKeys.Select(ContentHashFromKey).ToArray(), + ServedPackageVersion = PackageVersion + }; + } + + private async Task RequireAsync(CultRecordKey key) where T : class => + await _database.GetAsync(key).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Required geometry document '{key.Value}' was not found."); + + private static string ContentHashFromKey(string key) => + key[(key.LastIndexOf(':') + 1)..]; + + private static string PackageVersion => + typeof(CultGeometryWorkerProvider).Assembly + .GetCustomAttribute()?.InformationalVersion + ?? typeof(CultGeometryWorkerProvider).Assembly.GetName().Version?.ToString() + ?? "unknown"; + } +} diff --git a/src/GameCult.Geometry/CultMathMessagePackResolver.cs b/src/GameCult.Geometry/CultMathMessagePackResolver.cs new file mode 100644 index 00000000..6e0075cf --- /dev/null +++ b/src/GameCult.Geometry/CultMathMessagePackResolver.cs @@ -0,0 +1,106 @@ +using CultMath; +using GameCult.Caching.MessagePack; +using MessagePack; +using MessagePack.Formatters; + +[assembly: CultDocumentMessagePackResolvers(typeof(GameCult.Geometry.CultMathMessagePackResolver))] + +namespace GameCult.Geometry; + +/// +/// Owns the persisted positional representation of CultMath values used by +/// GameCult.Geometry documents. CultMath remains serialization-agnostic. +/// +public sealed class CultMathMessagePackResolver : IFormatterResolver +{ + public IMessagePackFormatter? GetFormatter() + { + if (typeof(T) == typeof(float2)) return (IMessagePackFormatter)(object)Float2Formatter.Instance; + if (typeof(T) == typeof(float3)) return (IMessagePackFormatter)(object)Float3Formatter.Instance; + if (typeof(T) == typeof(float4)) return (IMessagePackFormatter)(object)Float4Formatter.Instance; + if (typeof(T) == typeof(quaternion)) return (IMessagePackFormatter)(object)QuaternionFormatter.Instance; + return null; + } + + private static void RequireComponents(ref MessagePackReader reader, int expected, string typeName) + { + var actual = reader.ReadArrayHeader(); + if (actual != expected) + { + throw new MessagePackSerializationException($"{typeName} requires exactly {expected} components; payload contained {actual}."); + } + } + + internal sealed class Float2Formatter : IMessagePackFormatter + { + public static readonly Float2Formatter Instance = new(); + public void Serialize(ref MessagePackWriter writer, float2 value, MessagePackSerializerOptions options) + { + writer.WriteArrayHeader(2); + writer.Write(value.x); + writer.Write(value.y); + } + + public float2 Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + RequireComponents(ref reader, 2, nameof(float2)); + return new float2(reader.ReadSingle(), reader.ReadSingle()); + } + } + + internal sealed class Float3Formatter : IMessagePackFormatter + { + public static readonly Float3Formatter Instance = new(); + public void Serialize(ref MessagePackWriter writer, float3 value, MessagePackSerializerOptions options) + { + writer.WriteArrayHeader(3); + writer.Write(value.x); + writer.Write(value.y); + writer.Write(value.z); + } + + public float3 Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + RequireComponents(ref reader, 3, nameof(float3)); + return new float3(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()); + } + } + + internal sealed class Float4Formatter : IMessagePackFormatter + { + public static readonly Float4Formatter Instance = new(); + public void Serialize(ref MessagePackWriter writer, float4 value, MessagePackSerializerOptions options) + { + writer.WriteArrayHeader(4); + writer.Write(value.x); + writer.Write(value.y); + writer.Write(value.z); + writer.Write(value.w); + } + + public float4 Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + RequireComponents(ref reader, 4, nameof(float4)); + return new float4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()); + } + } + + internal sealed class QuaternionFormatter : IMessagePackFormatter + { + public static readonly QuaternionFormatter Instance = new(); + public void Serialize(ref MessagePackWriter writer, quaternion value, MessagePackSerializerOptions options) + { + writer.WriteArrayHeader(4); + writer.Write(value.x); + writer.Write(value.y); + writer.Write(value.z); + writer.Write(value.w); + } + + public quaternion Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + RequireComponents(ref reader, 4, nameof(quaternion)); + return new quaternion(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()); + } + } +} diff --git a/src/GameCult.Geometry/GameCult.Geometry.csproj b/src/GameCult.Geometry/GameCult.Geometry.csproj index a0192f69..705fa2f4 100644 --- a/src/GameCult.Geometry/GameCult.Geometry.csproj +++ b/src/GameCult.Geometry/GameCult.Geometry.csproj @@ -1,19 +1,33 @@ - Typed CultCache geometry domain and chunk artifact documents for VibeGeometry and Fensalir + Spatial values, planetary geometry, queries, and typed geometry state built on CultMath primitives GameCult.Geometry - game;geometry;csg;lod;cultcache;cultmesh;vibegeometry;fensalir + 0.1.0 + false + game;geometry;planetary;csg;lod;cultcache;cultmesh README.md + + LICENSES.md $(NoWarn);1591 + 0.1.0 + 1.0.3 - + + + + + + + + + diff --git a/src/GameCult.Geometry/IsExternalInit.cs b/src/GameCult.Geometry/IsExternalInit.cs new file mode 100644 index 00000000..b33e9e1d --- /dev/null +++ b/src/GameCult.Geometry/IsExternalInit.cs @@ -0,0 +1,7 @@ +#if NETSTANDARD2_1 +namespace System.Runtime.CompilerServices; + +internal static class IsExternalInit +{ +} +#endif diff --git a/src/GameCult.Geometry/LICENSES.md b/src/GameCult.Geometry/LICENSES.md new file mode 100644 index 00000000..67455b87 --- /dev/null +++ b/src/GameCult.Geometry/LICENSES.md @@ -0,0 +1,13 @@ +# GameCult.Geometry licenses + +GameCult.Geometry is a mixed-license package. License follows file ownership; +the package as a whole is not relicensed under one blanket expression. + +- CultLib-authored integration, persistence, schema, and packaging files are + licensed under the MIT License published by the CultLib repository. +- Files under `Planetary/` and `Shaders/` transferred from CultMath remain + licensed under the Mozilla Public License 2.0. Their source notices and + provenance are preserved in `THIRD-PARTY-NOTICES.md`. + +Downstream redistributors must preserve the applicable notices for the files +they redistribute. diff --git a/src/GameCult.Geometry/Planetary/AdvancedErosionFilter.cs b/src/GameCult.Geometry/Planetary/AdvancedErosionFilter.cs new file mode 100644 index 00000000..487cdc87 --- /dev/null +++ b/src/GameCult.Geometry/Planetary/AdvancedErosionFilter.cs @@ -0,0 +1,141 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. +// +// Mathematical procedure adapted from Rune Skovbo Johansen's Advanced Terrain +// Erosion Filter (2025): https://www.shadertoy.com/view/wXcfWn + +using CultMath; + +namespace GameCult.Geometry; + +public readonly record struct AdvancedErosionParameters( + float Scale, + float Strength, + float GullyWeight, + float Detail, + float4 Rounding, + float4 Onset, + float2 AssumedSlope, + float CellScale, + float Normalization, + int Octaves, + float Lacunarity, + float Gain) +{ + public static AdvancedErosionParameters Default => new( + 0.15f, 0.22f, 0.5f, 1.5f, + new float4(0.1f, 0.0f, 0.1f, 2.0f), + new float4(1.25f, 1.25f, 2.8f, 1.5f), + new float2(0.7f, 1.0f), + 0.7f, 0.5f, 5, 2.0f, 0.5f); +} + +public readonly record struct AdvancedErosionResult(float3 Delta, float Magnitude, float RidgeMap, float FadeTarget); + +public static class AdvancedErosionFilter +{ + public static AdvancedErosionResult Sample(float2 position, float3 baseHeightAndSlope, float fadeTarget, AdvancedErosionParameters p) + => Sample(position, baseHeightAndSlope, fadeTarget, p, new ErosionBandSelection(p.Octaves, 1.0f, 0.0f, 0.0f)); + + public static AdvancedErosionResult Sample(float2 position, float3 baseHeightAndSlope, float fadeTarget, AdvancedErosionParameters p, ErosionBandSelection band) + { + var strength = p.Strength * p.Scale; + fadeTarget = math.clamp(fadeTarget, -1.0f, 1.0f); + var input = baseHeightAndSlope; + var heightAndSlope = input; + var frequency = 1.0f / math.max(p.Scale * p.CellScale, 1.0e-10f); + var slope = new float2(heightAndSlope.y, heightAndSlope.z); + var slopeLength = math.max(math.length(slope), 1.0e-10f); + var magnitude = 0.0f; + var roundingMultiplier = 1.0f; + + var inputRounding = math.lerp(p.Rounding.y, p.Rounding.x, math.saturate(fadeTarget + 0.5f)) * p.Rounding.z; + var combinedMask = EaseOut(SmoothStart(slopeLength * p.Onset.x, inputRounding * p.Onset.x)); + var ridgeMask = EaseOut(slopeLength * p.Onset.z); + var ridgeFade = fadeTarget; + var gullySlope = math.lerp(slope, slope / slopeLength * p.AssumedSlope.x, p.AssumedSlope.y); + + var octaves = math.min(math.min(math.max(p.Octaves, 0), 8), math.max(band.ActiveOctaves, 0)); + for (var octave = 0; octave < octaves; octave++) + { + var phacelle = Phacelle(position * frequency, SafeNormalize(gullySlope), p.CellScale, 0.25f, p.Normalization); + var derivativeDirection = new float2(phacelle.z, phacelle.w) * -frequency; + var sloping = math.abs(phacelle.y); + gullySlope += math.sign(phacelle.y) * derivativeDirection * strength * p.GullyWeight; + + var gullies = new float3(phacelle.x, phacelle.y * derivativeDirection.x, phacelle.y * derivativeDirection.y); + var faded = math.lerp(new float3(fadeTarget, 0.0f, 0.0f), gullies * p.GullyWeight, combinedMask); + var octaveWeight = octave == octaves - 1 ? math.saturate(band.FinalOctaveWeight) : 1.0f; + heightAndSlope += faded * strength * octaveWeight; + magnitude += strength * octaveWeight; + fadeTarget = faded.x; + + var octaveRounding = math.lerp(p.Rounding.y, p.Rounding.x, math.saturate(phacelle.x + 0.5f)) * roundingMultiplier; + var newMask = EaseOut(SmoothStart(sloping * p.Onset.y, octaveRounding * p.Onset.y)); + combinedMask = PowInverse(combinedMask, p.Detail) * newMask; + + ridgeFade = math.lerp(ridgeFade, math.lerp(ridgeFade, gullies.x, ridgeMask), octaveWeight); + ridgeMask *= math.lerp(1.0f, EaseOut(sloping * p.Onset.w), octaveWeight); + strength *= p.Gain; + frequency *= p.Lacunarity; + roundingMultiplier *= p.Rounding.w; + } + + return new AdvancedErosionResult(heightAndSlope - input, magnitude, ridgeFade * (1.0f - ridgeMask), fadeTarget); + } + + public static float4 Phacelle(float2 position, float2 normalDirection, float frequency, float offsetCycles, float normalization) + { + var sideDirection = new float2(-normalDirection.y, normalDirection.x) * frequency * math.TAU; + var offset = offsetCycles * math.TAU; + var cell = math.floor(position); + var local = math.frac(position); + var phase = float2.zero; + var weightSum = 0.0f; + + for (var x = -1; x <= 2; x++) + for (var y = -1; y <= 2; y++) + { + var gridOffset = new float2(x, y); + var gridPoint = cell + gridOffset; + var randomOffset = Hash2(gridPoint) * 0.5f; + var delta = local - gridOffset - randomOffset; + var weight = math.max(0.0f, math.exp(-math.dot(delta, delta) * 2.0f) - 0.01111f); + weightSum += weight; + var wave = math.dot(delta, sideDirection) + offset; + phase += new float2(math.cos(wave), math.sin(wave)) * weight; + } + + var interpolated = phase / math.max(weightSum, 1.0e-20f); + var magnitude = math.max(1.0f - math.saturate(normalization), math.length(interpolated)); + var normalized = interpolated / math.max(magnitude, 1.0e-20f); + return new float4(normalized, sideDirection.x, sideDirection.y); + } + + private static float2 Hash2(float2 input) + { + var x = (int)input.x; + var y = (int)input.y; + return new float2(HashUnit(x, y, 0x68bc21ebu), HashUnit(x, y, 0x02e5be93u)) * 2.0f - float2.one; + } + + private static float HashUnit(int x, int y, uint salt) + { + var value = unchecked((uint)x * 0x8da6b343u ^ (uint)y * 0xd8163841u ^ salt); + value ^= value >> 16; value *= 0x7feb352du; value ^= value >> 15; value *= 0x846ca68bu; value ^= value >> 16; + return (value & 0x00ffffffu) * (1.0f / 16777216.0f); + } + + private static float2 SafeNormalize(float2 value) + { + var length = math.length(value); + return math.abs(length) > 1.0e-10f ? value / length : value; + } + + private static float PowInverse(float value, float power) => 1.0f - math.pow(1.0f - math.saturate(value), power); + private static float EaseOut(float value) { var inverse = 1.0f - math.saturate(value); return 1.0f - inverse * inverse; } + private static float SmoothStart(float value, float smoothing) => smoothing <= 1.0e-10f || value >= smoothing + ? value - 0.5f * smoothing + : 0.5f * value * value / smoothing; +} diff --git a/src/GameCult.Geometry/Planetary/ErosionFrequencyBands.cs b/src/GameCult.Geometry/Planetary/ErosionFrequencyBands.cs new file mode 100644 index 00000000..8cadbf11 --- /dev/null +++ b/src/GameCult.Geometry/Planetary/ErosionFrequencyBands.cs @@ -0,0 +1,63 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +using CultMath; + +namespace GameCult.Geometry; + +public readonly record struct ErosionBandSelection( + int ActiveOctaves, + float FinalOctaveWeight, + float FinestIncludedWavelength, + float UnresolvedHeightBound) +{ + public bool HasSamples => ActiveOctaves > 0; +} + +public static class ErosionFrequencyBands +{ + public static ErosionBandSelection Select( + float baseWavelength, + float sampleSpacing, + int maximumOctaves, + float lacunarity, + float baseAmplitude, + float gain, + float transitionRatio = 2.0f) + { + baseWavelength = math.max(baseWavelength, 1.0e-6f); + sampleSpacing = math.max(sampleSpacing, 0.0f); + lacunarity = math.max(lacunarity, 1.01f); + transitionRatio = math.max(transitionRatio, 1.01f); + maximumOctaves = math.min(math.max(maximumOctaves, 0), 16); + var nyquistWavelength = sampleSpacing * 2.0f; + var wavelength = baseWavelength; + var active = 0; + var finalWeight = 0.0f; + var finest = 0.0f; + + for (var octave = 0; octave < maximumOctaves; octave++) + { + var weight = nyquistWavelength <= 0.0f + ? 1.0f + : math.smoothstep(nyquistWavelength, nyquistWavelength * transitionRatio, wavelength); + if (weight <= 0.0f) break; + active++; + finalWeight = weight; + finest = wavelength; + wavelength /= lacunarity; + } + + var omittedAmplitude = 0.0f; + var amplitude = math.abs(baseAmplitude); + for (var octave = 0; octave < maximumOctaves; octave++) + { + if (octave >= active) omittedAmplitude += amplitude; + else if (octave == active - 1) omittedAmplitude += amplitude * (1.0f - finalWeight); + amplitude *= math.abs(gain); + } + + return new ErosionBandSelection(active, active > 0 ? finalWeight : 0.0f, finest, omittedAmplitude); + } +} diff --git a/src/GameCult.Geometry/Planetary/PlanetaryField.cs b/src/GameCult.Geometry/Planetary/PlanetaryField.cs new file mode 100644 index 00000000..21f4f805 --- /dev/null +++ b/src/GameCult.Geometry/Planetary/PlanetaryField.cs @@ -0,0 +1,220 @@ +using System; +using CultMath; + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +namespace GameCult.Geometry; + +public readonly record struct PlanetaryQueryScale(float FootprintMeters, float MaximumUnresolvedHeight) +{ + public static PlanetaryQueryScale AtFootprint(float footprintMeters, float maximumUnresolvedHeight = float.PositiveInfinity) + => new PlanetaryQueryScale(footprintMeters, maximumUnresolvedHeight).Validate(); + + public PlanetaryQueryScale Validate() + { + if (!float.IsFinite(FootprintMeters) || FootprintMeters < 0) throw new ArgumentOutOfRangeException(nameof(FootprintMeters)); + if (float.IsNaN(MaximumUnresolvedHeight) || MaximumUnresolvedHeight < 0) throw new ArgumentOutOfRangeException(nameof(MaximumUnresolvedHeight)); + return this; + } +} + +public readonly record struct PlanetaryFieldDefinition( + ulong FieldVersion, + float Radius, + int Seed, + AdvancedErosionParameters Erosion) +{ + public static PlanetaryFieldDefinition Create( + ulong baseFieldVersion, + float radius, + int seed, + AdvancedErosionParameters erosion) + => new PlanetaryFieldDefinition(PlanetaryFieldIdentity.Compute(baseFieldVersion, radius, seed, erosion), radius, seed, erosion).Validate(); + + public PlanetaryFieldDefinition Validate() + { + if (FieldVersion == 0) throw new ArgumentOutOfRangeException(nameof(FieldVersion)); + if (!float.IsFinite(Radius) || Radius <= 0) throw new ArgumentOutOfRangeException(nameof(Radius)); + if (!float.IsFinite(Erosion.Scale) || Erosion.Scale <= 0) throw new ArgumentOutOfRangeException(nameof(Erosion)); + return this; + } +} + +public static class PlanetaryFieldIdentity +{ + public static ulong Compute(ulong baseFieldVersion, float radius, int seed, in AdvancedErosionParameters erosion) + { + if (baseFieldVersion == 0) throw new ArgumentOutOfRangeException(nameof(baseFieldVersion)); + const ulong offset = 14695981039346656037UL; + const ulong prime = 1099511628211UL; + var hash = offset; + Add(baseFieldVersion); + Add((uint)BitConverter.SingleToInt32Bits(radius)); Add((uint)seed); + Add((uint)BitConverter.SingleToInt32Bits(erosion.Scale)); Add((uint)BitConverter.SingleToInt32Bits(erosion.Strength)); + Add((uint)BitConverter.SingleToInt32Bits(erosion.GullyWeight)); Add((uint)BitConverter.SingleToInt32Bits(erosion.Detail)); + AddFloat4(erosion.Rounding); AddFloat4(erosion.Onset); + Add((uint)BitConverter.SingleToInt32Bits(erosion.AssumedSlope.x)); Add((uint)BitConverter.SingleToInt32Bits(erosion.AssumedSlope.y)); + Add((uint)BitConverter.SingleToInt32Bits(erosion.CellScale)); Add((uint)BitConverter.SingleToInt32Bits(erosion.Normalization)); + Add((uint)erosion.Octaves); Add((uint)BitConverter.SingleToInt32Bits(erosion.Lacunarity)); Add((uint)BitConverter.SingleToInt32Bits(erosion.Gain)); + return hash == 0 ? 1 : hash; + + void Add(ulong value) => hash = unchecked((hash ^ value) * prime); + void AddFloat4(float4 value) + { + Add((uint)BitConverter.SingleToInt32Bits(value.x)); Add((uint)BitConverter.SingleToInt32Bits(value.y)); + Add((uint)BitConverter.SingleToInt32Bits(value.z)); Add((uint)BitConverter.SingleToInt32Bits(value.w)); + } + } +} + +/// +/// Application-supplied base evidence at one unit spherical direction. +/// FieldValue and FieldGradient drive erosion. RadialDisplacement and +/// RadialGradient describe the uneroded surface in metres. +/// +public readonly record struct PlanetaryBaseFieldSample( + float RadialDisplacement, + float3 RadialGradient, + float FieldValue, + float3 FieldGradient, + float FadeTarget); + +public readonly record struct PlanetarySurfaceSample( + ulong FieldVersion, + float3 UnitDirection, + float Radius, + float RadialDisplacement, + float3 TangentGradient, + float3 SurfaceNormal, + float Slope, + float Ridge, + float Gully, + float FinestResolvedWavelength, + float UnresolvedHeightBound) +{ + public float SurfaceRadius => Radius + RadialDisplacement; + public float3 PlanetLocalPosition => UnitDirection * SurfaceRadius; + public bool Meets(PlanetaryQueryScale scale) => UnresolvedHeightBound <= scale.MaximumUnresolvedHeight; +} + +public static class PlanetaryField +{ + public static PlanetarySurfaceSample Sample( + in PlanetaryFieldDefinition definition, + float3 unitDirection, + in PlanetaryBaseFieldSample baseSample, + in PlanetaryQueryScale scale) + { + definition.Validate(); + scale.Validate(); + PlanetaryTopology.ValidateDirection(unitDirection); + PlanetaryTopology.ValidateFinite(baseSample.RadialGradient, nameof(baseSample)); + PlanetaryTopology.ValidateFinite(baseSample.FieldGradient, nameof(baseSample)); + if (!float.IsFinite(baseSample.RadialDisplacement) || !float.IsFinite(baseSample.FieldValue) || !float.IsFinite(baseSample.FadeTarget)) + throw new ArgumentOutOfRangeException(nameof(baseSample)); + + var direction = math.normalize(unitDirection); + var radialGradient = Tangent(direction, baseSample.RadialGradient); + var fieldGradient = Tangent(direction, baseSample.FieldGradient); + var p = definition.Erosion; + var band = ErosionFrequencyBands.Select( + p.Scale * p.CellScale, + scale.FootprintMeters, + p.Octaves, + p.Lacunarity, + p.Strength * p.Scale, + p.Gain); + + var powers = new float3( + Pow4(MathF.Abs(direction.x)), + Pow4(MathF.Abs(direction.y)), + Pow4(MathF.Abs(direction.z))); + var powerSum = math.max(powers.x + powers.y + powers.z, 1.0e-6f); + var weights = powers / powerSum; + var world = direction * definition.Radius; + var seed = definition.Seed; + var xy = AdvancedErosionFilter.Sample( + world.xy + new float2(713 + seed * 0.754877666f, -291 + seed * 0.569840296f), + new float3(baseSample.FieldValue, fieldGradient.x, fieldGradient.y), baseSample.FadeTarget, p, band); + var yz = AdvancedErosionFilter.Sample( + world.yz + new float2(-431 + seed * 0.438289027f, 887 + seed * 0.328438163f), + new float3(baseSample.FieldValue, fieldGradient.y, fieldGradient.z), baseSample.FadeTarget, p, band); + var zx = AdvancedErosionFilter.Sample( + new float2(world.z, world.x) + new float2(197 + seed * 0.219783071f, 557 + seed * 0.145898034f), + new float3(baseSample.FieldValue, fieldGradient.z, fieldGradient.x), baseSample.FadeTarget, p, band); + + var erosionHeight = xy.Delta.x * weights.z + yz.Delta.x * weights.x + zx.Delta.x * weights.y; + var erosionGradient = new float3( + xy.Delta.y * weights.z + zx.Delta.z * weights.y, + xy.Delta.z * weights.z + yz.Delta.y * weights.x, + yz.Delta.z * weights.x + zx.Delta.y * weights.y); + var powerGradientX = Tangent(direction, new float3(4 * direction.x * direction.x * direction.x / definition.Radius, 0, 0)); + var powerGradientY = Tangent(direction, new float3(0, 4 * direction.y * direction.y * direction.y / definition.Radius, 0)); + var powerGradientZ = Tangent(direction, new float3(0, 0, 4 * direction.z * direction.z * direction.z / definition.Radius)); + var sumGradient = powerGradientX + powerGradientY + powerGradientZ; + var weightGradientX = (powerGradientX * powerSum - sumGradient * powers.x) / (powerSum * powerSum); + var weightGradientY = (powerGradientY * powerSum - sumGradient * powers.y) / (powerSum * powerSum); + var weightGradientZ = (powerGradientZ * powerSum - sumGradient * powers.z) / (powerSum * powerSum); + erosionGradient += yz.Delta.x * weightGradientX + zx.Delta.x * weightGradientY + xy.Delta.x * weightGradientZ; + var gradient = Tangent(direction, radialGradient + erosionGradient); + var ridgeEvidence = xy.RidgeMap * weights.z + yz.RidgeMap * weights.x + zx.RidgeMap * weights.y; + var ridge = math.saturate(ridgeEvidence * 0.5f + 0.5f); + var gully = math.saturate(0.5f - ridgeEvidence * 0.5f); + var displacement = baseSample.RadialDisplacement + erosionHeight; + + return new( + definition.FieldVersion, + direction, + definition.Radius, + displacement, + gradient, + PlanetaryTopology.SurfaceNormal(direction, gradient), + math.length(gradient), + ridge, + gully, + band.FinestIncludedWavelength, + band.UnresolvedHeightBound); + } + + public static void SampleBatch( + in PlanetaryFieldDefinition definition, + ReadOnlySpan directions, + in PlanetaryQueryScale scale, + TSource source, + Span destination) + where TSource : IPlanetaryBaseField + { + if (destination.Length < directions.Length) throw new ArgumentException("Destination is shorter than directions.", nameof(destination)); + for (var i = 0; i < directions.Length; i++) + { + var direction = math.normalize(directions[i]); + var baseSample = source.Sample(direction); + destination[i] = Sample(definition, direction, baseSample, scale); + } + } + + public static PlanetarySurfaceSample SamplePosition( + in PlanetaryFieldDefinition definition, + double3 planetLocalPosition, + in PlanetaryQueryScale scale, + TSource source) + where TSource : IPlanetaryBaseField + { + if (!double.IsFinite(planetLocalPosition.x) || !double.IsFinite(planetLocalPosition.y) || !double.IsFinite(planetLocalPosition.z)) + throw new ArgumentOutOfRangeException(nameof(planetLocalPosition)); + var length = Math.Sqrt(planetLocalPosition.x * planetLocalPosition.x + planetLocalPosition.y * planetLocalPosition.y + planetLocalPosition.z * planetLocalPosition.z); + if (length < 1.0e-12) throw new ArgumentOutOfRangeException(nameof(planetLocalPosition)); + var direction = new float3((float)(planetLocalPosition.x / length), (float)(planetLocalPosition.y / length), (float)(planetLocalPosition.z / length)); + return Sample(definition, direction, source.Sample(direction), scale); + } + + private static float3 Tangent(float3 direction, float3 gradient) => gradient - direction * math.dot(gradient, direction); + private static float Pow4(float value) { var square = value * value; return square * square; } +} + +public interface IPlanetaryBaseField +{ + PlanetaryBaseFieldSample Sample(float3 unitDirection); +} diff --git a/src/GameCult.Geometry/Planetary/PlanetaryGpuPages.cs b/src/GameCult.Geometry/Planetary/PlanetaryGpuPages.cs new file mode 100644 index 00000000..a6d7f4df --- /dev/null +++ b/src/GameCult.Geometry/Planetary/PlanetaryGpuPages.cs @@ -0,0 +1,60 @@ +using System; +using CultMath; + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +namespace GameCult.Geometry; + +public readonly record struct PlanetaryGpuPageInput(float4 DirectionRadius, float4 Sampling); + +public readonly record struct PlanetaryGpuPageMetadata(float4 Address, float4 Layout, float4 Bounds, float4 State); + +public readonly record struct PlanetaryGpuPageContent( + PlanetaryPageLayout Layout, + PlanetaryGpuPageInput[] Inputs, + float SampleSpacing, + float ParentSampleSpacing); + +public static class PlanetaryGpuPageBuilder +{ + public static PlanetaryGpuPageContent BuildContent(PlanetaryPageLayout layout, float radius) + { + layout.Validate(); + if (!float.IsFinite(radius) || radius <= 0) throw new ArgumentOutOfRangeException(nameof(radius)); + var spacing = PlanetaryPageSampling.NominalAngularTexelSize(layout) * radius; + var parentSpacing = layout.Tile.Level == 0 + ? 0 + : PlanetaryPageSampling.NominalAngularTexelSize(new(layout.Tile.Parent(), layout.InteriorSize, layout.BorderSize)) * radius; + var inputs = new PlanetaryGpuPageInput[layout.StorageSize * layout.StorageSize]; + for (var y = 0; y < layout.StorageSize; y++) + for (var x = 0; x < layout.StorageSize; x++) + { + var direction = PlanetaryPageSampling.Direction(layout, x, y); + inputs[y * layout.StorageSize + x] = new( + new float4(direction, radius), + new float4(spacing, parentSpacing, 0, 0)); + } + return new(layout, inputs, spacing, parentSpacing); + } + + public static PlanetaryGpuPageMetadata Metadata( + in PlanetaryGpuPageContent content, + int outputOffset, + float blend, + float4 bounds = default) + { + content.Layout.Validate(); + if (content.Inputs == null || content.Inputs.Length != content.Layout.StorageSize * content.Layout.StorageSize) + throw new ArgumentException("GPU page content does not match its layout.", nameof(content)); + if (outputOffset < 0) throw new ArgumentOutOfRangeException(nameof(outputOffset)); + if (!float.IsFinite(blend) || blend is < 0 or > 1) throw new ArgumentOutOfRangeException(nameof(blend)); + var tile = content.Layout.Tile; + return new( + new float4((int)tile.Face, tile.Level, tile.X, tile.Y), + new float4(outputOffset, content.Layout.StorageSize, content.Layout.InteriorSize, content.Layout.BorderSize), + bounds, + new float4(1, blend, PlanetaryPageSampling.NominalAngularTexelSize(content.Layout), content.SampleSpacing)); + } +} diff --git a/src/GameCult.Geometry/Planetary/PlanetaryMapTileEncoding.cs b/src/GameCult.Geometry/Planetary/PlanetaryMapTileEncoding.cs new file mode 100644 index 00000000..ed07e7ba --- /dev/null +++ b/src/GameCult.Geometry/Planetary/PlanetaryMapTileEncoding.cs @@ -0,0 +1,122 @@ +using System; +using System.IO; +using System.Text; +using CultMath; + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +namespace GameCult.Geometry; + +public static class PlanetaryMapTileEncoding +{ + public const uint FormatVersion = 1; + private static readonly byte[] Magic = Encoding.ASCII.GetBytes("CMPT"); + + public static void Write(Stream stream, in PlanetarySurfaceMapTile tile, bool leaveOpen = false) + { + if (stream == null || !stream.CanWrite) throw new ArgumentException("A writable stream is required.", nameof(stream)); + tile.Key.Validate(); + if (tile.Samples == null || tile.Validity == null || tile.Samples.Length != tile.Validity.Length) + throw new ArgumentException("Tile samples and validity must have equal lengths.", nameof(tile)); + var expectedCount = tile.Key.Layout.StorageSize * tile.Key.Layout.StorageSize; + if (tile.Samples.Length != expectedCount) throw new ArgumentException("Tile payload does not match its layout.", nameof(tile)); + + using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen); + writer.Write(Magic); + writer.Write(FormatVersion); + writer.Write(tile.Key.FieldVersion); + writer.Write(tile.Key.ProjectionVersion); + writer.Write(tile.Key.LayerId); + var projection = tile.Key.Layout.Projection; + writer.Write((int)projection.Kind); + writer.Write(projection.CenterLongitude); + writer.Write(projection.CenterLatitude); + writer.Write(projection.Scale); + writer.Write(tile.Key.Layout.Level); + writer.Write(tile.Key.Layout.X); + writer.Write(tile.Key.Layout.Y); + writer.Write(tile.Key.Layout.InteriorSize); + writer.Write(tile.Key.Layout.BorderSize); + writer.Write(tile.Key.QueryScale.FootprintMeters); + writer.Write(tile.Key.QueryScale.MaximumUnresolvedHeight); + writer.Write(tile.Samples.Length); + for (var i = 0; i < tile.Samples.Length; i++) + { + writer.Write(tile.Validity[i]); + if (!tile.Validity[i]) continue; + WriteSample(writer, tile.Samples[i], tile.Key.FieldVersion); + } + } + + public static PlanetarySurfaceMapTile Read(Stream stream, bool leaveOpen = false) + { + if (stream == null || !stream.CanRead) throw new ArgumentException("A readable stream is required.", nameof(stream)); + using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen); + var magic = reader.ReadBytes(Magic.Length); + if (magic.Length != Magic.Length || !magic.AsSpan().SequenceEqual(Magic)) throw new InvalidDataException("Not a GameCult.Geometry planetary tile."); + var version = reader.ReadUInt32(); + if (version != FormatVersion) throw new InvalidDataException($"Unsupported planetary tile version {version}."); + var fieldVersion = reader.ReadUInt64(); + var projectionVersion = reader.ReadUInt32(); + var layerId = reader.ReadUInt32(); + var kindValue = reader.ReadInt32(); + if (!Enum.IsDefined(typeof(PlanetaryProjectionKind), kindValue)) throw new InvalidDataException("Unknown planetary projection kind."); + var projection = new PlanetaryProjectionParameters( + (PlanetaryProjectionKind)kindValue, + reader.ReadDouble(), reader.ReadDouble(), reader.ReadDouble()).Validate(); + var layout = new PlanetaryMapTileLayout( + projection, + reader.ReadInt32(), reader.ReadInt32(), reader.ReadInt32(), + reader.ReadInt32(), reader.ReadInt32()).Validate(); + var scale = new PlanetaryQueryScale(reader.ReadSingle(), reader.ReadSingle()).Validate(); + var key = new PlanetaryMapTileKey(fieldVersion, projectionVersion, layerId, layout, scale).Validate(); + var count = reader.ReadInt32(); + if (count != layout.StorageSize * layout.StorageSize) throw new InvalidDataException("Planetary tile sample count does not match layout."); + var samples = new PlanetarySurfaceSample[count]; + var validity = new bool[count]; + for (var i = 0; i < count; i++) + { + validity[i] = reader.ReadBoolean(); + if (validity[i]) samples[i] = ReadSample(reader, fieldVersion); + } + if (stream.CanSeek && stream.Position != stream.Length) throw new InvalidDataException("Planetary tile has trailing bytes."); + return new(key, samples, validity); + } + + private static void WriteSample(BinaryWriter writer, PlanetarySurfaceSample sample, ulong fieldVersion) + { + if (sample.FieldVersion != fieldVersion) throw new ArgumentException("Sample field version does not match tile identity."); + Write(writer, sample.UnitDirection); + writer.Write(sample.Radius); + writer.Write(sample.RadialDisplacement); + Write(writer, sample.TangentGradient); + Write(writer, sample.SurfaceNormal); + writer.Write(sample.Slope); + writer.Write(sample.Ridge); + writer.Write(sample.Gully); + writer.Write(sample.FinestResolvedWavelength); + writer.Write(sample.UnresolvedHeightBound); + } + + private static PlanetarySurfaceSample ReadSample(BinaryReader reader, ulong fieldVersion) + { + var direction = ReadFloat3(reader); + var radius = reader.ReadSingle(); + var displacement = reader.ReadSingle(); + var gradient = ReadFloat3(reader); + var normal = ReadFloat3(reader); + return new( + fieldVersion, direction, radius, displacement, gradient, normal, + reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), + reader.ReadSingle(), reader.ReadSingle()); + } + + private static void Write(BinaryWriter writer, float3 value) + { + writer.Write(value.x); writer.Write(value.y); writer.Write(value.z); + } + + private static float3 ReadFloat3(BinaryReader reader) => new(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()); +} diff --git a/src/GameCult.Geometry/Planetary/PlanetaryPages.cs b/src/GameCult.Geometry/Planetary/PlanetaryPages.cs new file mode 100644 index 00000000..919a6320 --- /dev/null +++ b/src/GameCult.Geometry/Planetary/PlanetaryPages.cs @@ -0,0 +1,192 @@ +using System; +using CultMath; + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +namespace GameCult.Geometry; + +public readonly record struct PlanetaryPageLayout(PlanetaryTileAddress Tile, int InteriorSize, int BorderSize) +{ + public int StorageSize => checked(InteriorSize + BorderSize * 2); + + public PlanetaryPageLayout Validate() + { + if (InteriorSize < 2) throw new ArgumentOutOfRangeException(nameof(InteriorSize)); + if (BorderSize < 0 || BorderSize >= InteriorSize) throw new ArgumentOutOfRangeException(nameof(BorderSize)); + return this; + } +} + +public readonly record struct PlanetaryPageSummary( + float MinimumHeight, + float MaximumHeight, + float MaximumSlope, + float UnresolvedHeightBound, + float AngularTexelSize); + +public readonly record struct PlanetaryPageSample(float Height, float3 TangentGradient, float Ridge, float Gully); + +public static class PlanetaryPageSampling +{ + public static float3 Direction(PlanetaryPageLayout page, int storageX, int storageY) + { + page.Validate(); + if ((uint)storageX >= (uint)page.StorageSize || (uint)storageY >= (uint)page.StorageSize) + throw new ArgumentOutOfRangeException(nameof(storageX)); + var localU = (storageX - page.BorderSize) / (double)(page.InteriorSize - 1); + var localV = (storageY - page.BorderSize) / (double)(page.InteriorSize - 1); + return DirectionAtLocal(page, localU, localV); + } + + public static float3 DirectionAtLocal(PlanetaryPageLayout page, double localU, double localV) + { + page.Validate(); + if (!double.IsFinite(localU) || !double.IsFinite(localV)) throw new ArgumentOutOfRangeException(nameof(localU)); + var count = page.Tile.AxisTileCount; + return PlanetaryTopology.Direction(new( + page.Tile.Face, + -1.0 + 2.0 * (page.Tile.X + localU) / count, + -1.0 + 2.0 * (page.Tile.Y + localV) / count)); + } + + public static float AngularTexelSize(PlanetaryPageLayout page) + { + var center = page.BorderSize + (page.InteriorSize - 1) / 2; + var a = Direction(page, center, center); + var b = Direction(page, Math.Min(center + 1, page.StorageSize - 1), center); + return MathF.Acos(math.clamp(math.dot(a, b), -1.0f, 1.0f)); + } + + public static float NominalAngularTexelSize(PlanetaryPageLayout page) + { + page.Validate(); + return MathF.PI * 0.5f / (page.Tile.AxisTileCount * (page.InteriorSize - 1)); + } + + public static PlanetaryPageSample Bilinear( + ReadOnlySpan samples, + PlanetaryPageLayout page, + float2 local) + { + page.Validate(); + if (samples.Length != page.StorageSize * page.StorageSize) throw new ArgumentException("Page sample count does not match layout.", nameof(samples)); + var texel = local * (page.InteriorSize - 1) + page.BorderSize; + var x0 = Math.Clamp((int)MathF.Floor(texel.x), 0, page.StorageSize - 1); + var y0 = Math.Clamp((int)MathF.Floor(texel.y), 0, page.StorageSize - 1); + var x1 = Math.Min(x0 + 1, page.StorageSize - 1); + var y1 = Math.Min(y0 + 1, page.StorageSize - 1); + var tx = math.frac(texel.x); + var ty = math.frac(texel.y); + var a = Lerp(samples[y0 * page.StorageSize + x0], samples[y0 * page.StorageSize + x1], tx); + var b = Lerp(samples[y1 * page.StorageSize + x0], samples[y1 * page.StorageSize + x1], tx); + return Lerp(a, b, ty); + } + + public static PlanetaryPageSummary Summarize(ReadOnlySpan samples, PlanetaryPageLayout page, float unresolvedHeightBound) + { + page.Validate(); + if (samples.Length != page.StorageSize * page.StorageSize) throw new ArgumentException("Page sample count does not match layout.", nameof(samples)); + var min = float.PositiveInfinity; + var max = float.NegativeInfinity; + var maxSlope = 0.0f; + foreach (var sample in samples) + { + min = MathF.Min(min, sample.Height); + max = MathF.Max(max, sample.Height); + maxSlope = MathF.Max(maxSlope, math.length(sample.TangentGradient)); + } + return new(min, max, maxSlope, MathF.Max(unresolvedHeightBound, 0), AngularTexelSize(page)); + } + + private static PlanetaryPageSample Lerp(PlanetaryPageSample a, PlanetaryPageSample b, float t) => new( + math.lerp(a.Height, b.Height, t), + math.lerp(a.TangentGradient, b.TangentGradient, t), + math.lerp(a.Ridge, b.Ridge, t), + math.lerp(a.Gully, b.Gully, t)); +} + +public readonly record struct PlanetaryBakedPage( + PlanetaryPageLayout Layout, + PlanetaryPageSample[] Samples, + PlanetaryPageSummary Summary); + +public static class PlanetaryPageBaker +{ + public static PlanetaryBakedPage Bake( + in PlanetaryFieldDefinition field, + PlanetaryPageLayout layout, + TSource source) + where TSource : IPlanetaryBaseField + { + layout.Validate(); field.Validate(); + var spacing = PlanetaryPageSampling.NominalAngularTexelSize(layout) * field.Radius; + var parentSpacing = layout.Tile.Level == 0 + ? 0 + : PlanetaryPageSampling.NominalAngularTexelSize(new(layout.Tile.Parent(), layout.InteriorSize, layout.BorderSize)) * field.Radius; + var samples = new PlanetaryPageSample[layout.StorageSize * layout.StorageSize]; + var unresolved = 0.0f; + for (var y = 0; y < layout.StorageSize; y++) + for (var x = 0; x < layout.StorageSize; x++) + { + var direction = PlanetaryPageSampling.Direction(layout, x, y); + var baseSample = source.Sample(direction); + var child = PlanetaryField.Sample(field, direction, baseSample, PlanetaryQueryScale.AtFootprint(spacing)); + var height = child.RadialDisplacement; + var gradient = child.TangentGradient; + if (parentSpacing > 0) + { + var parent = PlanetaryField.Sample(field, direction, baseSample, PlanetaryQueryScale.AtFootprint(parentSpacing)); + height -= parent.RadialDisplacement; + gradient -= parent.TangentGradient; + } + samples[y * layout.StorageSize + x] = new(height, gradient, child.Ridge, child.Gully); + unresolved = MathF.Max(unresolved, child.UnresolvedHeightBound); + } + return new(layout, samples, PlanetaryPageSampling.Summarize(samples, layout, unresolved)); + } +} + +public readonly record struct PlanetaryResidentPage(PlanetaryBakedPage Page, float Blend) +{ + public PlanetaryResidentPage Validate() + { + if (!float.IsFinite(Blend) || Blend is < 0 or > 1) throw new ArgumentOutOfRangeException(nameof(Blend)); + Page.Layout.Validate(); + if (Page.Samples.Length != Page.Layout.StorageSize * Page.Layout.StorageSize) throw new ArgumentException("Page payload does not match layout."); + return this; + } +} + +public static class PlanetaryPageSetSampling +{ + public static bool TrySample(ReadOnlySpan pages, float3 direction, out PlanetaryPageSample sample) + { + PlanetaryTopology.ValidateDirection(direction); + var height = 0.0f; + var gradient = float3.zero; + var ridge = 0.0f; + var gully = 0.0f; + var found = false; + var deepestLevel = -1; + foreach (var resident in pages) + { + resident.Validate(); + if (!PlanetaryTopology.TryLocalCoordinate(direction, resident.Page.Layout.Tile, out var local)) continue; + var contribution = PlanetaryPageSampling.Bilinear(resident.Page.Samples, resident.Page.Layout, local); + var blend = math.saturate(resident.Blend); + height += contribution.Height * blend; + gradient += contribution.TangentGradient * blend; + if (resident.Page.Layout.Tile.Level >= deepestLevel) + { + ridge = found ? math.lerp(ridge, contribution.Ridge, blend) : contribution.Ridge; + gully = found ? math.lerp(gully, contribution.Gully, blend) : contribution.Gully; + deepestLevel = resident.Page.Layout.Tile.Level; + } + found = true; + } + sample = new(height, gradient, ridge, gully); + return found; + } +} diff --git a/src/GameCult.Geometry/Planetary/PlanetaryPatch.cs b/src/GameCult.Geometry/Planetary/PlanetaryPatch.cs new file mode 100644 index 00000000..76a3fe92 --- /dev/null +++ b/src/GameCult.Geometry/Planetary/PlanetaryPatch.cs @@ -0,0 +1,50 @@ +using System; +using System.Linq; +using CultMath; + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +namespace GameCult.Geometry; + +public readonly record struct PlanetaryPatchVertex(float2 LocalCoordinate, float3 UnitDirection); + +public readonly record struct PlanetaryPatchMesh( + PlanetaryCubeFace Face, + int CellsPerAxis, + PlanetaryPatchVertex[] Vertices, + int[] Indices); + +public static class PlanetaryPatch +{ + public static PlanetaryPatchMesh CreateFace(PlanetaryCubeFace face, int cellsPerAxis) + { + if (!Enum.IsDefined(typeof(PlanetaryCubeFace), face)) throw new ArgumentOutOfRangeException(nameof(face)); + if (cellsPerAxis < 1 || cellsPerAxis > 4096) throw new ArgumentOutOfRangeException(nameof(cellsPerAxis)); + var axisVertices = cellsPerAxis + 1; + var vertices = new PlanetaryPatchVertex[checked(axisVertices * axisVertices)]; + for (var y = 0; y < axisVertices; y++) + for (var x = 0; x < axisVertices; x++) + { + var local = new float2(x / (float)cellsPerAxis, y / (float)cellsPerAxis); + vertices[y * axisVertices + x] = new(local, PlanetaryTopology.Direction(new(face, local.x * 2 - 1, local.y * 2 - 1))); + } + var indices = new int[checked(cellsPerAxis * cellsPerAxis * 6)]; + var write = 0; + for (var y = 0; y < cellsPerAxis; y++) + for (var x = 0; x < cellsPerAxis; x++) + { + var lowerLeft = y * axisVertices + x; + var lowerRight = lowerLeft + 1; + var upperLeft = lowerLeft + axisVertices; + var upperRight = upperLeft + 1; + indices[write++] = lowerLeft; indices[write++] = lowerRight; indices[write++] = upperRight; + indices[write++] = lowerLeft; indices[write++] = upperRight; indices[write++] = upperLeft; + } + return new(face, cellsPerAxis, vertices, indices); + } + + public static PlanetaryPatchMesh[] CreateCubeSphere(int cellsPerAxis) + => ((PlanetaryCubeFace[])Enum.GetValues(typeof(PlanetaryCubeFace))).Select(face => CreateFace(face, cellsPerAxis)).ToArray(); +} diff --git a/src/GameCult.Geometry/Planetary/PlanetaryProjection.cs b/src/GameCult.Geometry/Planetary/PlanetaryProjection.cs new file mode 100644 index 00000000..5851d367 --- /dev/null +++ b/src/GameCult.Geometry/Planetary/PlanetaryProjection.cs @@ -0,0 +1,327 @@ +using System; +using CultMath; + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +namespace GameCult.Geometry; + +public enum PlanetaryProjectionKind +{ + Equirectangular, + WebMercator, + EqualEarth, + Orthographic, + AzimuthalEquidistant, + AzimuthalEqualArea, + CubeAtlas, + LocalTangent, +} + +public readonly record struct PlanetaryProjectionParameters( + PlanetaryProjectionKind Kind, + double CenterLongitude = 0, + double CenterLatitude = 0, + double Scale = 1) +{ + public PlanetaryProjectionParameters Validate() + { + if (!double.IsFinite(CenterLongitude) || !double.IsFinite(CenterLatitude) || !double.IsFinite(Scale) || Scale <= 0) + throw new ArgumentOutOfRangeException(nameof(Scale)); + if (CenterLatitude is < -Math.PI / 2 or > Math.PI / 2) throw new ArgumentOutOfRangeException(nameof(CenterLatitude)); + return this; + } +} + +public static class PlanetaryProjection +{ + private const double EqualEarthA1 = 1.340264; + private const double EqualEarthA2 = -0.081106; + private const double EqualEarthA3 = 0.000893; + private const double EqualEarthA4 = 0.003796; + private static readonly double EqualEarthM = Math.Sqrt(3) / 2; + private static readonly double EqualEarthXMax = Math.PI / (EqualEarthM * EqualEarthA1); + private static readonly double EqualEarthYMax = EqualEarthY(Math.Asin(EqualEarthM)); + public static readonly double WebMercatorMaximumLatitude = Math.Atan(Math.Sinh(Math.PI)); + + public static bool TryForward(float3 direction, PlanetaryProjectionParameters parameters, out double2 coordinate) + { + parameters.Validate(); + PlanetaryTopology.ValidateDirection(direction); + var d = math.normalize(direction); + if (parameters.Kind == PlanetaryProjectionKind.CubeAtlas) + { + var face = PlanetaryTopology.FaceCoordinate(d); + var index = (int)face.Face; + var column = index % 3; + var row = index / 3; + coordinate = new( + (-1 + (column + (face.U + 1) * 0.5) * (2.0 / 3.0)) / parameters.Scale, + (1 - (row + (face.V + 1) * 0.5)) / parameters.Scale); + return true; + } + + var longitude = Math.Atan2(d.y, d.x); + var latitude = Math.Asin(Math.Clamp(d.z, -1, 1)); + var deltaLongitude = WrapLongitude(longitude - parameters.CenterLongitude); + var centered = CenteredForward(deltaLongitude, latitude, parameters, out coordinate); + coordinate /= parameters.Scale; + return centered; + } + + public static bool TryInverse(double2 coordinate, PlanetaryProjectionParameters parameters, out float3 direction) + { + parameters.Validate(); + if (!double.IsFinite(coordinate.x) || !double.IsFinite(coordinate.y)) + { + direction = float3.zero; + return false; + } + coordinate *= parameters.Scale; + if (parameters.Kind == PlanetaryProjectionKind.CubeAtlas) + return TryInverseCubeAtlas(coordinate, out direction); + + if (!CenteredInverse(coordinate, parameters, out var deltaLongitude, out var latitude)) + { + direction = float3.zero; + return false; + } + var longitude = WrapLongitude(deltaLongitude + parameters.CenterLongitude); + var cosLatitude = Math.Cos(latitude); + direction = math.normalize(new float3( + (float)(cosLatitude * Math.Cos(longitude)), + (float)(cosLatitude * Math.Sin(longitude)), + (float)Math.Sin(latitude))); + return true; + } + + private static bool CenteredForward(double longitude, double latitude, PlanetaryProjectionParameters p, out double2 coordinate) + { + switch (p.Kind) + { + case PlanetaryProjectionKind.Equirectangular: + coordinate = new(longitude / Math.PI, latitude / (Math.PI * 0.5)); + return true; + case PlanetaryProjectionKind.WebMercator: + if (Math.Abs(latitude) > WebMercatorMaximumLatitude) { coordinate = double2.zero; return false; } + coordinate = new(longitude / Math.PI, Math.Asinh(Math.Tan(latitude)) / Math.PI); + return true; + case PlanetaryProjectionKind.EqualEarth: + { + var theta = Math.Asin(EqualEarthM * Math.Sin(latitude)); + var theta2 = theta * theta; + var theta6 = theta2 * theta2 * theta2; + var denominator = EqualEarthM * (EqualEarthA1 + 3 * EqualEarthA2 * theta2 + theta6 * (7 * EqualEarthA3 + 9 * EqualEarthA4 * theta2)); + coordinate = new(longitude * Math.Cos(theta) / denominator / EqualEarthXMax, EqualEarthY(theta) / EqualEarthYMax); + return true; + } + case PlanetaryProjectionKind.Orthographic: + case PlanetaryProjectionKind.AzimuthalEquidistant: + case PlanetaryProjectionKind.AzimuthalEqualArea: + case PlanetaryProjectionKind.LocalTangent: + return ForwardAzimuthal(longitude, latitude, p, out coordinate); + default: + throw new ArgumentOutOfRangeException(nameof(p)); + } + } + + private static bool CenteredInverse(double2 coordinate, PlanetaryProjectionParameters p, out double longitude, out double latitude) + { + longitude = latitude = 0; + switch (p.Kind) + { + case PlanetaryProjectionKind.Equirectangular: + if (Math.Abs(coordinate.y) > 1) return false; + longitude = coordinate.x * Math.PI; latitude = coordinate.y * Math.PI * 0.5; return Math.Abs(coordinate.x) <= 1; + case PlanetaryProjectionKind.WebMercator: + if (Math.Abs(coordinate.x) > 1 || Math.Abs(coordinate.y) > 1) return false; + longitude = coordinate.x * Math.PI; latitude = Math.Atan(Math.Sinh(coordinate.y * Math.PI)); return true; + case PlanetaryProjectionKind.EqualEarth: + { + if (Math.Abs(coordinate.x) > 1 || Math.Abs(coordinate.y) > 1) return false; + var targetY = coordinate.y * EqualEarthYMax; + var theta = targetY / EqualEarthA1; + for (var i = 0; i < 8; i++) + { + var theta2 = theta * theta; + var theta6 = theta2 * theta2 * theta2; + var f = EqualEarthY(theta) - targetY; + var derivative = EqualEarthA1 + 3 * EqualEarthA2 * theta2 + theta6 * (7 * EqualEarthA3 + 9 * EqualEarthA4 * theta2); + theta -= f / derivative; + } + var sinLatitude = Math.Sin(theta) / EqualEarthM; + if (Math.Abs(sinLatitude) > 1.0000000001) return false; + latitude = Math.Asin(Math.Clamp(sinLatitude, -1, 1)); + var thetaSquared = theta * theta; + var thetaSixth = thetaSquared * thetaSquared * thetaSquared; + var denominator = EqualEarthM * (EqualEarthA1 + 3 * EqualEarthA2 * thetaSquared + thetaSixth * (7 * EqualEarthA3 + 9 * EqualEarthA4 * thetaSquared)); + longitude = coordinate.x * EqualEarthXMax * denominator / Math.Cos(theta); + return Math.Abs(longitude) <= Math.PI + 1.0e-10; + } + case PlanetaryProjectionKind.Orthographic: + case PlanetaryProjectionKind.AzimuthalEquidistant: + case PlanetaryProjectionKind.AzimuthalEqualArea: + case PlanetaryProjectionKind.LocalTangent: + return InverseAzimuthal(coordinate, p, out longitude, out latitude); + default: + throw new ArgumentOutOfRangeException(nameof(p)); + } + } + + private static bool ForwardAzimuthal(double longitude, double latitude, PlanetaryProjectionParameters p, out double2 coordinate) + { + var sin0 = Math.Sin(p.CenterLatitude); var cos0 = Math.Cos(p.CenterLatitude); + var sin = Math.Sin(latitude); var cos = Math.Cos(latitude); var cosLon = Math.Cos(longitude); + var cosC = Math.Clamp(sin0 * sin + cos0 * cos * cosLon, -1, 1); + var rawX = cos * Math.Sin(longitude); + var rawY = cos0 * sin - sin0 * cos * cosLon; + double factor; + switch (p.Kind) + { + case PlanetaryProjectionKind.Orthographic: + if (cosC < 0) { coordinate = double2.zero; return false; } + factor = 1; break; + case PlanetaryProjectionKind.AzimuthalEquidistant: + { + var c = Math.Acos(cosC); factor = c < 1.0e-12 ? 1 : c / Math.Sin(c); factor /= Math.PI; break; + } + case PlanetaryProjectionKind.AzimuthalEqualArea: + factor = Math.Sqrt(2 / Math.Max(1 + cosC, 1.0e-20)) * 0.5; break; + case PlanetaryProjectionKind.LocalTangent: + if (cosC <= 0) { coordinate = double2.zero; return false; } + factor = 1 / cosC; break; + default: throw new ArgumentOutOfRangeException(nameof(p)); + } + coordinate = new(rawX * factor, rawY * factor); + return true; + } + + private static bool InverseAzimuthal(double2 coordinate, PlanetaryProjectionParameters p, out double longitude, out double latitude) + { + longitude = latitude = 0; + var rho = Math.Sqrt(coordinate.x * coordinate.x + coordinate.y * coordinate.y); + double c; + switch (p.Kind) + { + case PlanetaryProjectionKind.Orthographic: if (rho > 1) return false; c = Math.Asin(Math.Clamp(rho, 0, 1)); break; + case PlanetaryProjectionKind.AzimuthalEquidistant: if (rho > 1) return false; c = rho * Math.PI; break; + case PlanetaryProjectionKind.AzimuthalEqualArea: if (rho > 1) return false; c = 2 * Math.Asin(Math.Clamp(rho, 0, 1)); break; + case PlanetaryProjectionKind.LocalTangent: c = Math.Atan(rho); break; + default: throw new ArgumentOutOfRangeException(nameof(p)); + } + if (rho < 1.0e-12) { latitude = p.CenterLatitude; return true; } + var sinC = Math.Sin(c); var cosC = Math.Cos(c); + var sin0 = Math.Sin(p.CenterLatitude); var cos0 = Math.Cos(p.CenterLatitude); + latitude = Math.Asin(Math.Clamp(cosC * sin0 + coordinate.y * sinC * cos0 / rho, -1, 1)); + longitude = Math.Atan2(coordinate.x * sinC, rho * cos0 * cosC - coordinate.y * sin0 * sinC); + return true; + } + + private static bool TryInverseCubeAtlas(double2 coordinate, out float3 direction) + { + direction = float3.zero; + if (coordinate.x is < -1 or > 1 || coordinate.y is < -1 or > 1) return false; + var column = Math.Min((int)((coordinate.x + 1) * 1.5), 2); + var row = Math.Min((int)((1 - coordinate.y)), 1); + var u = ((coordinate.x + 1) * 1.5 - column) * 2 - 1; + var v = ((1 - coordinate.y) - row) * 2 - 1; + direction = PlanetaryTopology.Direction(new((PlanetaryCubeFace)(row * 3 + column), u, v)); + return true; + } + + private static double EqualEarthY(double theta) + { + var theta2 = theta * theta; + var theta6 = theta2 * theta2 * theta2; + return theta * (EqualEarthA1 + EqualEarthA2 * theta2 + theta6 * (EqualEarthA3 + EqualEarthA4 * theta2)); + } + + private static double WrapLongitude(double longitude) + { + longitude %= Math.PI * 2; + if (longitude > Math.PI) longitude -= Math.PI * 2; + if (longitude < -Math.PI) longitude += Math.PI * 2; + return longitude; + } +} + +public readonly record struct PlanetaryMapTileLayout( + PlanetaryProjectionParameters Projection, + int Level, + int X, + int Y, + int InteriorSize, + int BorderSize) +{ + public int StorageSize => checked(InteriorSize + BorderSize * 2); + + public PlanetaryMapTileLayout Validate() + { + Projection.Validate(); + if (Level is < 0 or > 30) throw new ArgumentOutOfRangeException(nameof(Level)); + var count = 1 << Level; + if ((uint)X >= (uint)count || (uint)Y >= (uint)count) throw new ArgumentOutOfRangeException(nameof(X)); + if (InteriorSize < 2 || BorderSize < 0 || BorderSize >= InteriorSize) throw new ArgumentOutOfRangeException(nameof(InteriorSize)); + return this; + } +} + +public static class PlanetaryMapTileSampling +{ + public static bool TryDirection(PlanetaryMapTileLayout tile, int storageX, int storageY, out float3 direction) + { + tile.Validate(); + if ((uint)storageX >= (uint)tile.StorageSize || (uint)storageY >= (uint)tile.StorageSize) throw new ArgumentOutOfRangeException(nameof(storageX)); + var count = 1 << tile.Level; + var localU = (storageX - tile.BorderSize) / (double)(tile.InteriorSize - 1); + var localV = (storageY - tile.BorderSize) / (double)(tile.InteriorSize - 1); + var map = new double2(-1 + 2 * (tile.X + localU) / count, 1 - 2 * (tile.Y + localV) / count); + return PlanetaryProjection.TryInverse(map, tile.Projection, out direction); + } +} + +public readonly record struct PlanetaryMapTileKey( + ulong FieldVersion, + uint ProjectionVersion, + uint LayerId, + PlanetaryMapTileLayout Layout, + PlanetaryQueryScale QueryScale) +{ + public PlanetaryMapTileKey Validate() + { + if (FieldVersion == 0 || ProjectionVersion == 0) throw new ArgumentOutOfRangeException(nameof(FieldVersion)); + Layout.Validate(); QueryScale.Validate(); + return this; + } +} + +public readonly record struct PlanetarySurfaceMapTile( + PlanetaryMapTileKey Key, + PlanetarySurfaceSample[] Samples, + bool[] Validity); + +public static class PlanetaryMapTileBaker +{ + public static PlanetarySurfaceMapTile Bake( + in PlanetaryFieldDefinition field, + PlanetaryMapTileKey key, + TSource source) + where TSource : IPlanetaryBaseField + { + field.Validate(); key.Validate(); + if (field.FieldVersion != key.FieldVersion) throw new ArgumentException("Tile field version does not match definition.", nameof(key)); + var count = key.Layout.StorageSize * key.Layout.StorageSize; + var samples = new PlanetarySurfaceSample[count]; + var validity = new bool[count]; + for (var y = 0; y < key.Layout.StorageSize; y++) + for (var x = 0; x < key.Layout.StorageSize; x++) + { + var index = y * key.Layout.StorageSize + x; + if (!PlanetaryMapTileSampling.TryDirection(key.Layout, x, y, out var direction)) continue; + samples[index] = PlanetaryField.Sample(field, direction, source.Sample(direction), key.QueryScale); + validity[index] = true; + } + return new(key, samples, validity); + } +} diff --git a/src/GameCult.Geometry/Planetary/PlanetaryQueries.cs b/src/GameCult.Geometry/Planetary/PlanetaryQueries.cs new file mode 100644 index 00000000..b50b7785 --- /dev/null +++ b/src/GameCult.Geometry/Planetary/PlanetaryQueries.cs @@ -0,0 +1,198 @@ +using System; +using CultMath; + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +namespace GameCult.Geometry; + +public readonly record struct PlanetaryRadialBounds(float MinimumDisplacement, float MaximumDisplacement) +{ + public PlanetaryRadialBounds Validate() + { + if (!float.IsFinite(MinimumDisplacement) || !float.IsFinite(MaximumDisplacement) || MinimumDisplacement > MaximumDisplacement) + throw new ArgumentOutOfRangeException(nameof(MinimumDisplacement)); + return this; + } +} + +public readonly record struct PlanetaryRayHit( + double Distance, + double3 PlanetLocalPosition, + PlanetarySurfaceSample Surface); + +public readonly record struct PlanetaryPathSample(double Distance, PlanetarySurfaceSample Surface); + +public readonly record struct PlanetaryRegionSummary( + float MinimumDisplacement, + float MaximumDisplacement, + float MaximumSlope, + float MaximumUnresolvedHeight); + +public static class PlanetaryQueries +{ + public static bool TryIntersectRay( + in PlanetaryFieldDefinition field, + double3 rayOrigin, + double3 rayDirection, + double maximumDistance, + in PlanetaryQueryScale scale, + in PlanetaryRadialBounds bounds, + TSource source, + out PlanetaryRayHit hit) + where TSource : IPlanetaryBaseField + { + field.Validate(); scale.Validate(); bounds.Validate(); + ValidateFinite(rayOrigin, nameof(rayOrigin)); ValidateFinite(rayDirection, nameof(rayDirection)); + if (!double.IsFinite(maximumDistance) || maximumDistance <= 0) throw new ArgumentOutOfRangeException(nameof(maximumDistance)); + var directionLength = Length(rayDirection); + if (directionLength < 1.0e-15) throw new ArgumentOutOfRangeException(nameof(rayDirection)); + var direction = rayDirection / directionLength; + var outerRadius = field.Radius + bounds.MaximumDisplacement + scale.MaximumUnresolvedHeight; + if (!double.IsFinite(outerRadius)) outerRadius = field.Radius + bounds.MaximumDisplacement; + if (!TrySphereInterval(rayOrigin, direction, outerRadius, out var enter, out var leave)) { hit = default; return false; } + var start = Math.Max(enter, 0); + var end = Math.Min(leave, maximumDistance); + if (start > end) { hit = default; return false; } + + var previousDistance = start; + var previous = SignedSurfaceDistance(field, rayOrigin + direction * previousDistance, scale, source, out var previousSample); + if (previous <= 0) + { + hit = new(previousDistance, rayOrigin + direction * previousDistance, previousSample); + return true; + } + var minimumStep = Math.Max(scale.FootprintMeters * 0.5, field.Radius * 1.0e-7); + for (var iteration = 0; iteration < 512 && previousDistance < end; iteration++) + { + var nextDistance = Math.Min(previousDistance + Math.Max(previous * 0.5, minimumStep), end); + var next = SignedSurfaceDistance(field, rayOrigin + direction * nextDistance, scale, source, out var nextSample); + if (next <= 0) + { + var low = previousDistance; + var high = nextDistance; + var surface = nextSample; + for (var refine = 0; refine < 32; refine++) + { + var middle = (low + high) * 0.5; + var value = SignedSurfaceDistance(field, rayOrigin + direction * middle, scale, source, out var middleSample); + if (value > 0) low = middle; + else { high = middle; surface = middleSample; } + } + var position = rayOrigin + direction * high; + hit = new(high, position, surface); + return true; + } + if (nextDistance >= end) break; + previousDistance = nextDistance; + previous = next; + previousSample = nextSample; + } + hit = default; + return false; + } + + public static double SegmentClearance( + in PlanetaryFieldDefinition field, + double3 start, + double3 end, + double sampleSpacing, + in PlanetaryQueryScale scale, + TSource source) + where TSource : IPlanetaryBaseField + { + ValidateFinite(start, nameof(start)); ValidateFinite(end, nameof(end)); + if (!double.IsFinite(sampleSpacing) || sampleSpacing <= 0) throw new ArgumentOutOfRangeException(nameof(sampleSpacing)); + var delta = end - start; + var distance = Length(delta); + var steps = Math.Max((int)Math.Ceiling(distance / sampleSpacing), 1); + var minimum = double.PositiveInfinity; + for (var i = 0; i <= steps; i++) + { + var position = start + delta * (i / (double)steps); + minimum = Math.Min(minimum, SignedSurfaceDistance(field, position, scale, source, out _)); + } + return minimum; + } + + public static PlanetaryPathSample[] SampleGreatCircle( + in PlanetaryFieldDefinition field, + float3 startDirection, + float3 endDirection, + int segmentCount, + in PlanetaryQueryScale scale, + TSource source) + where TSource : IPlanetaryBaseField + { + if (segmentCount < 1) throw new ArgumentOutOfRangeException(nameof(segmentCount)); + PlanetaryTopology.ValidateDirection(startDirection); PlanetaryTopology.ValidateDirection(endDirection); + var a = math.normalize(startDirection); var b = math.normalize(endDirection); + var angle = MathF.Acos(math.clamp(math.dot(a, b), -1, 1)); + var sinAngle = MathF.Sin(angle); + var result = new PlanetaryPathSample[segmentCount + 1]; + for (var i = 0; i <= segmentCount; i++) + { + var t = i / (float)segmentCount; + var direction = sinAngle < 1.0e-6f + ? math.normalize(math.lerp(a, b, t)) + : math.normalize(a * (MathF.Sin((1 - t) * angle) / sinAngle) + b * (MathF.Sin(t * angle) / sinAngle)); + var sample = PlanetaryField.Sample(field, direction, source.Sample(direction), scale); + result[i] = new(angle * field.Radius * t, sample); + } + return result; + } + + public static PlanetaryRegionSummary Summarize( + in PlanetaryFieldDefinition field, + ReadOnlySpan directions, + in PlanetaryQueryScale scale, + TSource source) + where TSource : IPlanetaryBaseField + { + if (directions.IsEmpty) throw new ArgumentException("At least one direction is required.", nameof(directions)); + var min = float.PositiveInfinity; var max = float.NegativeInfinity; var slope = 0.0f; var unresolved = 0.0f; + foreach (var direction in directions) + { + var normalized = math.normalize(direction); + var sample = PlanetaryField.Sample(field, normalized, source.Sample(normalized), scale); + min = MathF.Min(min, sample.RadialDisplacement); + max = MathF.Max(max, sample.RadialDisplacement); + slope = MathF.Max(slope, sample.Slope); + unresolved = MathF.Max(unresolved, sample.UnresolvedHeightBound); + } + return new(min, max, slope, unresolved); + } + + private static double SignedSurfaceDistance( + in PlanetaryFieldDefinition field, + double3 position, + in PlanetaryQueryScale scale, + TSource source, + out PlanetarySurfaceSample sample) + where TSource : IPlanetaryBaseField + { + var radius = Length(position); + if (radius < 1.0e-15) { sample = default; return double.NegativeInfinity; } + var direction = new float3((float)(position.x / radius), (float)(position.y / radius), (float)(position.z / radius)); + sample = PlanetaryField.Sample(field, direction, source.Sample(direction), scale); + return radius - sample.SurfaceRadius; + } + + private static bool TrySphereInterval(double3 origin, double3 direction, double radius, out double enter, out double leave) + { + var b = Dot(origin, direction); + var c = Dot(origin, origin) - radius * radius; + var discriminant = b * b - c; + if (discriminant < 0) { enter = leave = 0; return false; } + var root = Math.Sqrt(discriminant); + enter = -b - root; leave = -b + root; return leave >= 0; + } + + private static double Dot(double3 a, double3 b) => a.x * b.x + a.y * b.y + a.z * b.z; + private static double Length(double3 value) => Math.Sqrt(Dot(value, value)); + private static void ValidateFinite(double3 value, string name) + { + if (!double.IsFinite(value.x) || !double.IsFinite(value.y) || !double.IsFinite(value.z)) throw new ArgumentOutOfRangeException(name); + } +} diff --git a/src/GameCult.Geometry/Planetary/PlanetaryRadialRefinement.cs b/src/GameCult.Geometry/Planetary/PlanetaryRadialRefinement.cs new file mode 100644 index 00000000..a958bc1a --- /dev/null +++ b/src/GameCult.Geometry/Planetary/PlanetaryRadialRefinement.cs @@ -0,0 +1,34 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +using CultMath; + +namespace GameCult.Geometry; + +public static class PlanetaryRadialRefinement +{ + public static float3 Step( + float3 position, + float3 rayDirection, + float3 center, + float targetRadius, + float minimumDerivative = 0.2f, + float maximumCorrection = 0.08f) + { + var local = position - center; + var radius = math.length(local); + var radial = local / math.max(radius, 0.0001f); + var error = radius - targetRadius; + var derivative = math.dot(rayDirection, radial); + var safeMinimum = math.max(minimumDerivative, 0.0001f); + var safeDerivative = math.abs(derivative) > safeMinimum + ? derivative + : derivative < 0.0f ? -safeMinimum : safeMinimum; + var correction = math.clamp( + -error / safeDerivative, + -math.abs(maximumCorrection), + math.abs(maximumCorrection)); + return position + rayDirection * correction; + } +} diff --git a/src/GameCult.Geometry/Planetary/PlanetaryResidency.cs b/src/GameCult.Geometry/Planetary/PlanetaryResidency.cs new file mode 100644 index 00000000..79acc317 --- /dev/null +++ b/src/GameCult.Geometry/Planetary/PlanetaryResidency.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CultMath; + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +namespace GameCult.Geometry; + +public readonly record struct PlanetaryLodParameters( + int MaximumLevel, + int PageInteriorSize, + int PageBorderSize, + float ViewportHeightPixels, + float MinimumFootprintMeters) +{ + public PlanetaryLodParameters Validate() + { + if (MaximumLevel is < 0 or > PlanetaryTileAddress.MaxLevel) throw new ArgumentOutOfRangeException(nameof(MaximumLevel)); + _ = new PlanetaryPageLayout(new(PlanetaryCubeFace.PositiveX, 0, 0, 0), PageInteriorSize, PageBorderSize).Validate(); + if (!float.IsFinite(ViewportHeightPixels) || ViewportHeightPixels <= 0) throw new ArgumentOutOfRangeException(nameof(ViewportHeightPixels)); + if (!float.IsFinite(MinimumFootprintMeters) || MinimumFootprintMeters < 0) throw new ArgumentOutOfRangeException(nameof(MinimumFootprintMeters)); + return this; + } +} + +public static class PlanetaryLodSelector +{ + public static int SelectLevel( + in PlanetaryFieldDefinition field, + float cameraDistanceFromCenter, + in PlanetaryLodParameters parameters) + { + field.Validate(); parameters.Validate(); + if (!float.IsFinite(cameraDistanceFromCenter) || cameraDistanceFromCenter < 0) throw new ArgumentOutOfRangeException(nameof(cameraDistanceFromCenter)); + var altitude = MathF.Max(cameraDistanceFromCenter - field.Radius, 0.001f); + var footprint = MathF.Max(altitude / parameters.ViewportHeightPixels * 2, parameters.MinimumFootprintMeters); + var erosion = field.Erosion; + for (var level = 0; level <= parameters.MaximumLevel; level++) + { + var page = new PlanetaryPageLayout(new(PlanetaryCubeFace.PositiveX, level, 0, 0), parameters.PageInteriorSize, parameters.PageBorderSize); + var spacing = PlanetaryPageSampling.NominalAngularTexelSize(page) * field.Radius; + var unresolved = ErosionFrequencyBands.Select( + erosion.Scale * erosion.CellScale, spacing, erosion.Octaves, + erosion.Lacunarity, erosion.Strength * erosion.Scale, erosion.Gain).UnresolvedHeightBound; + if (unresolved <= footprint) return level; + } + return parameters.MaximumLevel; + } + + public static PlanetaryTileAddress[] SelectAncestorChain( + in PlanetaryFieldDefinition field, + float3 cameraDirection, + float cameraDistanceFromCenter, + in PlanetaryLodParameters parameters) + { + PlanetaryTopology.ValidateDirection(cameraDirection); + var level = SelectLevel(field, cameraDistanceFromCenter, parameters); + var result = new PlanetaryTileAddress[6 + level]; + for (var face = 0; face < 6; face++) result[face] = new((PlanetaryCubeFace)face, 0, 0, 0); + var leaf = PlanetaryTopology.TileAt(cameraDirection, level); + for (var currentLevel = level; currentLevel >= 1; currentLevel--) + { + result[6 + currentLevel - 1] = leaf; + leaf = leaf.Parent(); + } + return result; + } +} + +public readonly record struct PlanetaryResidentTile(PlanetaryTileAddress Tile, float Blend, bool Departing); + +public readonly record struct PlanetaryResidencySnapshot( + ulong ContentVersion, + ulong PresentationVersion, + PlanetaryResidentTile[] Tiles); + +/// Owns presentation residency only. It never owns field content. +public sealed class PlanetaryResidualResidency +{ + private readonly Dictionary residents = new(); + private readonly Dictionary arrivals = new(); + private readonly Dictionary departures = new(); + private float lastTime = float.NegativeInfinity; + + public void Reset() + { + residents.Clear(); arrivals.Clear(); departures.Clear(); lastTime = float.NegativeInfinity; + } + + public PlanetaryResidencySnapshot Update(ReadOnlySpan desired, float timeSeconds, float transitionSeconds) + { + if (!float.IsFinite(timeSeconds)) throw new ArgumentOutOfRangeException(nameof(timeSeconds)); + if (!float.IsFinite(transitionSeconds) || transitionSeconds < 0) throw new ArgumentOutOfRangeException(nameof(transitionSeconds)); + if (timeSeconds < lastTime) Reset(); + lastTime = timeSeconds; + + var desiredKeys = new HashSet(); + foreach (var tile in desired) + { + var key = tile.StableKey; + desiredKeys.Add(key); + residents[key] = tile; + if (!arrivals.ContainsKey(key)) arrivals[key] = timeSeconds; + departures.Remove(key); + } + + foreach (var pair in residents.ToArray()) + { + if (pair.Value.Level == 0 || desiredKeys.Contains(pair.Key)) continue; + if (!departures.ContainsKey(pair.Key)) + { + var startingBlend = transitionSeconds == 0 + ? 1 + : Math.Clamp((timeSeconds - arrivals[pair.Key]) / transitionSeconds, 0, 1); + departures[pair.Key] = (timeSeconds, startingBlend); + } + if (transitionSeconds > 0 && timeSeconds - departures[pair.Key].Time < transitionSeconds) continue; + residents.Remove(pair.Key); arrivals.Remove(pair.Key); departures.Remove(pair.Key); + } + + var ordered = residents + .OrderBy(pair => pair.Value.Level) + .ThenBy(pair => pair.Value.Face) + .ThenBy(pair => pair.Value.X) + .ThenBy(pair => pair.Value.Y) + .ToArray(); + var tiles = new PlanetaryResidentTile[ordered.Length]; + for (var i = 0; i < ordered.Length; i++) + { + var pair = ordered[i]; + var departing = departures.TryGetValue(pair.Key, out var departure); + var blend = pair.Value.Level == 0 || transitionSeconds == 0 + ? 1 + : departing + ? departure.StartingBlend * Math.Clamp(1 - (timeSeconds - departure.Time) / transitionSeconds, 0, 1) + : Math.Clamp((timeSeconds - arrivals[pair.Key]) / transitionSeconds, 0, 1); + tiles[i] = new(pair.Value, blend, departing); + } + return new(Hash(ordered.Select(pair => pair.Key)), Hash(tiles.Select(tile => tile.Tile.StableKey ^ (uint)BitConverter.SingleToInt32Bits(tile.Blend))), tiles); + } + + private static ulong Hash(IEnumerable values) + { + const ulong offset = 14695981039346656037UL; + const ulong prime = 1099511628211UL; + var hash = offset; + foreach (var value in values) hash = unchecked((hash ^ value) * prime); + return hash == 0 ? 1 : hash; + } +} diff --git a/src/GameCult.Geometry/Planetary/PlanetaryTopology.cs b/src/GameCult.Geometry/Planetary/PlanetaryTopology.cs new file mode 100644 index 00000000..18ff4f31 --- /dev/null +++ b/src/GameCult.Geometry/Planetary/PlanetaryTopology.cs @@ -0,0 +1,196 @@ +using System; +using CultMath; + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +namespace GameCult.Geometry; + +public enum PlanetaryCubeFace +{ + PositiveX, + NegativeX, + PositiveY, + NegativeY, + PositiveZ, + NegativeZ, +} + +public readonly record struct PlanetaryFaceCoordinate(PlanetaryCubeFace Face, double U, double V) +{ + public PlanetaryFaceCoordinate Validate(bool allowOutsideFace = false) + { + if (!double.IsFinite(U) || !double.IsFinite(V)) throw new ArgumentOutOfRangeException(nameof(U)); + if (!allowOutsideFace && (U is < -1.0 or > 1.0 || V is < -1.0 or > 1.0)) + throw new ArgumentOutOfRangeException(nameof(U), "Face coordinates must be in [-1, 1]."); + if (!Enum.IsDefined(typeof(PlanetaryCubeFace), Face)) throw new ArgumentOutOfRangeException(nameof(Face)); + return this; + } +} + +public readonly record struct PlanetaryTileAddress +{ + public const int MaxLevel = 30; + + public PlanetaryTileAddress(PlanetaryCubeFace face, int level, int x, int y) + { + if (!Enum.IsDefined(typeof(PlanetaryCubeFace), face)) throw new ArgumentOutOfRangeException(nameof(face)); + if (level is < 0 or > MaxLevel) throw new ArgumentOutOfRangeException(nameof(level)); + var count = 1 << level; + if ((uint)x >= (uint)count) throw new ArgumentOutOfRangeException(nameof(x)); + if ((uint)y >= (uint)count) throw new ArgumentOutOfRangeException(nameof(y)); + Face = face; + Level = level; + X = x; + Y = y; + } + + public PlanetaryCubeFace Face { get; } + public int Level { get; } + public int X { get; } + public int Y { get; } + public int AxisTileCount => 1 << Level; + + public PlanetaryTileAddress Parent() => Level == 0 + ? throw new InvalidOperationException("Root cube tile has no parent.") + : new(Face, Level - 1, X >> 1, Y >> 1); + + public PlanetaryTileAddress Child(int childX, int childY) + { + if ((uint)childX > 1) throw new ArgumentOutOfRangeException(nameof(childX)); + if ((uint)childY > 1) throw new ArgumentOutOfRangeException(nameof(childY)); + if (Level == MaxLevel) throw new InvalidOperationException($"Level {MaxLevel} cannot be subdivided."); + return new(Face, Level + 1, (X << 1) | childX, (Y << 1) | childY); + } + + public PlanetaryFaceCoordinate PositionAt(double localU, double localV) + { + if (!double.IsFinite(localU) || localU is < 0.0 or > 1.0) throw new ArgumentOutOfRangeException(nameof(localU)); + if (!double.IsFinite(localV) || localV is < 0.0 or > 1.0) throw new ArgumentOutOfRangeException(nameof(localV)); + var count = AxisTileCount; + return new(Face, -1.0 + 2.0 * (X + localU) / count, -1.0 + 2.0 * (Y + localV) / count); + } + + public ulong StableKey + { + get + { + const ulong offset = 14695981039346656037UL; + const ulong prime = 1099511628211UL; + var hash = offset; + hash = unchecked((hash ^ (uint)Face) * prime); + hash = unchecked((hash ^ (uint)Level) * prime); + hash = unchecked((hash ^ (uint)X) * prime); + hash = unchecked((hash ^ (uint)Y) * prime); + return hash == 0 ? 1 : hash; + } + } + public override string ToString() => $"{Face}/L{Level:D2}/{X}/{Y}"; +} + +public static class PlanetaryTopology +{ + private const float QuarterPi = MathF.PI * 0.25f; + + public static float3 Direction(PlanetaryFaceCoordinate coordinate) + { + coordinate.Validate(allowOutsideFace: true); + var u = MathF.Tan((float)coordinate.U * QuarterPi); + var v = MathF.Tan((float)coordinate.V * QuarterPi); + var cube = coordinate.Face switch + { + PlanetaryCubeFace.PositiveX => new float3(1, v, -u), + PlanetaryCubeFace.NegativeX => new float3(-1, v, u), + PlanetaryCubeFace.PositiveY => new float3(u, 1, -v), + PlanetaryCubeFace.NegativeY => new float3(u, -1, v), + PlanetaryCubeFace.PositiveZ => new float3(u, v, 1), + PlanetaryCubeFace.NegativeZ => new float3(-u, v, -1), + _ => throw new ArgumentOutOfRangeException(nameof(coordinate)), + }; + return math.normalize(cube); + } + + public static float3 NormalizedCubeDirection(PlanetaryFaceCoordinate coordinate) + { + coordinate.Validate(allowOutsideFace: true); + var u = (float)coordinate.U; + var v = (float)coordinate.V; + return math.normalize(CubeVector(coordinate.Face, u, v)); + } + + public static PlanetaryFaceCoordinate FaceCoordinate(float3 direction) + { + ValidateDirection(direction); + var d = math.normalize(direction); + var a = math.abs(d); + if (a.x >= a.y && a.x >= a.z) + return d.x >= 0 + ? FromCubeUv(PlanetaryCubeFace.PositiveX, -d.z / a.x, d.y / a.x) + : FromCubeUv(PlanetaryCubeFace.NegativeX, d.z / a.x, d.y / a.x); + if (a.y >= a.z) + return d.y >= 0 + ? FromCubeUv(PlanetaryCubeFace.PositiveY, d.x / a.y, -d.z / a.y) + : FromCubeUv(PlanetaryCubeFace.NegativeY, d.x / a.y, d.z / a.y); + return d.z >= 0 + ? FromCubeUv(PlanetaryCubeFace.PositiveZ, d.x / a.z, d.y / a.z) + : FromCubeUv(PlanetaryCubeFace.NegativeZ, -d.x / a.z, d.y / a.z); + } + + public static PlanetaryTileAddress TileAt(float3 direction, int level) + { + if (level is < 0 or > PlanetaryTileAddress.MaxLevel) throw new ArgumentOutOfRangeException(nameof(level)); + var face = FaceCoordinate(direction); + var count = 1 << level; + var x = Math.Clamp((int)((face.U * 0.5 + 0.5) * count), 0, count - 1); + var y = Math.Clamp((int)((face.V * 0.5 + 0.5) * count), 0, count - 1); + return new(face.Face, level, x, y); + } + + public static bool TryLocalCoordinate(float3 direction, PlanetaryTileAddress tile, out float2 local) + { + var face = FaceCoordinate(direction); + if (face.Face != tile.Face) { local = float2.zero; return false; } + var scaledU = (face.U * 0.5 + 0.5) * tile.AxisTileCount; + var scaledV = (face.V * 0.5 + 0.5) * tile.AxisTileCount; + local = new((float)(scaledU - tile.X), (float)(scaledV - tile.Y)); + return local.x is >= 0 and <= 1 && local.y is >= 0 and <= 1; + } + + public static float3 SurfaceNormal(float3 unitDirection, float3 worldDistanceTangentGradient) + { + ValidateDirection(unitDirection); + ValidateFinite(worldDistanceTangentGradient, nameof(worldDistanceTangentGradient)); + var direction = math.normalize(unitDirection); + var tangent = worldDistanceTangentGradient - direction * math.dot(worldDistanceTangentGradient, direction); + return math.normalize(direction - tangent); + } + + private static PlanetaryFaceCoordinate FromCubeUv(PlanetaryCubeFace face, float cubeU, float cubeV) => new( + face, + MathF.Atan(cubeU) / QuarterPi, + MathF.Atan(cubeV) / QuarterPi); + + private static float3 CubeVector(PlanetaryCubeFace face, float u, float v) => face switch + { + PlanetaryCubeFace.PositiveX => new float3(1, v, -u), + PlanetaryCubeFace.NegativeX => new float3(-1, v, u), + PlanetaryCubeFace.PositiveY => new float3(u, 1, -v), + PlanetaryCubeFace.NegativeY => new float3(u, -1, v), + PlanetaryCubeFace.PositiveZ => new float3(u, v, 1), + PlanetaryCubeFace.NegativeZ => new float3(-u, v, -1), + _ => throw new ArgumentOutOfRangeException(nameof(face)), + }; + + internal static void ValidateDirection(float3 direction) + { + ValidateFinite(direction, nameof(direction)); + if (math.length(direction) < 1.0e-12f) throw new ArgumentOutOfRangeException(nameof(direction)); + } + + internal static void ValidateFinite(float3 value, string name) + { + if (!float.IsFinite(value.x) || !float.IsFinite(value.y) || !float.IsFinite(value.z)) + throw new ArgumentOutOfRangeException(name); + } +} diff --git a/src/GameCult.Geometry/Planetary/SphericalErosion.cs b/src/GameCult.Geometry/Planetary/SphericalErosion.cs new file mode 100644 index 00000000..b20e0f00 --- /dev/null +++ b/src/GameCult.Geometry/Planetary/SphericalErosion.cs @@ -0,0 +1,114 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +using CultMath; + +namespace GameCult.Geometry; + +public readonly record struct SphericalErosionParameters( + float Frequency, + float Amplitude, + float Lacunarity, + float Gain, + float SlopeStrength, + float Detail, + float Seed, + int Octaves) +{ + public static SphericalErosionParameters Default => new(18.0f, 0.035f, 2.03f, 0.52f, 6.0f, 1.35f, 0.0f, 5); +} + +public readonly record struct SphericalErosionSample(float HeightOffset, float Ridge, float Gully, float3 Gradient); + +/// +/// Stateless, point-evaluable erosion-like synthesis on a unit sphere. +/// The matching shader body is gamecult_geometry_spherical_erosion in GameCult.Geometry.hlsl. +/// Position and gradient are planet-space; the gradient is projected tangent +/// internally so cube faces and tile request order never enter the result. +/// +public static class SphericalErosion +{ + public static SphericalErosionSample Sample( + float3 unitPosition, + float baseHeight, + float3 baseGradient, + SphericalErosionParameters parameters) + { + var position = math.normalize(unitPosition); + var gradient = baseGradient - position * math.dot(baseGradient, position); + var slope = math.length(gradient); + var flow = slope > 1.0e-6f ? -gradient / slope : StableTangent(position); + var frequency = math.max(parameters.Frequency, 0.001f); + var amplitude = parameters.Amplitude; + var combiMask = EaseOut(math.saturate(slope * parameters.SlopeStrength)); + var fadeTarget = math.saturate(baseHeight * 0.5f + 0.5f) * 2.0f - 1.0f; + var heightOffset = 0.0f; + var ridge = 0.0f; + var gully = 0.0f; + + var octaveCount = math.min(math.max(parameters.Octaves, 0), 8); + for (var octave = 0; octave < octaveCount; octave++) + { + var phase = DirectionalCellWave(position * frequency, flow, parameters.Seed + octave * 19.19f); + var wave = math.cos(phase); + var waveSlope = math.sin(phase); + var faded = math.lerp(fadeTarget, wave, combiMask); + heightOffset += faded * amplitude; + ridge = math.max(ridge, math.saturate(faded)); + gully = math.max(gully, math.saturate(-faded)); + + var across = math.normalize(math.cross(position, flow)); + gradient += across * (math.sign(waveSlope) * amplitude * frequency * combiMask); + gradient -= position * math.dot(gradient, position); + slope = math.length(gradient); + flow = slope > 1.0e-6f ? -gradient / slope : StableTangent(position); + + var newMask = EaseOut(math.saturate(math.abs(waveSlope) * 1.35f)); + combiMask = PowInverse(combiMask, parameters.Detail) * newMask; + fadeTarget = faded; + frequency *= math.max(parameters.Lacunarity, 1.01f); + amplitude *= math.saturate(parameters.Gain); + } + + return new SphericalErosionSample(heightOffset, ridge, gully, gradient); + } + + private static float DirectionalCellWave(float3 position, float3 flow, float seed) + { + var cell = math.floor(position); + var local = math.frac(position); + var weights = local * local * (3.0f - 2.0f * local); + var x00 = CellWave(position, flow, cell + new float3(0, 0, 0), seed); + var x10 = CellWave(position, flow, cell + new float3(1, 0, 0), seed); + var x01 = CellWave(position, flow, cell + new float3(0, 1, 0), seed); + var x11 = CellWave(position, flow, cell + new float3(1, 1, 0), seed); + var y00 = math.lerp(x00, x10, weights.x); + var y10 = math.lerp(x01, x11, weights.x); + var z0 = math.lerp(y00, y10, weights.y); + var x00z = CellWave(position, flow, cell + new float3(0, 0, 1), seed); + var x10z = CellWave(position, flow, cell + new float3(1, 0, 1), seed); + var x01z = CellWave(position, flow, cell + new float3(0, 1, 1), seed); + var x11z = CellWave(position, flow, cell + new float3(1, 1, 1), seed); + var z1 = math.lerp(math.lerp(x00z, x10z, weights.x), math.lerp(x01z, x11z, weights.x), weights.y); + return math.lerp(z0, z1, weights.z); + } + + private static float CellWave(float3 position, float3 flow, float3 cell, float seed) + { + var pivot = cell + new float3( + math.hash(cell + seed + 11.7f), + math.hash(cell.yzx + seed + 37.1f), + math.hash(cell.zyx + seed + 73.9f)); + return math.dot(position - pivot, flow) * math.TAU + math.hash(cell + seed + 101.3f) * math.TAU; + } + + private static float3 StableTangent(float3 position) + { + var axis = math.abs(position.z) < 0.8f ? new float3(0, 0, 1) : new float3(0, 1, 0); + return math.normalize(math.cross(axis, position)); + } + + private static float EaseOut(float value) => 1.0f - (1.0f - value) * (1.0f - value); + private static float PowInverse(float value, float power) => 1.0f - math.pow(1.0f - math.saturate(value), math.max(power, 0.001f)); +} diff --git a/src/GameCult.Geometry/README.md b/src/GameCult.Geometry/README.md index 200f14ed..42c681cb 100644 --- a/src/GameCult.Geometry/README.md +++ b/src/GameCult.Geometry/README.md @@ -1,8 +1,8 @@ # GameCult.Geometry -`GameCult.Geometry` defines shared geometry values plus CultCache-native -documents for procedural geometry domains, LOD build requests, selected-cut -manifests, and mesh chunk artifacts. +`GameCult.Geometry` owns spatial values, planetary fields and topology, +residual-page residency, map projections and tiles, geometry queries, and the +CultCache-native documents that coordinate geometry production. The package is deliberately substrate-shaped: @@ -20,8 +20,8 @@ The package is deliberately substrate-shaped: The small value layer is for cross-runtime query contracts, physics probes, native slices, and UI surfaces: -- `CultVec2` and `CultVec3`: whole-vector values for positions, velocities, - accelerations, and query inputs. SoA layouts may store them efficiently, but +- `CultMath.float2` and `CultMath.float3`: whole-vector values for positions, + velocities, accelerations, and query inputs. SoA layouts may store them efficiently, but the semantic API does not force callers to split x/y/z into unrelated fields. - `CultRect`: canonical XY viewport/query rectangle stored as min/max corners. - `CultCircle`: 2D influence/query brush with rect intersection helpers. @@ -33,10 +33,10 @@ runtime equivalents instead of bespoke `{ minX, minY, maxX, maxY }` payloads. ## Runtime Path -`vg-csg` emits domain documents, selected-cut manifests, and chunk artifacts as -MessagePack CultCache documents. A Unity runtime, local worker, or remote -geometry process should receive the same records through CultMesh/CultNet and -read them back as `CultGeometry*` types. +Geometry workers emit domain documents, selected-cut manifests, and chunk +artifacts as MessagePack CultCache documents. A Unity runtime, local worker, or +remote geometry process receives the same records through CultMesh/CultNet and +reads them back as `CultGeometry*` types. ```csharp using GameCult.Caching; @@ -44,11 +44,7 @@ using GameCult.Geometry; using GameCult.Mesh; using GameCult.Networking; -var geometryDocuments = new CultNetDocumentRegistry(CultDocumentRegistry.Shared) - .Register(CultNetDocumentBinding.ForDocument()) - .Register(CultNetDocumentBinding.ForDocument()) - .Register(CultNetDocumentBinding.ForDocument()) - .Register(CultNetDocumentBinding.ForDocument()); +var geometryDocuments = new CultNetDocumentRegistry(CultDocumentRegistry.Shared); using var node = await CultMesh.CreateNodeAsync("ragnarok-geometry.ccmp", new CultMeshNodeOptions { @@ -64,20 +60,33 @@ using var chunks = node.Database .Subscribe(change => ApplyChunk(change.Document)); ``` +The generated CultDocument metadata owns geometry registration. Consumers pass +the shared registry into CultNet; they do not maintain a second manual binding +list that can drift when the schema cohort changes. + The record key is part of the contract. Rust and C# must compute the same key for the same geometry record, so stable fingerprints use the Rust canonical -field order and invariant float text. If this drifts, the same chunk becomes two +field order and exact IEEE-754 component bits. If this drifts, the same chunk becomes two different chunks depending on which runtime touched it first. That is not a cache miss; that is split-brain geometry. ## Documents -- `CultVec2`, `CultVec3`, `CultRect`, `CultCircle`, `CultSphere`: shared value - primitives for query and view contracts. +- `CultMath.float2`, `CultMath.float3`, `CultRect`, `CultCircle`, and + `CultSphere`: shared value primitives for query and view contracts. - `CultGeometryDomainDocument`: one hierarchical domain tree, suitable for - Ragnarok/Fensalir-style feature DSL output. + hierarchical feature DSL output. - `CultGeometryBuildRequest`: one LOD/frustum/budget request for workers. - `CultGeometrySelectedCutManifest`: the selected domain cut, deferred children, fallback rows, and diagnostic contribution rows. - `CultGeometryChunkArtifact`: render mesh, optional collider mesh, source domain/claim identities, build counters, and transition clipping seed. + +## Planetary geometry + +The planetary field, cube-sphere topology, pages, residency, projections, +queries, and versioned `CMPT` map tiles are described in +[`docs/planetary.md`](docs/planetary.md). Shader consumers include +`Shaders/GameCult.Geometry.hlsl`; Unity consumers use the +`org.gamecult.geometry` package, and browser tile consumers use +`packages/gamecult-geometry-ts`. diff --git a/src/GameCult.Geometry/Shaders/AdvancedErosionFilter.hlsl b/src/GameCult.Geometry/Shaders/AdvancedErosionFilter.hlsl new file mode 100644 index 00000000..219afc86 --- /dev/null +++ b/src/GameCult.Geometry/Shaders/AdvancedErosionFilter.hlsl @@ -0,0 +1,147 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. +// Mathematical procedure adapted from Rune Skovbo Johansen's Advanced Terrain +// Erosion Filter (2025): https://www.shadertoy.com/view/wXcfWn + +#ifndef GAMECULT_GEOMETRY_ADVANCED_EROSION_FILTER_HLSL +#define GAMECULT_GEOMETRY_ADVANCED_EROSION_FILTER_HLSL + +struct GameCultGeometryAdvancedErosionParameters +{ + float scale; float strength; float gully_weight; float detail; + float4 rounding; float4 onset; float2 assumed_slope; + float cell_scale; float normalization; int octaves; float lacunarity; float gain; +}; + +struct GameCultGeometryAdvancedErosionResult +{ + float3 delta; float magnitude; float ridge_map; float fade_target; +}; + +struct GameCultGeometryErosionBandSelection +{ + int active_octaves; float final_octave_weight; float finest_included_wavelength; float unresolved_height_bound; +}; + +GameCultGeometryErosionBandSelection gamecult_geometry_select_erosion_bands(float base_wavelength, float sample_spacing, int maximum_octaves, float lacunarity, float base_amplitude, float gain, float transition_ratio) +{ + base_wavelength=max(base_wavelength,1.0e-6); sample_spacing=max(sample_spacing,0.0); lacunarity=max(lacunarity,1.01); transition_ratio=max(transition_ratio,1.01); maximum_octaves=min(max(maximum_octaves,0),16); + float nyquist=sample_spacing*2.0, wavelength=base_wavelength, final_weight=0.0, finest=0.0; int active=0; + [loop] for(int octave=0; octave=active) omitted+=amplitude; else if(index==active-1) omitted+=amplitude*(1.0-final_weight); amplitude*=abs(gain); + } + GameCultGeometryErosionBandSelection result; result.active_octaves=active; result.final_octave_weight=active>0?final_weight:0.0; result.finest_included_wavelength=finest; result.unresolved_height_bound=omitted; return result; +} + +float gamecult_geometry_erosion_hash_unit(int2 cell, uint salt) +{ + uint value = (uint)cell.x * 0x8da6b343u ^ (uint)cell.y * 0xd8163841u ^ salt; + value ^= value >> 16; value *= 0x7feb352du; value ^= value >> 15; value *= 0x846ca68bu; value ^= value >> 16; + return (value & 0x00ffffffu) * (1.0 / 16777216.0); +} +float2 gamecult_geometry_erosion_hash2(float2 input) +{ + int2 cell = (int2)input; + return float2(gamecult_geometry_erosion_hash_unit(cell, 0x68bc21ebu), gamecult_geometry_erosion_hash_unit(cell, 0x02e5be93u)) * 2.0 - 1.0; +} + +float4 gamecult_geometry_phacelle_noise(float2 position, float2 normal_direction, float frequency, float offset_cycles, float normalization) +{ + float2 side_direction = float2(-normal_direction.y, normal_direction.x) * frequency * 6.28318530718; + float offset = offset_cycles * 6.28318530718; + float2 cell = floor(position); + float2 local = frac(position); + float2 phase = 0.0; + float weight_sum = 0.0; + [unroll] for (int x = -1; x <= 2; x++) + [unroll] for (int y = -1; y <= 2; y++) + { + float2 grid_offset = float2(x, y); + float2 grid_point = cell + grid_offset; + float2 random_offset = gamecult_geometry_erosion_hash2(grid_point) * 0.5; + float2 delta = local - grid_offset - random_offset; + float weight = max(0.0, exp(-dot(delta, delta) * 2.0) - 0.01111); + weight_sum += weight; + float wave = dot(delta, side_direction) + offset; + phase += float2(cos(wave), sin(wave)) * weight; + } + float2 interpolated = phase / max(weight_sum, 1.0e-20); + float magnitude = max(1.0 - saturate(normalization), length(interpolated)); + return float4(interpolated / max(magnitude, 1.0e-20), side_direction); +} + +float gamecult_geometry_erosion_pow_inverse(float value, float power) { return 1.0 - pow(1.0 - saturate(value), power); } +float gamecult_geometry_erosion_ease_out(float value) { float inverse = 1.0 - saturate(value); return 1.0 - inverse * inverse; } +float gamecult_geometry_erosion_smooth_start(float value, float smoothing) +{ + return smoothing <= 1.0e-10 || value >= smoothing ? value - 0.5 * smoothing : 0.5 * value * value / smoothing; +} +float2 gamecult_geometry_erosion_safe_normalize(float2 value) +{ + float value_length = length(value); + return abs(value_length) > 1.0e-10 ? value / value_length : value; +} + +GameCultGeometryAdvancedErosionResult gamecult_geometry_advanced_erosion_filter_banded(float2 position, float3 base_height_and_slope, float fade_target, GameCultGeometryAdvancedErosionParameters p, GameCultGeometryErosionBandSelection band) +{ + float strength = p.strength * p.scale; + fade_target = clamp(fade_target, -1.0, 1.0); + float3 input_value = base_height_and_slope; + float3 height_and_slope = input_value; + float frequency = 1.0 / max(p.scale * p.cell_scale, 1.0e-10); + float2 slope = height_and_slope.yz; + float slope_length = max(length(slope), 1.0e-10); + float magnitude = 0.0; + float rounding_multiplier = 1.0; + float input_rounding = lerp(p.rounding.y, p.rounding.x, saturate(fade_target + 0.5)) * p.rounding.z; + float combined_mask = gamecult_geometry_erosion_ease_out(gamecult_geometry_erosion_smooth_start(slope_length * p.onset.x, input_rounding * p.onset.x)); + float ridge_mask = gamecult_geometry_erosion_ease_out(slope_length * p.onset.z); + float ridge_fade = fade_target; + float2 gully_slope = lerp(slope, slope / slope_length * p.assumed_slope.x, p.assumed_slope.y); + + int octave_count=min(min(max(p.octaves,0),8),max(band.active_octaves,0)); + [loop] for (int octave = 0; octave < octave_count; octave++) + { + float4 phacelle = gamecult_geometry_phacelle_noise(position * frequency, gamecult_geometry_erosion_safe_normalize(gully_slope), p.cell_scale, 0.25, p.normalization); + float2 derivative_direction = phacelle.zw * -frequency; + float sloping = abs(phacelle.y); + gully_slope += sign(phacelle.y) * derivative_direction * strength * p.gully_weight; + float3 gullies = float3(phacelle.x, phacelle.y * derivative_direction.x, phacelle.y * derivative_direction.y); + float3 faded = lerp(float3(fade_target, 0.0, 0.0), gullies * p.gully_weight, combined_mask); + float octave_weight=octave==octave_count-1?saturate(band.final_octave_weight):1.0; + height_and_slope += faded * strength * octave_weight; + magnitude += strength * octave_weight; + fade_target = faded.x; + float octave_rounding = lerp(p.rounding.y, p.rounding.x, saturate(phacelle.x + 0.5)) * rounding_multiplier; + float new_mask = gamecult_geometry_erosion_ease_out(gamecult_geometry_erosion_smooth_start(sloping * p.onset.y, octave_rounding * p.onset.y)); + combined_mask = gamecult_geometry_erosion_pow_inverse(combined_mask, p.detail) * new_mask; + ridge_fade = lerp(ridge_fade, lerp(ridge_fade, gullies.x, ridge_mask), octave_weight); + ridge_mask *= lerp(1.0, gamecult_geometry_erosion_ease_out(sloping * p.onset.w), octave_weight); + strength *= p.gain; + frequency *= p.lacunarity; + rounding_multiplier *= p.rounding.w; + } + + GameCultGeometryAdvancedErosionResult result; + result.delta = height_and_slope - input_value; + result.magnitude = magnitude; + result.ridge_map = ridge_fade * (1.0 - ridge_mask); + result.fade_target = fade_target; + return result; +} + +GameCultGeometryAdvancedErosionResult gamecult_geometry_advanced_erosion_filter(float2 position, float3 base_height_and_slope, float fade_target, GameCultGeometryAdvancedErosionParameters p) +{ + GameCultGeometryErosionBandSelection band; band.active_octaves=p.octaves; band.final_octave_weight=1.0; band.finest_included_wavelength=0.0; band.unresolved_height_bound=0.0; + return gamecult_geometry_advanced_erosion_filter_banded(position, base_height_and_slope, fade_target, p, band); +} + +#endif diff --git a/src/GameCult.Geometry/Shaders/AdvancedErosionFilter.hlsl.meta b/src/GameCult.Geometry/Shaders/AdvancedErosionFilter.hlsl.meta new file mode 100644 index 00000000..015c51c2 --- /dev/null +++ b/src/GameCult.Geometry/Shaders/AdvancedErosionFilter.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3252ac02e8853ef4b9d1fbc5204b5c6b +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/GameCult.Geometry/Shaders/GameCult.Geometry.hlsl b/src/GameCult.Geometry/Shaders/GameCult.Geometry.hlsl new file mode 100644 index 00000000..00623b75 --- /dev/null +++ b/src/GameCult.Geometry/Shaders/GameCult.Geometry.hlsl @@ -0,0 +1,14 @@ +#ifndef GAMECULT_GEOMETRY_HLSL +#define GAMECULT_GEOMETRY_HLSL + +#if defined(GAMECULT_GEOMETRY_UNITY_PACKAGE) +#include "Packages/org.gamecult.cultmath/shaders/CultMath.hlsl" +#else +#include "CultMath/CultMath.hlsl" +#endif +#include "AdvancedErosionFilter.hlsl" +#include "Planetary.hlsl" +#include "SphericalErosion.hlsl" +#include "PlanetaryRadialRefinement.hlsl" + +#endif diff --git a/src/GameCult.Geometry/Shaders/GameCult.Geometry.hlsl.meta b/src/GameCult.Geometry/Shaders/GameCult.Geometry.hlsl.meta new file mode 100644 index 00000000..d685ccce --- /dev/null +++ b/src/GameCult.Geometry/Shaders/GameCult.Geometry.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b253fa50ddd34bf3b69fb323fe6e00e8 +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/GameCult.Geometry/Shaders/Planetary.hlsl b/src/GameCult.Geometry/Shaders/Planetary.hlsl new file mode 100644 index 00000000..8e16bfff --- /dev/null +++ b/src/GameCult.Geometry/Shaders/Planetary.hlsl @@ -0,0 +1,392 @@ +#ifndef GAMECULT_GEOMETRY_PLANETARY_HLSL +#define GAMECULT_GEOMETRY_PLANETARY_HLSL + +static const float GAMECULT_GEOMETRY_PLANETARY_QUARTER_PI = 0.78539816339744830962; + +float3 gamecult_geometry_planetary_face_direction(int face, float2 coordinate) +{ + float2 tangent = tan(coordinate * GAMECULT_GEOMETRY_PLANETARY_QUARTER_PI); + float3 cube = face == 0 ? float3(1.0, tangent.y, -tangent.x) + : face == 1 ? float3(-1.0, tangent.y, tangent.x) + : face == 2 ? float3(tangent.x, 1.0, -tangent.y) + : face == 3 ? float3(tangent.x, -1.0, tangent.y) + : face == 4 ? float3(tangent.x, tangent.y, 1.0) + : float3(-tangent.x, tangent.y, -1.0); + return cultmath_normalize(cube); +} + +float2 gamecult_geometry_planetary_face_coordinate(float3 direction, out int face) +{ + float3 d = cultmath_normalize(direction); + float3 a = abs(d); + float2 cube_uv; + if (a.x >= a.y && a.x >= a.z) + { + if (d.x >= 0.0) { face = 0; cube_uv = float2(-d.z, d.y) / a.x; } + else { face = 1; cube_uv = float2(d.z, d.y) / a.x; } + } + else if (a.y >= a.z) + { + if (d.y >= 0.0) { face = 2; cube_uv = float2(d.x, -d.z) / a.y; } + else { face = 3; cube_uv = float2(d.x, d.z) / a.y; } + } + else + { + if (d.z >= 0.0) { face = 4; cube_uv = d.xy / a.z; } + else { face = 5; cube_uv = float2(-d.x, d.y) / a.z; } + } + return atan(cube_uv) / GAMECULT_GEOMETRY_PLANETARY_QUARTER_PI; +} + +bool gamecult_geometry_planetary_page_local(float3 direction, float4 tile_address, float active, out float2 local) +{ + int face; + float2 uv = gamecult_geometry_planetary_face_coordinate(direction, face); + local = 0.0; + if (active < 0.5 || abs((float)face - tile_address.x) > 0.25) return false; + float axis_tiles = exp2(tile_address.y); + float2 scaled = (uv * 0.5 + 0.5) * axis_tiles; + float2 canonical = floor(min(scaled, axis_tiles - 1.0e-5)); + if (any(abs(canonical - tile_address.zw) > 0.25)) return false; + local = scaled - tile_address.zw; + return all(local >= 0.0) && all(local <= 1.0); +} + +float3 gamecult_geometry_planetary_surface_normal(float3 direction, float3 world_distance_tangent_gradient) +{ + direction = cultmath_normalize(direction); + float3 tangent_gradient = world_distance_tangent_gradient - direction * dot(world_distance_tangent_gradient, direction); + return cultmath_normalize(direction - tangent_gradient); +} + +float2 gamecult_geometry_planetary_equirectangular_forward(float3 direction) +{ + direction = cultmath_normalize(direction); + return float2(atan2(direction.y, direction.x) / CULTMATH_PI, asin(clamp(direction.z, -1.0, 1.0)) / CULTMATH_HALF_PI); +} + +float3 gamecult_geometry_planetary_equirectangular_inverse(float2 coordinate) +{ + float longitude = coordinate.x * CULTMATH_PI; + float latitude = coordinate.y * CULTMATH_HALF_PI; + float cos_latitude = cos(latitude); + return cultmath_normalize(float3(cos_latitude * cos(longitude), cos_latitude * sin(longitude), sin(latitude))); +} + +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_A1 = 1.340264; +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_A2 = -0.081106; +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_A3 = 0.000893; +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_A4 = 0.003796; +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_M = 0.8660254037844386; +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_X_MAX = 2.706629; +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_Y_MAX = 1.3173628; + +float gamecult_geometry_equal_earth_y(float theta) +{ + float theta2 = theta * theta; + float theta6 = theta2 * theta2 * theta2; + return theta * (GAMECULT_GEOMETRY_EQUAL_EARTH_A1 + GAMECULT_GEOMETRY_EQUAL_EARTH_A2 * theta2 + theta6 * (GAMECULT_GEOMETRY_EQUAL_EARTH_A3 + GAMECULT_GEOMETRY_EQUAL_EARTH_A4 * theta2)); +} + +float2 gamecult_geometry_planetary_equal_earth_forward(float3 direction) +{ + direction = cultmath_normalize(direction); + float longitude = atan2(direction.y, direction.x); + float latitude = asin(clamp(direction.z, -1.0, 1.0)); + float theta = asin(GAMECULT_GEOMETRY_EQUAL_EARTH_M * sin(latitude)); + float theta2 = theta * theta; + float theta6 = theta2 * theta2 * theta2; + float denominator = GAMECULT_GEOMETRY_EQUAL_EARTH_M * (GAMECULT_GEOMETRY_EQUAL_EARTH_A1 + 3.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A2 * theta2 + theta6 * (7.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A3 + 9.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A4 * theta2)); + return float2(longitude * cos(theta) / denominator / GAMECULT_GEOMETRY_EQUAL_EARTH_X_MAX, gamecult_geometry_equal_earth_y(theta) / GAMECULT_GEOMETRY_EQUAL_EARTH_Y_MAX); +} + +float3 gamecult_geometry_planetary_equal_earth_inverse(float2 coordinate) +{ + float target_y = coordinate.y * GAMECULT_GEOMETRY_EQUAL_EARTH_Y_MAX; + float theta = target_y / GAMECULT_GEOMETRY_EQUAL_EARTH_A1; + [unroll] for (int iteration = 0; iteration < 8; iteration++) + { + float theta2 = theta * theta; + float theta6 = theta2 * theta2 * theta2; + float derivative = GAMECULT_GEOMETRY_EQUAL_EARTH_A1 + 3.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A2 * theta2 + theta6 * (7.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A3 + 9.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A4 * theta2); + theta -= (gamecult_geometry_equal_earth_y(theta) - target_y) / derivative; + } + float latitude = asin(clamp(sin(theta) / GAMECULT_GEOMETRY_EQUAL_EARTH_M, -1.0, 1.0)); + float theta2 = theta * theta; + float theta6 = theta2 * theta2 * theta2; + float denominator = GAMECULT_GEOMETRY_EQUAL_EARTH_M * (GAMECULT_GEOMETRY_EQUAL_EARTH_A1 + 3.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A2 * theta2 + theta6 * (7.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A3 + 9.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A4 * theta2)); + float longitude = coordinate.x * GAMECULT_GEOMETRY_EQUAL_EARTH_X_MAX * denominator / cos(theta); + float cos_latitude = cos(latitude); + return cultmath_normalize(float3(cos_latitude * cos(longitude), cos_latitude * sin(longitude), sin(latitude))); +} + +float gamecult_geometry_planetary_wrap_longitude(float longitude) +{ + longitude = fmod(longitude, CULTMATH_TAU); + if (longitude > CULTMATH_PI) longitude -= CULTMATH_TAU; + if (longitude < -CULTMATH_PI) longitude += CULTMATH_TAU; + return longitude; +} + +float3 gamecult_geometry_planetary_lon_lat_direction(float longitude, float latitude) +{ + float cos_latitude = cos(latitude); + return cultmath_normalize(float3(cos_latitude * cos(longitude), cos_latitude * sin(longitude), sin(latitude))); +} + +float gamecult_geometry_planetary_asinh(float value) { return log(value + sqrt(value * value + 1.0)); } +float gamecult_geometry_planetary_sinh(float value) { return 0.5 * (exp(value) - exp(-value)); } + +bool gamecult_geometry_planetary_azimuthal_forward(float3 direction, float2 center_lon_lat, int kind, out float2 coordinate) +{ + direction = cultmath_normalize(direction); + float longitude = gamecult_geometry_planetary_wrap_longitude(atan2(direction.y, direction.x) - center_lon_lat.x); + float latitude = asin(clamp(direction.z, -1.0, 1.0)); + float sin0 = sin(center_lon_lat.y), cos0 = cos(center_lon_lat.y); + float sin_latitude = sin(latitude), cos_latitude = cos(latitude), cos_longitude = cos(longitude); + float cos_c = clamp(sin0 * sin_latitude + cos0 * cos_latitude * cos_longitude, -1.0, 1.0); + float2 raw = float2(cos_latitude * sin(longitude), cos0 * sin_latitude - sin0 * cos_latitude * cos_longitude); + float factor = 1.0; + if (kind == 3) + { + if (cos_c < 0.0) { coordinate = 0.0; return false; } + } + else if (kind == 4) + { + float c = acos(cos_c); factor = (c < 1.0e-6 ? 1.0 : c / sin(c)) / CULTMATH_PI; + } + else if (kind == 5) factor = sqrt(2.0 / max(1.0 + cos_c, 1.0e-20)) * 0.5; + else + { + if (cos_c <= 0.0) { coordinate = 0.0; return false; } + factor = 1.0 / cos_c; + } + coordinate = raw * factor; + return true; +} + +bool gamecult_geometry_planetary_azimuthal_inverse(float2 coordinate, float2 center_lon_lat, int kind, out float3 direction) +{ + float rho = length(coordinate); + float c; + if (kind == 3) { if (rho > 1.0) { direction = 0.0; return false; } c = asin(clamp(rho, 0.0, 1.0)); } + else if (kind == 4) { if (rho > 1.0) { direction = 0.0; return false; } c = rho * CULTMATH_PI; } + else if (kind == 5) { if (rho > 1.0) { direction = 0.0; return false; } c = 2.0 * asin(clamp(rho, 0.0, 1.0)); } + else c = atan(rho); + if (rho < 1.0e-6) { direction = gamecult_geometry_planetary_lon_lat_direction(center_lon_lat.x, center_lon_lat.y); return true; } + float sin_c = sin(c), cos_c = cos(c), sin0 = sin(center_lon_lat.y), cos0 = cos(center_lon_lat.y); + float latitude = asin(clamp(cos_c * sin0 + coordinate.y * sin_c * cos0 / rho, -1.0, 1.0)); + float delta_longitude = atan2(coordinate.x * sin_c, rho * cos0 * cos_c - coordinate.y * sin0 * sin_c); + direction = gamecult_geometry_planetary_lon_lat_direction(gamecult_geometry_planetary_wrap_longitude(delta_longitude + center_lon_lat.x), latitude); + return true; +} + +bool gamecult_geometry_planetary_cube_atlas_forward(float3 direction, out float2 coordinate) +{ + int face; + float2 face_coordinate = gamecult_geometry_planetary_face_coordinate(direction, face); + int column = face % 3; + int row = face / 3; + coordinate = float2(-1.0 + (column + (face_coordinate.x + 1.0) * 0.5) * (2.0 / 3.0), 1.0 - (row + (face_coordinate.y + 1.0) * 0.5)); + return true; +} + +bool gamecult_geometry_planetary_cube_atlas_inverse(float2 coordinate, out float3 direction) +{ + if (any(coordinate < -1.0) || any(coordinate > 1.0)) { direction = 0.0; return false; } + int column = min((int)((coordinate.x + 1.0) * 1.5), 2); + int row = min((int)(1.0 - coordinate.y), 1); + float u = ((coordinate.x + 1.0) * 1.5 - column) * 2.0 - 1.0; + float v = ((1.0 - coordinate.y) - row) * 2.0 - 1.0; + direction = gamecult_geometry_planetary_face_direction(row * 3 + column, float2(u, v)); + return true; +} + +bool gamecult_geometry_planetary_projection_forward(float3 direction, int kind, float3 center_lon_lat_scale, out float2 coordinate) +{ + float scale = max(center_lon_lat_scale.z, 1.0e-20); + if (kind == 6) + { + bool valid = gamecult_geometry_planetary_cube_atlas_forward(direction, coordinate); + coordinate /= scale; return valid; + } + if (kind >= 3) + { + bool valid = gamecult_geometry_planetary_azimuthal_forward(direction, center_lon_lat_scale.xy, kind, coordinate); + coordinate /= scale; return valid; + } + direction = cultmath_normalize(direction); + float longitude = gamecult_geometry_planetary_wrap_longitude(atan2(direction.y, direction.x) - center_lon_lat_scale.x); + float latitude = asin(clamp(direction.z, -1.0, 1.0)); + if (kind == 0) coordinate = float2(longitude / CULTMATH_PI, latitude / CULTMATH_HALF_PI); + else if (kind == 1) + { + const float maximum_latitude = 1.4844222297453324; + if (abs(latitude) > maximum_latitude) { coordinate = 0.0; return false; } + coordinate = float2(longitude / CULTMATH_PI, gamecult_geometry_planetary_asinh(tan(latitude)) / CULTMATH_PI); + } + else + { + float3 centered = gamecult_geometry_planetary_lon_lat_direction(longitude, latitude); + coordinate = gamecult_geometry_planetary_equal_earth_forward(centered); + } + coordinate /= scale; + return true; +} + +bool gamecult_geometry_planetary_projection_inverse(float2 coordinate, int kind, float3 center_lon_lat_scale, out float3 direction) +{ + coordinate *= max(center_lon_lat_scale.z, 1.0e-20); + if (kind == 6) return gamecult_geometry_planetary_cube_atlas_inverse(coordinate, direction); + if (kind >= 3) return gamecult_geometry_planetary_azimuthal_inverse(coordinate, center_lon_lat_scale.xy, kind, direction); + if (any(abs(coordinate) > 1.0)) { direction = 0.0; return false; } + float longitude, latitude; + if (kind == 0) { longitude = coordinate.x * CULTMATH_PI; latitude = coordinate.y * CULTMATH_HALF_PI; } + else if (kind == 1) { longitude = coordinate.x * CULTMATH_PI; latitude = atan(gamecult_geometry_planetary_sinh(coordinate.y * CULTMATH_PI)); } + else + { + float3 centered = gamecult_geometry_planetary_equal_earth_inverse(coordinate); + longitude = atan2(centered.y, centered.x); latitude = asin(clamp(centered.z, -1.0, 1.0)); + } + direction = gamecult_geometry_planetary_lon_lat_direction(gamecult_geometry_planetary_wrap_longitude(longitude + center_lon_lat_scale.x), latitude); + return true; +} + +struct GameCultGeometryPlanetaryFieldDefinition +{ + float radius; + int seed; + GameCultGeometryAdvancedErosionParameters erosion; +}; + +struct GameCultGeometryPlanetaryBaseFieldSample +{ + float radial_displacement; + float3 radial_gradient; + float field_value; + float3 field_gradient; + float fade_target; +}; + +struct GameCultGeometryPlanetarySurfaceSample +{ + float radial_displacement; + float3 tangent_gradient; + float3 surface_normal; + float slope; + float ridge; + float gully; + float finest_resolved_wavelength; + float unresolved_height_bound; +}; + +struct GameCultGeometryPlanetaryPageSample +{ + float4 height_gradient; + float2 masks; +}; + +GameCultGeometryPlanetaryPageSample gamecult_geometry_planetary_page_lerp( + GameCultGeometryPlanetaryPageSample a, + GameCultGeometryPlanetaryPageSample b, + GameCultGeometryPlanetaryPageSample c, + GameCultGeometryPlanetaryPageSample d, + float2 fraction) +{ + GameCultGeometryPlanetaryPageSample result; + result.height_gradient = lerp(lerp(a.height_gradient, b.height_gradient, fraction.x), lerp(c.height_gradient, d.height_gradient, fraction.x), fraction.y); + result.masks = lerp(lerp(a.masks, b.masks, fraction.x), lerp(c.masks, d.masks, fraction.x), fraction.y); + return result; +} + +GameCultGeometryPlanetaryPageSample gamecult_geometry_planetary_residual_sample( + GameCultGeometryPlanetarySurfaceSample child, + GameCultGeometryPlanetarySurfaceSample parent, + bool has_parent) +{ + GameCultGeometryPlanetaryPageSample result; + result.height_gradient = float4(child.radial_displacement, child.tangent_gradient); + if (has_parent) result.height_gradient -= float4(parent.radial_displacement, parent.tangent_gradient); + result.masks = float2(child.ridge, child.gully); + return result; +} + +float4 gamecult_geometry_planetary_summary_empty() +{ + return float4(3.402823466e+38, -3.402823466e+38, 0.0, 0.0); +} + +float4 gamecult_geometry_planetary_summary_accumulate(float4 summary, float4 height_gradient, float unresolved_height) +{ + summary.x = min(summary.x, height_gradient.x); + summary.y = max(summary.y, height_gradient.x); + summary.z = max(summary.z, length(height_gradient.yzw)); + summary.w = max(summary.w, max(unresolved_height, 0.0)); + return summary; +} + +float gamecult_geometry_planetary_pow4(float value) { float square = value * value; return square * square; } + +GameCultGeometryPlanetarySurfaceSample gamecult_geometry_planetary_field_sample( + GameCultGeometryPlanetaryFieldDefinition definition, + float3 unit_direction, + GameCultGeometryPlanetaryBaseFieldSample base_sample, + float footprint_meters) +{ + float3 direction = cultmath_normalize(unit_direction); + float3 radial_gradient = base_sample.radial_gradient - direction * dot(base_sample.radial_gradient, direction); + float3 field_gradient = base_sample.field_gradient - direction * dot(base_sample.field_gradient, direction); + GameCultGeometryAdvancedErosionParameters p = definition.erosion; + GameCultGeometryErosionBandSelection band = gamecult_geometry_select_erosion_bands( + p.scale * p.cell_scale, footprint_meters, p.octaves, p.lacunarity, + p.strength * p.scale, p.gain, 2.0); + float3 powers = float3( + gamecult_geometry_planetary_pow4(abs(direction.x)), + gamecult_geometry_planetary_pow4(abs(direction.y)), + gamecult_geometry_planetary_pow4(abs(direction.z))); + float power_sum = max(powers.x + powers.y + powers.z, 1.0e-6); + float3 weights = powers / power_sum; + float3 world = direction * definition.radius; + float seed = (float)definition.seed; + GameCultGeometryAdvancedErosionResult xy = gamecult_geometry_advanced_erosion_filter_banded( + world.xy + float2(713.0 + seed * 0.754877666, -291.0 + seed * 0.569840296), + float3(base_sample.field_value, field_gradient.x, field_gradient.y), base_sample.fade_target, p, band); + GameCultGeometryAdvancedErosionResult yz = gamecult_geometry_advanced_erosion_filter_banded( + world.yz + float2(-431.0 + seed * 0.438289027, 887.0 + seed * 0.328438163), + float3(base_sample.field_value, field_gradient.y, field_gradient.z), base_sample.fade_target, p, band); + GameCultGeometryAdvancedErosionResult zx = gamecult_geometry_advanced_erosion_filter_banded( + world.zx + float2(197.0 + seed * 0.219783071, 557.0 + seed * 0.145898034), + float3(base_sample.field_value, field_gradient.z, field_gradient.x), base_sample.fade_target, p, band); + float erosion_height = xy.delta.x * weights.z + yz.delta.x * weights.x + zx.delta.x * weights.y; + float3 erosion_gradient = float3( + xy.delta.y * weights.z + zx.delta.z * weights.y, + xy.delta.z * weights.z + yz.delta.y * weights.x, + yz.delta.z * weights.x + zx.delta.y * weights.y); + float3 power_gradient_x = float3(4.0 * direction.x * direction.x * direction.x / definition.radius, 0.0, 0.0); + float3 power_gradient_y = float3(0.0, 4.0 * direction.y * direction.y * direction.y / definition.radius, 0.0); + float3 power_gradient_z = float3(0.0, 0.0, 4.0 * direction.z * direction.z * direction.z / definition.radius); + power_gradient_x -= direction * dot(power_gradient_x, direction); + power_gradient_y -= direction * dot(power_gradient_y, direction); + power_gradient_z -= direction * dot(power_gradient_z, direction); + float3 power_sum_gradient = power_gradient_x + power_gradient_y + power_gradient_z; + float inverse_power_sum_squared = 1.0 / (power_sum * power_sum); + float3 weight_gradient_x = (power_gradient_x * power_sum - power_sum_gradient * powers.x) * inverse_power_sum_squared; + float3 weight_gradient_y = (power_gradient_y * power_sum - power_sum_gradient * powers.y) * inverse_power_sum_squared; + float3 weight_gradient_z = (power_gradient_z * power_sum - power_sum_gradient * powers.z) * inverse_power_sum_squared; + erosion_gradient += yz.delta.x * weight_gradient_x + zx.delta.x * weight_gradient_y + xy.delta.x * weight_gradient_z; + float3 gradient = radial_gradient + erosion_gradient; + gradient -= direction * dot(gradient, direction); + float ridge_evidence = xy.ridge_map * weights.z + yz.ridge_map * weights.x + zx.ridge_map * weights.y; + GameCultGeometryPlanetarySurfaceSample result; + result.radial_displacement = base_sample.radial_displacement + erosion_height; + result.tangent_gradient = gradient; + result.surface_normal = gamecult_geometry_planetary_surface_normal(direction, gradient); + result.slope = length(gradient); + result.ridge = saturate(ridge_evidence * 0.5 + 0.5); + result.gully = saturate(0.5 - ridge_evidence * 0.5); + result.finest_resolved_wavelength = band.finest_included_wavelength; + result.unresolved_height_bound = band.unresolved_height_bound; + return result; +} + +#endif diff --git a/src/GameCult.Geometry/Shaders/Planetary.hlsl.meta b/src/GameCult.Geometry/Shaders/Planetary.hlsl.meta new file mode 100644 index 00000000..009c2989 --- /dev/null +++ b/src/GameCult.Geometry/Shaders/Planetary.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: bb055ad5328888e479e90ae3fe62c3fe +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/GameCult.Geometry/Shaders/PlanetaryRadialRefinement.hlsl b/src/GameCult.Geometry/Shaders/PlanetaryRadialRefinement.hlsl new file mode 100644 index 00000000..20f3e2e5 --- /dev/null +++ b/src/GameCult.Geometry/Shaders/PlanetaryRadialRefinement.hlsl @@ -0,0 +1,27 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +#ifndef GAMECULT_GEOMETRY_PLANETARY_RADIAL_REFINEMENT_HLSL +#define GAMECULT_GEOMETRY_PLANETARY_RADIAL_REFINEMENT_HLSL + +float3 gamecult_geometry_planetary_radial_refinement_step( + float3 position, + float3 ray_direction, + float3 center, + float target_radius, + float minimum_derivative, + float maximum_correction) +{ + float3 local=position-center; + float radius=length(local); + float3 radial=local/max(radius,0.0001); + float error=radius-target_radius; + float derivative=dot(ray_direction,radial); + float safe_minimum=max(minimum_derivative,0.0001); + float safe_derivative=abs(derivative)>safe_minimum?derivative:(derivative<0?-safe_minimum:safe_minimum); + float correction=clamp(-error/safe_derivative,-abs(maximum_correction),abs(maximum_correction)); + return position+ray_direction*correction; +} + +#endif diff --git a/src/GameCult.Geometry/Shaders/PlanetaryRadialRefinement.hlsl.meta b/src/GameCult.Geometry/Shaders/PlanetaryRadialRefinement.hlsl.meta new file mode 100644 index 00000000..28d629b1 --- /dev/null +++ b/src/GameCult.Geometry/Shaders/PlanetaryRadialRefinement.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 356389371b854e12b9d419f7ee310d81 +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/GameCult.Geometry/Shaders/SphericalErosion.hlsl b/src/GameCult.Geometry/Shaders/SphericalErosion.hlsl new file mode 100644 index 00000000..107ba24b --- /dev/null +++ b/src/GameCult.Geometry/Shaders/SphericalErosion.hlsl @@ -0,0 +1,80 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +#ifndef GAMECULT_GEOMETRY_SPHERICAL_EROSION_HLSL +#define GAMECULT_GEOMETRY_SPHERICAL_EROSION_HLSL + +struct GameCultGeometrySphericalErosionParameters +{ + float frequency; float amplitude; float lacunarity; float gain; + float slope_strength; float detail; float seed; int octaves; +}; + +struct GameCultGeometrySphericalErosionSample +{ + float height_offset; float ridge; float gully; float3 gradient; +}; + +float gamecult_geometry_spherical_cell_wave(float3 position, float3 flow, float3 cell, float seed) +{ + float3 pivot = cell + float3(cultmath_hash(cell + seed + 11.7), cultmath_hash(cell.yzx + seed + 37.1), cultmath_hash(cell.zyx + seed + 73.9)); + return dot(position - pivot, flow) * 6.28318530718 + cultmath_hash(cell + seed + 101.3) * 6.28318530718; +} + +float gamecult_geometry_spherical_directional_wave(float3 position, float3 flow, float seed) +{ + float3 cell = floor(position); + float3 local = cultmath_frac(position); + float3 weights = local * local * (3.0 - 2.0 * local); + float z0 = cultmath_lerp( + cultmath_lerp(gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(0,0,0), seed), gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(1,0,0), seed), weights.x), + cultmath_lerp(gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(0,1,0), seed), gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(1,1,0), seed), weights.x), weights.y); + float z1 = cultmath_lerp( + cultmath_lerp(gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(0,0,1), seed), gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(1,0,1), seed), weights.x), + cultmath_lerp(gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(0,1,1), seed), gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(1,1,1), seed), weights.x), weights.y); + return cultmath_lerp(z0, z1, weights.z); +} + +float3 gamecult_geometry_spherical_stable_tangent(float3 position) +{ + float3 axis = abs(position.z) < 0.8 ? float3(0,0,1) : float3(0,1,0); + return cultmath_normalize(cross(axis, position)); +} + +GameCultGeometrySphericalErosionSample gamecult_geometry_spherical_erosion(float3 unit_position, float base_height, float3 base_gradient, GameCultGeometrySphericalErosionParameters p) +{ + float3 position = cultmath_normalize(unit_position); + float3 gradient = base_gradient - position * dot(base_gradient, position); + float slope = length(gradient); + float3 flow = slope > 1.0e-6 ? -gradient / slope : gamecult_geometry_spherical_stable_tangent(position); + float frequency = max(p.frequency, 0.001); + float amplitude = p.amplitude; + float combi_mask = 1.0 - pow(1.0 - cultmath_saturate(slope * p.slope_strength), 2.0); + float fade_target = cultmath_saturate(base_height * 0.5 + 0.5) * 2.0 - 1.0; + GameCultGeometrySphericalErosionSample result = (GameCultGeometrySphericalErosionSample)0; + [loop] for (int octave = 0; octave < min(max(p.octaves, 0), 8); octave++) + { + float phase = gamecult_geometry_spherical_directional_wave(position * frequency, flow, p.seed + octave * 19.19); + float wave = cos(phase); + float wave_slope = sin(phase); + float faded = cultmath_lerp(fade_target, wave, combi_mask); + result.height_offset += faded * amplitude; + result.ridge = max(result.ridge, cultmath_saturate(faded)); + result.gully = max(result.gully, cultmath_saturate(-faded)); + float3 across = cultmath_normalize(cross(position, flow)); + gradient += across * (sign(wave_slope) * amplitude * frequency * combi_mask); + gradient -= position * dot(gradient, position); + slope = length(gradient); + flow = slope > 1.0e-6 ? -gradient / slope : gamecult_geometry_spherical_stable_tangent(position); + float new_mask = 1.0 - pow(1.0 - cultmath_saturate(abs(wave_slope) * 1.35), 2.0); + combi_mask = (1.0 - pow(1.0 - cultmath_saturate(combi_mask), max(p.detail, 0.001))) * new_mask; + fade_target = faded; + frequency *= max(p.lacunarity, 1.01); + amplitude *= cultmath_saturate(p.gain); + } + result.gradient = gradient; + return result; +} + +#endif diff --git a/src/GameCult.Geometry/Shaders/SphericalErosion.hlsl.meta b/src/GameCult.Geometry/Shaders/SphericalErosion.hlsl.meta new file mode 100644 index 00000000..7b7ddc79 --- /dev/null +++ b/src/GameCult.Geometry/Shaders/SphericalErosion.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 43d458fc5b6b441a941e37001df919e3 +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/GameCult.Geometry/THIRD-PARTY-NOTICES.md b/src/GameCult.Geometry/THIRD-PARTY-NOTICES.md new file mode 100644 index 00000000..3388b601 --- /dev/null +++ b/src/GameCult.Geometry/THIRD-PARTY-NOTICES.md @@ -0,0 +1,24 @@ +# GameCult.Geometry source provenance + +## CultMath planetary geometry + +The C# files under `Planetary/` and the HLSL files under `Shaders/` are licensed +under the Mozilla Public License 2.0 and retain MPL-2.0 notices. CultMath owns +the numeric primitives used by these geometry systems; GameCult.Geometry owns +their spatial meaning. + +## Advanced Terrain Erosion Filter + +`Planetary/AdvancedErosionFilter.cs` and `Shaders/AdvancedErosionFilter.hlsl` +adapt the mathematical procedure from +Rune Skovbo Johansen's **Advanced Terrain Erosion Filter**. + +- Copyright 2025 Rune Skovbo Johansen. +- Upstream: +- Explanation: +- License: Mozilla Public License 2.0, + +The implementation includes C#/HLSL type lowering, bounded octave counts, +finite zero-rounding behavior, and a shared integer avalanche hash for CPU/GPU +cell-selection parity. The technique builds on earlier work credited upstream +to Fewes and Clay John; the hash form follows public shader math by Inigo Quilez. diff --git a/src/GameCult.Geometry/docs/planetary.md b/src/GameCult.Geometry/docs/planetary.md new file mode 100644 index 00000000..94eab553 --- /dev/null +++ b/src/GameCult.Geometry/docs/planetary.md @@ -0,0 +1,135 @@ +# Planetary Field Runtime + +GameCult.Geometry owns the deterministic, renderer-independent planetary field. The same +definition can be queried by a CPU simulation, baked into residual pages, +sampled through a map projection, or evaluated by HLSL. Render resources and +gameplay interpretation remain consumer responsibilities. + +## Authority + +- `PlanetaryFieldDefinition` identifies radius, seed, erosion parameters, and a + version that includes the consumer-owned base-field version. +- `IPlanetaryBaseField` supplies deterministic application-authored base + evidence without making Geometry depend on an application. +- `PlanetaryQueryScale` makes the physical footprint and accepted unresolved + error explicit. There is deliberately no scale-free authoritative + `HeightAt` call. +- `PlanetarySurfaceSample` carries displacement, tangent gradient, normal, + slope, ridge/gully evidence, resolved wavelength, and unresolved bound. +- pages, residency, patches, and map tiles are disposable lowerings of that + contract. + +The C# surface targets .NET and Unity-compatible `netstandard2.1`. The matching +HLSL lives in `Shaders/Planetary.hlsl`. Consumers include +`Shaders/GameCult.Geometry.hlsl`, which imports CultMath's numeric mirror before +the geometry kernels. + +## Point and batch queries + +```csharp +var erosion = AdvancedErosionParameters.Default with +{ + Scale = radius * 0.075f, + Strength = 0.12f, + Octaves = 7, +}; +var field = PlanetaryFieldDefinition.Create( + baseFieldVersion: 17, + radius, + seed: 42, + erosion); +var scale = PlanetaryQueryScale.AtFootprint( + footprintMeters: 10, + maximumUnresolvedHeight: 0.5f); + +PlanetaryBaseFieldSample basis = source.Sample(direction); +PlanetarySurfaceSample sample = PlanetaryField.Sample( + field, direction, basis, scale); +``` + +`PlanetaryField.SampleBatch` writes into a caller-owned span. `SamplePosition` +accepts `double3`, reduces the astronomical position to a stable unit direction, +and then evaluates the same float field contract. + +Higher-level CPU queries include bounded ray intersection, segment clearance, +great-circle profiles, and region summaries. Their answers remain field queries; +only a consuming simulation can turn them into committed game consequences. + +## Cube-sphere topology and pages + +`PlanetaryTopology` owns the tangent/QSC cube-sphere mapping, inverse face +coordinates, tile selection, local page coordinates, and radial-graph surface +normal. `PlanetaryPageSampling` owns bordered directions, nominal spacing, +bilinear sampling, and summaries. + +`PlanetaryPageBaker` evaluates a root page at its physical sample spacing. A +child stores `child filtered field - parent filtered field`. A +`PlanetaryPageSetSampling` query adds every containing ancestor contribution +and applies transition weights only to residuals. + +`PlanetaryLodSelector` selects six roots plus one camera-facing ancestor chain +from the unresolved-height error. `PlanetaryResidualResidency` owns arrival and +departure presentation weights, content and presentation versions, and nothing +about field truth. + +## Patches + +`PlanetaryPatch.CreateFace` and `CreateCubeSphere` produce renderer-neutral +coarse meshes from the canonical topology. HLSL calls +`gamecult_geometry_planetary_face_direction` for procedural vertices and +`gamecult_geometry_planetary_radial_refinement_step` for bounded pixel refinement. + +Renderers still own cameras, clip space, depth, materials, buffers, dispatch, +and draw submission. + +`PlanetaryGpuPageBuilder` owns the renderer-neutral page input and metadata +payload. Host adapters may convert its `float4` values to native vector types, +but do not recalculate directions, spacing, addressing, or transition state. + +The `GameCult.Geometry.Unity` assembly in `org.gamecult.geometry` converts a canonical face patch into a +Unity `Mesh` and converts the shared GPU payload into Unity-native structs. It +owns no field parameters, page selection, or simulation state. The package's +Planetary Field Viewer sample renders the shared HLSL field and reports matching +CPU queries. Unity render code remains responsible for allocating/binding +graphics buffers and dispatching application-specific base-field entry points. +The host Unity project must set `-langversion:latest` in `Assets/csc.rsp`. + +## Maps + +`PlanetaryProjection` supplies forward and inverse transforms for: + +- equirectangular; +- Web Mercator with an explicit polar cutoff; +- Equal Earth; +- orthographic; +- azimuthal equidistant and equal-area; +- cube atlas; +- local tangent/gnomonic views. + +`PlanetaryMapTileBaker` maps each valid tile sample through the inverse +projection and canonical field query. `PlanetaryMapTileKey` includes field, +projection, layer, layout, and query-scale identity. Palettes, contours, labels, +and strategic overlays do not enter GameCult.Geometry's canonical field. + +`PlanetaryMapTileEncoding` writes the resulting evidence as a versioned +little-endian `CMPT` binary asset. +`packages/gamecult-geometry-ts/src/planetary-tile.ts` decodes that exact format +into browser-native values without defining another terrain generator. +Cross-runtime fixtures are generated by +`tools/GameCult.Geometry.PlanetaryTileFixture`. +Typed CultMesh documents should carry the tile key and asset reference; the +binary payload is derived CDN/cache data, not committed game state. + +## Numeric contract + +Integer hashes and published field versions are exact. Basic arithmetic follows +CultMath's matched C#/HLSL semantics. Hardware transcendental functions such as +`atan` and `tan` use measured tolerances rather than fictional bit identity. +Every filtered terrain query carries an unresolved-height bound so consumers can +reject insufficient fidelity explicitly. + +Current D3D12 parity evidence covers the advanced erosion kernel, QSC topology, +and forward/inverse transforms for every supported map projection, including +center and scale parameters. Production page and patch shaders use the common +functions and retain their seam, residual, summary, and radial-hit integration +probes in Fensalir. diff --git a/src/GameCult.Mesh/docs/cross-runtime-primitives-roadmap.md b/src/GameCult.Mesh/docs/cross-runtime-primitives-roadmap.md index 93fb3f3c..53db8c7b 100644 --- a/src/GameCult.Mesh/docs/cross-runtime-primitives-roadmap.md +++ b/src/GameCult.Mesh/docs/cross-runtime-primitives-roadmap.md @@ -121,7 +121,7 @@ var viewport = await aetheria.Zone(zoneId) await aetheria.Entity(ravenId) .Pilot() - .MoveAsync(CultVec2.Right, claim: "pilot-control"); + .MoveAsync(new CultMath.float2(1f, 0f), claim: "pilot-control"); ``` Current seed: @@ -134,7 +134,7 @@ var verse = await CultMesh.ConnectVerseAsync( new[] { new CultMeshAuthorityClaim("pilot-control", shardId: "zone:raven") }); var aetheria = verse.Use(context => new AetheriaGeneratedFacade(context)); -await aetheria.Entity(ravenId).Pilot.MoveAsync(CultVec2.Right); +await aetheria.Entity(ravenId).Pilot.MoveAsync(new CultMath.float2(1f, 0f)); ``` ```ts diff --git a/src/GameCult.Mesh/docs/public-api.md b/src/GameCult.Mesh/docs/public-api.md index 512b3e30..ff00e515 100644 --- a/src/GameCult.Mesh/docs/public-api.md +++ b/src/GameCult.Mesh/docs/public-api.md @@ -393,8 +393,8 @@ var view = new CultMeshNativeSliceViewDescriptor( rowCount, new[] { - CultMeshNativeSliceColumn.For("position"), - CultMeshNativeSliceColumn.For("velocity") + CultMeshNativeSliceColumn.For("position"), + CultMeshNativeSliceColumn.For("velocity") }, new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory)); ``` diff --git a/tests/GameCult.Caching.Tests/DocumentAssemblyResolverTests.cs b/tests/GameCult.Caching.Tests/DocumentAssemblyResolverTests.cs new file mode 100644 index 00000000..02db2c14 --- /dev/null +++ b/tests/GameCult.Caching.Tests/DocumentAssemblyResolverTests.cs @@ -0,0 +1,88 @@ +#nullable enable +using System; +using System.Buffers; +using GameCult.Caching.MessagePack; +using MessagePack; +using MessagePack.Formatters; +using NUnit.Framework; + +[assembly: CultDocumentMessagePackResolvers(typeof(GameCult.Caching.Tests.FirstOwnerResolver), typeof(GameCult.Caching.Tests.SecondOwnerResolver))] + +namespace GameCult.Caching.Tests; + +public sealed class DocumentAssemblyResolverTests +{ + [Test] + public void OptionsFor_CachesOptionsByDocumentAssembly() + { + var first = CultDocumentMessagePackSerialization.OptionsFor(typeof(ResolverDocument)); + var second = CultDocumentMessagePackSerialization.OptionsFor(typeof(ResolverDocument).Assembly); + + Assert.That(second, Is.SameAs(first)); + Assert.That(first, Is.Not.SameAs(CultDocumentMessagePackSerialization.Options)); + } + + [Test] + public void GeneratedDocumentSerialization_UsesOwnerResolversInDeclaredOrder() + { + FirstOwnerResolver.SerializeCount = 0; + SecondOwnerResolver.FormatterRequestCount = 0; + var original = new ResolverDocument { Name = "probe", Value = new ExternalValue(41) }; + + var payload = CultDocumentMessagePackSerialization.SerializeUntyped(original, typeof(ResolverDocument)); + var decoded = (ResolverDocument)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(ResolverDocument), payload); + + Assert.That(decoded.Name, Is.EqualTo("probe")); + Assert.That(decoded.Value.Number, Is.EqualTo(42)); + Assert.That(FirstOwnerResolver.SerializeCount, Is.EqualTo(1)); + Assert.That(SecondOwnerResolver.FormatterRequestCount, Is.Zero); + } +} + +[CultDocument("tests.assembly_resolver", "tests.assembly_resolver.v1")] +public sealed class ResolverDocument +{ + [Key(0)] public string Name { get; set; } = string.Empty; + [Key(1)] public ExternalValue Value { get; set; } +} + +public readonly struct ExternalValue +{ + public ExternalValue(int number) => Number = number; + public int Number { get; } +} + +public sealed class FirstOwnerResolver : IFormatterResolver +{ + public static int SerializeCount; + + public IMessagePackFormatter? GetFormatter() + { + return typeof(T) == typeof(ExternalValue) + ? (IMessagePackFormatter)(object)new FirstExternalValueFormatter() + : null; + } + + public sealed class FirstExternalValueFormatter : IMessagePackFormatter + { + public void Serialize(ref MessagePackWriter writer, ExternalValue value, MessagePackSerializerOptions options) + { + SerializeCount++; + writer.Write(value.Number + 1); + } + + public ExternalValue Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + => new(reader.ReadInt32()); + } +} + +public sealed class SecondOwnerResolver : IFormatterResolver +{ + public static int FormatterRequestCount; + + public IMessagePackFormatter? GetFormatter() + { + if (typeof(T) == typeof(ExternalValue)) FormatterRequestCount++; + return null; + } +} diff --git a/tests/GameCult.Caching.Tests/SoaValueColumnTests.cs b/tests/GameCult.Caching.Tests/SoaValueColumnTests.cs new file mode 100644 index 00000000..e64310a6 --- /dev/null +++ b/tests/GameCult.Caching.Tests/SoaValueColumnTests.cs @@ -0,0 +1,104 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using GameCult.Caching; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + public sealed class SoaValueColumnTests + { + [Test] + public async Task Soa_Preserves_Unmanaged_Value_Members_As_Intact_Columns() + { + var cache = new CultCache(); + var first = new CultRecordHandle(new CultRecordKey("vector:first")); + var second = new CultRecordHandle(new CultRecordKey("vector:second")); + + await cache.UpsertAsync(new VectorDocument + { + Name = "first", + Position = new TestFloat3(1, 2, 3), + Health = 10, + }, first); + await cache.UpsertAsync(new VectorDocument + { + Name = "second", + Position = new TestFloat3(4, 5, 6), + Health = 20, + }, second); + await cache.UpsertAsync(new VectorDocument + { + Name = "first-updated", + Position = new TestFloat3(7, 8, 9), + Health = 30, + }, first); + + var table = cache.Soa(); + + Assert.That(table.Column(nameof(VectorDocument.Position)).Span.ToArray(), Is.EqualTo(new[] + { + new TestFloat3(7, 8, 9), + new TestFloat3(4, 5, 6), + })); + Assert.That(table.Column(nameof(VectorDocument.Health)).Span.ToArray(), Is.EqualTo(new[] { 30, 20 })); + Assert.That(() => table.Column("Position.x"), Throws.TypeOf()); + Assert.That(() => table.Column("x"), Throws.TypeOf()); + + Assert.That(cache.Remove(first.Key), Is.True); + var afterRemoval = cache.Soa(); + Assert.That(afterRemoval.Keys.Select(key => key.Value).ToArray(), Is.EqualTo(new[] { "vector:second" })); + Assert.That( + afterRemoval.Column(nameof(VectorDocument.Position)).Span.ToArray(), + Is.EqualTo(new[] { new TestFloat3(4, 5, 6) })); + } + + [Test] + public async Task Soa_Rejects_Value_Members_Containing_Managed_References() + { + var cache = new CultCache(); + await cache.UpsertAsync(new VectorDocument + { + Name = "managed", + Position = new TestFloat3(1, 2, 3), + ManagedValue = new ManagedValue(42, "not a column"), + }); + + var table = cache.Soa(); + + Assert.That( + () => table.Column(nameof(VectorDocument.ManagedValue)), + Throws.TypeOf()); + } + + [MessagePackObject] + public readonly record struct TestFloat3( + [property: Key(0)] float x, + [property: Key(1)] float y, + [property: Key(2)] float z); + + [MessagePackObject] + public readonly record struct ManagedValue( + [property: Key(0)] int Code, + [property: Key(1)] string Label); + + [CultDocument("tests.soa_value_column", "tests.soa_value_column.v1")] + internal sealed class VectorDocument + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + public TestFloat3 Position; + + [Key(2)] + public int Health; + + [Key(3)] + public ManagedValue ManagedValue; + } + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/AdvancedErosionFilterTests.cs b/tests/GameCult.Geometry.Planetary.Tests/AdvancedErosionFilterTests.cs new file mode 100644 index 00000000..de5ccfb0 --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/AdvancedErosionFilterTests.cs @@ -0,0 +1,47 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class AdvancedErosionFilterTests +{ + [Fact] + public void DefaultReferenceFixtureIsStable() + { + var result = AdvancedErosionFilter.Sample( + new float2(0.42f, 0.31f), + new float3(0.5f, 0.1f, -0.2f), + 0.0f, + AdvancedErosionParameters.Default); + + Assert.Equal(0.0639375f, result.Magnitude, precision: 7); + Assert.True(float.IsFinite(result.Delta.x)); + Assert.True(float.IsFinite(result.Delta.y)); + Assert.True(float.IsFinite(result.Delta.z)); + Assert.InRange(result.RidgeMap, -1.0f, 1.0f); + } + + [Fact] + public void FlatInputAndZeroRoundingRemainFinite() + { + var parameters = AdvancedErosionParameters.Default with { Rounding = float4.zero }; + var result = AdvancedErosionFilter.Sample(new float2(0.25f, 0.75f), float3.zero, 0.0f, parameters); + + Assert.True(float.IsFinite(result.Delta.x)); + Assert.True(float.IsFinite(result.RidgeMap)); + Assert.True(float.IsFinite(result.FadeTarget)); + } + + [Fact] + public void PhacellePartialNormalizationIsFiniteAtEndpoints() + { + foreach (var normalization in new[] { 0.0f, 0.5f, 1.0f }) + { + var sample = AdvancedErosionFilter.Phacelle(new float2(1.7f, -3.2f), new float2(0.6f, -0.8f), 0.7f, 0.25f, normalization); + Assert.True(float.IsFinite(sample.x)); + Assert.True(float.IsFinite(sample.y)); + } + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/ErosionFrequencyBandsTests.cs b/tests/GameCult.Geometry.Planetary.Tests/ErosionFrequencyBandsTests.cs new file mode 100644 index 00000000..b17a2292 --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/ErosionFrequencyBandsTests.cs @@ -0,0 +1,37 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class ErosionFrequencyBandsTests +{ + [Fact] + public void RadiusIndependentWorldWavelengthSelectsSameBands() + { + var small = ErosionFrequencyBands.Select(1_000.0f, 100.0f, 8, 2.0f, 20.0f, 0.5f); + var large = ErosionFrequencyBands.Select(1_000.0f, 100.0f, 8, 2.0f, 20.0f, 0.5f); + Assert.Equal(small, large); + Assert.Equal(3, small.ActiveOctaves); + } + + [Fact] + public void TransitionPartiallyWeightsLastResolvableOctave() + { + var selection = ErosionFrequencyBands.Select(1_000.0f, 50.0f, 8, 2.0f, 1.0f, 0.5f); + Assert.Equal(4, selection.ActiveOctaves); + Assert.InRange(selection.FinalOctaveWeight, 0.0f, 1.0f); + Assert.True(selection.UnresolvedHeightBound > 0.0f); + } + + [Fact] + public void BandedFilterPreservesLowOctaveResultExactly() + { + var p = AdvancedErosionParameters.Default; + var band = new ErosionBandSelection(2, 1.0f, 0.0f, 0.0f); + var banded = AdvancedErosionFilter.Sample(new float2(0.42f, 0.31f), new float3(0.5f, 0.1f, -0.2f), 0.0f, p, band); + var truncated = AdvancedErosionFilter.Sample(new float2(0.42f, 0.31f), new float3(0.5f, 0.1f, -0.2f), 0.0f, p with { Octaves = 2 }); + Assert.Equal(truncated, banded); + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/GameCult.Geometry.Planetary.Tests.csproj b/tests/GameCult.Geometry.Planetary.Tests/GameCult.Geometry.Planetary.Tests.csproj new file mode 100644 index 00000000..b2511182 --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/GameCult.Geometry.Planetary.Tests.csproj @@ -0,0 +1,18 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + diff --git a/tests/GameCult.Geometry.Planetary.Tests/PlanetaryFieldTests.cs b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryFieldTests.cs new file mode 100644 index 00000000..ba934f33 --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryFieldTests.cs @@ -0,0 +1,83 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class PlanetaryFieldTests +{ + private static readonly PlanetaryFieldDefinition Definition = new( + 0x7f59_6d11_02ab_9c31UL, + 8.4f, + 42, + AdvancedErosionParameters.Default with { Scale = 0.63f, Strength = 0.12f, Octaves = 7 }); + + [Fact] + public void SampleIsDeterministicScaleAwareAndTangent() + { + var direction = math.normalize(new float3(0.31f, -0.72f, 0.61f)); + var source = new FixtureField(); + var coarse = PlanetaryField.Sample(Definition, direction, source.Sample(direction), PlanetaryQueryScale.AtFootprint(0.1f)); + var fine = PlanetaryField.Sample(Definition, direction, source.Sample(direction), PlanetaryQueryScale.AtFootprint(0.01f)); + var repeated = PlanetaryField.Sample(Definition, direction, source.Sample(direction), PlanetaryQueryScale.AtFootprint(0.01f)); + + Assert.Equal(fine, repeated); + Assert.Equal(Definition.FieldVersion, fine.FieldVersion); + Assert.True(fine.UnresolvedHeightBound <= coarse.UnresolvedHeightBound); + Assert.True(fine.FinestResolvedWavelength <= coarse.FinestResolvedWavelength); + Assert.InRange(MathF.Abs(math.dot(fine.UnitDirection, fine.TangentGradient)), 0, 2.0e-5f); + Assert.Equal(1, math.length(fine.SurfaceNormal), 5); + } + + [Fact] + public void BatchAndDoublePositionUseThePointContract() + { + var directions = new[] + { + math.normalize(new float3(1, 2, 3)), + math.normalize(new float3(-2, 0.5f, 1)), + math.normalize(new float3(0.1f, -0.2f, -1)), + }; + var output = new PlanetarySurfaceSample[directions.Length]; + var source = new FixtureField(); + var scale = PlanetaryQueryScale.AtFootprint(0.05f); + PlanetaryField.SampleBatch(Definition, directions, scale, source, output); + + for (var i = 0; i < directions.Length; i++) + { + var normalized = math.normalize(directions[i]); + var expected = PlanetaryField.Sample(Definition, normalized, source.Sample(normalized), scale); + Assert.InRange(MathF.Abs(expected.RadialDisplacement - output[i].RadialDisplacement), 0, 1.0e-6f); + Assert.InRange(math.distance(expected.TangentGradient, output[i].TangentGradient), 0, 1.0e-6f); + } + + var d = directions[0]; + var position = new double3(d.x * 8.0e11, d.y * 8.0e11, d.z * 8.0e11); + var positioned = PlanetaryField.SamplePosition(Definition, position, scale, source); + Assert.InRange(math.distance(positioned.UnitDirection, d), 0, 2.0e-6f); + } + + [Fact] + public void FieldIdentityChangesWithEveryLoadBearingInput() + { + var erosion = AdvancedErosionParameters.Default; + var baseline = PlanetaryFieldDefinition.Create(3, 8.4f, 7, erosion); + Assert.Equal(baseline.FieldVersion, PlanetaryFieldDefinition.Create(3, 8.4f, 7, erosion).FieldVersion); + Assert.NotEqual(baseline.FieldVersion, PlanetaryFieldDefinition.Create(4, 8.4f, 7, erosion).FieldVersion); + Assert.NotEqual(baseline.FieldVersion, PlanetaryFieldDefinition.Create(3, 8.5f, 7, erosion).FieldVersion); + Assert.NotEqual(baseline.FieldVersion, PlanetaryFieldDefinition.Create(3, 8.4f, 8, erosion).FieldVersion); + Assert.NotEqual(baseline.FieldVersion, PlanetaryFieldDefinition.Create(3, 8.4f, 7, erosion with { Gain = 0.49f }).FieldVersion); + } + + private readonly struct FixtureField : IPlanetaryBaseField + { + public PlanetaryBaseFieldSample Sample(float3 direction) + { + var value = direction.x * 0.6f + direction.y * direction.z * 0.3f; + var fieldGradient = new float3(0.6f, direction.z * 0.3f, direction.y * 0.3f); + fieldGradient -= direction * math.dot(fieldGradient, direction); + return new(value * 0.1f, fieldGradient * 0.1f, value, fieldGradient, math.clamp(value, -1, 1)); + } + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/PlanetaryGpuPageTests.cs b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryGpuPageTests.cs new file mode 100644 index 00000000..1a8f96ef --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryGpuPageTests.cs @@ -0,0 +1,28 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class PlanetaryGpuPageTests +{ + [Fact] + public void UploadContentUsesCanonicalDirectionsSpacingAndMetadata() + { + var layout = new PlanetaryPageLayout(new(PlanetaryCubeFace.NegativeY, 3, 4, 2), 9, 2); + var content = PlanetaryGpuPageBuilder.BuildContent(layout, 8.4f); + Assert.Equal(layout.StorageSize * layout.StorageSize, content.Inputs.Length); + Assert.True(content.ParentSampleSpacing > content.SampleSpacing); + var first = content.Inputs[0]; + Assert.Equal(PlanetaryPageSampling.Direction(layout, 0, 0), first.DirectionRadius.xyz); + Assert.Equal(8.4f, first.DirectionRadius.w); + Assert.Equal(content.SampleSpacing, first.Sampling.x); + Assert.Equal(content.ParentSampleSpacing, first.Sampling.y); + + var metadata = PlanetaryGpuPageBuilder.Metadata(content, 37, 0.25f); + Assert.Equal(new float4((int)layout.Tile.Face, layout.Tile.Level, layout.Tile.X, layout.Tile.Y), metadata.Address); + Assert.Equal(new float4(37, layout.StorageSize, layout.InteriorSize, layout.BorderSize), metadata.Layout); + Assert.Equal(0.25f, metadata.State.y); + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/PlanetaryMapTileEncodingTests.cs b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryMapTileEncodingTests.cs new file mode 100644 index 00000000..5b7f2397 --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryMapTileEncodingTests.cs @@ -0,0 +1,55 @@ +using System.IO; +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class PlanetaryMapTileEncodingTests +{ + [Fact] + public void BinaryTileRoundTripsIdentityValidityAndSurfaceEvidence() + { + var field = PlanetaryFieldDefinition.Create(5, 10, 3, AdvancedErosionParameters.Default); + var layout = new PlanetaryMapTileLayout(new(PlanetaryProjectionKind.EqualEarth), 1, 1, 0, 5, 1); + var key = new PlanetaryMapTileKey(field.FieldVersion, 2, 17, layout, PlanetaryQueryScale.AtFootprint(0.25f, 0.1f)); + var tile = PlanetaryMapTileBaker.Bake(field, key, new FlatField()); + using var bytes = new MemoryStream(); + PlanetaryMapTileEncoding.Write(bytes, tile, leaveOpen: true); + bytes.Position = 0; + var decoded = PlanetaryMapTileEncoding.Read(bytes); + + Assert.Equal(tile.Key, decoded.Key); + Assert.Equal(tile.Validity, decoded.Validity); + for (var i = 0; i < tile.Samples.Length; i++) + if (tile.Validity[i]) Assert.Equal(tile.Samples[i], decoded.Samples[i]); + } + + [Fact] + public void DecoderRejectsForeignMagicAndVersions() + { + Assert.Throws(() => PlanetaryMapTileEncoding.Read(new MemoryStream(new byte[] { 1, 2, 3, 4, 1, 0, 0, 0 }))); + var bytes = new byte[] { (byte)'C', (byte)'M', (byte)'P', (byte)'T', 99, 0, 0, 0 }; + Assert.Throws(() => PlanetaryMapTileEncoding.Read(new MemoryStream(bytes))); + } + + [Fact] + public void DecoderRejectsTrailingBytes() + { + var field = PlanetaryFieldDefinition.Create(2, 1, 0, AdvancedErosionParameters.Default); + var layout = new PlanetaryMapTileLayout(new(PlanetaryProjectionKind.Equirectangular), 0, 0, 0, 2, 0); + var key = new PlanetaryMapTileKey(field.FieldVersion, 1, 0, layout, PlanetaryQueryScale.AtFootprint(1)); + var tile = PlanetaryMapTileBaker.Bake(field, key, new FlatField()); + using var bytes = new MemoryStream(); + PlanetaryMapTileEncoding.Write(bytes, tile, leaveOpen: true); + bytes.WriteByte(0xff); + bytes.Position = 0; + Assert.Throws(() => PlanetaryMapTileEncoding.Read(bytes)); + } + + private readonly struct FlatField : IPlanetaryBaseField + { + public PlanetaryBaseFieldSample Sample(float3 direction) => new(0, float3.zero, 0, float3.zero, 0); + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/PlanetaryPageTests.cs b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryPageTests.cs new file mode 100644 index 00000000..88ae15c1 --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryPageTests.cs @@ -0,0 +1,60 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class PlanetaryPageTests +{ + [Fact] + public void BilinearSamplingAndSummaryUseOnePageContract() + { + var layout = new PlanetaryPageLayout(new(PlanetaryCubeFace.PositiveZ, 0, 0, 0), 3, 1); + var samples = new PlanetaryPageSample[layout.StorageSize * layout.StorageSize]; + for (var y = 0; y < layout.StorageSize; y++) + for (var x = 0; x < layout.StorageSize; x++) + samples[y * layout.StorageSize + x] = new(x + y * 10, new float3(x, y, 0), x / 10.0f, y / 10.0f); + + var sample = PlanetaryPageSampling.Bilinear(samples, layout, new float2(0.25f, 0.75f)); + Assert.Equal(26.5f, sample.Height, 4); + Assert.Equal(new float3(1.5f, 2.5f, 0), sample.TangentGradient); + var summary = PlanetaryPageSampling.Summarize(samples, layout, 0.125f); + Assert.Equal(0, summary.MinimumHeight); + Assert.Equal(44, summary.MaximumHeight); + Assert.Equal(0.125f, summary.UnresolvedHeightBound); + Assert.True(summary.MaximumSlope > 0); + } + + [Fact] + public void ChildPageStoresOnlyResidualAndComposesBackToDirectField() + { + var field = new PlanetaryFieldDefinition(7, 8.4f, 3, AdvancedErosionParameters.Default with + { + Scale = 0.63f, + Strength = 0.12f, + Octaves = 7, + }); + var source = new FixtureField(); + var root = PlanetaryPageBaker.Bake(field, new(new(PlanetaryCubeFace.PositiveZ, 0, 0, 0), 17, 2), source); + var child = PlanetaryPageBaker.Bake(field, new(new(PlanetaryCubeFace.PositiveZ, 1, 1, 0), 17, 2), source); + var direction = PlanetaryPageSampling.DirectionAtLocal(child.Layout, 0.5, 0.5); + var pages = new[] { new PlanetaryResidentPage(root, 1), new PlanetaryResidentPage(child, 1) }; + Assert.True(PlanetaryPageSetSampling.TrySample(pages, direction, out var composed)); + var spacing = PlanetaryPageSampling.NominalAngularTexelSize(child.Layout) * field.Radius; + var direct = PlanetaryField.Sample(field, direction, source.Sample(direction), PlanetaryQueryScale.AtFootprint(spacing)); + Assert.InRange(MathF.Abs(composed.Height - direct.RadialDisplacement), 0, 2.0e-5f); + Assert.InRange(math.distance(composed.TangentGradient, direct.TangentGradient), 0, 3.0e-5f); + } + + private readonly struct FixtureField : IPlanetaryBaseField + { + public PlanetaryBaseFieldSample Sample(float3 direction) + { + var value = direction.x * 0.4f - direction.y * 0.2f + direction.z * 0.5f; + var gradient = new float3(0.4f, -0.2f, 0.5f); + gradient -= direction * math.dot(gradient, direction); + return new(value * 0.08f, gradient * 0.08f, value, gradient, value); + } + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/PlanetaryPatchTests.cs b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryPatchTests.cs new file mode 100644 index 00000000..bea83edf --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryPatchTests.cs @@ -0,0 +1,43 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class PlanetaryPatchTests +{ + [Fact] + public void CubeSpherePatchMeshUsesCanonicalTopology() + { + var meshes = PlanetaryPatch.CreateCubeSphere(8); + Assert.Equal(6, meshes.Length); + foreach (var mesh in meshes) + { + Assert.Equal(81, mesh.Vertices.Length); + Assert.Equal(8 * 8 * 6, mesh.Indices.Length); + Assert.All(mesh.Vertices, vertex => Assert.Equal(1, math.length(vertex.UnitDirection), 5)); + foreach (var index in mesh.Indices) Assert.InRange(index, 0, mesh.Vertices.Length - 1); + } + } + + [Fact] + public void NeighborFaceCornersAreSingleValued() + { + var directions = PlanetaryPatch.CreateCubeSphere(1) + .SelectMany(mesh => mesh.Vertices.Select(vertex => vertex.UnitDirection)) + .ToArray(); + var groups = directions.GroupBy(direction => new + { + X = Math.Sign(direction.x), + Y = Math.Sign(direction.y), + Z = Math.Sign(direction.z), + }); + Assert.Equal(8, groups.Count()); + Assert.All(groups, group => + { + var first = group.First(); + Assert.All(group, direction => Assert.InRange(math.distance(first, direction), 0, 1.0e-6f)); + }); + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/PlanetaryProjectionTests.cs b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryProjectionTests.cs new file mode 100644 index 00000000..55f5676b --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryProjectionTests.cs @@ -0,0 +1,76 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class PlanetaryProjectionTests +{ + [Theory] + [InlineData(PlanetaryProjectionKind.Equirectangular)] + [InlineData(PlanetaryProjectionKind.WebMercator)] + [InlineData(PlanetaryProjectionKind.EqualEarth)] + [InlineData(PlanetaryProjectionKind.Orthographic)] + [InlineData(PlanetaryProjectionKind.AzimuthalEquidistant)] + [InlineData(PlanetaryProjectionKind.AzimuthalEqualArea)] + [InlineData(PlanetaryProjectionKind.LocalTangent)] + public void ProjectionRoundTripsVisibleDirections(PlanetaryProjectionKind kind) + { + var parameters = new PlanetaryProjectionParameters(kind, 0.31, -0.22); + foreach (var longitude in new[] { -0.4, -0.1, 0.2, 0.65 }) + foreach (var latitude in new[] { -0.6, -0.15, 0.3, 0.7 }) + { + var direction = Direction(longitude + parameters.CenterLongitude, latitude); + if (!PlanetaryProjection.TryForward(direction, parameters, out var map)) continue; + Assert.True(PlanetaryProjection.TryInverse(map, parameters, out var roundTrip)); + Assert.InRange(math.distance(direction, roundTrip), 0, 3.0e-5f); + } + } + + [Fact] + public void CubeAtlasRoundTripsEveryFaceInterior() + { + var projection = new PlanetaryProjectionParameters(PlanetaryProjectionKind.CubeAtlas); + foreach (var face in Enum.GetValues()) + { + var direction = PlanetaryTopology.Direction(new(face, 0.23, -0.41)); + Assert.True(PlanetaryProjection.TryForward(direction, projection, out var map)); + Assert.True(PlanetaryProjection.TryInverse(map, projection, out var roundTrip)); + Assert.InRange(math.distance(direction, roundTrip), 0, 2.0e-6f); + } + } + + [Fact] + public void MercatorRejectsPolarSingularity() + { + var northPole = new float3(0, 0, 1); + Assert.False(PlanetaryProjection.TryForward(northPole, new(PlanetaryProjectionKind.WebMercator), out _)); + } + + [Fact] + public void MapTileBakerCarriesFieldIdentityAndProjectionValidity() + { + var field = new PlanetaryFieldDefinition(99, 10, 0, AdvancedErosionParameters.Default); + var layout = new PlanetaryMapTileLayout( + new(PlanetaryProjectionKind.Orthographic), 0, 0, 0, 9, 1); + var key = new PlanetaryMapTileKey(field.FieldVersion, 1, 7, layout, PlanetaryQueryScale.AtFootprint(0.1f)); + var tile = PlanetaryMapTileBaker.Bake(field, key, new FlatField()); + Assert.Equal(key, tile.Key); + Assert.Equal(layout.StorageSize * layout.StorageSize, tile.Samples.Length); + Assert.Contains(tile.Validity, valid => valid); + Assert.Contains(tile.Validity, valid => !valid); + Assert.All(tile.Samples.Where((_, index) => tile.Validity[index]), sample => Assert.Equal(field.FieldVersion, sample.FieldVersion)); + } + + private static float3 Direction(double longitude, double latitude) + { + var cos = Math.Cos(latitude); + return new((float)(cos * Math.Cos(longitude)), (float)(cos * Math.Sin(longitude)), (float)Math.Sin(latitude)); + } + + private readonly struct FlatField : IPlanetaryBaseField + { + public PlanetaryBaseFieldSample Sample(float3 unitDirection) => new(0, float3.zero, 0, float3.zero, 0); + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/PlanetaryQueryTests.cs b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryQueryTests.cs new file mode 100644 index 00000000..17636165 --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryQueryTests.cs @@ -0,0 +1,49 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class PlanetaryQueryTests +{ + private static readonly PlanetaryFieldDefinition Field = new(12, 10, 0, AdvancedErosionParameters.Default with { Strength = 0, Octaves = 0 }); + private static readonly PlanetaryQueryScale Scale = PlanetaryQueryScale.AtFootprint(0.1f, 0); + + [Fact] + public void RayIntersectionAndClearanceUseTheCpuSurfaceContract() + { + var source = new BulgedSphere(); + Assert.True(PlanetaryQueries.TryIntersectRay( + Field, new double3(0, 0, 20), new double3(0, 0, -1), 30, + Scale, new PlanetaryRadialBounds(-1, 1), source, out var hit)); + Assert.InRange(hit.Distance, 8.99999, 9.00001); + Assert.InRange(hit.PlanetLocalPosition.z, 10.99999, 11.00001); + var clearance = PlanetaryQueries.SegmentClearance( + Field, new double3(-2, 0, 12), new double3(2, 0, 12), 0.1, Scale, source); + Assert.True(clearance > 0.8); + } + + [Fact] + public void GreatCircleAndRegionQueriesReturnVersionedSamples() + { + var source = new BulgedSphere(); + var path = PlanetaryQueries.SampleGreatCircle(Field, new float3(1, 0, 0), new float3(0, 1, 0), 8, Scale, source); + Assert.Equal(9, path.Length); + Assert.Equal(MathF.PI * 5, path[^1].Distance, 4); + Assert.All(path, point => Assert.Equal(Field.FieldVersion, point.Surface.FieldVersion)); + var summary = PlanetaryQueries.Summarize(Field, path.Select(point => point.Surface.UnitDirection).ToArray(), Scale, source); + Assert.True(summary.MaximumDisplacement >= summary.MinimumDisplacement); + Assert.True(summary.MaximumSlope >= 0); + } + + private readonly struct BulgedSphere : IPlanetaryBaseField + { + public PlanetaryBaseFieldSample Sample(float3 direction) + { + var displacement = direction.z; + var gradient = new float3(0, 0, 1) - direction * direction.z; + return new(displacement, gradient, 0, float3.zero, 0); + } + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/PlanetaryRadialRefinementTests.cs b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryRadialRefinementTests.cs new file mode 100644 index 00000000..18d8529b --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryRadialRefinementTests.cs @@ -0,0 +1,32 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class PlanetaryRadialRefinementTests +{ + [Fact] + public void FourBoundedStepsConvergeFromTheRasterCaptureShell() + { + var center=new float3(0.3f,-0.2f,0.7f); + var radial=math.normalize(new float3(0.2f,-0.4f,0.9f)); + var targetRadius=3.85f; + var target=center+radial*targetRadius; + var origin=target+math.normalize(radial+new float3(0.12f,-0.05f,0.02f))*6.0f; + var position=center+radial*(targetRadius+0.30f); + var ray=math.normalize(position-origin); + for(var step=0;step<4;step++) + position=PlanetaryRadialRefinement.Step(position,ray,center,targetRadius); + Assert.InRange(math.abs(math.length(position-center)-targetRadius),0.0f,1.0e-4f); + } + + [Fact] + public void StepClampsWeakDerivativeCorrections() + { + var position=new float3(0,0,4.1f); + var result=PlanetaryRadialRefinement.Step(position,new float3(1,0,0),float3.zero,3.8f); + Assert.InRange(math.length(result-position),0.07999f,0.08001f); + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/PlanetaryResidencyTests.cs b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryResidencyTests.cs new file mode 100644 index 00000000..b8ed63f5 --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryResidencyTests.cs @@ -0,0 +1,60 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class PlanetaryResidencyTests +{ + private static readonly PlanetaryFieldDefinition Field = new( + 1, 8.4f, 0, AdvancedErosionParameters.Default with { Scale = 0.63f, Strength = 0.12f, Octaves = 7 }); + private static readonly PlanetaryLodParameters Lod = new(5, 17, 2, 720, 8.4f / 65536); + + [Fact] + public void SelectorReturnsSixRootsAndOneAncestorChain() + { + var direction = math.normalize(new float3(-0.2f, 0.91f, 0.37f)); + var selected = PlanetaryLodSelector.SelectAncestorChain(Field, direction, 8.45f, Lod); + Assert.Equal(6, selected.Count(tile => tile.Level == 0)); + var descendants = selected.Where(tile => tile.Level > 0).OrderBy(tile => tile.Level).ToArray(); + for (var i = 1; i < descendants.Length; i++) Assert.Equal(descendants[i - 1], descendants[i].Parent()); + if (descendants.Length > 0) Assert.Equal(PlanetaryTopology.TileAt(direction, descendants[^1].Level), descendants[^1]); + } + + [Fact] + public void ResidencyFadesResidualsWithoutChangingContentDuringPresentation() + { + var root = new PlanetaryTileAddress(PlanetaryCubeFace.PositiveX, 0, 0, 0); + var child = root.Child(1, 0); + var residency = new PlanetaryResidualResidency(); + var arrival = residency.Update(new[] { root, child }, 0, 1); + var midpoint = residency.Update(new[] { root, child }, 0.5f, 1); + var settled = residency.Update(new[] { root, child }, 1, 1); + Assert.Equal(arrival.ContentVersion, midpoint.ContentVersion); + Assert.Equal(midpoint.ContentVersion, settled.ContentVersion); + Assert.Equal(0, arrival.Tiles.Single(tile => tile.Tile == child).Blend); + Assert.Equal(0.5f, midpoint.Tiles.Single(tile => tile.Tile == child).Blend); + Assert.Equal(1, settled.Tiles.Single(tile => tile.Tile == child).Blend); + + var leaving = residency.Update(new[] { root }, 1.25f, 1); + Assert.True(leaving.Tiles.Single(tile => tile.Tile == child).Departing); + Assert.Equal(1, leaving.Tiles.Single(tile => tile.Tile == child).Blend); + var gone = residency.Update(new[] { root }, 2.25f, 1); + Assert.DoesNotContain(gone.Tiles, tile => tile.Tile == child); + } + + [Fact] + public void ReversingAnArrivalDepartsFromItsCurrentWeightWithoutJumping() + { + var root = new PlanetaryTileAddress(PlanetaryCubeFace.PositiveX, 0, 0, 0); + var child = root.Child(0, 1); + var residency = new PlanetaryResidualResidency(); + _ = residency.Update(new[] { root, child }, 0, 1); + var arriving = residency.Update(new[] { root, child }, 0.25f, 1); + var departing = residency.Update(new[] { root }, 0.25f, 1); + Assert.Equal(0.25f, arriving.Tiles.Single(tile => tile.Tile == child).Blend); + Assert.Equal(0.25f, departing.Tiles.Single(tile => tile.Tile == child).Blend); + Assert.True(departing.Tiles.Single(tile => tile.Tile == child).Departing); + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/PlanetaryTopologyTests.cs b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryTopologyTests.cs new file mode 100644 index 00000000..27bb4cef --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryTopologyTests.cs @@ -0,0 +1,66 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class PlanetaryTopologyTests +{ + [Fact] + public void FaceDirectionRoundTripsAcrossEveryFace() + { + foreach (var face in Enum.GetValues()) + foreach (var u in new[] { -0.9, -0.25, 0.0, 0.4, 0.9 }) + foreach (var v in new[] { -0.85, -0.1, 0.3, 0.88 }) + { + var direction = PlanetaryTopology.Direction(new(face, u, v)); + var roundTrip = PlanetaryTopology.FaceCoordinate(direction); + Assert.Equal(face, roundTrip.Face); + Assert.Equal(u, roundTrip.U, 5); + Assert.Equal(v, roundTrip.V, 5); + } + } + + [Fact] + public void SiblingPagesShareBitStableBoundaryDirections() + { + var left = new PlanetaryPageLayout(new(PlanetaryCubeFace.PositiveZ, 1, 0, 0), 17, 2); + var right = new PlanetaryPageLayout(new(PlanetaryCubeFace.PositiveZ, 1, 1, 0), 17, 2); + for (var y = 0; y < left.InteriorSize; y++) + { + var a = PlanetaryPageSampling.Direction(left, left.BorderSize + left.InteriorSize - 1, left.BorderSize + y); + var b = PlanetaryPageSampling.Direction(right, right.BorderSize, right.BorderSize + y); + Assert.Equal(a, b); + } + } + + [Fact] + public void DirectionSelectsContainingTileAndLocalCoordinate() + { + var expected = new PlanetaryTileAddress(PlanetaryCubeFace.NegativeY, 5, 19, 7); + var direction = PlanetaryPageSampling.DirectionAtLocal(new(expected, 17, 2), 0.37, 0.61); + Assert.Equal(expected, PlanetaryTopology.TileAt(direction, expected.Level)); + Assert.True(PlanetaryTopology.TryLocalCoordinate(direction, expected, out var local)); + Assert.Equal(0.37f, local.x, 4); + Assert.Equal(0.61f, local.y, 4); + } + + [Fact] + public void BorderSamplesDoNotRequireNeighborResidency() + { + var page = new PlanetaryPageLayout(new(PlanetaryCubeFace.PositiveX, 0, 0, 0), 17, 3); + var edge = PlanetaryPageSampling.Direction(page, page.BorderSize + page.InteriorSize - 1, page.BorderSize + 8); + var border = PlanetaryPageSampling.Direction(page, page.StorageSize - 1, page.BorderSize + 8); + Assert.NotEqual(edge, border); + Assert.Equal(1, math.length(border), 5); + } + + [Fact] + public void NormalizedCubeBaselineSharesFaceEdges() + { + var a = PlanetaryTopology.NormalizedCubeDirection(new(PlanetaryCubeFace.PositiveZ, 1, -0.4)); + var b = PlanetaryTopology.NormalizedCubeDirection(new(PlanetaryCubeFace.PositiveX, -1, -0.4)); + Assert.InRange(math.distance(a, b), 0, 1.0e-6f); + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/SphericalErosionTests.cs b/tests/GameCult.Geometry.Planetary.Tests/SphericalErosionTests.cs new file mode 100644 index 00000000..adf7ea0e --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/SphericalErosionTests.cs @@ -0,0 +1,37 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class SphericalErosionTests +{ + [Fact] + public void SampleIsDeterministicAndScaleInvariant() + { + var parameters = SphericalErosionParameters.Default with { Seed = 42.0f }; + var a = SphericalErosion.Sample(new float3(0.31f, -0.74f, 0.59f), 0.2f, new float3(0.4f, -0.1f, 0.2f), parameters); + var repeated = SphericalErosion.Sample(new float3(0.31f, -0.74f, 0.59f), 0.2f, new float3(0.4f, -0.1f, 0.2f), parameters); + var b = SphericalErosion.Sample(new float3(3.1f, -7.4f, 5.9f), 0.2f, new float3(0.4f, -0.1f, 0.2f), parameters); + + Assert.Equal(a, repeated); + Assert.InRange(math.abs(a.HeightOffset-b.HeightOffset),0.0f,1.0e-6f); + Assert.InRange(math.abs(a.Ridge-b.Ridge),0.0f,1.0e-6f); + Assert.InRange(math.abs(a.Gully-b.Gully),0.0f,5.0e-5f); + Assert.InRange(math.length(a.Gradient-b.Gradient),0.0f,5.0e-6f); + Assert.InRange(a.HeightOffset, -0.08f, 0.08f); + Assert.InRange(a.Ridge, 0.0f, 1.0f); + Assert.InRange(a.Gully, 0.0f, 1.0f); + } + + [Fact] + public void NearbySamplesRemainContinuousAcrossAChartBoundary() + { + var parameters = SphericalErosionParameters.Default; + var left = SphericalErosion.Sample(math.normalize(new float3(1.0f, 0.99999f, 0.2f)), 0.1f, new float3(0.2f, -0.1f, 0.05f), parameters); + var right = SphericalErosion.Sample(math.normalize(new float3(0.99999f, 1.0f, 0.2f)), 0.1f, new float3(0.2f, -0.1f, 0.05f), parameters); + + Assert.InRange(math.abs(left.HeightOffset - right.HeightOffset), 0.0f, 0.0002f); + } +} diff --git a/tests/GameCult.Geometry.Tests/CultGeometryWorkerProviderTests.cs b/tests/GameCult.Geometry.Tests/CultGeometryWorkerProviderTests.cs new file mode 100644 index 00000000..0941f0f4 --- /dev/null +++ b/tests/GameCult.Geometry.Tests/CultGeometryWorkerProviderTests.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using CultMath; +using FluentAssertions; +using GameCult.Caching; +using GameCult.Mesh; +using GameCult.Networking; +using NUnit.Framework; +using R3; + +namespace GameCult.Geometry.Tests +{ + public sealed class CultGeometryWorkerProviderTests + { + [Test] + public async Task BuildOperation_UsesTheDirectCommitPath_AndPublishesTypedState() + { + using var database = CreateDatabase("geometry-worker"); + var (domain, request) = await SeedAsync(database); + var provider = new CultGeometryWorkerProvider("worker-1", database, new FakePipeline()); + var artifactChanges = new List>(); + using var subscription = database.Watch() + .Subscribe(change => artifactChanges.Add(change)); + + var receipt = await provider.BuildOperation.InvokeAsync( + new CultGeometryBuildCommand { RequestKey = CultGeometryBuildRequest.CreateRecordKey(request).Value }, + "test-runtime"); + + receipt.RequestKey.Should().Be(CultGeometryBuildRequest.CreateRecordKey(request).Value); + receipt.ArtifactKeys.Should().ContainSingle(); + receipt.ContentHashes.Should().ContainSingle(); + artifactChanges.Should().ContainSingle(change => change.Key.Value == receipt.ArtifactKeys[0]); + (await database.GetAsync(new CultRecordKey(receipt.SelectedCutKey))) + .Should().NotBeNull(); + (await database.GetAsync(new CultRecordKey(receipt.ArtifactKeys[0]))) + .Should().NotBeNull(); + + var state = await database.GetAsync(provider.WorkerStateKey); + state.Should().NotBeNull(); + state!.ActiveRequestKey.Should().Be(receipt.RequestKey); + state.LastSelectedCutKey.Should().Be(receipt.SelectedCutKey); + domain.DomainId.Should().Be("domain-1"); + } + + [Test] + public async Task DirectAndOperationBuilds_DeriveTheSameCanonicalOutputKeys() + { + using var database = CreateDatabase("geometry-direct-operation"); + var (_, request) = await SeedAsync(database); + var provider = new CultGeometryWorkerProvider("worker-1", database, new FakePipeline()); + var command = new CultGeometryBuildCommand + { + RequestKey = CultGeometryBuildRequest.CreateRecordKey(request).Value + }; + + var direct = await provider.BuildAsync(command); + var operation = await provider.BuildOperation.InvokeAsync(command, "test-runtime"); + + operation.SelectedCutKey.Should().Be(direct.SelectedCutKey); + operation.ArtifactKeys.Should().Equal(direct.ArtifactKeys); + operation.ContentHashes.Should().Equal(direct.ContentHashes); + } + + [Test] + public async Task Artifact_RawEnvelopeReplicates_AndWorkerStateCannotOverrideProbeTruth() + { + using var source = CreateDatabase("geometry-source"); + var (_, request) = await SeedAsync(source); + var provider = new CultGeometryWorkerProvider("worker-1", source, new FakePipeline()); + var receipt = await provider.BuildAsync(new CultGeometryBuildCommand + { + RequestKey = CultGeometryBuildRequest.CreateRecordKey(request).Value + }); + var artifactKey = new CultRecordKey(receipt.ArtifactKeys[0]); + var artifact = await source.GetAsync(artifactKey); + artifact.Should().NotBeNull(); + + var put = source.Documents.CreateRawDocumentPutMessage( + "geometry-artifact", + new CultRecordHandle(artifactKey), + artifact!, + new CultNetDocumentMessageOptions { SourceRuntimeId = "geometry-source", SourceRole = "geometry-worker" }); + put.Document.PayloadEncoding.Should().Be("messagepack"); + + var targetCache = new CultCache(); + var replicated = await source.Documents.ApplyRawDocumentPutMessageAsync(targetCache, put); + CultGeometryChunkArtifact.CreateRecordKey(replicated).Should().Be(artifactKey); + + await source.PutAsync(provider.WorkerStateKey, new CultGeometryWorkerState + { + WorkerId = "worker-1", + Phase = "lying-status", + ActiveRequestKey = receipt.RequestKey, + LastSelectedCutKey = receipt.SelectedCutKey, + LastArtifactKeys = receipt.ArtifactKeys, + ServedPackageVersion = "false-version" + }); + var probe = await provider.ProbeAsync(); + + probe.Owner.Should().Be(CultGeometryWorkerProvider.Owner); + probe.SchemaVersion.Should().Be(CultGeometrySchemaVersions.SelectedCut); + probe.SelectedCutKey.Should().Be(receipt.SelectedCutKey); + probe.ContentHashes.Should().Equal(receipt.ContentHashes); + probe.ServedPackageVersion.Should().NotBe("false-version"); + (await source.GetAsync(artifactKey))!.ChunkId.Should().Be("chunk-1"); + } + + [Test] + public void WorkerState_RegistersThroughTheGeneratedDocumentRegistry() + { + var descriptor = new CultCache().Registry.GetRequired(); + descriptor.SchemaVersion.Should().Be(CultGeometryWorkerSchemaVersions.State); + descriptor.SchemaName.Should().Be("gamecult.geometry.worker_state"); + } + + private static CultNetDatabase CreateDatabase(string runtimeId) + { + var cache = new CultCache(); + return new CultNetDatabase(cache, new CultNetDatabaseOptions + { + RuntimeId = runtimeId, + DocumentRegistry = new CultNetDocumentRegistry(cache.Registry) + }); + } + + private static async Task<(CultGeometryDomainDocument Domain, CultGeometryBuildRequest Request)> SeedAsync( + CultNetDatabase database) + { + var domain = new CultGeometryDomainDocument + { + DomainId = "domain-1", + RootKey = "root", + SourceRuntime = "test", + Root = new CultGeometryDomainNode { Name = "root", Kind = "root" } + }; + var domainKey = CultGeometryDomainDocument.CreateRecordKey(domain); + var request = new CultGeometryBuildRequest + { + RequestId = "request-1", + DomainKey = domainKey.Value, + WorkerGroup = "test", + ViewportHeightPixels = 1080, + VerticalFovRadians = 1, + TargetError = 0.01f, + TriangleBudget = 100, + ColliderBudget = 100 + }; + await database.PutAsync(domainKey, domain); + await database.PutAsync(CultGeometryBuildRequest.CreateRecordKey(request), request); + return (domain, request); + } + + private sealed class FakePipeline : ICultGeometryBuildPipeline + { + public Task BuildAsync( + CultGeometryDomainDocument domain, + CultGeometryBuildRequest request) + { + return Task.FromResult(new CultGeometryBuildOutput + { + SelectedCut = new CultGeometrySelectedCutManifest + { + CutId = "cut-1", + SelectedNodes = [domain.Root.Name] + }, + Artifacts = + [ + new CultGeometryChunkArtifact + { + ChunkId = "chunk-1", + BoundsMin = float3.zero, + BoundsMax = new float3(1, 1, 1), + SourceDomainKeys = [request.DomainKey], + RenderMesh = new CultGeometryTriangleMesh() + } + ] + }); + } + } + } +} diff --git a/tests/GameCult.Geometry.Tests/CultMathValueIntegrationTests.cs b/tests/GameCult.Geometry.Tests/CultMathValueIntegrationTests.cs new file mode 100644 index 00000000..b76248c0 --- /dev/null +++ b/tests/GameCult.Geometry.Tests/CultMathValueIntegrationTests.cs @@ -0,0 +1,76 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using CultMath; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Geometry.Tests; + +public sealed class CultMathValueIntegrationTests +{ + private static readonly MessagePackSerializerOptions GeometryOptions = + CultDocumentMessagePackSerialization.OptionsFor(typeof(CultGeometryDomainDocument)); + + [TestCaseSource(nameof(ValueEncodingCases))] + public void GeometryResolver_UsesExactPositionalVectorEncoding(object value, Type type, string expectedHex) + { + var payload = MessagePackSerializer.Serialize(type, value, GeometryOptions); + + Assert.That(Convert.ToHexString(payload), Is.EqualTo(expectedHex)); + Assert.That(MessagePackSerializer.Deserialize(type, payload, GeometryOptions), Is.EqualTo(value)); + } + + [Test] + public void GeometryResolver_RejectsWrongVectorWidth() + { + var twoComponents = Convert.FromHexString("92CA3F800000CA40000000"); + + Assert.That( + () => MessagePackSerializer.Deserialize(twoComponents, GeometryOptions), + Throws.TypeOf()); + } + + [Test] + public async Task GeometrySoa_ExposesCultMathVectorsAsIntactColumns() + { + var cache = new CultCache(); + await cache.UpsertAsync(new VectorColumnDocument + { + Name = "probe", + Uv = new float2(0.25f, 0.75f), + Position = new float3(1f, 2f, 3f), + }); + + var table = cache.Soa(); + + Assert.That(table.Column(nameof(VectorColumnDocument.Uv)).Span.ToArray(), + Is.EqualTo(new[] { new float2(0.25f, 0.75f) })); + Assert.That(table.Column(nameof(VectorColumnDocument.Position)).Span.ToArray(), + Is.EqualTo(new[] { new float3(1f, 2f, 3f) })); + Assert.That(() => table.Column("Position.x"), Throws.TypeOf()); + } + + private static object[] ValueEncodingCases => + [ + new object[] { new float2(1f, -2.5f), typeof(float2), "92CA3F800000CAC0200000" }, + new object[] { new float3(1f, -2.5f, 0.5f), typeof(float3), "93CA3F800000CAC0200000CA3F000000" }, + new object[] { new float4(1f, -2.5f, 0.5f, 4f), typeof(float4), "94CA3F800000CAC0200000CA3F000000CA40800000" }, + new object[] { new quaternion(1f, -2.5f, 0.5f, 4f), typeof(quaternion), "94CA3F800000CAC0200000CA3F000000CA40800000" }, + ]; + + [CultDocument("tests.geometry_vector_column", "tests.geometry_vector_column.v1")] + private sealed class VectorColumnDocument + { + [Key(0), CultName] + public string Name { get; set; } = string.Empty; + + [Key(1)] + public float2 Uv { get; set; } + + [Key(2)] + public float3 Position { get; set; } + } +} diff --git a/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs b/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs index b39b5182..345c270e 100644 --- a/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs +++ b/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs @@ -3,6 +3,7 @@ using GameCult.Caching; using GameCult.Caching.MessagePack; using GameCult.Mesh; using GameCult.Networking; +using CultMath; using MessagePack; using NUnit.Framework; using R3; @@ -21,52 +22,56 @@ namespace GameCult.Geometry.Tests { var viewport = new CultRect(8f, 4f, -8f, -4f); - viewport.Min.Should().Be(new CultVec2(-8f, -4f)); - viewport.Max.Should().Be(new CultVec2(8f, 4f)); - viewport.Center.Should().Be(CultVec2.Zero); - viewport.Contains(new CultVec2(2f, 3f)).Should().BeTrue(); + viewport.Min.Should().Be(new float2(-8f, -4f)); + viewport.Max.Should().Be(new float2(8f, 4f)); + viewport.Center.Should().Be(float2.zero); + viewport.Contains(new float2(2f, 3f)).Should().BeTrue(); viewport.Intersects(new CultRect(7f, 3f, 10f, 5f)).Should().BeTrue(); viewport.Intersects(new CultRect(9f, 5f, 10f, 6f)).Should().BeFalse(); - var gravityBrush = new CultCircle(new CultVec2(10f, 0f), 3f); + var gravityBrush = new CultCircle(new float2(10f, 0f), 3f); gravityBrush.Intersects(viewport).Should().BeTrue(); - gravityBrush.Contains(new CultVec2(12f, 0f)).Should().BeTrue(); - gravityBrush.Contains(new CultVec2(14f, 0f)).Should().BeFalse(); + gravityBrush.Contains(new float2(12f, 0f)).Should().BeTrue(); + gravityBrush.Contains(new float2(14f, 0f)).Should().BeFalse(); } [Test] public void GeometryPrimitives_PreserveWholeVectorsForSoaAndPhysicsQueries() { - var sphere = new CultSphere(new CultVec3(1f, 2f, 3f), 5f); + var sphere = new CultSphere(new float3(1f, 2f, 3f), 5f); - sphere.Contains(new CultVec3(1f, 6f, 3f)).Should().BeTrue(); - sphere.Contains(new CultVec3(1f, 8f, 3f)).Should().BeFalse(); - sphere.Center.Xy.Should().Be(new CultVec2(1f, 2f)); - sphere.Center.Xz.Should().Be(new CultVec2(1f, 3f)); + sphere.Contains(new float3(1f, 6f, 3f)).Should().BeTrue(); + sphere.Contains(new float3(1f, 8f, 3f)).Should().BeFalse(); + sphere.Center.xy.Should().Be(new float2(1f, 2f)); + sphere.Center.xz.Should().Be(new float2(1f, 3f)); sphere.XyCircle.Bounds.Should().Be(new CultRect(-4f, -3f, 6f, 7f)); } [Test] public void GeometryPrimitives_RoundTripThroughMessagePack() { - var rect = new CultRect(new CultVec2(5f, -2f), new CultVec2(-1f, 7f)); - var payload = MessagePackSerializer.Serialize(rect); - var decoded = MessagePackSerializer.Deserialize(payload); + var rect = new CultRect(new float2(5f, -2f), new float2(-1f, 7f)); + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(CultRect)); + var payload = MessagePackSerializer.Serialize(rect, options); + var decoded = MessagePackSerializer.Deserialize(payload, options); decoded.Should().Be(rect); - decoded.Min.Should().Be(new CultVec2(-1f, -2f)); - decoded.Max.Should().Be(new CultVec2(5f, 7f)); + decoded.Min.Should().Be(new float2(-1f, -2f)); + decoded.Max.Should().Be(new float2(5f, 7f)); } [Test] - public void GeometryPrimitives_JsonOmitsComputedConvenienceProperties() + public void GeometryPrimitive_JsonInspectionProjection_UsesLowercaseComponents() { - var json = JsonSerializer.Serialize(new CultVec3(1f, 2f, 3f)); + var value = new float3(1f, 2f, 3f); + var json = JsonSerializer.Serialize(new { value.x, value.y, value.z }); - json.Should().Be("""{"X":1,"Y":2,"Z":3}"""); - json.Should().NotContain("LengthSquared"); - json.Should().NotContain("Xy"); - json.Should().NotContain("Xz"); + json.Should().Contain("\"x\":1"); + json.Should().Contain("\"y\":2"); + json.Should().Contain("\"z\":3"); + json.Should().NotContain("\"X\""); + json.Should().NotContain("\"Y\""); + json.Should().NotContain("\"Z\""); } [Test] @@ -82,6 +87,15 @@ namespace GameCult.Geometry.Tests .Should().Be(CultGeometrySchemaVersions.SelectedCut); cache.Registry.GetRequired().SchemaVersion .Should().Be(CultGeometrySchemaVersions.ChunkArtifact); + + cache.Registry.GetRequired().SchemaId + .Should().Be("sha256:e3358436f8a07c84cc66b5196380cdfcd6920fa15f15d052d30b42b73762421d"); + cache.Registry.GetRequired().SchemaId + .Should().Be("sha256:2de3162db128fc2c5cc2ea3dae46d0196110a9e5f969997002309e636a3721d3"); + cache.Registry.GetRequired().SchemaId + .Should().Be("sha256:864412d3cf0f6b079ceb0bbd3b36232f11380e63cdf0bfd64d505a710fb49ff8"); + cache.Registry.GetRequired().SchemaId + .Should().Be("sha256:2d28046b7c76244ced70ee6de09bcbb5c64d364aa578e5a4c903d397a7a56156"); } [Test] @@ -93,9 +107,9 @@ namespace GameCult.Geometry.Tests { DomainKey = domainKey.Value, WorkerGroup = "ragnarok-column-workers", - CameraPosition = [36f, -42f, 30f], - FrustumMin = [-75f, -75f, -30f], - FrustumMax = [75f, 75f, 120f], + CameraPosition = new float3(36f, -42f, 30f), + FrustumMin = new float3(-75f, -75f, -30f), + FrustumMax = new float3(75f, 75f, 120f), ViewportHeightPixels = 1080f, VerticalFovRadians = 1.0471976f, TargetError = 0.01f, @@ -126,6 +140,11 @@ namespace GameCult.Geometry.Tests decoded.ChunkId.Should().Be(chunk.ChunkId); decoded.RenderMesh.TriangleCount.Should().Be(1); + decoded.BoundsMin.Should().Be(new float3(-1f, -1f, 0f)); + decoded.RenderMesh.Positions.Should().Equal(chunk.RenderMesh.Positions); + decoded.RenderMesh.Normals.Should().HaveCount(decoded.RenderMesh.Positions.Length); + decoded.RenderMesh.Uvs.Should().HaveCount(decoded.RenderMesh.Positions.Length); + decoded.RenderMesh.Indices.Length.Should().Be(decoded.RenderMesh.TriangleMaterials.Length * 3); CultGeometryChunkArtifact.CreateRecordKey(decoded) .Should().Be(CultGeometryChunkArtifact.CreateRecordKey(chunk)); } @@ -147,29 +166,26 @@ namespace GameCult.Geometry.Tests } [Test] - public void RustExportedDomainDocument_DecodesWithStableCultCacheKey() + public void TypedV2Domain_RoundTripsAndPreservesTheV1RecordKey() { - var payload = File.ReadAllBytes(FixturePath("ragnarok-domain.msgpack")); - var domain = CultDocumentMessagePackSerialization.Deserialize(payload); + var domain = SampleDomain(); + var payload = CultDocumentMessagePackSerialization.Serialize(domain); + var decoded = CultDocumentMessagePackSerialization.Deserialize(payload); - domain.DomainId.Should().Be("ragnarok-column"); - domain.Root.Children.Should().NotBeEmpty(); - domain.Root.Children[0].Name.Should().Be("stellarator-column-00"); - CultGeometryDomainDocument.CreateRecordKey(domain).Value - .Should().Be("geometry:domain:02c9b5810977406b0c206f3a3494327a423abb9448192be1b9a1863cd0f2ed95"); + decoded.Root.Rotation.Should().Be(quaternion.identity); + decoded.Root.Children[0].Claims[0].SupportCenter.Should().Be(new float3(0f, 0f, 45f)); + CultGeometryDomainDocument.CreateRecordKey(decoded).Value.Should().Be( + "geometry:domain:175899ea97548da0599e12bcaccd07fa1d6009ed450fadb3de229d47bab04431"); } [Test] - public void RustExportedChunkArtifact_DecodesWithStableCultCacheKey() + public void V2Chunk_DoesNotAcceptTheLegacyFlatMeshPayload() { var payload = File.ReadAllBytes(FixturePath("ragnarok-first-chunk.msgpack")); - var chunk = CultDocumentMessagePackSerialization.Deserialize(payload); - chunk.ChunkId.Should().StartWith("chunk/"); - chunk.RenderMesh.TriangleCount.Should().BeGreaterThan(0); - chunk.SourceDomainKeys.Should().NotBeEmpty(); - CultGeometryChunkArtifact.CreateRecordKey(chunk).Value - .Should().Be("geometry:chunk:b94dcd7d6776df15d0fa5fcf7f4b24f1384876b13b2d38cc57c4468136521af5"); + FluentActions.Invoking(() => + CultDocumentMessagePackSerialization.Deserialize(payload)) + .Should().Throw(); } [Test] @@ -320,8 +336,8 @@ namespace GameCult.Geometry.Tests { Name = "ragnarok-column", Kind = "Root", - Translation = [0f, 0f, 0f], - RotationXyzw = [0f, 0f, 0f, 1f], + Translation = float3.zero, + Rotation = quaternion.identity, Seed = 0x5EED, Claims = [], Children = @@ -330,18 +346,18 @@ namespace GameCult.Geometry.Tests { Name = "stellarator-column-00", Kind = "Column", - Translation = [0f, 0f, 0f], - RotationXyzw = [0f, 0f, 0f, 1f], + Translation = float3.zero, + Rotation = quaternion.identity, Seed = 0xC0110000, Claims = [ new CultGeometryFeatureClaim { Name = "column-support-shell", - Translation = [0f, 0f, 0f], - RotationXyzw = [0f, 0f, 0f, 1f], - SupportCenter = [0f, 0f, 45f], - SupportSize = [18f, 18f, 96f], + Translation = float3.zero, + Rotation = quaternion.identity, + SupportCenter = new float3(0f, 0f, 45f), + SupportSize = new float3(18f, 18f, 96f), Kind = "SupportShell", Material = 10, LoweringPolicy = "RenderAndCollider" @@ -354,15 +370,6 @@ namespace GameCult.Geometry.Tests }; } - private static string FixturePath(string fileName) - { - return Path.Combine( - TestContext.CurrentContext.TestDirectory, - "Fixtures", - "vg-csg-ragnarok", - fileName); - } - private static CultGeometryChunkArtifact SampleChunk() { return new CultGeometryChunkArtifact @@ -370,15 +377,15 @@ namespace GameCult.Geometry.Tests ChunkId = "chunk/ragnarok-column/column-00", CutKey = "geometry:cut:test", SelectedCutId = "cut-test", - BoundsMin = [-1f, -1f, 0f], - BoundsMax = [1f, 1f, 1f], + BoundsMin = new float3(-1f, -1f, 0f), + BoundsMax = new float3(1f, 1f, 1f), SourceDomainKeys = ["ragnarok-column/stellarator-column-00"], SourceClaimKeys = ["ragnarok-column/stellarator-column-00/claim/support"], RenderMesh = new CultGeometryTriangleMesh { - Positions = [0f, 0f, 0f, 1f, 0f, 0f, 0f, 1f, 0f], - Normals = [0f, 0f, 1f, 0f, 0f, 1f, 0f, 0f, 1f], - Uvs = [0f, 0f, 1f, 0f, 0f, 1f], + Positions = [new float3(0f, 0f, 0f), new float3(1f, 0f, 0f), new float3(0f, 1f, 0f)], + Normals = [new float3(0f, 0f, 1f), new float3(0f, 0f, 1f), new float3(0f, 0f, 1f)], + Uvs = [new float2(0f, 0f), new float2(1f, 0f), new float2(0f, 1f)], Indices = [0u, 1u, 2u], TriangleMaterials = [20u] }, @@ -388,5 +395,14 @@ namespace GameCult.Geometry.Tests SupportsParentChildCoexistence = true }; } + + private static string FixturePath(string fileName) + { + return Path.Combine( + TestContext.CurrentContext.TestDirectory, + "Fixtures", + "vg-csg-ragnarok", + fileName); + } } } diff --git a/tests/GameCult.Geometry.Tests/GeometryShaderOwnershipTests.cs b/tests/GameCult.Geometry.Tests/GeometryShaderOwnershipTests.cs new file mode 100644 index 00000000..e3650f85 --- /dev/null +++ b/tests/GameCult.Geometry.Tests/GeometryShaderOwnershipTests.cs @@ -0,0 +1,79 @@ +using System; +using System.IO; +using NUnit.Framework; + +namespace GameCult.Geometry.Tests; + +public sealed class GeometryShaderOwnershipTests +{ + [Test] + public void GeometryUmbrellaOwnsTheCompleteShaderSurface() + { + var shaders = Path.Combine(GetRepositoryRoot(), "src", "GameCult.Geometry", "Shaders"); + var umbrella = File.ReadAllText(Path.Combine(shaders, "GameCult.Geometry.hlsl")); + var erosion = File.ReadAllText(Path.Combine(shaders, "AdvancedErosionFilter.hlsl")); + var planetary = File.ReadAllText(Path.Combine(shaders, "Planetary.hlsl")); + var spherical = File.ReadAllText(Path.Combine(shaders, "SphericalErosion.hlsl")); + var refinement = File.ReadAllText(Path.Combine(shaders, "PlanetaryRadialRefinement.hlsl")); + + Assert.That(umbrella, Does.Contain("#include \"CultMath/CultMath.hlsl\"") + .And.Contain("#include \"Packages/org.gamecult.cultmath/shaders/CultMath.hlsl\"") + .And.Contain("#include \"AdvancedErosionFilter.hlsl\"") + .And.Contain("#include \"Planetary.hlsl\"") + .And.Contain("#include \"SphericalErosion.hlsl\"") + .And.Contain("#include \"PlanetaryRadialRefinement.hlsl\"")); + Assert.That(erosion, Does.Contain("gamecult_geometry_advanced_erosion_filter") + .And.Contain("GameCultGeometryAdvancedErosionParameters")); + Assert.That(planetary, Does.Contain("gamecult_geometry_planetary_face_direction") + .And.Contain("gamecult_geometry_planetary_field_sample") + .And.Contain("GameCultGeometryPlanetarySurfaceSample")); + Assert.That(spherical, Does.Contain("gamecult_geometry_spherical_erosion") + .And.Contain("GameCultGeometrySphericalErosionParameters")); + Assert.That(refinement, Does.Contain("gamecult_geometry_planetary_radial_refinement_step")); + } + + [Test] + public void GeometryShadersDoNotPublishCultMathGeometrySymbols() + { + var shaders = Path.Combine(GetRepositoryRoot(), "src", "GameCult.Geometry", "Shaders"); + foreach (var path in Directory.GetFiles(shaders, "*.hlsl")) + { + var source = File.ReadAllText(path); + Assert.That(source, Does.Not.Contain("cultmath_planetary_"), Path.GetFileName(path)); + Assert.That(source, Does.Not.Contain("cultmath_spherical_"), Path.GetFileName(path)); + Assert.That(source, Does.Not.Contain("cultmath_advanced_erosion_"), Path.GetFileName(path)); + } + } + + [Test] + public void UnityPlanetaryViewerUsesTheGeometryUmbrellaAndOwnedSymbols() + { + var shader = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), + "unity", "org.gamecult.geometry", "Samples~", "PlanetaryViewer", "Runtime", + "GameCultGeometryPlanetaryViewer.shader")); + + Assert.That(shader, Does.Contain( + "#define GAMECULT_GEOMETRY_UNITY_PACKAGE 1") + .And.Contain("#include \"Packages/org.gamecult.geometry/Shaders/GameCult.Geometry.hlsl\"") + .And.Contain("gamecult_geometry_planetary_field_sample") + .And.Not.Contain("cultmath_planetary_") + .And.Not.Contain("CultMathPlanetary")); + } + + private static string GetRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "CultLib.sln"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new InvalidOperationException("Could not locate CultLib repository root."); + } +} diff --git a/tests/GameCult.Geometry.Tests/GeometryV2WireParityTests.cs b/tests/GameCult.Geometry.Tests/GeometryV2WireParityTests.cs new file mode 100644 index 00000000..4c475683 --- /dev/null +++ b/tests/GameCult.Geometry.Tests/GeometryV2WireParityTests.cs @@ -0,0 +1,153 @@ +using System; +using CultMath; +using FluentAssertions; +using GameCult.Caching.MessagePack; +using NUnit.Framework; + +namespace GameCult.Geometry.Tests; + +public sealed class GeometryV2WireParityTests +{ + private const string DomainHex = "95AE666978747572652D646F6D61696EAC666978747572652F726F6F74B467616D6563756C742D67656F6D657472792D727397A4726F6F74A4526F6F7493CA3F800000CAC0200000CA0000000094CA00000000CA00000000CA00000000CA3F8000002A9090B4323032362D30372D32325430303A30303A30305A"; + private const string RequestHex = "9FAF726571756573742D66697874757265D95067656F6D657472793A646F6D61696E3A35303563393465373339333538306134653562303438623136396161346461393535646136303666666432386330353462373636613462393465353035303265A7776F726B65727393CA3F800000CA40000000CA4040000093CAC0800000CAC0A00000CAC0C0000093CA40800000CA40A00000CA40C00000CA44870000CA3F800000CA3E800000643291A4526F6F749090B4323032362D30372D32325430303A30303A30315A"; + private const string CutHex = "96AB6375742D66697874757265B867656F6D657472793A726571756573743A6669787475726591AC666978747572652F726F6F74909090"; + private const string ChunkHex = "9EAD666978747572652F6368756E6BB467656F6D657472793A6375743A66697874757265AB6375742D6669787475726593CABF800000CAC0000000CAC040000093CA3F800000CA40000000CA4040000091AC666978747572652F726F6F7491B2666978747572652F726F6F742F636C61696D959393CA00000000CA00000000CA0000000093CA3F800000CA00000000CA0000000093CA00000000CA3F800000CA000000009393CA00000000CA00000000CA3F80000093CA00000000CA00000000CA3F80000093CA00000000CA00000000CA3F8000009392CA00000000CA0000000092CA3F800000CA0000000092CA00000000CA3F800000930001029107C0010000CF0123456789ABCDEFC3"; + + [Test] + public void Domain_MatchesRustV2BytesAndKey() + { + var document = Domain(); + AssertWire(document, DomainHex); + CultGeometryDomainDocument.CreateRecordKey(document).Value.Should().Be( + "geometry:domain:505c94e7393580a4e5b048b169aa4da955da606ffd28c054b766a4b94e50502e"); + } + + [Test] + public void BuildRequest_MatchesRustV2BytesAndKey() + { + var document = Request(); + AssertWire(document, RequestHex); + CultGeometryBuildRequest.CreateRecordKey(document).Value.Should().Be( + "geometry:request:36e25b80c04a6c54c8b3137abb4a8c961468a9b1c0e7dd4d5632cf5a6f847966"); + } + + [Test] + public void SelectedCut_MatchesRustV2BytesAndKey() + { + var document = Cut(); + AssertWire(document, CutHex); + CultGeometrySelectedCutManifest.CreateRecordKey(document).Value.Should().Be( + "geometry:cut:5614eae3fe2a17dca651d1e32f401c50e1c3b9cf941f5789655990325da64fb3"); + } + + [Test] + public void Chunk_MatchesRustV2BytesAndKey() + { + var document = Chunk(); + AssertWire(document, ChunkHex); + CultGeometryChunkArtifact.CreateRecordKey(document).Value.Should().Be( + "geometry:chunk:ca2874d3638800179c906087ec817ea715c43b70b99ead04edccce2a1b3d6ebb"); + } + + [Test] + public void BuildRequest_ScalarIdentityUsesExactIeeeBits() + { + var document = Request(); + document.ViewportHeightPixels = -0f; + document.VerticalFovRadians = BitConverter.Int32BitsToSingle(unchecked((int)0x7fc01234)); + document.TargetError = float.NegativeInfinity; + + CultGeometryBuildRequest.CreateRecordKey(document).Value.Should().Be( + "geometry:request:978dd0913fc3e12c04766e065320d485d172d60d61be920a9a32637d1c439c81"); + + var payload = CultDocumentMessagePackSerialization.Serialize(document); + var decoded = CultDocumentMessagePackSerialization.Deserialize(payload); + BitConverter.SingleToInt32Bits(decoded.ViewportHeightPixels).Should().Be(unchecked((int)0x80000000)); + BitConverter.SingleToInt32Bits(decoded.VerticalFovRadians).Should().Be(unchecked((int)0x7fc01234)); + BitConverter.SingleToInt32Bits(decoded.TargetError).Should().Be(unchecked((int)0xff800000)); + } + + private static void AssertWire(T document, string expectedHex) + { + var payload = CultDocumentMessagePackSerialization.Serialize(document); + Convert.ToHexString(payload).Should().Be(expectedHex); + var decoded = CultDocumentMessagePackSerialization.Deserialize(payload); + Convert.ToHexString(CultDocumentMessagePackSerialization.Serialize(decoded)).Should().Be(expectedHex); + + var rustPayload = Convert.FromHexString(expectedHex); + var decodedFromRust = CultDocumentMessagePackSerialization.Deserialize(rustPayload); + Convert.ToHexString(CultDocumentMessagePackSerialization.Serialize(decodedFromRust)).Should().Be(expectedHex); + } + + private static CultGeometryDomainDocument Domain() => new() + { + DomainId = "fixture-domain", + RootKey = "fixture/root", + SourceRuntime = "gamecult-geometry-rs", + Root = new CultGeometryDomainNode + { + Name = "root", + Kind = "Root", + Translation = new float3(1f, -2.5f, 0f), + Rotation = quaternion.identity, + Seed = 42, + Claims = [], + Children = [], + }, + CreatedAt = "2026-07-22T00:00:00Z", + }; + + private static CultGeometryBuildRequest Request() => new() + { + RequestId = "request-fixture", + DomainKey = CultGeometryDomainDocument.CreateRecordKey(Domain()).Value, + WorkerGroup = "workers", + CameraPosition = new float3(1f, 2f, 3f), + FrustumMin = new float3(-4f, -5f, -6f), + FrustumMax = new float3(4f, 5f, 6f), + ViewportHeightPixels = 1080f, + VerticalFovRadians = 1f, + TargetError = 0.25f, + TriangleBudget = 100, + ColliderBudget = 50, + SemanticFilter = ["Root"], + RequestedChunkKeys = [], + DirtyDomainKeys = [], + CreatedAt = "2026-07-22T00:00:01Z", + }; + + private static CultGeometrySelectedCutManifest Cut() => new() + { + CutId = "cut-fixture", + RequestKey = "geometry:request:fixture", + SelectedNodes = ["fixture/root"], + DeferredChildRequests = [], + ParentFallbackNodes = [], + Diagnostics = [], + }; + + private static CultGeometryChunkArtifact Chunk() => new() + { + ChunkId = "fixture/chunk", + CutKey = "geometry:cut:fixture", + SelectedCutId = "cut-fixture", + BoundsMin = new float3(-1f, -2f, -3f), + BoundsMax = new float3(1f, 2f, 3f), + SourceDomainKeys = ["fixture/root"], + SourceClaimKeys = ["fixture/root/claim"], + RenderMesh = new CultGeometryTriangleMesh + { + Positions = [new float3(0f, 0f, 0f), new float3(1f, 0f, 0f), new float3(0f, 1f, 0f)], + Normals = [new float3(0f, 0f, 1f), new float3(0f, 0f, 1f), new float3(0f, 0f, 1f)], + Uvs = [new float2(0f, 0f), new float2(1f, 0f), new float2(0f, 1f)], + Indices = [0, 1, 2], + TriangleMaterials = [7], + }, + ColliderMesh = null, + InputBrushes = 1, + CandidatePairs = 0, + RejectedPairs = 0, + StableClipSeed = 0x0123_4567_89ab_cdef, + SupportsParentChildCoexistence = true, + }; +} diff --git a/tools/GameCult.Geometry.PlanetaryTileFixture/GameCult.Geometry.PlanetaryTileFixture.csproj b/tools/GameCult.Geometry.PlanetaryTileFixture/GameCult.Geometry.PlanetaryTileFixture.csproj new file mode 100644 index 00000000..21a2c04b --- /dev/null +++ b/tools/GameCult.Geometry.PlanetaryTileFixture/GameCult.Geometry.PlanetaryTileFixture.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + enable + + + + + diff --git a/tools/GameCult.Geometry.PlanetaryTileFixture/Program.cs b/tools/GameCult.Geometry.PlanetaryTileFixture/Program.cs new file mode 100644 index 00000000..c9ce965b --- /dev/null +++ b/tools/GameCult.Geometry.PlanetaryTileFixture/Program.cs @@ -0,0 +1,23 @@ +using CultMath; +using GameCult.Geometry; + +if (args.Length != 1) throw new ArgumentException("Expected one output path."); +var field = GameCult.Geometry.PlanetaryFieldDefinition.Create(0x55aa, 10, 7, GameCult.Geometry.AdvancedErosionParameters.Default); +var layout = new GameCult.Geometry.PlanetaryMapTileLayout(new(GameCult.Geometry.PlanetaryProjectionKind.EqualEarth), 0, 0, 0, 5, 1); +var key = new GameCult.Geometry.PlanetaryMapTileKey(field.FieldVersion, 3, 29, layout, GameCult.Geometry.PlanetaryQueryScale.AtFootprint(0.25f, 0.125f)); +var tile = GameCult.Geometry.PlanetaryMapTileBaker.Bake(field, key, new FixtureField()); +var path = Path.GetFullPath(args[0]); +Directory.CreateDirectory(Path.GetDirectoryName(path)!); +using var stream = File.Create(path); +GameCult.Geometry.PlanetaryMapTileEncoding.Write(stream, tile); + +readonly struct FixtureField : GameCult.Geometry.IPlanetaryBaseField +{ + public GameCult.Geometry.PlanetaryBaseFieldSample Sample(float3 direction) + { + var value = direction.x * 0.2f + direction.z * 0.4f; + var gradient = new float3(0.2f, 0, 0.4f); + gradient -= direction * math.dot(gradient, direction); + return new(value, gradient, value, gradient * 0.1f, value); + } +} diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll index 4b83a9e6..70b09dcb 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb index dce8af30..8fc696ee 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll index 79b3eab5..8e95cd6d 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb index dbde95fd..a7e77b61 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll index 0909150a..b4f85278 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb index 238279b2..c083a175 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll index b145b1d4..bb5425d7 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb index b2122925..87fe95e4 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll index a345ae39..78f194d7 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb index c86d6287..0449019f 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb differ diff --git a/unity/org.gamecult.cultlib/package.json b/unity/org.gamecult.cultlib/package.json index cd0c11ac..9fc5e04e 100644 --- a/unity/org.gamecult.cultlib/package.json +++ b/unity/org.gamecult.cultlib/package.json @@ -1,7 +1,7 @@ { "name": "org.gamecult.cultlib", "displayName": "CultLib", - "version": "1.0.7", + "version": "1.0.44", "unity": "2021.3", "description": "CultCache, CultNet, and CultMesh managed runtime assemblies for Unity.", "license": "MIT", diff --git a/unity/org.gamecult.geometry/LICENSES.md b/unity/org.gamecult.geometry/LICENSES.md new file mode 100644 index 00000000..67455b87 --- /dev/null +++ b/unity/org.gamecult.geometry/LICENSES.md @@ -0,0 +1,13 @@ +# GameCult.Geometry licenses + +GameCult.Geometry is a mixed-license package. License follows file ownership; +the package as a whole is not relicensed under one blanket expression. + +- CultLib-authored integration, persistence, schema, and packaging files are + licensed under the MIT License published by the CultLib repository. +- Files under `Planetary/` and `Shaders/` transferred from CultMath remain + licensed under the Mozilla Public License 2.0. Their source notices and + provenance are preserved in `THIRD-PARTY-NOTICES.md`. + +Downstream redistributors must preserve the applicable notices for the files +they redistribute. diff --git a/unity/org.gamecult.geometry/README.md b/unity/org.gamecult.geometry/README.md new file mode 100644 index 00000000..3fe2f10e --- /dev/null +++ b/unity/org.gamecult.geometry/README.md @@ -0,0 +1,15 @@ +# GameCult.Geometry for Unity + +This package is the Unity projection of the GameCult.Geometry authority. It +depends on `org.gamecult.cultmath` for numeric primitives and +`org.gamecult.cultlib` for CultCache/CultNet/CultMesh integration. + +Build it with `scripts/build-geometry-unity-package.ps1`. Do not edit generated +plugin assemblies in the package output. + +CultLib-authored integration files are MIT licensed. The planetary C# compiled +into `GameCult.Geometry.dll` and the geometry HLSL under `Shaders` are +MPL-2.0 and retain their source notices and provenance. See `LICENSES.md` and +`THIRD-PARTY-NOTICES.md` in the staged package. No MPL-covered source is +relicensed. The package manifest therefore uses `SEE LICENSES IN README.md` +rather than claiming one license for every file. diff --git a/unity/org.gamecult.geometry/Runtime.meta b/unity/org.gamecult.geometry/Runtime.meta new file mode 100644 index 00000000..0f218627 --- /dev/null +++ b/unity/org.gamecult.geometry/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d888f08710df48a780e393bc7b6ce1f5 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Runtime/GameCult.Geometry.asmdef b/unity/org.gamecult.geometry/Runtime/GameCult.Geometry.asmdef new file mode 100644 index 00000000..ea9a8c9f --- /dev/null +++ b/unity/org.gamecult.geometry/Runtime/GameCult.Geometry.asmdef @@ -0,0 +1,19 @@ +{ + "name": "GameCult.Geometry", + "rootNamespace": "GameCult.Geometry", + "references": [ + "CultMath", + "GameCult.CultLib" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "GameCult.Geometry.dll" + ], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": true +} diff --git a/unity/org.gamecult.geometry/Runtime/GameCult.Geometry.asmdef.meta b/unity/org.gamecult.geometry/Runtime/GameCult.Geometry.asmdef.meta new file mode 100644 index 00000000..ab0835ee --- /dev/null +++ b/unity/org.gamecult.geometry/Runtime/GameCult.Geometry.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a4dee1a0c3f04709994b8cc1f974da7a +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Runtime/Plugins.meta b/unity/org.gamecult.geometry/Runtime/Plugins.meta new file mode 100644 index 00000000..98acd60d --- /dev/null +++ b/unity/org.gamecult.geometry/Runtime/Plugins.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7f8484deafdf444abe3b1dedb9d09d16 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.dll b/unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.dll new file mode 100644 index 00000000..82c42828 Binary files /dev/null and b/unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.dll differ diff --git a/unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.dll.meta b/unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.dll.meta new file mode 100644 index 00000000..4253270b --- /dev/null +++ b/unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.dll.meta @@ -0,0 +1,21 @@ +fileFormatVersion: 2 +guid: 798aad4558fb450cb48da955d4a9b973 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Any: + second: + enabled: 1 + settings: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.pdb b/unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.pdb new file mode 100644 index 00000000..33bf653a Binary files /dev/null and b/unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.pdb differ diff --git a/unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.pdb.meta b/unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.pdb.meta new file mode 100644 index 00000000..4b609773 --- /dev/null +++ b/unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.pdb.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a7d52cf0bec7478fbce9866155e4ce17 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Runtime/Unity.meta b/unity/org.gamecult.geometry/Runtime/Unity.meta new file mode 100644 index 00000000..0b6207ab --- /dev/null +++ b/unity/org.gamecult.geometry/Runtime/Unity.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3436e51255844bf896e76ecfce1a8a5c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Runtime/Unity/GameCult.Geometry.Unity.asmdef b/unity/org.gamecult.geometry/Runtime/Unity/GameCult.Geometry.Unity.asmdef new file mode 100644 index 00000000..3a0a02ee --- /dev/null +++ b/unity/org.gamecult.geometry/Runtime/Unity/GameCult.Geometry.Unity.asmdef @@ -0,0 +1,8 @@ +{ + "name": "GameCult.Geometry.Unity", + "rootNamespace": "GameCult.Geometry.Unity", + "references": ["CultMath", "GameCult.Geometry"], + "includePlatforms": [], "excludePlatforms": [], "allowUnsafeCode": false, + "overrideReferences": false, "precompiledReferences": [], "autoReferenced": true, + "defineConstraints": [], "versionDefines": [], "noEngineReferences": false +} diff --git a/unity/org.gamecult.geometry/Runtime/Unity/GameCult.Geometry.Unity.asmdef.meta b/unity/org.gamecult.geometry/Runtime/Unity/GameCult.Geometry.Unity.asmdef.meta new file mode 100644 index 00000000..deaa0678 --- /dev/null +++ b/unity/org.gamecult.geometry/Runtime/Unity/GameCult.Geometry.Unity.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 5a5dc5d9ba8d4aea93b53061d6376f7f +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPageUpload.cs b/unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPageUpload.cs new file mode 100644 index 00000000..195903b4 --- /dev/null +++ b/unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPageUpload.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using CultMath; +using UnityEngine; + +namespace GameCult.Geometry.Unity +{ + +[StructLayout(LayoutKind.Sequential)] public readonly struct PlanetaryPageInputGpu { public PlanetaryPageInputGpu(Vector4 directionRadius, Vector4 sampling) { DirectionRadius = directionRadius; Sampling = sampling; } public readonly Vector4 DirectionRadius; public readonly Vector4 Sampling; } +[StructLayout(LayoutKind.Sequential)] public readonly struct PlanetaryPageMetadataGpu { public PlanetaryPageMetadataGpu(Vector4 address, Vector4 layout, Vector4 bounds, Vector4 state) { Address = address; Layout = layout; Bounds = bounds; State = state; } public readonly Vector4 Address; public readonly Vector4 Layout; public readonly Vector4 Bounds; public readonly Vector4 State; } +public readonly struct PlanetaryPageUpload { public PlanetaryPageUpload(PlanetaryPageInputGpu[] inputs, PlanetaryPageMetadataGpu[] metadata, int outputSampleCount, ulong contentVersion, ulong presentationVersion) { Inputs = inputs; Metadata = metadata; OutputSampleCount = outputSampleCount; ContentVersion = contentVersion; PresentationVersion = presentationVersion; } public PlanetaryPageInputGpu[] Inputs { get; } public PlanetaryPageMetadataGpu[] Metadata { get; } public int OutputSampleCount { get; } public ulong ContentVersion { get; } public ulong PresentationVersion { get; } } +public static class PlanetaryPageUploadBuilder +{ + public static PlanetaryPageUpload Build(in PlanetaryResidencySnapshot snapshot, int interiorSize, int borderSize, float radius) + { + if (!float.IsFinite(radius) || radius <= 0) throw new ArgumentOutOfRangeException(nameof(radius)); + var inputs = new List(); var metadata = new PlanetaryPageMetadataGpu[snapshot.Tiles.Length]; + foreach (var (resident, pageIndex) in snapshot.Tiles.Select((resident, index) => (resident, index))) + { + var content = PlanetaryGpuPageBuilder.BuildContent(new PlanetaryPageLayout(resident.Tile, interiorSize, borderSize).Validate(), radius); var outputOffset = inputs.Count; + foreach (var input in content.Inputs) inputs.Add(new(ToUnity(input.DirectionRadius), ToUnity(input.Sampling))); + var common = PlanetaryGpuPageBuilder.Metadata(content, outputOffset, resident.Blend); + metadata[pageIndex] = new(ToUnity(common.Address), ToUnity(common.Layout), ToUnity(common.Bounds), ToUnity(common.State)); + } + return new(inputs.ToArray(), metadata, inputs.Count, snapshot.ContentVersion, snapshot.PresentationVersion); + } + private static Vector4 ToUnity(float4 value) => new(value.x, value.y, value.z, value.w); +} +} diff --git a/unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPageUpload.cs.meta b/unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPageUpload.cs.meta new file mode 100644 index 00000000..904f752e --- /dev/null +++ b/unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPageUpload.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7edd3bb2edb2e6448a2d54fc082c27dc diff --git a/unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPatchMeshAdapter.cs b/unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPatchMeshAdapter.cs new file mode 100644 index 00000000..e880842d --- /dev/null +++ b/unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPatchMeshAdapter.cs @@ -0,0 +1,29 @@ +using System; +using CultMath; +using UnityEngine; +using UnityEngine.Rendering; + +namespace GameCult.Geometry.Unity +{ + +public static class PlanetaryPatchMeshAdapter +{ + public static UnityEngine.Mesh CreateFaceMesh(PlanetaryCubeFace face, int cellsPerAxis, float radius = 1) + { + if (!float.IsFinite(radius) || radius <= 0) throw new ArgumentOutOfRangeException(nameof(radius)); + var source = PlanetaryPatch.CreateFace(face, cellsPerAxis); + var vertices = new Vector3[source.Vertices.Length]; + var normals = new Vector3[source.Vertices.Length]; + var uv = new Vector2[source.Vertices.Length]; + for (var i = 0; i < source.Vertices.Length; i++) + { + var direction = source.Vertices[i].UnitDirection; + var normal = new Vector3(direction.x, direction.y, direction.z); + vertices[i] = normal * radius; normals[i] = normal; + uv[i] = new Vector2(source.Vertices[i].LocalCoordinate.x, source.Vertices[i].LocalCoordinate.y); + } + var mesh = new UnityEngine.Mesh { name = $"GameCult.Geometry Planetary {face} {cellsPerAxis}x{cellsPerAxis}", indexFormat = vertices.Length > ushort.MaxValue ? IndexFormat.UInt32 : IndexFormat.UInt16, vertices = vertices, normals = normals, uv = uv, triangles = source.Indices }; + mesh.RecalculateBounds(); return mesh; + } +} +} diff --git a/unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPatchMeshAdapter.cs.meta b/unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPatchMeshAdapter.cs.meta new file mode 100644 index 00000000..4a2d9754 --- /dev/null +++ b/unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPatchMeshAdapter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9ad7b581e14ef774d8435069bb77ca6a diff --git a/unity/org.gamecult.geometry/Samples~.meta b/unity/org.gamecult.geometry/Samples~.meta new file mode 100644 index 00000000..a5100a64 --- /dev/null +++ b/unity/org.gamecult.geometry/Samples~.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e8f73941932842338866bc1e6a8a97cc +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer.meta b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer.meta new file mode 100644 index 00000000..88718b45 --- /dev/null +++ b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a67e7a0f4e73446fb92f992c2a2f03a2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor.meta b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor.meta new file mode 100644 index 00000000..48746b57 --- /dev/null +++ b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d94beefdeae3412c9e0b2609a440e64a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCult.Geometry.PlanetaryViewer.Editor.asmdef b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCult.Geometry.PlanetaryViewer.Editor.asmdef new file mode 100644 index 00000000..7f842880 --- /dev/null +++ b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCult.Geometry.PlanetaryViewer.Editor.asmdef @@ -0,0 +1 @@ +{"name":"GameCult.Geometry.PlanetaryViewer.Editor","rootNamespace":"GameCult.Geometry.Unity.Samples.Editor","references":["GameCult.Geometry.PlanetaryViewer"],"includePlatforms":["Editor"],"excludePlatforms":[],"allowUnsafeCode":false,"overrideReferences":false,"precompiledReferences":[],"autoReferenced":true,"defineConstraints":[],"versionDefines":[],"noEngineReferences":false} diff --git a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCult.Geometry.PlanetaryViewer.Editor.asmdef.meta b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCult.Geometry.PlanetaryViewer.Editor.asmdef.meta new file mode 100644 index 00000000..6934a1cc --- /dev/null +++ b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCult.Geometry.PlanetaryViewer.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 39e5e41ae5914e60ad8f7dfab7334fdb +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCultGeometryPlanetaryViewerMenu.cs b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCultGeometryPlanetaryViewerMenu.cs new file mode 100644 index 00000000..59ee6c22 --- /dev/null +++ b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCultGeometryPlanetaryViewerMenu.cs @@ -0,0 +1,29 @@ +using UnityEditor; +using UnityEngine; +using GameCult.Geometry.Unity.Samples; + +namespace GameCult.Geometry.Unity.Samples.Editor +{ + +public static class GameCultGeometryPlanetaryViewerMenu +{ + [MenuItem("GameObject/GameCult.Geometry/Planetary Field Viewer", false, 10)] + public static void CreateViewer() + { + var root = new GameObject("GameCult.Geometry Planetary Field Viewer"); + Undo.RegisterCreatedObjectUndo(root, "Create GameCult.Geometry Planetary Viewer"); + root.AddComponent(); + Selection.activeGameObject = root; + + if (Camera.main != null) return; + var cameraObject = new GameObject("Planetary Viewer Camera"); + Undo.RegisterCreatedObjectUndo(cameraObject, "Create Planetary Viewer Camera"); + cameraObject.tag = "MainCamera"; + cameraObject.transform.position = new Vector3(0, 0, -24); + cameraObject.transform.LookAt(root.transform); + var camera = cameraObject.AddComponent(); + camera.nearClipPlane = 0.01f; + camera.farClipPlane = 1000; + } +} +} diff --git a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCultGeometryPlanetaryViewerMenu.cs.meta b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCultGeometryPlanetaryViewerMenu.cs.meta new file mode 100644 index 00000000..8e952599 --- /dev/null +++ b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCultGeometryPlanetaryViewerMenu.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4cbba35a250d4e2aadc125df07f85a54 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/README.md b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/README.md new file mode 100644 index 00000000..85bb4585 --- /dev/null +++ b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/README.md @@ -0,0 +1,7 @@ +# Planetary Field Viewer + +Import this sample through Unity Package Manager, then choose +`GameObject > GameCult.Geometry > Planetary Field Viewer`. + +The six-face mesh and planetary field are owned by GameCult.Geometry and use +CultMath numeric primitives. This is a renderer fixture, not gameplay state. diff --git a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime.meta b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime.meta new file mode 100644 index 00000000..112754b8 --- /dev/null +++ b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b7259ed205ed4795b5281effe598092e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCult.Geometry.PlanetaryViewer.asmdef b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCult.Geometry.PlanetaryViewer.asmdef new file mode 100644 index 00000000..ff41a80b --- /dev/null +++ b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCult.Geometry.PlanetaryViewer.asmdef @@ -0,0 +1 @@ +{"name":"GameCult.Geometry.PlanetaryViewer","rootNamespace":"GameCult.Geometry.Unity.Samples","references":["CultMath","GameCult.Geometry","GameCult.Geometry.Unity"],"includePlatforms":[],"excludePlatforms":[],"allowUnsafeCode":false,"overrideReferences":false,"precompiledReferences":[],"autoReferenced":true,"defineConstraints":[],"versionDefines":[],"noEngineReferences":false} diff --git a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCult.Geometry.PlanetaryViewer.asmdef.meta b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCult.Geometry.PlanetaryViewer.asmdef.meta new file mode 100644 index 00000000..917abca3 --- /dev/null +++ b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCult.Geometry.PlanetaryViewer.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 53e45dc58e30456db0c0b5b2252b5508 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.cs b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.cs new file mode 100644 index 00000000..2f874747 --- /dev/null +++ b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using CultMath; +using GameCult.Geometry; +using UnityEngine; + +namespace GameCult.Geometry.Unity.Samples +{ + +[ExecuteAlways] +public sealed class GameCultGeometryPlanetaryViewer : MonoBehaviour +{ + [Min(0.001f)] public float Radius = 8.4f; + [Range(1, 256)] public int CellsPerFace = 64; + public int Seed = 42; + [Min(0.0001f)] public float SampleFootprint = 0.01f; + public Shader? PlanetShader; + public Vector3 LightDirection = new(-0.4f, 0.7f, -0.6f); + public Color LowColor = new(0.055f, 0.16f, 0.09f, 1); + public Color HighColor = new(0.62f, 0.48f, 0.28f, 1); + public Color RidgeColor = new(0.88f, 0.83f, 0.7f, 1); + + private readonly List meshes = new(); + private Material? material; + private int builtCells; + + public PlanetaryFieldDefinition Definition => PlanetaryFieldDefinition.Create( + 1, + Radius, + Seed, + ViewerErosion(Radius)); + + public PlanetarySurfaceSample Sample(float3 direction) + { + var source = new ViewerBaseField(Radius); + var normalized = math.normalize(direction); + return PlanetaryField.Sample(Definition, normalized, source.Sample(normalized), PlanetaryQueryScale.AtFootprint(SampleFootprint)); + } + + private void OnEnable() => Rebuild(); + + private void OnValidate() + { + Radius = Mathf.Max(Radius, 0.001f); + CellsPerFace = Mathf.Clamp(CellsPerFace, 1, 256); + SampleFootprint = Mathf.Max(SampleFootprint, 0.0001f); + if (isActiveAndEnabled) Rebuild(); + } + + private void Update() + { + if (builtCells != CellsPerFace || material == null) Rebuild(); + ApplyMaterial(); + } + + private void Rebuild() + { + ClearGenerated(); + PlanetShader ??= Shader.Find("GameCult.Geometry/Planetary Field Viewer"); + if (PlanetShader == null) return; + material = new Material(PlanetShader) { name = "GameCult.Geometry Planetary Viewer Material", hideFlags = HideFlags.HideAndDontSave }; + foreach (var face in (PlanetaryCubeFace[])Enum.GetValues(typeof(PlanetaryCubeFace))) + { + var mesh = PlanetaryPatchMeshAdapter.CreateFaceMesh(face, CellsPerFace); + mesh.hideFlags = HideFlags.HideAndDontSave; + meshes.Add(mesh); + var child = new GameObject(face.ToString()) { hideFlags = HideFlags.DontSave }; + child.transform.SetParent(transform, false); + child.AddComponent().sharedMesh = mesh; + child.AddComponent().sharedMaterial = material; + } + builtCells = CellsPerFace; + ApplyMaterial(); + } + + private void ApplyMaterial() + { + if (material == null) return; + var p = ViewerErosion(Radius); + material.SetFloat("_Radius", Radius); + material.SetFloat("_SampleFootprint", SampleFootprint); + material.SetInt("_Seed", Seed); + material.SetFloat("_ErosionScale", p.Scale); + material.SetFloat("_ErosionStrength", p.Strength); + material.SetFloat("_GullyWeight", p.GullyWeight); + material.SetFloat("_Detail", p.Detail); + material.SetVector("_Rounding", new(p.Rounding.x, p.Rounding.y, p.Rounding.z, p.Rounding.w)); + material.SetVector("_Onset", new(p.Onset.x, p.Onset.y, p.Onset.z, p.Onset.w)); + material.SetVector("_AssumedSlope", new(p.AssumedSlope.x, p.AssumedSlope.y, 0, 0)); + material.SetFloat("_CellScale", p.CellScale); + material.SetFloat("_Normalization", p.Normalization); + material.SetInt("_Octaves", p.Octaves); + material.SetFloat("_Lacunarity", p.Lacunarity); + material.SetFloat("_Gain", p.Gain); + material.SetVector("_LightDirection", LightDirection.normalized); + material.SetColor("_LowColor", LowColor); + material.SetColor("_HighColor", HighColor); + material.SetColor("_RidgeColor", RidgeColor); + } + + private void OnDisable() => ClearGenerated(); + private void OnDestroy() => ClearGenerated(); + + private void ClearGenerated() + { + for (var i = transform.childCount - 1; i >= 0; i--) DestroyGenerated(transform.GetChild(i).gameObject); + foreach (var mesh in meshes) DestroyGenerated(mesh); + meshes.Clear(); + if (material != null) DestroyGenerated(material); + material = null; + builtCells = 0; + } + + private static void DestroyGenerated(UnityEngine.Object value) + { + if (Application.isPlaying) Destroy(value); + else DestroyImmediate(value); + } + + private static AdvancedErosionParameters ViewerErosion(float radius) => new( + radius * 0.075f, 0.12f, 0.58f, 1.45f, + new float4(0.1f, 0.015f, 0.1f, 2), + new float4(1.25f, 1.25f, 2.8f, 1.5f), + new float2(0.7f, 0.85f), 0.7f, 0.5f, 7, 2, 0.5f); + + private readonly struct ViewerBaseField : IPlanetaryBaseField + { + private readonly float radius; + public ViewerBaseField(float radius) => this.radius = radius; + + public PlanetaryBaseFieldSample Sample(float3 direction) + { + var value = direction.x * 0.6f + direction.y * direction.z * 0.3f; + var angularGradient = new float3(0.6f, direction.z * 0.3f, direction.y * 0.3f); + angularGradient -= direction * math.dot(angularGradient, direction); + var fieldGradient = angularGradient / radius; + var radialDisplacement = value * radius * 0.035f; + var radialGradient = angularGradient * 0.035f; + return new(radialDisplacement, radialGradient, value, fieldGradient, math.clamp(value, -1, 1)); + } + } +} +} diff --git a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.cs.meta b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.cs.meta new file mode 100644 index 00000000..fbd1b4b1 --- /dev/null +++ b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 131c938f36b74c7389ca4df8a43e3bc2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.shader b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.shader new file mode 100644 index 00000000..9b4981c0 --- /dev/null +++ b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.shader @@ -0,0 +1,91 @@ +Shader "GameCult.Geometry/Planetary Field Viewer" +{ + Properties + { + _LowColor ("Low Color", Color) = (0.055, 0.16, 0.09, 1) + _HighColor ("High Color", Color) = (0.62, 0.48, 0.28, 1) + _RidgeColor ("Ridge Color", Color) = (0.88, 0.83, 0.7, 1) + } + SubShader + { + Tags { "RenderType"="Opaque" "Queue"="Geometry" } + Pass + { + Cull Back + ZWrite On + HLSLPROGRAM + #pragma target 5.0 + #pragma vertex vert + #pragma fragment frag + #include "UnityCG.cginc" + #define GAMECULT_GEOMETRY_UNITY_PACKAGE 1 + #include "Packages/org.gamecult.geometry/Shaders/GameCult.Geometry.hlsl" + + float _Radius; + float _SampleFootprint; + int _Seed; + float _ErosionScale, _ErosionStrength, _GullyWeight, _Detail; + float4 _Rounding, _Onset, _AssumedSlope; + float _CellScale, _Normalization, _Lacunarity, _Gain; + int _Octaves; + float3 _LightDirection; + float4 _LowColor, _HighColor, _RidgeColor; + + struct Attributes { float4 vertex : POSITION; }; + struct Varyings + { + float4 position : SV_POSITION; + float3 worldNormal : TEXCOORD0; + float height : TEXCOORD1; + float ridge : TEXCOORD2; + }; + + GameCultGeometryPlanetarySurfaceSample viewer_sample(float3 direction) + { + direction = normalize(direction); + float value = direction.x * 0.6 + direction.y * direction.z * 0.3; + float3 angular_gradient = float3(0.6, direction.z * 0.3, direction.y * 0.3); + angular_gradient -= direction * dot(angular_gradient, direction); + GameCultGeometryPlanetaryBaseFieldSample base_sample; + base_sample.radial_displacement = value * _Radius * 0.035; + base_sample.radial_gradient = angular_gradient * 0.035; + base_sample.field_value = value; + base_sample.field_gradient = angular_gradient / _Radius; + base_sample.fade_target = clamp(value, -1.0, 1.0); + GameCultGeometryAdvancedErosionParameters erosion; + erosion.scale = _ErosionScale; erosion.strength = _ErosionStrength; + erosion.gully_weight = _GullyWeight; erosion.detail = _Detail; + erosion.rounding = _Rounding; erosion.onset = _Onset; + erosion.assumed_slope = _AssumedSlope.xy; erosion.cell_scale = _CellScale; + erosion.normalization = _Normalization; erosion.octaves = _Octaves; + erosion.lacunarity = _Lacunarity; erosion.gain = _Gain; + GameCultGeometryPlanetaryFieldDefinition definition; + definition.radius = _Radius; definition.seed = _Seed; definition.erosion = erosion; + return gamecult_geometry_planetary_field_sample(definition, direction, base_sample, _SampleFootprint); + } + + Varyings vert(Attributes input) + { + float3 direction = normalize(input.vertex.xyz); + GameCultGeometryPlanetarySurfaceSample sample = viewer_sample(direction); + float3 local = direction * (_Radius + sample.radial_displacement); + Varyings output; + output.position = UnityObjectToClipPos(float4(local, 1)); + output.worldNormal = UnityObjectToWorldNormal(sample.surface_normal); + output.height = sample.radial_displacement / max(_Radius * 0.1, 1.0e-5); + output.ridge = sample.ridge; + return output; + } + + float4 frag(Varyings input) : SV_Target + { + float height = saturate(input.height * 0.5 + 0.5); + float3 albedo = lerp(_LowColor.rgb, _HighColor.rgb, height); + albedo = lerp(albedo, _RidgeColor.rgb, saturate((input.ridge - 0.55) * 2.5)); + float diffuse = saturate(dot(normalize(input.worldNormal), normalize(-_LightDirection))) * 0.82 + 0.08; + return float4(albedo * diffuse, 1); + } + ENDHLSL + } + } +} diff --git a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.shader.meta b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.shader.meta new file mode 100644 index 00000000..ea0d8c6f --- /dev/null +++ b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.shader.meta @@ -0,0 +1,9 @@ +fileFormatVersion: 2 +guid: 615f5397a76c495aa9b028fa7751a3b8 +ShaderImporter: + externalObjects: {} + defaultTextures: [] + nonModifiableTextures: [] + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Shaders.meta b/unity/org.gamecult.geometry/Shaders.meta new file mode 100644 index 00000000..3f517308 --- /dev/null +++ b/unity/org.gamecult.geometry/Shaders.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1695895ad6a5467f8136d33a1f160133 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Shaders/AdvancedErosionFilter.hlsl b/unity/org.gamecult.geometry/Shaders/AdvancedErosionFilter.hlsl new file mode 100644 index 00000000..219afc86 --- /dev/null +++ b/unity/org.gamecult.geometry/Shaders/AdvancedErosionFilter.hlsl @@ -0,0 +1,147 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. +// Mathematical procedure adapted from Rune Skovbo Johansen's Advanced Terrain +// Erosion Filter (2025): https://www.shadertoy.com/view/wXcfWn + +#ifndef GAMECULT_GEOMETRY_ADVANCED_EROSION_FILTER_HLSL +#define GAMECULT_GEOMETRY_ADVANCED_EROSION_FILTER_HLSL + +struct GameCultGeometryAdvancedErosionParameters +{ + float scale; float strength; float gully_weight; float detail; + float4 rounding; float4 onset; float2 assumed_slope; + float cell_scale; float normalization; int octaves; float lacunarity; float gain; +}; + +struct GameCultGeometryAdvancedErosionResult +{ + float3 delta; float magnitude; float ridge_map; float fade_target; +}; + +struct GameCultGeometryErosionBandSelection +{ + int active_octaves; float final_octave_weight; float finest_included_wavelength; float unresolved_height_bound; +}; + +GameCultGeometryErosionBandSelection gamecult_geometry_select_erosion_bands(float base_wavelength, float sample_spacing, int maximum_octaves, float lacunarity, float base_amplitude, float gain, float transition_ratio) +{ + base_wavelength=max(base_wavelength,1.0e-6); sample_spacing=max(sample_spacing,0.0); lacunarity=max(lacunarity,1.01); transition_ratio=max(transition_ratio,1.01); maximum_octaves=min(max(maximum_octaves,0),16); + float nyquist=sample_spacing*2.0, wavelength=base_wavelength, final_weight=0.0, finest=0.0; int active=0; + [loop] for(int octave=0; octave=active) omitted+=amplitude; else if(index==active-1) omitted+=amplitude*(1.0-final_weight); amplitude*=abs(gain); + } + GameCultGeometryErosionBandSelection result; result.active_octaves=active; result.final_octave_weight=active>0?final_weight:0.0; result.finest_included_wavelength=finest; result.unresolved_height_bound=omitted; return result; +} + +float gamecult_geometry_erosion_hash_unit(int2 cell, uint salt) +{ + uint value = (uint)cell.x * 0x8da6b343u ^ (uint)cell.y * 0xd8163841u ^ salt; + value ^= value >> 16; value *= 0x7feb352du; value ^= value >> 15; value *= 0x846ca68bu; value ^= value >> 16; + return (value & 0x00ffffffu) * (1.0 / 16777216.0); +} +float2 gamecult_geometry_erosion_hash2(float2 input) +{ + int2 cell = (int2)input; + return float2(gamecult_geometry_erosion_hash_unit(cell, 0x68bc21ebu), gamecult_geometry_erosion_hash_unit(cell, 0x02e5be93u)) * 2.0 - 1.0; +} + +float4 gamecult_geometry_phacelle_noise(float2 position, float2 normal_direction, float frequency, float offset_cycles, float normalization) +{ + float2 side_direction = float2(-normal_direction.y, normal_direction.x) * frequency * 6.28318530718; + float offset = offset_cycles * 6.28318530718; + float2 cell = floor(position); + float2 local = frac(position); + float2 phase = 0.0; + float weight_sum = 0.0; + [unroll] for (int x = -1; x <= 2; x++) + [unroll] for (int y = -1; y <= 2; y++) + { + float2 grid_offset = float2(x, y); + float2 grid_point = cell + grid_offset; + float2 random_offset = gamecult_geometry_erosion_hash2(grid_point) * 0.5; + float2 delta = local - grid_offset - random_offset; + float weight = max(0.0, exp(-dot(delta, delta) * 2.0) - 0.01111); + weight_sum += weight; + float wave = dot(delta, side_direction) + offset; + phase += float2(cos(wave), sin(wave)) * weight; + } + float2 interpolated = phase / max(weight_sum, 1.0e-20); + float magnitude = max(1.0 - saturate(normalization), length(interpolated)); + return float4(interpolated / max(magnitude, 1.0e-20), side_direction); +} + +float gamecult_geometry_erosion_pow_inverse(float value, float power) { return 1.0 - pow(1.0 - saturate(value), power); } +float gamecult_geometry_erosion_ease_out(float value) { float inverse = 1.0 - saturate(value); return 1.0 - inverse * inverse; } +float gamecult_geometry_erosion_smooth_start(float value, float smoothing) +{ + return smoothing <= 1.0e-10 || value >= smoothing ? value - 0.5 * smoothing : 0.5 * value * value / smoothing; +} +float2 gamecult_geometry_erosion_safe_normalize(float2 value) +{ + float value_length = length(value); + return abs(value_length) > 1.0e-10 ? value / value_length : value; +} + +GameCultGeometryAdvancedErosionResult gamecult_geometry_advanced_erosion_filter_banded(float2 position, float3 base_height_and_slope, float fade_target, GameCultGeometryAdvancedErosionParameters p, GameCultGeometryErosionBandSelection band) +{ + float strength = p.strength * p.scale; + fade_target = clamp(fade_target, -1.0, 1.0); + float3 input_value = base_height_and_slope; + float3 height_and_slope = input_value; + float frequency = 1.0 / max(p.scale * p.cell_scale, 1.0e-10); + float2 slope = height_and_slope.yz; + float slope_length = max(length(slope), 1.0e-10); + float magnitude = 0.0; + float rounding_multiplier = 1.0; + float input_rounding = lerp(p.rounding.y, p.rounding.x, saturate(fade_target + 0.5)) * p.rounding.z; + float combined_mask = gamecult_geometry_erosion_ease_out(gamecult_geometry_erosion_smooth_start(slope_length * p.onset.x, input_rounding * p.onset.x)); + float ridge_mask = gamecult_geometry_erosion_ease_out(slope_length * p.onset.z); + float ridge_fade = fade_target; + float2 gully_slope = lerp(slope, slope / slope_length * p.assumed_slope.x, p.assumed_slope.y); + + int octave_count=min(min(max(p.octaves,0),8),max(band.active_octaves,0)); + [loop] for (int octave = 0; octave < octave_count; octave++) + { + float4 phacelle = gamecult_geometry_phacelle_noise(position * frequency, gamecult_geometry_erosion_safe_normalize(gully_slope), p.cell_scale, 0.25, p.normalization); + float2 derivative_direction = phacelle.zw * -frequency; + float sloping = abs(phacelle.y); + gully_slope += sign(phacelle.y) * derivative_direction * strength * p.gully_weight; + float3 gullies = float3(phacelle.x, phacelle.y * derivative_direction.x, phacelle.y * derivative_direction.y); + float3 faded = lerp(float3(fade_target, 0.0, 0.0), gullies * p.gully_weight, combined_mask); + float octave_weight=octave==octave_count-1?saturate(band.final_octave_weight):1.0; + height_and_slope += faded * strength * octave_weight; + magnitude += strength * octave_weight; + fade_target = faded.x; + float octave_rounding = lerp(p.rounding.y, p.rounding.x, saturate(phacelle.x + 0.5)) * rounding_multiplier; + float new_mask = gamecult_geometry_erosion_ease_out(gamecult_geometry_erosion_smooth_start(sloping * p.onset.y, octave_rounding * p.onset.y)); + combined_mask = gamecult_geometry_erosion_pow_inverse(combined_mask, p.detail) * new_mask; + ridge_fade = lerp(ridge_fade, lerp(ridge_fade, gullies.x, ridge_mask), octave_weight); + ridge_mask *= lerp(1.0, gamecult_geometry_erosion_ease_out(sloping * p.onset.w), octave_weight); + strength *= p.gain; + frequency *= p.lacunarity; + rounding_multiplier *= p.rounding.w; + } + + GameCultGeometryAdvancedErosionResult result; + result.delta = height_and_slope - input_value; + result.magnitude = magnitude; + result.ridge_map = ridge_fade * (1.0 - ridge_mask); + result.fade_target = fade_target; + return result; +} + +GameCultGeometryAdvancedErosionResult gamecult_geometry_advanced_erosion_filter(float2 position, float3 base_height_and_slope, float fade_target, GameCultGeometryAdvancedErosionParameters p) +{ + GameCultGeometryErosionBandSelection band; band.active_octaves=p.octaves; band.final_octave_weight=1.0; band.finest_included_wavelength=0.0; band.unresolved_height_bound=0.0; + return gamecult_geometry_advanced_erosion_filter_banded(position, base_height_and_slope, fade_target, p, band); +} + +#endif diff --git a/unity/org.gamecult.geometry/Shaders/AdvancedErosionFilter.hlsl.meta b/unity/org.gamecult.geometry/Shaders/AdvancedErosionFilter.hlsl.meta new file mode 100644 index 00000000..015c51c2 --- /dev/null +++ b/unity/org.gamecult.geometry/Shaders/AdvancedErosionFilter.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3252ac02e8853ef4b9d1fbc5204b5c6b +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Shaders/GameCult.Geometry.hlsl b/unity/org.gamecult.geometry/Shaders/GameCult.Geometry.hlsl new file mode 100644 index 00000000..00623b75 --- /dev/null +++ b/unity/org.gamecult.geometry/Shaders/GameCult.Geometry.hlsl @@ -0,0 +1,14 @@ +#ifndef GAMECULT_GEOMETRY_HLSL +#define GAMECULT_GEOMETRY_HLSL + +#if defined(GAMECULT_GEOMETRY_UNITY_PACKAGE) +#include "Packages/org.gamecult.cultmath/shaders/CultMath.hlsl" +#else +#include "CultMath/CultMath.hlsl" +#endif +#include "AdvancedErosionFilter.hlsl" +#include "Planetary.hlsl" +#include "SphericalErosion.hlsl" +#include "PlanetaryRadialRefinement.hlsl" + +#endif diff --git a/unity/org.gamecult.geometry/Shaders/GameCult.Geometry.hlsl.meta b/unity/org.gamecult.geometry/Shaders/GameCult.Geometry.hlsl.meta new file mode 100644 index 00000000..d685ccce --- /dev/null +++ b/unity/org.gamecult.geometry/Shaders/GameCult.Geometry.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b253fa50ddd34bf3b69fb323fe6e00e8 +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Shaders/Planetary.hlsl b/unity/org.gamecult.geometry/Shaders/Planetary.hlsl new file mode 100644 index 00000000..8e16bfff --- /dev/null +++ b/unity/org.gamecult.geometry/Shaders/Planetary.hlsl @@ -0,0 +1,392 @@ +#ifndef GAMECULT_GEOMETRY_PLANETARY_HLSL +#define GAMECULT_GEOMETRY_PLANETARY_HLSL + +static const float GAMECULT_GEOMETRY_PLANETARY_QUARTER_PI = 0.78539816339744830962; + +float3 gamecult_geometry_planetary_face_direction(int face, float2 coordinate) +{ + float2 tangent = tan(coordinate * GAMECULT_GEOMETRY_PLANETARY_QUARTER_PI); + float3 cube = face == 0 ? float3(1.0, tangent.y, -tangent.x) + : face == 1 ? float3(-1.0, tangent.y, tangent.x) + : face == 2 ? float3(tangent.x, 1.0, -tangent.y) + : face == 3 ? float3(tangent.x, -1.0, tangent.y) + : face == 4 ? float3(tangent.x, tangent.y, 1.0) + : float3(-tangent.x, tangent.y, -1.0); + return cultmath_normalize(cube); +} + +float2 gamecult_geometry_planetary_face_coordinate(float3 direction, out int face) +{ + float3 d = cultmath_normalize(direction); + float3 a = abs(d); + float2 cube_uv; + if (a.x >= a.y && a.x >= a.z) + { + if (d.x >= 0.0) { face = 0; cube_uv = float2(-d.z, d.y) / a.x; } + else { face = 1; cube_uv = float2(d.z, d.y) / a.x; } + } + else if (a.y >= a.z) + { + if (d.y >= 0.0) { face = 2; cube_uv = float2(d.x, -d.z) / a.y; } + else { face = 3; cube_uv = float2(d.x, d.z) / a.y; } + } + else + { + if (d.z >= 0.0) { face = 4; cube_uv = d.xy / a.z; } + else { face = 5; cube_uv = float2(-d.x, d.y) / a.z; } + } + return atan(cube_uv) / GAMECULT_GEOMETRY_PLANETARY_QUARTER_PI; +} + +bool gamecult_geometry_planetary_page_local(float3 direction, float4 tile_address, float active, out float2 local) +{ + int face; + float2 uv = gamecult_geometry_planetary_face_coordinate(direction, face); + local = 0.0; + if (active < 0.5 || abs((float)face - tile_address.x) > 0.25) return false; + float axis_tiles = exp2(tile_address.y); + float2 scaled = (uv * 0.5 + 0.5) * axis_tiles; + float2 canonical = floor(min(scaled, axis_tiles - 1.0e-5)); + if (any(abs(canonical - tile_address.zw) > 0.25)) return false; + local = scaled - tile_address.zw; + return all(local >= 0.0) && all(local <= 1.0); +} + +float3 gamecult_geometry_planetary_surface_normal(float3 direction, float3 world_distance_tangent_gradient) +{ + direction = cultmath_normalize(direction); + float3 tangent_gradient = world_distance_tangent_gradient - direction * dot(world_distance_tangent_gradient, direction); + return cultmath_normalize(direction - tangent_gradient); +} + +float2 gamecult_geometry_planetary_equirectangular_forward(float3 direction) +{ + direction = cultmath_normalize(direction); + return float2(atan2(direction.y, direction.x) / CULTMATH_PI, asin(clamp(direction.z, -1.0, 1.0)) / CULTMATH_HALF_PI); +} + +float3 gamecult_geometry_planetary_equirectangular_inverse(float2 coordinate) +{ + float longitude = coordinate.x * CULTMATH_PI; + float latitude = coordinate.y * CULTMATH_HALF_PI; + float cos_latitude = cos(latitude); + return cultmath_normalize(float3(cos_latitude * cos(longitude), cos_latitude * sin(longitude), sin(latitude))); +} + +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_A1 = 1.340264; +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_A2 = -0.081106; +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_A3 = 0.000893; +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_A4 = 0.003796; +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_M = 0.8660254037844386; +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_X_MAX = 2.706629; +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_Y_MAX = 1.3173628; + +float gamecult_geometry_equal_earth_y(float theta) +{ + float theta2 = theta * theta; + float theta6 = theta2 * theta2 * theta2; + return theta * (GAMECULT_GEOMETRY_EQUAL_EARTH_A1 + GAMECULT_GEOMETRY_EQUAL_EARTH_A2 * theta2 + theta6 * (GAMECULT_GEOMETRY_EQUAL_EARTH_A3 + GAMECULT_GEOMETRY_EQUAL_EARTH_A4 * theta2)); +} + +float2 gamecult_geometry_planetary_equal_earth_forward(float3 direction) +{ + direction = cultmath_normalize(direction); + float longitude = atan2(direction.y, direction.x); + float latitude = asin(clamp(direction.z, -1.0, 1.0)); + float theta = asin(GAMECULT_GEOMETRY_EQUAL_EARTH_M * sin(latitude)); + float theta2 = theta * theta; + float theta6 = theta2 * theta2 * theta2; + float denominator = GAMECULT_GEOMETRY_EQUAL_EARTH_M * (GAMECULT_GEOMETRY_EQUAL_EARTH_A1 + 3.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A2 * theta2 + theta6 * (7.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A3 + 9.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A4 * theta2)); + return float2(longitude * cos(theta) / denominator / GAMECULT_GEOMETRY_EQUAL_EARTH_X_MAX, gamecult_geometry_equal_earth_y(theta) / GAMECULT_GEOMETRY_EQUAL_EARTH_Y_MAX); +} + +float3 gamecult_geometry_planetary_equal_earth_inverse(float2 coordinate) +{ + float target_y = coordinate.y * GAMECULT_GEOMETRY_EQUAL_EARTH_Y_MAX; + float theta = target_y / GAMECULT_GEOMETRY_EQUAL_EARTH_A1; + [unroll] for (int iteration = 0; iteration < 8; iteration++) + { + float theta2 = theta * theta; + float theta6 = theta2 * theta2 * theta2; + float derivative = GAMECULT_GEOMETRY_EQUAL_EARTH_A1 + 3.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A2 * theta2 + theta6 * (7.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A3 + 9.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A4 * theta2); + theta -= (gamecult_geometry_equal_earth_y(theta) - target_y) / derivative; + } + float latitude = asin(clamp(sin(theta) / GAMECULT_GEOMETRY_EQUAL_EARTH_M, -1.0, 1.0)); + float theta2 = theta * theta; + float theta6 = theta2 * theta2 * theta2; + float denominator = GAMECULT_GEOMETRY_EQUAL_EARTH_M * (GAMECULT_GEOMETRY_EQUAL_EARTH_A1 + 3.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A2 * theta2 + theta6 * (7.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A3 + 9.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A4 * theta2)); + float longitude = coordinate.x * GAMECULT_GEOMETRY_EQUAL_EARTH_X_MAX * denominator / cos(theta); + float cos_latitude = cos(latitude); + return cultmath_normalize(float3(cos_latitude * cos(longitude), cos_latitude * sin(longitude), sin(latitude))); +} + +float gamecult_geometry_planetary_wrap_longitude(float longitude) +{ + longitude = fmod(longitude, CULTMATH_TAU); + if (longitude > CULTMATH_PI) longitude -= CULTMATH_TAU; + if (longitude < -CULTMATH_PI) longitude += CULTMATH_TAU; + return longitude; +} + +float3 gamecult_geometry_planetary_lon_lat_direction(float longitude, float latitude) +{ + float cos_latitude = cos(latitude); + return cultmath_normalize(float3(cos_latitude * cos(longitude), cos_latitude * sin(longitude), sin(latitude))); +} + +float gamecult_geometry_planetary_asinh(float value) { return log(value + sqrt(value * value + 1.0)); } +float gamecult_geometry_planetary_sinh(float value) { return 0.5 * (exp(value) - exp(-value)); } + +bool gamecult_geometry_planetary_azimuthal_forward(float3 direction, float2 center_lon_lat, int kind, out float2 coordinate) +{ + direction = cultmath_normalize(direction); + float longitude = gamecult_geometry_planetary_wrap_longitude(atan2(direction.y, direction.x) - center_lon_lat.x); + float latitude = asin(clamp(direction.z, -1.0, 1.0)); + float sin0 = sin(center_lon_lat.y), cos0 = cos(center_lon_lat.y); + float sin_latitude = sin(latitude), cos_latitude = cos(latitude), cos_longitude = cos(longitude); + float cos_c = clamp(sin0 * sin_latitude + cos0 * cos_latitude * cos_longitude, -1.0, 1.0); + float2 raw = float2(cos_latitude * sin(longitude), cos0 * sin_latitude - sin0 * cos_latitude * cos_longitude); + float factor = 1.0; + if (kind == 3) + { + if (cos_c < 0.0) { coordinate = 0.0; return false; } + } + else if (kind == 4) + { + float c = acos(cos_c); factor = (c < 1.0e-6 ? 1.0 : c / sin(c)) / CULTMATH_PI; + } + else if (kind == 5) factor = sqrt(2.0 / max(1.0 + cos_c, 1.0e-20)) * 0.5; + else + { + if (cos_c <= 0.0) { coordinate = 0.0; return false; } + factor = 1.0 / cos_c; + } + coordinate = raw * factor; + return true; +} + +bool gamecult_geometry_planetary_azimuthal_inverse(float2 coordinate, float2 center_lon_lat, int kind, out float3 direction) +{ + float rho = length(coordinate); + float c; + if (kind == 3) { if (rho > 1.0) { direction = 0.0; return false; } c = asin(clamp(rho, 0.0, 1.0)); } + else if (kind == 4) { if (rho > 1.0) { direction = 0.0; return false; } c = rho * CULTMATH_PI; } + else if (kind == 5) { if (rho > 1.0) { direction = 0.0; return false; } c = 2.0 * asin(clamp(rho, 0.0, 1.0)); } + else c = atan(rho); + if (rho < 1.0e-6) { direction = gamecult_geometry_planetary_lon_lat_direction(center_lon_lat.x, center_lon_lat.y); return true; } + float sin_c = sin(c), cos_c = cos(c), sin0 = sin(center_lon_lat.y), cos0 = cos(center_lon_lat.y); + float latitude = asin(clamp(cos_c * sin0 + coordinate.y * sin_c * cos0 / rho, -1.0, 1.0)); + float delta_longitude = atan2(coordinate.x * sin_c, rho * cos0 * cos_c - coordinate.y * sin0 * sin_c); + direction = gamecult_geometry_planetary_lon_lat_direction(gamecult_geometry_planetary_wrap_longitude(delta_longitude + center_lon_lat.x), latitude); + return true; +} + +bool gamecult_geometry_planetary_cube_atlas_forward(float3 direction, out float2 coordinate) +{ + int face; + float2 face_coordinate = gamecult_geometry_planetary_face_coordinate(direction, face); + int column = face % 3; + int row = face / 3; + coordinate = float2(-1.0 + (column + (face_coordinate.x + 1.0) * 0.5) * (2.0 / 3.0), 1.0 - (row + (face_coordinate.y + 1.0) * 0.5)); + return true; +} + +bool gamecult_geometry_planetary_cube_atlas_inverse(float2 coordinate, out float3 direction) +{ + if (any(coordinate < -1.0) || any(coordinate > 1.0)) { direction = 0.0; return false; } + int column = min((int)((coordinate.x + 1.0) * 1.5), 2); + int row = min((int)(1.0 - coordinate.y), 1); + float u = ((coordinate.x + 1.0) * 1.5 - column) * 2.0 - 1.0; + float v = ((1.0 - coordinate.y) - row) * 2.0 - 1.0; + direction = gamecult_geometry_planetary_face_direction(row * 3 + column, float2(u, v)); + return true; +} + +bool gamecult_geometry_planetary_projection_forward(float3 direction, int kind, float3 center_lon_lat_scale, out float2 coordinate) +{ + float scale = max(center_lon_lat_scale.z, 1.0e-20); + if (kind == 6) + { + bool valid = gamecult_geometry_planetary_cube_atlas_forward(direction, coordinate); + coordinate /= scale; return valid; + } + if (kind >= 3) + { + bool valid = gamecult_geometry_planetary_azimuthal_forward(direction, center_lon_lat_scale.xy, kind, coordinate); + coordinate /= scale; return valid; + } + direction = cultmath_normalize(direction); + float longitude = gamecult_geometry_planetary_wrap_longitude(atan2(direction.y, direction.x) - center_lon_lat_scale.x); + float latitude = asin(clamp(direction.z, -1.0, 1.0)); + if (kind == 0) coordinate = float2(longitude / CULTMATH_PI, latitude / CULTMATH_HALF_PI); + else if (kind == 1) + { + const float maximum_latitude = 1.4844222297453324; + if (abs(latitude) > maximum_latitude) { coordinate = 0.0; return false; } + coordinate = float2(longitude / CULTMATH_PI, gamecult_geometry_planetary_asinh(tan(latitude)) / CULTMATH_PI); + } + else + { + float3 centered = gamecult_geometry_planetary_lon_lat_direction(longitude, latitude); + coordinate = gamecult_geometry_planetary_equal_earth_forward(centered); + } + coordinate /= scale; + return true; +} + +bool gamecult_geometry_planetary_projection_inverse(float2 coordinate, int kind, float3 center_lon_lat_scale, out float3 direction) +{ + coordinate *= max(center_lon_lat_scale.z, 1.0e-20); + if (kind == 6) return gamecult_geometry_planetary_cube_atlas_inverse(coordinate, direction); + if (kind >= 3) return gamecult_geometry_planetary_azimuthal_inverse(coordinate, center_lon_lat_scale.xy, kind, direction); + if (any(abs(coordinate) > 1.0)) { direction = 0.0; return false; } + float longitude, latitude; + if (kind == 0) { longitude = coordinate.x * CULTMATH_PI; latitude = coordinate.y * CULTMATH_HALF_PI; } + else if (kind == 1) { longitude = coordinate.x * CULTMATH_PI; latitude = atan(gamecult_geometry_planetary_sinh(coordinate.y * CULTMATH_PI)); } + else + { + float3 centered = gamecult_geometry_planetary_equal_earth_inverse(coordinate); + longitude = atan2(centered.y, centered.x); latitude = asin(clamp(centered.z, -1.0, 1.0)); + } + direction = gamecult_geometry_planetary_lon_lat_direction(gamecult_geometry_planetary_wrap_longitude(longitude + center_lon_lat_scale.x), latitude); + return true; +} + +struct GameCultGeometryPlanetaryFieldDefinition +{ + float radius; + int seed; + GameCultGeometryAdvancedErosionParameters erosion; +}; + +struct GameCultGeometryPlanetaryBaseFieldSample +{ + float radial_displacement; + float3 radial_gradient; + float field_value; + float3 field_gradient; + float fade_target; +}; + +struct GameCultGeometryPlanetarySurfaceSample +{ + float radial_displacement; + float3 tangent_gradient; + float3 surface_normal; + float slope; + float ridge; + float gully; + float finest_resolved_wavelength; + float unresolved_height_bound; +}; + +struct GameCultGeometryPlanetaryPageSample +{ + float4 height_gradient; + float2 masks; +}; + +GameCultGeometryPlanetaryPageSample gamecult_geometry_planetary_page_lerp( + GameCultGeometryPlanetaryPageSample a, + GameCultGeometryPlanetaryPageSample b, + GameCultGeometryPlanetaryPageSample c, + GameCultGeometryPlanetaryPageSample d, + float2 fraction) +{ + GameCultGeometryPlanetaryPageSample result; + result.height_gradient = lerp(lerp(a.height_gradient, b.height_gradient, fraction.x), lerp(c.height_gradient, d.height_gradient, fraction.x), fraction.y); + result.masks = lerp(lerp(a.masks, b.masks, fraction.x), lerp(c.masks, d.masks, fraction.x), fraction.y); + return result; +} + +GameCultGeometryPlanetaryPageSample gamecult_geometry_planetary_residual_sample( + GameCultGeometryPlanetarySurfaceSample child, + GameCultGeometryPlanetarySurfaceSample parent, + bool has_parent) +{ + GameCultGeometryPlanetaryPageSample result; + result.height_gradient = float4(child.radial_displacement, child.tangent_gradient); + if (has_parent) result.height_gradient -= float4(parent.radial_displacement, parent.tangent_gradient); + result.masks = float2(child.ridge, child.gully); + return result; +} + +float4 gamecult_geometry_planetary_summary_empty() +{ + return float4(3.402823466e+38, -3.402823466e+38, 0.0, 0.0); +} + +float4 gamecult_geometry_planetary_summary_accumulate(float4 summary, float4 height_gradient, float unresolved_height) +{ + summary.x = min(summary.x, height_gradient.x); + summary.y = max(summary.y, height_gradient.x); + summary.z = max(summary.z, length(height_gradient.yzw)); + summary.w = max(summary.w, max(unresolved_height, 0.0)); + return summary; +} + +float gamecult_geometry_planetary_pow4(float value) { float square = value * value; return square * square; } + +GameCultGeometryPlanetarySurfaceSample gamecult_geometry_planetary_field_sample( + GameCultGeometryPlanetaryFieldDefinition definition, + float3 unit_direction, + GameCultGeometryPlanetaryBaseFieldSample base_sample, + float footprint_meters) +{ + float3 direction = cultmath_normalize(unit_direction); + float3 radial_gradient = base_sample.radial_gradient - direction * dot(base_sample.radial_gradient, direction); + float3 field_gradient = base_sample.field_gradient - direction * dot(base_sample.field_gradient, direction); + GameCultGeometryAdvancedErosionParameters p = definition.erosion; + GameCultGeometryErosionBandSelection band = gamecult_geometry_select_erosion_bands( + p.scale * p.cell_scale, footprint_meters, p.octaves, p.lacunarity, + p.strength * p.scale, p.gain, 2.0); + float3 powers = float3( + gamecult_geometry_planetary_pow4(abs(direction.x)), + gamecult_geometry_planetary_pow4(abs(direction.y)), + gamecult_geometry_planetary_pow4(abs(direction.z))); + float power_sum = max(powers.x + powers.y + powers.z, 1.0e-6); + float3 weights = powers / power_sum; + float3 world = direction * definition.radius; + float seed = (float)definition.seed; + GameCultGeometryAdvancedErosionResult xy = gamecult_geometry_advanced_erosion_filter_banded( + world.xy + float2(713.0 + seed * 0.754877666, -291.0 + seed * 0.569840296), + float3(base_sample.field_value, field_gradient.x, field_gradient.y), base_sample.fade_target, p, band); + GameCultGeometryAdvancedErosionResult yz = gamecult_geometry_advanced_erosion_filter_banded( + world.yz + float2(-431.0 + seed * 0.438289027, 887.0 + seed * 0.328438163), + float3(base_sample.field_value, field_gradient.y, field_gradient.z), base_sample.fade_target, p, band); + GameCultGeometryAdvancedErosionResult zx = gamecult_geometry_advanced_erosion_filter_banded( + world.zx + float2(197.0 + seed * 0.219783071, 557.0 + seed * 0.145898034), + float3(base_sample.field_value, field_gradient.z, field_gradient.x), base_sample.fade_target, p, band); + float erosion_height = xy.delta.x * weights.z + yz.delta.x * weights.x + zx.delta.x * weights.y; + float3 erosion_gradient = float3( + xy.delta.y * weights.z + zx.delta.z * weights.y, + xy.delta.z * weights.z + yz.delta.y * weights.x, + yz.delta.z * weights.x + zx.delta.y * weights.y); + float3 power_gradient_x = float3(4.0 * direction.x * direction.x * direction.x / definition.radius, 0.0, 0.0); + float3 power_gradient_y = float3(0.0, 4.0 * direction.y * direction.y * direction.y / definition.radius, 0.0); + float3 power_gradient_z = float3(0.0, 0.0, 4.0 * direction.z * direction.z * direction.z / definition.radius); + power_gradient_x -= direction * dot(power_gradient_x, direction); + power_gradient_y -= direction * dot(power_gradient_y, direction); + power_gradient_z -= direction * dot(power_gradient_z, direction); + float3 power_sum_gradient = power_gradient_x + power_gradient_y + power_gradient_z; + float inverse_power_sum_squared = 1.0 / (power_sum * power_sum); + float3 weight_gradient_x = (power_gradient_x * power_sum - power_sum_gradient * powers.x) * inverse_power_sum_squared; + float3 weight_gradient_y = (power_gradient_y * power_sum - power_sum_gradient * powers.y) * inverse_power_sum_squared; + float3 weight_gradient_z = (power_gradient_z * power_sum - power_sum_gradient * powers.z) * inverse_power_sum_squared; + erosion_gradient += yz.delta.x * weight_gradient_x + zx.delta.x * weight_gradient_y + xy.delta.x * weight_gradient_z; + float3 gradient = radial_gradient + erosion_gradient; + gradient -= direction * dot(gradient, direction); + float ridge_evidence = xy.ridge_map * weights.z + yz.ridge_map * weights.x + zx.ridge_map * weights.y; + GameCultGeometryPlanetarySurfaceSample result; + result.radial_displacement = base_sample.radial_displacement + erosion_height; + result.tangent_gradient = gradient; + result.surface_normal = gamecult_geometry_planetary_surface_normal(direction, gradient); + result.slope = length(gradient); + result.ridge = saturate(ridge_evidence * 0.5 + 0.5); + result.gully = saturate(0.5 - ridge_evidence * 0.5); + result.finest_resolved_wavelength = band.finest_included_wavelength; + result.unresolved_height_bound = band.unresolved_height_bound; + return result; +} + +#endif diff --git a/unity/org.gamecult.geometry/Shaders/Planetary.hlsl.meta b/unity/org.gamecult.geometry/Shaders/Planetary.hlsl.meta new file mode 100644 index 00000000..009c2989 --- /dev/null +++ b/unity/org.gamecult.geometry/Shaders/Planetary.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: bb055ad5328888e479e90ae3fe62c3fe +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Shaders/PlanetaryRadialRefinement.hlsl b/unity/org.gamecult.geometry/Shaders/PlanetaryRadialRefinement.hlsl new file mode 100644 index 00000000..20f3e2e5 --- /dev/null +++ b/unity/org.gamecult.geometry/Shaders/PlanetaryRadialRefinement.hlsl @@ -0,0 +1,27 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +#ifndef GAMECULT_GEOMETRY_PLANETARY_RADIAL_REFINEMENT_HLSL +#define GAMECULT_GEOMETRY_PLANETARY_RADIAL_REFINEMENT_HLSL + +float3 gamecult_geometry_planetary_radial_refinement_step( + float3 position, + float3 ray_direction, + float3 center, + float target_radius, + float minimum_derivative, + float maximum_correction) +{ + float3 local=position-center; + float radius=length(local); + float3 radial=local/max(radius,0.0001); + float error=radius-target_radius; + float derivative=dot(ray_direction,radial); + float safe_minimum=max(minimum_derivative,0.0001); + float safe_derivative=abs(derivative)>safe_minimum?derivative:(derivative<0?-safe_minimum:safe_minimum); + float correction=clamp(-error/safe_derivative,-abs(maximum_correction),abs(maximum_correction)); + return position+ray_direction*correction; +} + +#endif diff --git a/unity/org.gamecult.geometry/Shaders/PlanetaryRadialRefinement.hlsl.meta b/unity/org.gamecult.geometry/Shaders/PlanetaryRadialRefinement.hlsl.meta new file mode 100644 index 00000000..28d629b1 --- /dev/null +++ b/unity/org.gamecult.geometry/Shaders/PlanetaryRadialRefinement.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 356389371b854e12b9d419f7ee310d81 +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Shaders/SphericalErosion.hlsl b/unity/org.gamecult.geometry/Shaders/SphericalErosion.hlsl new file mode 100644 index 00000000..107ba24b --- /dev/null +++ b/unity/org.gamecult.geometry/Shaders/SphericalErosion.hlsl @@ -0,0 +1,80 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +#ifndef GAMECULT_GEOMETRY_SPHERICAL_EROSION_HLSL +#define GAMECULT_GEOMETRY_SPHERICAL_EROSION_HLSL + +struct GameCultGeometrySphericalErosionParameters +{ + float frequency; float amplitude; float lacunarity; float gain; + float slope_strength; float detail; float seed; int octaves; +}; + +struct GameCultGeometrySphericalErosionSample +{ + float height_offset; float ridge; float gully; float3 gradient; +}; + +float gamecult_geometry_spherical_cell_wave(float3 position, float3 flow, float3 cell, float seed) +{ + float3 pivot = cell + float3(cultmath_hash(cell + seed + 11.7), cultmath_hash(cell.yzx + seed + 37.1), cultmath_hash(cell.zyx + seed + 73.9)); + return dot(position - pivot, flow) * 6.28318530718 + cultmath_hash(cell + seed + 101.3) * 6.28318530718; +} + +float gamecult_geometry_spherical_directional_wave(float3 position, float3 flow, float seed) +{ + float3 cell = floor(position); + float3 local = cultmath_frac(position); + float3 weights = local * local * (3.0 - 2.0 * local); + float z0 = cultmath_lerp( + cultmath_lerp(gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(0,0,0), seed), gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(1,0,0), seed), weights.x), + cultmath_lerp(gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(0,1,0), seed), gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(1,1,0), seed), weights.x), weights.y); + float z1 = cultmath_lerp( + cultmath_lerp(gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(0,0,1), seed), gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(1,0,1), seed), weights.x), + cultmath_lerp(gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(0,1,1), seed), gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(1,1,1), seed), weights.x), weights.y); + return cultmath_lerp(z0, z1, weights.z); +} + +float3 gamecult_geometry_spherical_stable_tangent(float3 position) +{ + float3 axis = abs(position.z) < 0.8 ? float3(0,0,1) : float3(0,1,0); + return cultmath_normalize(cross(axis, position)); +} + +GameCultGeometrySphericalErosionSample gamecult_geometry_spherical_erosion(float3 unit_position, float base_height, float3 base_gradient, GameCultGeometrySphericalErosionParameters p) +{ + float3 position = cultmath_normalize(unit_position); + float3 gradient = base_gradient - position * dot(base_gradient, position); + float slope = length(gradient); + float3 flow = slope > 1.0e-6 ? -gradient / slope : gamecult_geometry_spherical_stable_tangent(position); + float frequency = max(p.frequency, 0.001); + float amplitude = p.amplitude; + float combi_mask = 1.0 - pow(1.0 - cultmath_saturate(slope * p.slope_strength), 2.0); + float fade_target = cultmath_saturate(base_height * 0.5 + 0.5) * 2.0 - 1.0; + GameCultGeometrySphericalErosionSample result = (GameCultGeometrySphericalErosionSample)0; + [loop] for (int octave = 0; octave < min(max(p.octaves, 0), 8); octave++) + { + float phase = gamecult_geometry_spherical_directional_wave(position * frequency, flow, p.seed + octave * 19.19); + float wave = cos(phase); + float wave_slope = sin(phase); + float faded = cultmath_lerp(fade_target, wave, combi_mask); + result.height_offset += faded * amplitude; + result.ridge = max(result.ridge, cultmath_saturate(faded)); + result.gully = max(result.gully, cultmath_saturate(-faded)); + float3 across = cultmath_normalize(cross(position, flow)); + gradient += across * (sign(wave_slope) * amplitude * frequency * combi_mask); + gradient -= position * dot(gradient, position); + slope = length(gradient); + flow = slope > 1.0e-6 ? -gradient / slope : gamecult_geometry_spherical_stable_tangent(position); + float new_mask = 1.0 - pow(1.0 - cultmath_saturate(abs(wave_slope) * 1.35), 2.0); + combi_mask = (1.0 - pow(1.0 - cultmath_saturate(combi_mask), max(p.detail, 0.001))) * new_mask; + fade_target = faded; + frequency *= max(p.lacunarity, 1.01); + amplitude *= cultmath_saturate(p.gain); + } + result.gradient = gradient; + return result; +} + +#endif diff --git a/unity/org.gamecult.geometry/Shaders/SphericalErosion.hlsl.meta b/unity/org.gamecult.geometry/Shaders/SphericalErosion.hlsl.meta new file mode 100644 index 00000000..7b7ddc79 --- /dev/null +++ b/unity/org.gamecult.geometry/Shaders/SphericalErosion.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 43d458fc5b6b441a941e37001df919e3 +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/THIRD-PARTY-NOTICES.md b/unity/org.gamecult.geometry/THIRD-PARTY-NOTICES.md new file mode 100644 index 00000000..3388b601 --- /dev/null +++ b/unity/org.gamecult.geometry/THIRD-PARTY-NOTICES.md @@ -0,0 +1,24 @@ +# GameCult.Geometry source provenance + +## CultMath planetary geometry + +The C# files under `Planetary/` and the HLSL files under `Shaders/` are licensed +under the Mozilla Public License 2.0 and retain MPL-2.0 notices. CultMath owns +the numeric primitives used by these geometry systems; GameCult.Geometry owns +their spatial meaning. + +## Advanced Terrain Erosion Filter + +`Planetary/AdvancedErosionFilter.cs` and `Shaders/AdvancedErosionFilter.hlsl` +adapt the mathematical procedure from +Rune Skovbo Johansen's **Advanced Terrain Erosion Filter**. + +- Copyright 2025 Rune Skovbo Johansen. +- Upstream: +- Explanation: +- License: Mozilla Public License 2.0, + +The implementation includes C#/HLSL type lowering, bounded octave counts, +finite zero-rounding behavior, and a shared integer avalanche hash for CPU/GPU +cell-selection parity. The technique builds on earlier work credited upstream +to Fewes and Clay John; the hash form follows public shader math by Inigo Quilez. diff --git a/unity/org.gamecult.geometry/Tests.meta b/unity/org.gamecult.geometry/Tests.meta new file mode 100644 index 00000000..68cfab58 --- /dev/null +++ b/unity/org.gamecult.geometry/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 97ae3726627d441b8d85e69d4ea804b9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Tests/Editor.meta b/unity/org.gamecult.geometry/Tests/Editor.meta new file mode 100644 index 00000000..89790e17 --- /dev/null +++ b/unity/org.gamecult.geometry/Tests/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3aa2de1207ba4593a6927bed95bdfea0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Tests/Editor/GameCult.Geometry.Tests.Editor.asmdef b/unity/org.gamecult.geometry/Tests/Editor/GameCult.Geometry.Tests.Editor.asmdef new file mode 100644 index 00000000..1b93ed0d --- /dev/null +++ b/unity/org.gamecult.geometry/Tests/Editor/GameCult.Geometry.Tests.Editor.asmdef @@ -0,0 +1,20 @@ +{ + "name": "GameCult.Geometry.Tests.Editor", + "rootNamespace": "GameCult.Geometry.Tests.Editor", + "references": [ + "GameCult.Geometry.Unity" + ], + "includePlatforms": ["Editor"], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "CultMath.dll", + "GameCult.Geometry.dll" + ], + "autoReferenced": false, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false, + "optionalUnityReferences": ["TestAssemblies"] +} diff --git a/unity/org.gamecult.geometry/Tests/Editor/GameCult.Geometry.Tests.Editor.asmdef.meta b/unity/org.gamecult.geometry/Tests/Editor/GameCult.Geometry.Tests.Editor.asmdef.meta new file mode 100644 index 00000000..7d75ac20 --- /dev/null +++ b/unity/org.gamecult.geometry/Tests/Editor/GameCult.Geometry.Tests.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 73e8e3a82a3441ec9f2a0f668c90f5b5 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Tests/Editor/GeometryPackageTests.cs b/unity/org.gamecult.geometry/Tests/Editor/GeometryPackageTests.cs new file mode 100644 index 00000000..dec669a7 --- /dev/null +++ b/unity/org.gamecult.geometry/Tests/Editor/GeometryPackageTests.cs @@ -0,0 +1,54 @@ +using CultMath; +using GameCult.Geometry.Unity; +using NUnit.Framework; +using UnityEngine; + +namespace GameCult.Geometry.Tests.Editor +{ + public sealed class GeometryPackageTests + { + [Test] + public void CultMathPrimitivesAreVisible() + { + var value = new float3(2f, 3f, 4f); + + Assert.That(value.x, Is.EqualTo(2f)); + Assert.That(value.y, Is.EqualTo(3f)); + Assert.That(value.z, Is.EqualTo(4f)); + } + + [Test] + public void CoreGeometryTypesUseCultMathPrimitives() + { + var sphere = new CultSphere(new float3(1f, 2f, 3f), 4f); + + Assert.That(sphere.Center, Is.EqualTo(new float3(1f, 2f, 3f))); + Assert.That(sphere.Radius, Is.EqualTo(4f)); + Assert.That(sphere.Contains(new float3(1f, 2f, 7f)), Is.True); + } + + [Test] + public void PlanetaryAdapterCreatesUnityMesh() + { + var mesh = PlanetaryPatchMeshAdapter.CreateFaceMesh( + PlanetaryCubeFace.PositiveZ, + 2, + 3f); + + try + { + Assert.That(mesh, Is.Not.Null); + Assert.That(mesh.name, Does.Contain("PositiveZ")); + Assert.That(mesh.vertexCount, Is.EqualTo(9)); + Assert.That(mesh.normals.Length, Is.EqualTo(9)); + Assert.That(mesh.uv.Length, Is.EqualTo(9)); + Assert.That(mesh.triangles.Length, Is.EqualTo(24)); + Assert.That(mesh.bounds.size.sqrMagnitude, Is.GreaterThan(0f)); + } + finally + { + Object.DestroyImmediate(mesh); + } + } + } +} diff --git a/unity/org.gamecult.geometry/Tests/Editor/GeometryPackageTests.cs.meta b/unity/org.gamecult.geometry/Tests/Editor/GeometryPackageTests.cs.meta new file mode 100644 index 00000000..89858d8d --- /dev/null +++ b/unity/org.gamecult.geometry/Tests/Editor/GeometryPackageTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cc0a7b5b20004c72b9c9395d3c73360c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/package.json b/unity/org.gamecult.geometry/package.json new file mode 100644 index 00000000..27877abd --- /dev/null +++ b/unity/org.gamecult.geometry/package.json @@ -0,0 +1,16 @@ +{ + "name": "org.gamecult.geometry", + "displayName": "GameCult.Geometry", + "version": "0.1.0", + "unity": "2021.3", + "description": "GameCult geometry values, typed state contracts, and runtime algorithms built on CultMath primitives.", + "license": "SEE LICENSES IN README.md", + "author": { + "name": "GameCult" + }, + "dependencies": { + "org.gamecult.cultmath": "0.1.0", + "org.gamecult.cultlib": "1.0.44" + }, + "samples": [{"displayName":"Planetary Field Viewer","description":"Planetary geometry CPU/HLSL parity viewer.","path":"Samples~/PlanetaryViewer"}] +}