Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
Meta Vens
6a65369357 Record completed geometry migration 2026-07-22 06:20:40 +01:00
Meta Vens
9e8504f2b9 Reference Geometry test plugins explicitly 2026-07-22 05:44:34 +01:00
Meta Vens
3f454f2a8b Refresh CultLib Unity binaries 2026-07-22 05:23:40 +01:00
Meta Vens
98d15eaf65 Harden CultLib Unity package build 2026-07-22 05:19:48 +01:00
Meta Vens
4df2c2a42b Add Geometry Unity EditMode package tests 2026-07-22 05:17:07 +01:00
Meta Vens
d91b59eb4a Disambiguate Unity mesh adapter type 2026-07-22 05:05:56 +01:00
Meta Vens
4fcb181d8d Make Geometry Unity sources C# 9 compatible 2026-07-22 04:59:59 +01:00
Meta Vens
ace29fe2b5 Verify Geometry Unity package release version 2026-07-22 04:42:56 +01:00
Meta Vens
a9b8eb0fb6 Stabilize Geometry Unity release binaries 2026-07-22 04:40:44 +01:00
Meta Vens
383a13b5e2 Refresh Geometry Unity release assembly 2026-07-22 04:38:57 +01:00
Meta Vens
0a3aebd12e Normalize Geometry release package versions 2026-07-22 04:37:09 +01:00
Meta Vens
77fe95c7b9 Make Geometry Git UPM package self-contained 2026-07-22 04:25:04 +01:00
Meta Vens
37e70a346c Record consumer cutover and authority audit 2026-07-22 04:16:16 +01:00
Meta Vens
b9f20a640e Update Geometry repository scope 2026-07-22 04:14:10 +01:00
Meta Vens
8a7eee717e Document generated geometry registry ownership 2026-07-22 04:13:43 +01:00
Meta Vens
381d23acee Add coordinated Geometry candidate build 2026-07-22 04:08:32 +01:00
Meta Vens
cdeea47454 Use compiler-portable geometry shader includes 2026-07-22 04:07:20 +01:00
Meta Vens
9758940b35 Integrate geometry worker through CultMesh 2026-07-22 04:01:20 +01:00
Meta Vens
6b44f969a9 Absorb CSG kernel into GameCult.Geometry 2026-07-22 03:56:28 +01:00
Meta Vens
99c893bd64 Fix remaining planetary ownership labels 2026-07-22 03:52:56 +01:00
Meta Vens
756f5a3337 Transfer planetary geometry into GameCult.Geometry 2026-07-22 03:47:57 +01:00
Meta Vens
9293471630 Add cross-runtime geometry v2 contract 2026-07-22 03:25:46 +01:00
Meta Vens
8a219a479a Publish typed geometry v2 contracts 2026-07-22 03:12:55 +01:00
Meta Vens
317b7d538e Delete duplicate geometry vectors 2026-07-22 03:07:31 +01:00
Meta Vens
0f16f14415 Add dedicated Geometry Unity package 2026-07-22 03:01:08 +01:00
Meta Vens
343e03d8a2 Record geometry wire witnesses 2026-07-22 02:59:20 +01:00
Meta Vens
364c9cdf48 Integrate CultMath vectors into geometry 2026-07-22 02:58:42 +01:00
Meta Vens
9698805588 Add geometry serialization and SoA foundations 2026-07-22 02:53:29 +01:00
Meta Vens
c83ce2e7e6 Record geometry migration baseline 2026-07-22 02:47:08 +01:00
Meta Vens
13e8c56f5f Plan geometry ownership migration 2026-07-22 02:26:02 +01:00
Meta Vens
9295278531 Return RUDP application receipts 2026-07-13 17:30:08 +01:00
156 changed files with 14933 additions and 317 deletions

13
.gitignore vendored
View file

@ -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

View file

@ -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

View file

@ -15,6 +15,8 @@
<Copyright>© Meta Vens and contributors</Copyright>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<Version>1.0.3</Version>
<CultMathCandidateFeed Condition="'$(CultMathCandidateFeed)' == ''">$(MSBuildThisFileDirectory)..\CultMath\.tools\local-feed</CultMathCandidateFeed>
<RestoreAdditionalProjectSources Condition="Exists('$(CultMathCandidateFeed)')">$(RestoreAdditionalProjectSources);$(CultMathCandidateFeed)</RestoreAdditionalProjectSources>
<!-- Symbols/debugging -->
<EmbedUntrackedSources>true</EmbedUntrackedSources>

View file

@ -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

View file

@ -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<float2>` and `Column<float3>` 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.

View file

@ -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<float2>`/`Column<float3>` 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.

View file

@ -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",

View file

@ -4088,11 +4088,25 @@ export interface CultMeshRudpDocumentPut {
};
}
export interface CultMeshRudpDocumentReceipt<TDefinition extends AnyCultCacheDocumentDefinition = AnyCultCacheDocumentDefinition> {
binding: CultNetDocumentBinding<TDefinition>;
recordKey: string;
value: CultCacheDocumentValue<TDefinition>;
messageId?: string;
sourceRuntimeId?: string;
sourceAgentId?: string;
sourceRole?: string;
tags?: string[];
}
export interface CultMeshRudpDocumentServerOptions extends CultMeshRudpSocketOptions {
documents: CultNetDocumentRegistry;
getCache?: () => Promise<CultCache> | CultCache;
onError?: (error: Error) => void;
onDocumentPutRaw?: (document: CultMeshRudpDocumentPut) => void | Promise<void>;
onDocumentPutRaw?: (document: CultMeshRudpDocumentPut) =>
| CultMeshRudpDocumentReceipt
| void
| Promise<CultMeshRudpDocumentReceipt | void>;
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<TDefinition extends AnyCultCacheDocumentDefinition, TReceiptDefinition extends AnyCultCacheDocumentDefinition>(
runtimeId: string, connectionId: number, endpoint: string | CultMeshRudpEndpoint,
binding: CultNetDocumentBinding<TDefinition>, recordKey: string, value: CultCacheDocumentValue<TDefinition>,
receiptBinding: CultNetDocumentBinding<TReceiptDefinition>, options: CultMeshRudpDocumentReceiptOptions = {},
): Promise<CultCacheDocumentValue<TReceiptDefinition>> {
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<CultCacheDocumentValue<TReceiptDefinition>>((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<TReceiptDefinition>);
} 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,

View file

@ -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(

969
packages/gamecult-geometry-rs/Cargo.lock generated Normal file
View file

@ -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",
]

View file

@ -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"

View file

@ -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.

View file

@ -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).

File diff suppressed because it is too large Load diff

View file

@ -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<Item = Self>) -> bool {
others.into_iter().any(|other| self.intersects(other))
}
pub fn intersection(self, other: Self) -> Option<Self> {
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<Self> {
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<Aabb>, 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<String>,
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<Aabb> {
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,
])
}

View file

@ -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<Vec3>,
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<Vec3>, 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::<Vec3>() / self.vertices.len() as f32
}
fn with_vertices(&self, vertices: Vec<Vec3>) -> 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<ConvexPolygon>,
pub clip_planes: Vec<Plane>,
pub material: MaterialId,
pub bounds: Aabb,
}
#[derive(Clone, Debug, Default)]
pub struct CategorizedPolygons {
pub inside: Vec<ConvexPolygon>,
pub outside: Vec<ConvexPolygon>,
pub aligned: Vec<ConvexPolygon>,
pub reverse_aligned: Vec<ConvexPolygon>,
}
#[derive(Default)]
pub struct PolygonRouteScratch {
pieces: Vec<CategorizationPiece>,
inside_pieces: Vec<CategorizationPiece>,
cap_points: Vec<Vec3>,
}
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> {
self.clone().subtract_convex_owned(cutter)
}
pub fn subtract_convex_owned(self, cutter: &Self) -> Vec<Self> {
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> {
self.clone().intersect_convex_owned(cutter)
}
pub fn intersect_convex_owned(self, cutter: &Self) -> Option<Self> {
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<ConvexPolygon>,
cutter: &Self,
) -> Vec<ConvexPolygon> {
let categorized = PolygonRouteScratch::default().categorize_against(&polygons, cutter);
categorized.outside
}
pub fn route_polygons_inside_of(
polygons: Vec<ConvexPolygon>,
cutter: &Self,
) -> Vec<ConvexPolygon> {
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<ConvexPolygon> {
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<ConvexPolygon>,
cutter: &ConvexSolid,
) -> Vec<ConvexPolygon> {
self.categorize_against(&polygons, cutter).outside
}
pub fn route_inside_of(
&mut self,
polygons: Vec<ConvexPolygon>,
cutter: &ConvexSolid,
) -> Vec<ConvexPolygon> {
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<PolygonCategory>,
}
#[derive(Clone, Copy)]
enum KeepSide {
Front,
Back,
}
fn classify_piece_against_plane(
mut piece: CategorizationPiece,
plane: Plane,
inside_pieces: &mut Vec<CategorizationPiece>,
categorized: &mut CategorizedPolygons,
cap_points: &mut Vec<Vec3>,
) {
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<Vec3>,
) -> Vec<Vec3> {
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 &current 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::<Vec3>() / 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<Vec3>) -> Vec<Vec3> {
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<Plane> {
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<PolygonCategory> {
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)
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -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<String>,
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<String>, 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<String>,
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<String>,
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<String>,
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<String>, 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<String>, 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());
}
}

View file

@ -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<DemandPair>,
pub rejected_pairs: usize,
pub operator_brushes: usize,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct DirtyDemandFrontier {
pub first_dirty_index: Option<usize>,
pub pairs: Vec<DemandPair>,
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::<usize>::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<usize> {
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<Item = usize>,
) -> 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::<usize>::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<usize> {
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);
}
}

View file

@ -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<Vec<u8>, rmp_serde::encode::Error> {
rmp_serde::to_vec(self)
}
fn from_msgpack(payload: &[u8]) -> Result<Self, rmp_serde::decode::Error> {
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<String>,
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<String>, 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<GeometryFeatureClaim>,
pub Vec<GeometryDomainNode>,
);
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<String>,
pub Vec<String>,
pub Vec<String>,
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<String>,
pub Vec<String>,
pub Vec<String>,
pub Vec<GeometryContributionRow>,
);
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<String>,
pub Vec<String>,
pub GeometryTriangleMesh,
pub Option<GeometryTriangleMesh>,
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<Float3>,
pub Vec<Float3>,
pub Vec<Float2>,
pub Vec<u32>,
pub Vec<u32>,
);
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::<Vec<_>>()
.join(",")
}
fn stable_float3_array(values: &[Float3]) -> String {
values
.iter()
.map(|v| stable_float3(*v))
.collect::<Vec<_>>()
.join(",")
}
fn stable_u32_array(values: &[u32]) -> String {
values
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.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());
}
}

View file

@ -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<Vec3>,
pub normals: Vec<Vec3>,
pub uvs: Vec<Vec2>,
pub indices: Vec<u32>,
pub triangle_materials: Vec<MaterialId>,
}
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);
}
}

View file

@ -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
}

View file

@ -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::<usize>(),
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);
}

View file

@ -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<CsgNodeId>,
},
}
#[derive(Clone, Debug, Default)]
pub struct CsgTreeArena {
nodes: Vec<CsgNode>,
}
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<String>,
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<String>,
op: CsgBranchOp,
children: impl IntoIterator<Item = CsgNodeId>,
) -> 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<Item = CsgNodeId>,
) -> 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<Aabb> {
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<CsgOperationType>,
) {
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<Aabb> {
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<BrushOp> 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);
}
}

View file

@ -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"
}
}

View file

@ -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 };
}

View file

@ -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));
}

View file

@ -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/);

View file

@ -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

View file

@ -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)"

View file

@ -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."

View file

@ -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." }

View file

@ -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<byte>();");
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}();");

View file

@ -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;
/// <summary>
/// Declares MessagePack resolvers owned by the assembly that defines a Cult document.
/// </summary>
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
public sealed class CultDocumentMessagePackResolversAttribute : Attribute
{
/// <summary>Creates a resolver declaration in precedence order.</summary>
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;
}
/// <summary>Gets the declared resolver types in precedence order.</summary>
public Type[] ResolverTypes { get; }
}
/// <summary>
/// MessagePack resolver for CultCache-specific value types.
/// </summary>
@ -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<Assembly, MessagePackSerializerOptions> DocumentAssemblyOptions = new();
/// <summary>
/// Gets the shared MessagePack serializer options for CultCache payloads.
@ -54,12 +79,29 @@ public static class CultDocumentMessagePackSerialization
StandardResolver.Instance))
.WithSecurity(MessagePackSecurity.UntrustedData);
/// <summary>
/// Gets serializer options for documents owned by an assembly. CultCache formatters
/// take precedence, followed by assembly-declared resolvers and the standard resolver.
/// </summary>
public static MessagePackSerializerOptions OptionsFor(Assembly documentAssembly)
{
if (documentAssembly == null) throw new ArgumentNullException(nameof(documentAssembly));
return DocumentAssemblyOptions.GetOrAdd(documentAssembly, CreateDocumentAssemblyOptions);
}
/// <summary>Gets serializer options for the assembly that owns a document type.</summary>
public static MessagePackSerializerOptions OptionsFor(Type documentType)
{
if (documentType == null) throw new ArgumentNullException(nameof(documentType));
return OptionsFor(documentType.Assembly);
}
/// <summary>
/// Serializes a typed value with the CultCache MessagePack options.
/// </summary>
public static byte[] Serialize<T>(T value)
{
return MessagePackSerializer.Serialize(value, Options);
return MessagePackSerializer.Serialize(value, OptionsFor(typeof(T)));
}
/// <summary>
@ -67,7 +109,7 @@ public static class CultDocumentMessagePackSerialization
/// </summary>
public static T Deserialize<T>(byte[] payload)
{
return MessagePackSerializer.Deserialize<T>(payload, Options);
return MessagePackSerializer.Deserialize<T>(payload, OptionsFor(typeof(T)));
}
/// <summary>
@ -84,7 +126,7 @@ public static class CultDocumentMessagePackSerialization
}
}
return MessagePackSerializer.Serialize(type, value, Options);
return MessagePackSerializer.Serialize(type, value, OptionsFor(type));
}
/// <summary>
@ -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<CultDocumentMessagePackResolversAttribute>()
.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 ?? "<null>"}' 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.");
}
/// <summary>
/// Serializes one persisted store record.
/// </summary>

View file

@ -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<Type>());
}
private static bool ContainsManagedReferences(Type type, HashSet<Type> 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));
}
}
}

View file

@ -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
/// </summary>
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";
}
/// <summary>
@ -67,10 +68,10 @@ namespace GameCult.Geometry
public string Kind { get; set; } = string.Empty;
[Key(2)]
public float[] Translation { get; set; } = Array.Empty<float>();
public float3 Translation { get; set; }
[Key(3)]
public float[] RotationXyzw { get; set; } = Array.Empty<float>();
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<float>();
public float3 Translation { get; set; }
[Key(2)]
public float[] RotationXyzw { get; set; } = Array.Empty<float>();
public quaternion Rotation { get; set; } = quaternion.identity;
[Key(3)]
public float[] SupportCenter { get; set; } = Array.Empty<float>();
public float3 SupportCenter { get; set; }
[Key(4)]
public float[] SupportSize { get; set; } = Array.Empty<float>();
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<float>();
public float3 CameraPosition { get; set; }
[Key(4)]
public float[] FrustumMin { get; set; } = Array.Empty<float>();
public float3 FrustumMin { get; set; }
[Key(5)]
public float[] FrustumMax { get; set; } = Array.Empty<float>();
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<float>();
public float3 BoundsMin { get; set; }
[Key(4)]
public float[] BoundsMax { get; set; } = Array.Empty<float>();
public float3 BoundsMax { get; set; }
[Key(5)]
public string[] SourceDomainKeys { get; set; } = Array.Empty<string>();
@ -374,13 +375,13 @@ namespace GameCult.Geometry
public sealed class CultGeometryTriangleMesh
{
[Key(0)]
public float[] Positions { get; set; } = Array.Empty<float>();
public float3[] Positions { get; set; } = Array.Empty<float3>();
[Key(1)]
public float[] Normals { get; set; } = Array.Empty<float>();
public float3[] Normals { get; set; } = Array.Empty<float3>();
[Key(2)]
public float[] Uvs { get; set; } = Array.Empty<float>();
public float2[] Uvs { get; set; } = Array.Empty<float2>();
[Key(3)]
public uint[] Indices { get; set; } = Array.Empty<uint>();
@ -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)
{

View file

@ -1,126 +1,10 @@
using System;
using System.Text.Json.Serialization;
using CultMath;
using MessagePack;
namespace GameCult.Geometry
{
/// <summary>
/// Deterministic two-dimensional float vector for cross-runtime geometry and query contracts.
/// </summary>
[MessagePackObject]
public readonly struct CultVec2 : IEquatable<CultVec2>
{
/// <summary>Creates a two-dimensional vector.</summary>
public CultVec2(float x, float y)
{
X = x;
Y = y;
}
/// <summary>Gets the x component.</summary>
[Key(0)]
public float X { get; }
/// <summary>Gets the y component.</summary>
[Key(1)]
public float Y { get; }
/// <summary>Gets the zero vector.</summary>
[IgnoreMember]
[JsonIgnore]
public static CultVec2 Zero => new(0f, 0f);
/// <summary>Gets the squared vector length.</summary>
[IgnoreMember]
[JsonIgnore]
public float LengthSquared => (X * X) + (Y * Y);
/// <summary>Returns whether this vector is equal to another vector.</summary>
public bool Equals(CultVec2 other) => X.Equals(other.X) && Y.Equals(other.Y);
/// <inheritdoc />
public override bool Equals(object? obj) => obj is CultVec2 other && Equals(other);
/// <inheritdoc />
public override int GetHashCode() => HashCode.Combine(X, Y);
/// <inheritdoc />
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);
}
/// <summary>
/// Deterministic three-dimensional float vector for cross-runtime geometry and query contracts.
/// </summary>
[MessagePackObject]
public readonly struct CultVec3 : IEquatable<CultVec3>
{
/// <summary>Creates a three-dimensional vector.</summary>
public CultVec3(float x, float y, float z)
{
X = x;
Y = y;
Z = z;
}
/// <summary>Gets the x component.</summary>
[Key(0)]
public float X { get; }
/// <summary>Gets the y component.</summary>
[Key(1)]
public float Y { get; }
/// <summary>Gets the z component.</summary>
[Key(2)]
public float Z { get; }
/// <summary>Gets the zero vector.</summary>
[IgnoreMember]
[JsonIgnore]
public static CultVec3 Zero => new(0f, 0f, 0f);
/// <summary>Gets the XY plane projection.</summary>
[IgnoreMember]
[JsonIgnore]
public CultVec2 Xy => new(X, Y);
/// <summary>Gets the XZ plane projection for Unity compatibility adapters.</summary>
[IgnoreMember]
[JsonIgnore]
public CultVec2 Xz => new(X, Z);
/// <summary>Gets the squared vector length.</summary>
[IgnoreMember]
[JsonIgnore]
public float LengthSquared => (X * X) + (Y * Y) + (Z * Z);
/// <summary>Returns whether this vector is equal to another vector.</summary>
public bool Equals(CultVec3 other) => X.Equals(other.X) && Y.Equals(other.Y) && Z.Equals(other.Z);
/// <inheritdoc />
public override bool Equals(object? obj) => obj is CultVec3 other && Equals(other);
/// <inheritdoc />
public override int GetHashCode() => HashCode.Combine(X, Y, Z);
/// <inheritdoc />
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);
}
/// <summary>
/// Axis-aligned two-dimensional rectangle represented by canonical min and max corners.
/// </summary>
@ -128,52 +12,52 @@ namespace GameCult.Geometry
public readonly struct CultRect : IEquatable<CultRect>
{
/// <summary>Creates a rectangle and canonicalizes min/max corners.</summary>
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));
}
/// <summary>Creates a rectangle and canonicalizes min/max corners.</summary>
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))
{
}
/// <summary>Gets the canonical minimum corner.</summary>
[Key(0)]
public CultVec2 Min { get; }
public float2 Min { get; }
/// <summary>Gets the canonical maximum corner.</summary>
[Key(1)]
public CultVec2 Max { get; }
public float2 Max { get; }
/// <summary>Gets the rectangle size.</summary>
[IgnoreMember]
[JsonIgnore]
public CultVec2 Size => Max - Min;
public float2 Size => Max - Min;
/// <summary>Gets the rectangle center.</summary>
[IgnoreMember]
[JsonIgnore]
public CultVec2 Center => (Min + Max) * 0.5f;
public float2 Center => (Min + Max) * 0.5f;
/// <summary>Returns whether the point lies inside or on the rectangle edge.</summary>
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;
}
/// <summary>Returns whether this rectangle intersects another rectangle.</summary>
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;
}
/// <summary>Returns whether this rectangle is equal to another rectangle.</summary>
@ -199,7 +83,7 @@ namespace GameCult.Geometry
public readonly struct CultCircle : IEquatable<CultCircle>
{
/// <summary>Creates a circle.</summary>
public CultCircle(CultVec2 center, float radius)
public CultCircle(float2 center, float radius)
{
if (radius < 0f)
{
@ -212,7 +96,7 @@ namespace GameCult.Geometry
/// <summary>Gets the circle center.</summary>
[Key(0)]
public CultVec2 Center { get; }
public float2 Center { get; }
/// <summary>Gets the circle radius.</summary>
[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);
/// <summary>Returns whether the point lies inside or on the circle edge.</summary>
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;
}
/// <summary>Returns whether this circle intersects a rectangle.</summary>
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));
}
/// <summary>Returns whether this circle is equal to another circle.</summary>
@ -262,7 +146,7 @@ namespace GameCult.Geometry
public readonly struct CultSphere : IEquatable<CultSphere>
{
/// <summary>Creates a sphere.</summary>
public CultSphere(CultVec3 center, float radius)
public CultSphere(float3 center, float radius)
{
if (radius < 0f)
{
@ -275,7 +159,7 @@ namespace GameCult.Geometry
/// <summary>Gets the sphere center.</summary>
[Key(0)]
public CultVec3 Center { get; }
public float3 Center { get; }
/// <summary>Gets the sphere radius.</summary>
[Key(1)]
@ -284,13 +168,13 @@ namespace GameCult.Geometry
/// <summary>Gets the XY circle projection.</summary>
[IgnoreMember]
[JsonIgnore]
public CultCircle XyCircle => new(Center.Xy, Radius);
public CultCircle XyCircle => new(Center.xy, Radius);
/// <summary>Returns whether the point lies inside or on the sphere surface.</summary>
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;
}
/// <summary>Returns whether this sphere is equal to another sphere.</summary>

View file

@ -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<string>();
[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<string>();
[Key(3)]
public string[] ContentHashes { get; set; } = Array.Empty<string>();
}
public sealed class CultGeometryBuildOutput
{
public CultGeometrySelectedCutManifest SelectedCut { get; init; } = new();
public CultGeometryChunkArtifact[] Artifacts { get; init; } = Array.Empty<CultGeometryChunkArtifact>();
}
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<string>();
public string[] ArtifactKeys { get; init; } = Array.Empty<string>();
public string[] ContentHashes { get; init; } = Array.Empty<string>();
public string ServedPackageVersion { get; init; } = string.Empty;
}
}

View file

@ -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<CultGeometryBuildOutput> BuildAsync(
CultGeometryDomainDocument domain,
CultGeometryBuildRequest request);
}
/// <summary>Owns geometry worker commands and the authoritative output commit path.</summary>
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<CultGeometryBuildCommand, CultGeometryBuildReceipt>(
BuildOperationId,
(command, _) => BuildAsync(command));
}
public CultMeshOperationHandle<CultGeometryBuildCommand, CultGeometryBuildReceipt> BuildOperation { get; }
public CultRecordKey WorkerStateKey => CultGeometryWorkerState.CreateRecordKey(_workerId);
public async Task<CultGeometryBuildReceipt> 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<CultGeometryBuildRequest>(requestKey).ConfigureAwait(false);
var domain = await RequireAsync<CultGeometryDomainDocument>(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<CultGeometryDevelopmentProbe> ProbeAsync()
{
var state = await RequireAsync<CultGeometryWorkerState>(WorkerStateKey).ConfigureAwait(false);
var cut = await RequireAsync<CultGeometrySelectedCutManifest>(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<T> RequireAsync<T>(CultRecordKey key) where T : class =>
await _database.GetAsync<T>(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<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? typeof(CultGeometryWorkerProvider).Assembly.GetName().Version?.ToString()
?? "unknown";
}
}

View file

@ -0,0 +1,106 @@
using CultMath;
using GameCult.Caching.MessagePack;
using MessagePack;
using MessagePack.Formatters;
[assembly: CultDocumentMessagePackResolvers(typeof(GameCult.Geometry.CultMathMessagePackResolver))]
namespace GameCult.Geometry;
/// <summary>
/// Owns the persisted positional representation of CultMath values used by
/// GameCult.Geometry documents. CultMath remains serialization-agnostic.
/// </summary>
public sealed class CultMathMessagePackResolver : IFormatterResolver
{
public IMessagePackFormatter<T>? GetFormatter<T>()
{
if (typeof(T) == typeof(float2)) return (IMessagePackFormatter<T>)(object)Float2Formatter.Instance;
if (typeof(T) == typeof(float3)) return (IMessagePackFormatter<T>)(object)Float3Formatter.Instance;
if (typeof(T) == typeof(float4)) return (IMessagePackFormatter<T>)(object)Float4Formatter.Instance;
if (typeof(T) == typeof(quaternion)) return (IMessagePackFormatter<T>)(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<float2>
{
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<float3>
{
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<float4>
{
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<quaternion>
{
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());
}
}
}

View file

@ -1,19 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>Typed CultCache geometry domain and chunk artifact documents for VibeGeometry and Fensalir</Description>
<Description>Spatial values, planetary geometry, queries, and typed geometry state built on CultMath primitives</Description>
<PackageId>GameCult.Geometry</PackageId>
<PackageTags>game;geometry;csg;lod;cultcache;cultmesh;vibegeometry;fensalir</PackageTags>
<Version>0.1.0</Version>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<PackageTags>game;geometry;planetary;csg;lod;cultcache;cultmesh</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseExpression></PackageLicenseExpression>
<PackageLicenseFile>LICENSES.md</PackageLicenseFile>
<NoWarn>$(NoWarn);1591</NoWarn>
<CultMathVersion>0.1.0</CultMathVersion>
<CultLibSupportVersion>1.0.3</CultLibSupportVersion>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\GameCult.Caching\GameCult.Caching.csproj" />
<ProjectReference Include="..\GameCult.Caching\GameCult.Caching.csproj" AdditionalProperties="Version=$(CultLibSupportVersion);PackageVersion=$(CultLibSupportVersion)" />
<ProjectReference Include="..\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" AdditionalProperties="Version=$(CultLibSupportVersion);PackageVersion=$(CultLibSupportVersion)" />
<ProjectReference Include="..\GameCult.Mesh\GameCult.Mesh.csproj" AdditionalProperties="Version=$(CultLibSupportVersion);PackageVersion=$(CultLibSupportVersion)" />
<ProjectReference Include="..\GameCult.Networking\GameCult.Networking.csproj" AdditionalProperties="Version=$(CultLibSupportVersion);PackageVersion=$(CultLibSupportVersion)" />
<PackageReference Include="CultMath" Version="$(CultMathVersion)" />
<PackageReference Include="MessagePack" Version="3.1.7" />
<PackageReference Include="System.Text.Json" Version="9.0.10" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
<None Include="LICENSES.md" Pack="true" PackagePath="\" />
<None Include="THIRD-PARTY-NOTICES.md" Pack="true" PackagePath="\" />
<None Include="docs\planetary.md" Pack="true" PackagePath="docs\" />
<None Include="Shaders\*.hlsl" Pack="true" PackagePath="contentFiles\any\any\shaders\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,7 @@
#if NETSTANDARD2_1
namespace System.Runtime.CompilerServices;
internal static class IsExternalInit
{
}
#endif

View file

@ -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.

View file

@ -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;
}

View file

@ -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);
}
}

View file

@ -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));
}
}
}
/// <summary>
/// Application-supplied base evidence at one unit spherical direction.
/// FieldValue and FieldGradient drive erosion. RadialDisplacement and
/// RadialGradient describe the uneroded surface in metres.
/// </summary>
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<TSource>(
in PlanetaryFieldDefinition definition,
ReadOnlySpan<float3> directions,
in PlanetaryQueryScale scale,
TSource source,
Span<PlanetarySurfaceSample> 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<TSource>(
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);
}

View file

@ -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));
}
}

View file

@ -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());
}

View file

@ -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<PlanetaryPageSample> 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<PlanetaryPageSample> 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<TSource>(
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<PlanetaryResidentPage> 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;
}
}

View file

@ -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();
}

View file

@ -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<TSource>(
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);
}
}

View file

@ -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<TSource>(
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<TSource>(
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<TSource>(
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<TSource>(
in PlanetaryFieldDefinition field,
ReadOnlySpan<float3> 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<TSource>(
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);
}
}

View file

@ -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;
}
}

View file

@ -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);
/// <summary>Owns presentation residency only. It never owns field content.</summary>
public sealed class PlanetaryResidualResidency
{
private readonly Dictionary<ulong, PlanetaryTileAddress> residents = new();
private readonly Dictionary<ulong, float> arrivals = new();
private readonly Dictionary<ulong, (float Time, float StartingBlend)> departures = new();
private float lastTime = float.NegativeInfinity;
public void Reset()
{
residents.Clear(); arrivals.Clear(); departures.Clear(); lastTime = float.NegativeInfinity;
}
public PlanetaryResidencySnapshot Update(ReadOnlySpan<PlanetaryTileAddress> 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<ulong>();
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<ulong> 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;
}
}

View file

@ -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);
}
}

View file

@ -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);
/// <summary>
/// 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.
/// </summary>
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));
}

View file

@ -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<CultGeometryDomainDocument>())
.Register(CultNetDocumentBinding.ForDocument<CultGeometryBuildRequest>())
.Register(CultNetDocumentBinding.ForDocument<CultGeometrySelectedCutManifest>())
.Register(CultNetDocumentBinding.ForDocument<CultGeometryChunkArtifact>());
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`.

View file

@ -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<maximum_octaves; octave++)
{
float weight=nyquist<=0.0?1.0:smoothstep(nyquist,nyquist*transition_ratio,wavelength);
if(weight<=0.0) break; active++; final_weight=weight; finest=wavelength; wavelength/=lacunarity;
}
float omitted=0.0, amplitude=abs(base_amplitude);
[loop] for(int index=0; index<maximum_octaves; index++)
{
if(index>=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

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 3252ac02e8853ef4b9d1fbc5204b5c6b
ShaderIncludeImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: b253fa50ddd34bf3b69fb323fe6e00e8
ShaderIncludeImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: bb055ad5328888e479e90ae3fe62c3fe
ShaderIncludeImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 356389371b854e12b9d419f7ee310d81
ShaderIncludeImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 43d458fc5b6b441a941e37001df919e3
ShaderIncludeImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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: <https://www.shadertoy.com/view/wXcfWn>
- Explanation: <https://blog.runevision.com/2026/03/fast-and-gorgeous-erosion-filter.html>
- License: Mozilla Public License 2.0, <https://www.mozilla.org/MPL/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.

View file

@ -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.

View file

@ -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

View file

@ -393,8 +393,8 @@ var view = new CultMeshNativeSliceViewDescriptor(
rowCount,
new[]
{
CultMeshNativeSliceColumn.For<CultVec2>("position"),
CultMeshNativeSliceColumn.For<CultVec2>("velocity")
CultMeshNativeSliceColumn.For<CultMath.float2>("position"),
CultMeshNativeSliceColumn.For<CultMath.float2>("velocity")
},
new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory));
```

View file

@ -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<T>? GetFormatter<T>()
{
return typeof(T) == typeof(ExternalValue)
? (IMessagePackFormatter<T>)(object)new FirstExternalValueFormatter()
: null;
}
public sealed class FirstExternalValueFormatter : IMessagePackFormatter<ExternalValue>
{
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<T>? GetFormatter<T>()
{
if (typeof(T) == typeof(ExternalValue)) FormatterRequestCount++;
return null;
}
}

View file

@ -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<VectorDocument>(new CultRecordKey("vector:first"));
var second = new CultRecordHandle<VectorDocument>(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<VectorDocument>();
Assert.That(table.Column<TestFloat3>(nameof(VectorDocument.Position)).Span.ToArray(), Is.EqualTo(new[]
{
new TestFloat3(7, 8, 9),
new TestFloat3(4, 5, 6),
}));
Assert.That(table.Column<int>(nameof(VectorDocument.Health)).Span.ToArray(), Is.EqualTo(new[] { 30, 20 }));
Assert.That(() => table.Column<float>("Position.x"), Throws.TypeOf<KeyNotFoundException>());
Assert.That(() => table.Column<float>("x"), Throws.TypeOf<KeyNotFoundException>());
Assert.That(cache.Remove(first.Key), Is.True);
var afterRemoval = cache.Soa<VectorDocument>();
Assert.That(afterRemoval.Keys.Select(key => key.Value).ToArray(), Is.EqualTo(new[] { "vector:second" }));
Assert.That(
afterRemoval.Column<TestFloat3>(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<VectorDocument>();
Assert.That(
() => table.Column<int>(nameof(VectorDocument.ManagedValue)),
Throws.TypeOf<KeyNotFoundException>());
}
[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;
}
}
}

View file

@ -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));
}
}
}

View file

@ -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);
}
}

View file

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\GameCult.Geometry\GameCult.Geometry.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit.v3" Version="3.2.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
</Project>

View file

@ -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));
}
}
}

View file

@ -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);
}
}

View file

@ -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<InvalidDataException>(() => 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<InvalidDataException>(() => 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<InvalidDataException>(() => PlanetaryMapTileEncoding.Read(bytes));
}
private readonly struct FlatField : IPlanetaryBaseField
{
public PlanetaryBaseFieldSample Sample(float3 direction) => new(0, float3.zero, 0, float3.zero, 0);
}
}

View file

@ -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);
}
}
}

View file

@ -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));
});
}
}

View file

@ -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<PlanetaryCubeFace>())
{
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);
}
}

View file

@ -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);
}
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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<PlanetaryCubeFace>())
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);
}
}

View file

@ -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);
}
}

View file

@ -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<CultNetDatabaseChange<CultGeometryChunkArtifact>>();
using var subscription = database.Watch<CultGeometryChunkArtifact>()
.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<CultGeometrySelectedCutManifest>(new CultRecordKey(receipt.SelectedCutKey)))
.Should().NotBeNull();
(await database.GetAsync<CultGeometryChunkArtifact>(new CultRecordKey(receipt.ArtifactKeys[0])))
.Should().NotBeNull();
var state = await database.GetAsync<CultGeometryWorkerState>(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<CultGeometryChunkArtifact>(artifactKey);
artifact.Should().NotBeNull();
var put = source.Documents.CreateRawDocumentPutMessage(
"geometry-artifact",
new CultRecordHandle<CultGeometryChunkArtifact>(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<CultGeometryChunkArtifact>(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<CultGeometryChunkArtifact>(artifactKey))!.ChunkId.Should().Be("chunk-1");
}
[Test]
public void WorkerState_RegistersThroughTheGeneratedDocumentRegistry()
{
var descriptor = new CultCache().Registry.GetRequired<CultGeometryWorkerState>();
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<CultGeometryBuildOutput> 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()
}
]
});
}
}
}
}

View file

@ -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<float3>(twoComponents, GeometryOptions),
Throws.TypeOf<MessagePackSerializationException>());
}
[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<VectorColumnDocument>();
Assert.That(table.Column<float2>(nameof(VectorColumnDocument.Uv)).Span.ToArray(),
Is.EqualTo(new[] { new float2(0.25f, 0.75f) }));
Assert.That(table.Column<float3>(nameof(VectorColumnDocument.Position)).Span.ToArray(),
Is.EqualTo(new[] { new float3(1f, 2f, 3f) }));
Assert.That(() => table.Column<float>("Position.x"), Throws.TypeOf<System.Collections.Generic.KeyNotFoundException>());
}
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; }
}
}

View file

@ -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<CultRect>(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<CultRect>(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<CultGeometryChunkArtifact>().SchemaVersion
.Should().Be(CultGeometrySchemaVersions.ChunkArtifact);
cache.Registry.GetRequired<CultGeometryDomainDocument>().SchemaId
.Should().Be("sha256:e3358436f8a07c84cc66b5196380cdfcd6920fa15f15d052d30b42b73762421d");
cache.Registry.GetRequired<CultGeometryBuildRequest>().SchemaId
.Should().Be("sha256:2de3162db128fc2c5cc2ea3dae46d0196110a9e5f969997002309e636a3721d3");
cache.Registry.GetRequired<CultGeometrySelectedCutManifest>().SchemaId
.Should().Be("sha256:864412d3cf0f6b079ceb0bbd3b36232f11380e63cdf0bfd64d505a710fb49ff8");
cache.Registry.GetRequired<CultGeometryChunkArtifact>().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<CultGeometryDomainDocument>(payload);
var domain = SampleDomain();
var payload = CultDocumentMessagePackSerialization.Serialize(domain);
var decoded = CultDocumentMessagePackSerialization.Deserialize<CultGeometryDomainDocument>(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<CultGeometryChunkArtifact>(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<CultGeometryChunkArtifact>(payload))
.Should().Throw<MessagePackSerializationException>();
}
[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);
}
}
}

View file

@ -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.");
}
}

View file

@ -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<CultGeometryBuildRequest>(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>(T document, string expectedHex)
{
var payload = CultDocumentMessagePackSerialization.Serialize(document);
Convert.ToHexString(payload).Should().Be(expectedHex);
var decoded = CultDocumentMessagePackSerialization.Deserialize<T>(payload);
Convert.ToHexString(CultDocumentMessagePackSerialization.Serialize(decoded)).Should().Be(expectedHex);
var rustPayload = Convert.FromHexString(expectedHex);
var decodedFromRust = CultDocumentMessagePackSerialization.Deserialize<T>(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,
};
}

View file

@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\GameCult.Geometry\GameCult.Geometry.csproj" />
</ItemGroup>
</Project>

View file

@ -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);
}
}

Some files were not shown because too many files have changed in this diff Show more