From 929527853113b01bd7404910510fd435f79f5cd6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 13 Jul 2026 17:30:08 +0100 Subject: [PATCH 01/30] Return RUDP application receipts --- packages/cultmesh-ts/src/index.ts | 63 +++++++++++++++++++++- packages/cultmesh-ts/test/cultmesh.test.ts | 30 +++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index c61dfee1..687f9b8a 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -4088,11 +4088,25 @@ export interface CultMeshRudpDocumentPut { }; } +export interface CultMeshRudpDocumentReceipt { + binding: CultNetDocumentBinding; + recordKey: string; + value: CultCacheDocumentValue; + messageId?: string; + sourceRuntimeId?: string; + sourceAgentId?: string; + sourceRole?: string; + tags?: string[]; +} + export interface CultMeshRudpDocumentServerOptions extends CultMeshRudpSocketOptions { documents: CultNetDocumentRegistry; getCache?: () => Promise | CultCache; onError?: (error: Error) => void; - onDocumentPutRaw?: (document: CultMeshRudpDocumentPut) => void | Promise; + onDocumentPutRaw?: (document: CultMeshRudpDocumentPut) => + | CultMeshRudpDocumentReceipt + | void + | Promise; wireContract?: CultNetWireContract; sessionTimeoutMs?: number; } @@ -4112,6 +4126,10 @@ export interface CultMeshRudpDocumentPublishOptions extends CultMeshRudpPeerOpti flushTimeoutMs?: number; } +export interface CultMeshRudpDocumentReceiptOptions extends CultMeshRudpDocumentPublishOptions { + receiptTimeoutMs?: number; +} + export interface CultMeshRudpEndpoint { host: string; port: number; @@ -5638,7 +5656,18 @@ export class CultMesh { return; case "cultnet.document_put_raw.v0": if (options.onDocumentPutRaw) { - await options.onDocumentPutRaw(normalizeRudpDocumentPut(message, record.remote)); + const receipt = await options.onDocumentPutRaw(normalizeRudpDocumentPut(message, record.remote)); + if (receipt) { + sendSchemaMessage(record, options.documents.createRawDocumentPutMessage( + receipt.binding, receipt.messageId ?? message.messageId, receipt.recordKey, receipt.value, + { + sourceRuntimeId: receipt.sourceRuntimeId ?? runtimeId, + sourceAgentId: receipt.sourceAgentId, + sourceRole: receipt.sourceRole ?? "provider", + tags: receipt.tags ?? ["receipt"], + }, + )); + } } return; default: @@ -5753,6 +5782,36 @@ export class CultMesh { } } + public static async publishRudpDocumentAndWaitForReceipt( + runtimeId: string, connectionId: number, endpoint: string | CultMeshRudpEndpoint, + binding: CultNetDocumentBinding, recordKey: string, value: CultCacheDocumentValue, + receiptBinding: CultNetDocumentBinding, options: CultMeshRudpDocumentReceiptOptions = {}, + ): Promise> { + requireNonEmpty(runtimeId, "runtimeId"); + requireNonEmpty(recordKey, "recordKey"); + const messageId = options.messageId ?? `${runtimeId}:${binding.definition.type}:${recordKey}`; + const peer = await CultMesh.createRudpPeer(runtimeId, connectionId, endpoint, options); + try { + const receipt = new Promise>((resolve, reject) => { + const timeout = setTimeout(() => { cleanup(); reject(new Error(`Timed out waiting for RUDP application receipt for ${recordKey}.`)); }, options.receiptTimeoutMs ?? 5_000); + const onMessage = (message: CultNetMessage): void => { + if (message.schemaVersion !== "cultnet.document_put_raw.v0" || message.messageId !== messageId || message.document.schemaId !== receiptBinding.definition.schemaId) return; + try { + const decoded = decode(new Uint8Array(message.document.payload)); + cleanup(); + resolve(receiptBinding.definition.schema.parse(decoded) as CultCacheDocumentValue); + } catch (error) { cleanup(); reject(error); } + }; + const cleanup = (): void => { clearTimeout(timeout); peer.off("message", onMessage); }; + peer.on("message", onMessage); + }); + peer.send(new CultNetDocumentRegistry([binding]).createRawDocumentPutMessage(binding, messageId, recordKey, value, { + sourceRuntimeId: options.sourceRuntimeId ?? runtimeId, sourceAgentId: options.sourceAgentId, sourceRole: options.sourceRole, tags: options.tags, + })); + return await receipt; + } finally { peer.close(); } + } + public static async createRudpServer( runtimeId: string, connectionId: number, diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index f4e8f9cc..285ee09d 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -34,6 +34,13 @@ const noteAliasDocument = defineDocumentType({ name: "noteId", }); +const noteReceiptDocument = defineDocumentType({ + type: "cultmesh.note_receipt", + schemaId: "cultmesh.note_receipt.v0", + schema: z.object({ commandId: z.string(), state: z.literal("applied"), body: z.string() }), + name: "commandId", +}); + const foreignNoteDocument = defineDocumentType({ type: "runtime.generated.cultmesh.note", schemaId: "runtime.generated.cultmesh.note.ui.42", @@ -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( From 13e8c56f5f9cdda892752695ebbc54ba36e06a29 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 02:26:02 +0100 Subject: [PATCH 02/30] Plan geometry ownership migration --- ...ltmath-gamecult-geometry-migration-plan.md | 552 ++++++++++++++++++ 1 file changed, 552 insertions(+) create mode 100644 docs/cultmath-gamecult-geometry-migration-plan.md diff --git a/docs/cultmath-gamecult-geometry-migration-plan.md b/docs/cultmath-gamecult-geometry-migration-plan.md new file mode 100644 index 00000000..e8e961c4 --- /dev/null +++ b/docs/cultmath-gamecult-geometry-migration-plan.md @@ -0,0 +1,552 @@ +# CultMath and GameCult.Geometry Staged Migration Plan + +Status: proposed for execution + +Date: 2026-07-22 + +## Decision + +`GameCult.Geometry` is the sole owner of geometric meaning and geometry +machinery. It builds on CultMath numeric primitives and participates in +CultCache, CultNet, and CultMesh through the normal CultLib integration paths. + +CultMath owns the HLSL-shaped numeric substrate: scalar operations, vectors, +quaternions, and general numeric kernels that have no spatial domain authority. +It does not own regions, topology, projections, surfaces, planetary fields, +patches, geometry residency, meshes, or geometry workflow state. + +VibeGeometry and `vg-csg` are sources to mine. They are not surviving owners or +dependencies. + +Source transfer does not erase provenance. CultMath is MPL-2.0 and CultLib is +MIT-licensed; moved files require an explicit licensing decision and preserved +notices before they enter a differently licensed package. + +## Objective + +Produce one coherent geometry stack in which: + +- consumers use `CultMath.float2`, `float3`, and `float4` as the canonical + floating-point vector primitives; +- `GameCult.Geometry` owns reusable geometry, planetary geometry, CSG, mesh + production, spatial queries, and the typed documents that coordinate those + systems; +- CultMath no longer publishes planetary or other geometry-owned APIs; +- `CultVec2` and `CultVec3` no longer exist; +- VibeGeometry can be archived after its useful implementation, evidence, and + doctrine have been absorbed; +- persisted geometry remains readable through an explicit migration path when + a live persisted corpus requires one. + +## Authority Map + +| Organ | Owns | Does not own | +| --- | --- | --- | +| CultMath | `float2`, `float3`, `float4`, integer/double/bool vector primitives, quaternion algebra, scalar and component-wise intrinsics, coordinate-agnostic numeric kernels, CPU/HLSL parity for those primitives | Rectangles, spheres, topology, projections, planetary fields, geometry pages, meshes, CSG, persistence schemas, worker commands | +| GameCult.Geometry | Spatial value types built from CultMath primitives, spatial queries, topology and projection, planetary field and page machinery, mesh and CSG implementation, geometry schema documents, stable geometry identities, geometry-specific HLSL and cross-runtime fixtures | Competing vector types, generic storage/network ownership, application gameplay consequences | +| CultCache/CultNet/CultMesh | Typed persistence, schema resolution, transport, discovery, publication, commands, and interface projection | Geometry semantics or geometry algorithm ownership | +| VibeGeometry / `vg-csg` | Nothing after migration | Any live runtime, schema, package, or compatibility authority | + +Authority sentence: + +> CultMath owns how numeric primitives behave; GameCult.Geometry owns what those +> primitives mean in space. + +## Current Mechanism + +The live body is split across three places: + +1. CultMath publishes the canonical HLSL-shaped vector types, but also contains + `rect`, planetary topology/projection/field/page/residency systems, spherical + erosion, planetary HLSL, Unity adapters, web fixtures, and planetary tools. +2. `GameCult.Geometry` contains duplicate `CultVec2` and `CultVec3` values, + geometric primitives, and four CultCache document roots. Its document + members still encode many vectors, bounds, transforms, vertices, normals, + and UVs as anonymous `float[]` values. +3. VibeGeometry wraps the `vg-csg` Rust implementation for domain-tree intent, + LOD-aware selected cuts, CSG lowering, and triangle/collider output. + +CultCache's current managed SoA projection only admits primitive scalar members. +It neither preserves arbitrary unmanaged structs as columns nor recursively +splits them. A `float2` or `float3` member is therefore omitted from the SoA +until that storage seam is repaired. + +## Invariants + +The migration must preserve these statements at every published boundary: + +1. There is one canonical vector vocabulary: CultMath's HLSL-shaped primitive + types. +2. Geometry algorithms and geometry state have one owner: `GameCult.Geometry`. +3. CultMath never depends on CultLib, CultCache, MessagePack, or + `GameCult.Geometry`. +4. `GameCult.Geometry` may depend on CultMath and CultLib. +5. Persistence decoration and formatters do not contaminate CultMath primitive + definitions. +6. A geometry schema has one active writer. Legacy readers or offline + migrators may exist temporarily, but they cannot decide live behavior. +7. Record keys and stable fingerprints are identical across supported runtimes + for the same semantic geometry value. +8. SoA storage preserves each authored unmanaged member as one column of its + exact type. It never decomposes `float2`, `float3`, quaternion, or another + value type by reflection. +9. CPU, HLSL, Unity, web, and Rust fixtures compare the same claimed layer of + geometry behavior. +10. VibeGeometry is not retained as a wrapper, submodule, compatibility crate, + or second source of truth. + +## Target Package Shape + +The exact directory names may follow existing CultLib conventions, but the +ownership should lower into these runtime surfaces: + +```text +CultMath + C# and Unity numeric primitives + CultMath.hlsl numeric mirror + coordinate-agnostic native numeric kernels + +CultLib + src/GameCult.Geometry/ + C# geometry API + planetary geometry + CultCache document contracts and formatters + geometry-specific HLSL packaged as content + packages/gamecult-geometry-rs/ + Rust geometry, CSG, and cross-runtime fixtures + packages/gamecult-geometry-ts/ # only if the web runtime remains earned + web projection/tile helpers and parity fixtures + src/GameCult.Unity/Assets/Geometry/ # Unity-owned lowering/adapters + mesh/page upload and presentation adapters + Packages/org.gamecult.geometry/ # final location follows packaging audit + Unity package depending on CultMath and CultLib +``` + +The runtime directories are projections of one geometry authority. They are not +independent products allowed to invent their own vector, topology, schema, or +fingerprint semantics. + +Each stage is a commit/review boundary. Do not begin the next stage until the +current gate passes, and never publish a state in which both repositories claim +the same geometry authority. + +## Source Classification + +### Keep in CultMath + +- HLSL-shaped primitive types such as `float2`, `float3`, and `float4`. +- Integer, double, and boolean vector primitives. +- Quaternion algebra. +- `math` scalar/component-wise operations and the core `CultMath.hlsl` mirror. +- Numeric batch kernels whose contracts are independent of regions, topology, + surfaces, planets, meshes, CSG, rendering policy, and persistence. + +### Move from CultMath to GameCult.Geometry + +The initial move set is: + +- `rect` and other spatial-region values; +- `PlanetaryTopology`; +- `PlanetaryProjection`; +- `PlanetaryQueries`; +- `PlanetaryPatch`; +- `PlanetaryPages`; +- `PlanetaryGpuPages`; +- `PlanetaryResidency`; +- `PlanetaryRadialRefinement`; +- `PlanetaryMapTileEncoding`; +- `PlanetaryField`; +- `AdvancedErosionFilter`; +- `ErosionFrequencyBands`; +- `SphericalErosion`; +- `AdvancedErosionFilter.hlsl`; +- `Planetary.hlsl`; +- spherical-erosion and planetary-radial-refinement shader bodies currently + embedded in `CultMath.hlsl`; +- the versioned `CMPT` planetary tile format and its TypeScript decoder; +- planetary tests, docs, tools, Unity adapters, web fixtures, and samples. + +`AdvancedErosionFilter` and `ErosionFrequencyBands` move with planetary +geometry. Their public contracts encode terrain-field policy, they are direct +dependencies of the planetary field definition, and their HLSL is coupled to +that field. Voronoi tone rendering and `native/cultmath-core` are outside this +migration: the native crate currently owns only that tone kernel and has no +planetary implementation. Audit those separately rather than dragging them +across this boundary because their names look spatial. + +### Rebuild inside GameCult.Geometry + +- Make `CultRect`, `CultCircle`, and `CultSphere` the GameCult.Geometry-owned + spatial values, implemented over CultMath primitives, and delete + `CultMath.rect` at cutover. +- Replace `CultVec2` and `CultVec3` with `float2` and `float3` at every typed + boundary. +- Replace vector-shaped `float[]` document members with typed CultMath values or + intentional packed buffers. +- Keep packed mesh buffers packed when they are genuinely bulk artifact storage; + do not turn every vertex buffer into a forest of object wrappers. +- Put MessagePack/CultCache formatters for CultMath values in the integration + package or generated formatter output, not on the CultMath types. + +### Mine from VibeGeometry and `vg-csg` + +Evaluate and extract, in dependency order: + +- convex plane splitting and brush operations; +- CSG tree and ordered operation semantics; +- domain-tree DSL and lowering; +- dirty-frontier and prefix-checkpoint work; +- selected-cut and LOD planning; +- triangle/collider assembly; +- performance fixtures and parity evidence; +- useful RealtimeCSG-derived behavioral fixtures; +- runtime doctrine that still describes the adopted machine. + +Rewrite extracted code under GameCult.Geometry ownership. Preserve third-party +license notices and source provenance. Do not retain a `vg-csg` forwarding +crate or a VibeGeometry submodule after consumers have moved. + +## Staged Migration + +### Stage 0: Freeze and Baseline + +Purpose: establish the evidence needed to move authority without guessing. + +Work: + +- Freeze new planetary and geometry API additions in CultMath, VibeGeometry, + and the current duplicate primitive surface. +- Do not publish an intermediate release containing duplicate live planetary + implementations. A coordinated working branch may hold source temporarily + for comparison, but only one owner may enter a package feed. +- Inventory public symbols, package versions, HLSL entrypoints, Unity samples, + web fixtures, native exports, persisted schema IDs, record-key algorithms, + and known consumers. +- Inventory exact contract constants: `FieldVersion`, enum ordinals, tile stable + keys, `CMPT` versions and bytes, MessagePack slots, GPU buffer layouts, + integer hashes, topology face/seam/corner rules, and shader include paths. +- Search deployed and checked-in `.cc` stores for the four + `gamecult.geometry.*.v1` schemas. +- Record whether any external consumer relies on CultMath planetary namespaces, + `CultVec*`, `CultMath.rect`, `vg-csg`, or the current HLSL function names. +- Capture passing baselines for CultMath tests, GameCult.Geometry tests, + `vg-csg` tests/clippy, cross-runtime planetary fixtures, and package builds. +- Add representative fixture hashes for projection, topology, field sampling, + patch selection, page encoding, CSG output, and geometry record keys. +- Resolve licensing and provenance for MPL-2.0 CultMath sources and any + third-party-derived `vg-csg` behavior before copying source into CultLib. + +Gate: + +- Every published surface has a named consumer or is marked for deletion. +- The persisted v1 corpus is known rather than assumed. +- Baseline fixtures can be run without booting an entire daemon. + +### Stage 1: Establish the Destination and Dependency Direction + +Purpose: make `GameCult.Geometry` capable of receiving geometry without growing +a second primitive vocabulary. + +Work: + +- Add the explicit `GameCult.Geometry -> CultMath` dependency. +- Define the target namespaces and package content layout for C#, Rust, HLSL, + Unity, and any retained web surface. +- Establish distribution before source transfer: an explicit NuGet dependency + and a dedicated `org.gamecult.geometry` Unity package depending on + `org.gamecult.cultmath` and `org.gamecult.cultlib`, unless package inspection + proves an existing surface already owns that role. +- Retain `net8.0` and `netstandard2.1` targets and Unity 2021.3 compatibility. + The geometry asmdef references CultMath; the Unity lowering assembly + references both. CultMath's asmdef remains reference-free with + `noEngineReferences: true`. +- Add GameCult.Geometry-owned MessagePack formatters for `float2`, `float3`, + `float4`, and quaternion with fixed positional layouts. +- Add cross-runtime byte fixtures for those value encodings. +- Repair CultCache's SoA member discovery so any direct unmanaged member is one + exact typed column. Add `Column` and `Column` tests and a + negative test proving no scalar decomposition occurs. +- Ensure generated schema metadata describes the authored vector member rather + than synthetic x/y/z members. + +Gate: + +- CultMath has no new dependency. +- `GameCult.Geometry` can serialize, deserialize, index, and expose intact + CultMath vectors. +- CultCache hot-path access returns intact vector spans. +- Built NuGet and UPM artifacts contain the expected dependency metadata, + formatters, shaders, and owned runtime assets. + +### Stage 2: Replace Duplicate Primitives and Repair Geometry Schemas + +Purpose: remove the duplicate vocabulary before moving larger geometry systems. + +Work: + +- Rewrite `CultRect`, `CultCircle`, and `CultSphere` with CultMath primitives. +- Delete `CultVec2` and `CultVec3`; do not add aliases or conversion wrappers. +- Replace typed references and opaque native-slice metadata using the obsolete + names. +- Classify every vector-shaped `float[]` in `CultGeometryDocuments.cs`: + - `Translation`, `SupportCenter`, `SupportSize`, `CameraPosition`, + `FrustumMin`, `FrustumMax`, `BoundsMin`, and `BoundsMax` become `float3`; + - `RotationXyzw` becomes quaternion; + - mesh `Positions` and `Normals` become `float3[]`; + - mesh `Uvs` becomes `float2[]`; + - scalar and index buffers remain packed arrays; + - use a named geometry value when bounds/topology carries an invariant. +- Define v2 schemas for every document whose field types change. CultCache + includes CLR type identity in the canonical schema and must reject an in-place + slot type mutation even when no durable v1 corpus is found. +- If live v1 documents exist, add bounded v1 reader DTOs and an offline + v1-to-v2 migrator. Stop all v1 writers at cutover. If no live persisted corpus + exists, omit the migrator and delete v1 runtime support, but still publish the + corrected contract under v2 identity. +- Reject malformed legacy vectors: fixed vectors must have the exact expected + component count, while packed mesh streams must be divisible by their element + width before conversion. +- Recompute and cross-check stable fingerprints and record keys against Rust + fixtures before accepting the new schemas. Preserve an old record key only + when the typed representation reproduces the prior IEEE-bit canonicalization + exactly; otherwise version the identity deliberately. +- Use CultMath's lowercase component names in v2 JSON inspection projections. + JSON is not the runtime transport, and uppercase `X/Y/Z` aliases must not keep + the deleted vector vocabulary alive. Any xenos-facing alternate casing is an + explicit boundary projection. + +Gate: + +- Repository-wide searches find no `CultVec2` or `CultVec3` outside historical + evidence. +- Repository and package searches find no `CultMath.rect`. +- No live writer emits vector-shaped anonymous arrays for semantic single + values. +- The old schema cannot overwrite, repair, or become a second owner of v2 + state. + +### Stage 3: Transfer Planetary Geometry out of CultMath + +Purpose: move planetary authority as one coherent vertical slice. + +Work: + +- Move the classified planetary C# implementation and tests into + `GameCult.Geometry`. +- Move `Planetary.hlsl` and rename its include ownership and public entrypoints + to GameCult.Geometry. It may include CultMath numeric HLSL; CultMath must no + longer include planetary geometry. Remove the current implicit planetary + include from `CultMath.hlsl` only after consumers include the new owner. +- Move `AdvancedErosionFilter.hlsl` plus the spherical-erosion and + planetary-radial-refinement bodies embedded in `CultMath.hlsl`. Publish one + geometry-owned umbrella include that imports CultMath first and geometry + organs afterward. +- Move planetary documentation, fixture tools, web tile code, Unity page/mesh + adapters, and the planetary viewer sample to their consumer-owned runtime + locations. +- Move ownership of the `CMPT` little-endian tile format and TypeScript decoder. + Preserve v1 bytes exactly or introduce a new format version; never reuse a + wire version after changing its bytes. +- Rename public namespaces and symbols to their GameCult.Geometry authority. + Prefer a coordinated breaking release over permanent forwarding types. +- Move spherical/terrain erosion components classified as geometry. Keep only + genuinely coordinate-agnostic numeric kernels in CultMath. +- Run CPU/HLSL and cross-runtime fixtures from the new owner before deleting + the old files. +- Preserve exact fixtures for field versions, enum ordinals, integer hashes, + tile keys/bytes, GPU buffer packing, MessagePack slots, topology seams and + corners, and record hashes. Use explicit tolerances only for floating + CPU/HLSL comparisons. +- Delete the corresponding CultMath sources, package entries, and implicit + includes in the same authority-transfer stage after the destination passes. + Do not defer the actual ownership cut to consumer cleanup. + +Gate: + +- GameCult.Geometry passes the captured planetary baseline and owns the fixture + commands. +- CultMath packages no `Planetary.hlsl`, planetary sample, planetary tool, or + planetary public namespace. +- No source or shader include can select an old CultMath planetary path. +- Package inspection finds no planetary API, Unity adapter, sample, tile + decoder, documentation claim, or implicit shader include in CultMath. + +### Stage 4: Mine and Absorb `vg-csg` + +Purpose: preserve useful CSG machinery without preserving the defunct owner. + +Work: + +- Import the earned Rust implementation into the GameCult.Geometry runtime + surface with source provenance and applicable license notices. +- Replace `bevy_math`-shaped public contracts where they conflict with the + canonical cross-runtime primitive/layout contract. Internal use may remain + where it protects a measured invariant and does not leak a second public + vocabulary. +- Connect domain trees, selected cuts, build requests, and emitted artifacts to + the GameCult.Geometry schema definitions. +- Port useful fixtures before refactoring algorithms so observable behavior has + a witness. +- Preserve the existing Rust v1 tuple MessagePack and stable-hash fixtures as + migration witnesses, then make the GameCult.Geometry-owned Rust package emit + the v2 contract. +- Re-run performance fixtures and retain thresholds only where the fixture + represents a real workload. +- Move surviving doctrine into GameCult.Geometry docs and discard historical + instructions that would recreate VibeGeometry ownership. + +Gate: + +- No production build or test depends on the VibeGeometry repository or + `vg-csg` submodule. +- CSG output satisfies the adopted correctness and performance fixtures from + its new package. +- The Rust runtime speaks the same schema IDs, vector layouts, fingerprints, + and record keys as C#. + +### Stage 5: Integrate Geometry Through CultCache, CultNet, and CultMesh + +Purpose: prove the new owner through the real typed-state path. + +Work: + +- Register the adopted geometry schemas and formatters through the generated + registry path. +- Prove raw envelope interchange for geometry documents across C# and Rust, + adding TypeScript only if an actual web consumer remains. +- Publish geometry worker state and commands through the provider-owned + CultMesh surface. +- Ensure geometry build requests, selected cuts, chunk artifacts, and planetary + page state share one commit/derivation path for direct and programmatic use. +- Add a development probe exposing schema version, owner, source record key, + selected cut/page, content hash, and served package version. + +Gate: + +- A pipeline smoke proves typed handoff from domain input through selected cut + or planetary page selection to a persisted and republished artifact. +- The visible/debug projection and persisted state identify the same owner and + version. +- No ad hoc JSON or parallel status model becomes load-bearing geometry truth. + +### Stage 6: Consumer Cutover and Coordinated Release + +Purpose: switch consumers without maintaining two live authorities. + +Release order: + +1. Build an unpublished CultMath candidate that contains the stable primitive + contract and removes geometry-owned APIs. +2. Build an unpublished GameCult.Geometry candidate against that exact CultMath + version, containing adopted schemas, planetary geometry, and runtime packages. +3. Update and test GameCult services, Unity projects, web consumers, and tools + against a local candidate feed. +4. Publish the CultMath and GameCult.Geometry candidates in one coordinated + release window, with the primitives-only CultMath dependency entering the + feed first. +5. Publish consumer updates after both owning packages are available. +6. Convert persisted v1 geometry stores, if any, and remove migration tooling + after the retained-corpus policy is satisfied. + +Work: + +- Pin compatible package versions across the coordinated release. +- Update examples and developer-facing entrypoints to use + `GameCult.Geometry` plus CultMath primitives directly. +- Cut Fensalir's Engine/Contracts public planetary types and shader includes to + the new assembly before removing CultMath geometry. Its CPU/GPU parity tests + are release gates. +- Add the geometry package to Aetheria/Unity, move its directly compiled + planetary sources to the new assembly, preserve moved Unity `.meta` GUIDs, + and regenerate Unity project files. +- Verify served package, shader, schema, and migration versions in at least one + real consumer for each retained runtime. +- Run the preserved parity corpus through known consumers including Fensalir, + Zyphos, Aetheria/Unity, the web tile decoder when retained, C#, Rust, and HLSL. +- Refuse mixed old/new geometry writers rather than reconciling them later. + +Gate: + +- Every known consumer runs on the new owner. +- No published package graph has `CultMath -> CultLib` or + `CultMath -> GameCult.Geometry`. +- Old and new writers cannot target the same state surface. + +### Stage 7: Delete Obsolete Authorities + +Purpose: make the old ownership structurally unable to return. + +Work: + +- Confirm deletion of all CultMath planetary/geometry sources, tests, shaders, + samples, docs, tools, web surfaces, package content entries, and Unity + adapters; remove any residue found after consumer cutover. +- Delete obsolete GameCult.Geometry duplicate primitive code and stale schema + paths. +- Delete VibeGeometry workspace wiring and the `vg-csg` submodule dependency; + archive the repositories after provenance links and release tags are + recorded. +- Remove compatibility aliases, forwarding namespaces, dual registries, and + temporary migration flags. +- Update source indexes and repository memory so future agents retrieve the new + authority first. + +Gate: + +- Negative searches and package inspection prove the old paths are absent. +- Building CultMath alone cannot access planetary or geometry APIs. +- Building GameCult.Geometry cannot access `CultVec*` or VibeGeometry. +- The old persisted schema path cannot write or override current geometry. + +## Schema Migration Policy + +Do not create a permanent compatibility model pre-emptively. + +- If no durable v1 corpus exists, change the schema once, update fixtures, and + publish the corrected contract as v2, then delete the old form without + building a migrator. +- If a durable v1 corpus exists, create an offline migrator with explicit input + and output schema IDs, content hashes, counts, and a dry-run report. +- A v1 reader may exist only inside migration/inspection tooling. +- The runtime writes only the current schema after cutover. +- Migration completes only when retained stores have receipts and no active + service advertises or writes v1. + +## Verification Matrix + +| Layer | Required evidence | +| --- | --- | +| CultMath primitives | C#/HLSL parity, unmanaged layout/size checks, package inspection | +| Geometry values | Exact `float2`/`float3` field ownership, intersection/query unit tests, MessagePack byte fixtures | +| CultCache SoA | Intact vector columns, no recursive flattening, add/update/remove/snapshot behavior | +| Geometry schemas | Catalog IDs and slots, v1 inventory or migration receipts, stable record-key parity | +| Planetary geometry | Projection round trips, topology adjacency, field/normal samples, radial refinement, page encoding/residency, CPU/HLSL parity | +| CSG | Boolean/convex fixtures, dirty rebuild behavior, selected-cut correctness, triangle/collider receipts, performance fixtures | +| Cross-runtime | C#/Rust payload and fingerprint parity; web parity only for retained web consumers | +| Packaging | CultMath contains no geometry assets; GameCult.Geometry includes its owned HLSL/docs/runtime assets | +| End to end | Domain or planetary input to persisted artifact through CultMesh with visible owner/version evidence | + +## Negative Completion Checks + +The migration is not complete until all are true: + +- `CultVec2` and `CultVec3` cannot appear in newly generated schemas or native + slice metadata. +- CultMath cannot publish or execute planetary geometry through an old entrypoint. +- VibeGeometry and `vg-csg` cannot influence a build, runtime, fixture, or + generated artifact. +- VibeGeometry is absent from project references, package manifests, + submodules, CI, runtime identifiers, and authoritative documentation. +- Legacy v1 state cannot override or repair current geometry after the fact. +- CultCache cannot decompose a vector into independently owned scalar columns. +- Manual, programmatic, imported, persisted, and network-received geometry use + the same schema and commit paths. +- Passing unit tests are accompanied by package inspection and cross-runtime + evidence at the layer users actually consume. + +## Definition of Done + +The work is complete when `GameCult.Geometry` is the only geometry authority, +CultMath is a clean numeric substrate, all retained runtime projections agree on +typed layouts and stable identities, VibeGeometry has no live dependency path, +and the old owners are structurally unable to emit current geometry state. From c83ce2e7e6a91388f2801bf560a46b9a5896773f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 02:47:08 +0100 Subject: [PATCH 03/30] Record geometry migration baseline --- ...th-gamecult-geometry-migration-progress.md | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 docs/cultmath-gamecult-geometry-migration-progress.md diff --git a/docs/cultmath-gamecult-geometry-migration-progress.md b/docs/cultmath-gamecult-geometry-migration-progress.md new file mode 100644 index 00000000..834a64c8 --- /dev/null +++ b/docs/cultmath-gamecult-geometry-migration-progress.md @@ -0,0 +1,115 @@ +# CultMath and GameCult.Geometry Migration Progress + +Plan: `docs/cultmath-gamecult-geometry-migration-plan.md` (locked) + +Started: 2026-07-22 + +Status: Stage 0 complete; Stage 1 in progress + +## 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 | In progress | Resolver ownership and exact unmanaged-column support are the first cuts | +| 2. Primitive deletion and v2 schemas | Pending | Requires intact CultMath vector serialization and SoA | +| 3. Planetary transfer | Pending | Requires destination packaging and preserved parity corpus | +| 4. Mine `vg-csg` | Pending | Requires v1 fixture capture and target Rust package seam | +| 5. CultCache/CultNet/CultMesh integration | Pending | Requires current schemas and runtime packages | +| 6. Consumer cutover/release | Pending | Requires local candidate feed and downstream passes | +| 7. Delete obsolete authorities/audit | Pending | Requires every prior stage gate | + +## 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. + +## 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. + +## Next Actions + +1. Implement the owner-assembly MessagePack resolver seam and its isolation tests. +2. Establish the Geometry -> CultMath dependency and exact vector formatter + fixtures. +3. Cut the scalar-only SoA whitelist so intact unmanaged vectors can exist as + columns before v2 document fields adopt them. From 9698805588059244e00b2decd45921405f26e0ac Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 02:53:29 +0100 Subject: [PATCH 04/30] Add geometry serialization and SoA foundations --- ...th-gamecult-geometry-migration-progress.md | 29 ++++- .../CultDocumentMessagePackGenerator.cs | 4 +- .../CultDocumentMessagePackSerialization.cs | 83 +++++++++++++- src/GameCult.Caching/CultManagedDocument.cs | 36 ++++-- .../DocumentAssemblyResolverTests.cs | 88 +++++++++++++++ .../SoaValueColumnTests.cs | 104 ++++++++++++++++++ 6 files changed, 321 insertions(+), 23 deletions(-) create mode 100644 tests/GameCult.Caching.Tests/DocumentAssemblyResolverTests.cs create mode 100644 tests/GameCult.Caching.Tests/SoaValueColumnTests.cs diff --git a/docs/cultmath-gamecult-geometry-migration-progress.md b/docs/cultmath-gamecult-geometry-migration-progress.md index 834a64c8..0d6db0e6 100644 --- a/docs/cultmath-gamecult-geometry-migration-progress.md +++ b/docs/cultmath-gamecult-geometry-migration-progress.md @@ -92,6 +92,25 @@ a broader stage gate. 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. + ## Decisions and Deviations - Licensing: no relicensing is assumed. Moved CultMath files retain MPL-2.0 @@ -108,8 +127,8 @@ a broader stage gate. ## Next Actions -1. Implement the owner-assembly MessagePack resolver seam and its isolation tests. -2. Establish the Geometry -> CultMath dependency and exact vector formatter - fixtures. -3. Cut the scalar-only SoA whitelist so intact unmanaged vectors can exist as - columns before v2 document fields adopt them. +1. Establish the Geometry -> CultMath dependency through an explicit candidate + package/local-feed workflow rather than an implicit sibling checkout. +2. Add Geometry-owned exact vector/quaternion formatters and byte fixtures. +3. Prove actual `Column` and `Column` access from Geometry, then + inspect the NuGet and UPM artifacts before adopting vectors in v2 schemas. diff --git a/src/GameCult.Caching.MessagePack.Generator/CultDocumentMessagePackGenerator.cs b/src/GameCult.Caching.MessagePack.Generator/CultDocumentMessagePackGenerator.cs index eeb09475..2948837b 100644 --- a/src/GameCult.Caching.MessagePack.Generator/CultDocumentMessagePackGenerator.cs +++ b/src/GameCult.Caching.MessagePack.Generator/CultDocumentMessagePackGenerator.cs @@ -274,7 +274,7 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator builder.AppendLine($" private static byte[] {document.PayloadSerializerMethodName}(object document)"); builder.AppendLine(" {"); builder.AppendLine($" var typed = ({document.DocumentTypeName})document;"); - builder.AppendLine(" var options = global::GameCult.Caching.MessagePack.CultDocumentMessagePackSerialization.Options;"); + builder.AppendLine($" var options = global::GameCult.Caching.MessagePack.CultDocumentMessagePackSerialization.OptionsFor(typeof({document.DocumentTypeName}));"); builder.AppendLine(" var buffer = new global::System.Buffers.ArrayBufferWriter();"); builder.AppendLine(" var writer = new global::MessagePack.MessagePackWriter(buffer);"); builder.AppendLine($" writer.WriteArrayHeader({document.Members.Length});"); @@ -292,7 +292,7 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator { builder.AppendLine($" private static object {document.PayloadDeserializerMethodName}(byte[] payload)"); builder.AppendLine(" {"); - builder.AppendLine(" var options = global::GameCult.Caching.MessagePack.CultDocumentMessagePackSerialization.Options;"); + builder.AppendLine($" var options = global::GameCult.Caching.MessagePack.CultDocumentMessagePackSerialization.OptionsFor(typeof({document.DocumentTypeName}));"); builder.AppendLine(" var reader = new global::MessagePack.MessagePackReader(payload);"); builder.AppendLine(" var count = reader.ReadArrayHeader();"); builder.AppendLine($" var value = new {document.DocumentTypeName}();"); diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs index 21278fa8..688d23f6 100644 --- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs +++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs @@ -1,5 +1,8 @@ using System; using System.Buffers; +using System.Collections.Concurrent; +using System.Linq; +using System.Reflection; using GameCult.Caching; using MessagePack; using MessagePack.Formatters; @@ -7,6 +10,27 @@ using MessagePack.Resolvers; namespace GameCult.Caching.MessagePack; +/// +/// Declares MessagePack resolvers owned by the assembly that defines a Cult document. +/// +[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] +public sealed class CultDocumentMessagePackResolversAttribute : Attribute +{ + /// Creates a resolver declaration in precedence order. + public CultDocumentMessagePackResolversAttribute(params Type[] resolverTypes) + { + if (resolverTypes == null || resolverTypes.Length == 0) + { + throw new ArgumentException("At least one resolver type is required.", nameof(resolverTypes)); + } + + ResolverTypes = resolverTypes; + } + + /// Gets the declared resolver types in precedence order. + public Type[] ResolverTypes { get; } +} + /// /// MessagePack resolver for CultCache-specific value types. /// @@ -43,6 +67,7 @@ public static class CultDocumentMessagePackSerialization private const int SchemaCatalogEntryFieldCount = 7; private const int SchemaCatalogMemberFieldCount = 8; private const int StoreSnapshotFieldCount = 3; + private static readonly ConcurrentDictionary DocumentAssemblyOptions = new(); /// /// Gets the shared MessagePack serializer options for CultCache payloads. @@ -54,12 +79,29 @@ public static class CultDocumentMessagePackSerialization StandardResolver.Instance)) .WithSecurity(MessagePackSecurity.UntrustedData); + /// + /// Gets serializer options for documents owned by an assembly. CultCache formatters + /// take precedence, followed by assembly-declared resolvers and the standard resolver. + /// + public static MessagePackSerializerOptions OptionsFor(Assembly documentAssembly) + { + if (documentAssembly == null) throw new ArgumentNullException(nameof(documentAssembly)); + return DocumentAssemblyOptions.GetOrAdd(documentAssembly, CreateDocumentAssemblyOptions); + } + + /// Gets serializer options for the assembly that owns a document type. + public static MessagePackSerializerOptions OptionsFor(Type documentType) + { + if (documentType == null) throw new ArgumentNullException(nameof(documentType)); + return OptionsFor(documentType.Assembly); + } + /// /// Serializes a typed value with the CultCache MessagePack options. /// public static byte[] Serialize(T value) { - return MessagePackSerializer.Serialize(value, Options); + return MessagePackSerializer.Serialize(value, OptionsFor(typeof(T))); } /// @@ -67,7 +109,7 @@ public static class CultDocumentMessagePackSerialization /// public static T Deserialize(byte[] payload) { - return MessagePackSerializer.Deserialize(payload, Options); + return MessagePackSerializer.Deserialize(payload, OptionsFor(typeof(T))); } /// @@ -84,7 +126,7 @@ public static class CultDocumentMessagePackSerialization } } - return MessagePackSerializer.Serialize(type, value, Options); + return MessagePackSerializer.Serialize(type, value, OptionsFor(type)); } /// @@ -98,10 +140,43 @@ public static class CultDocumentMessagePackSerialization return descriptor.GeneratedPayloadDeserializer(payload); } - return MessagePackSerializer.Deserialize(type, payload, Options) + return MessagePackSerializer.Deserialize(type, payload, OptionsFor(type)) ?? throw new InvalidOperationException($"MessagePack returned null for Cult document type {type.FullName}."); } + private static MessagePackSerializerOptions CreateDocumentAssemblyOptions(Assembly documentAssembly) + { + var ownerResolvers = documentAssembly + .GetCustomAttributes() + .SelectMany(attribute => attribute.ResolverTypes) + .Select(CreateOwnerResolver) + .ToArray(); + + if (ownerResolvers.Length == 0) + { + return Options; + } + + var resolvers = new IFormatterResolver[ownerResolvers.Length + 2]; + resolvers[0] = CultDocumentResolver.Instance; + Array.Copy(ownerResolvers, 0, resolvers, 1, ownerResolvers.Length); + resolvers[resolvers.Length - 1] = StandardResolver.Instance; + return Options.WithResolver(CompositeResolver.Create(resolvers)); + } + + private static IFormatterResolver CreateOwnerResolver(Type resolverType) + { + if (resolverType == null || !typeof(IFormatterResolver).IsAssignableFrom(resolverType)) + { + throw new InvalidOperationException( + $"Cult document MessagePack resolver '{resolverType?.FullName ?? ""}' must implement {typeof(IFormatterResolver).FullName}."); + } + + return Activator.CreateInstance(resolverType) as IFormatterResolver + ?? throw new InvalidOperationException( + $"Cult document MessagePack resolver '{resolverType.FullName}' must have a public parameterless constructor."); + } + /// /// Serializes one persisted store record. /// diff --git a/src/GameCult.Caching/CultManagedDocument.cs b/src/GameCult.Caching/CultManagedDocument.cs index 100cb628..1919ea88 100644 --- a/src/GameCult.Caching/CultManagedDocument.cs +++ b/src/GameCult.Caching/CultManagedDocument.cs @@ -398,18 +398,30 @@ namespace GameCult.Caching private static bool IsColumnType(Type type) { - return type.IsEnum || - type == typeof(byte) || - type == typeof(sbyte) || - type == typeof(short) || - type == typeof(ushort) || - type == typeof(int) || - type == typeof(uint) || - type == typeof(long) || - type == typeof(ulong) || - type == typeof(float) || - type == typeof(double) || - type == typeof(bool); + return type.IsValueType && + !type.IsByRefLike && + !ContainsManagedReferences(type, new HashSet()); + } + + private static bool ContainsManagedReferences(Type type, HashSet visited) + { + if (type.IsPointer) + { + return false; + } + + if (!type.IsValueType) + { + return true; + } + + if (type.IsPrimitive || type.IsEnum || !visited.Add(type)) + { + return false; + } + + const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + return type.GetFields(flags).Any(field => ContainsManagedReferences(field.FieldType, visited)); } } } diff --git a/tests/GameCult.Caching.Tests/DocumentAssemblyResolverTests.cs b/tests/GameCult.Caching.Tests/DocumentAssemblyResolverTests.cs new file mode 100644 index 00000000..02db2c14 --- /dev/null +++ b/tests/GameCult.Caching.Tests/DocumentAssemblyResolverTests.cs @@ -0,0 +1,88 @@ +#nullable enable +using System; +using System.Buffers; +using GameCult.Caching.MessagePack; +using MessagePack; +using MessagePack.Formatters; +using NUnit.Framework; + +[assembly: CultDocumentMessagePackResolvers(typeof(GameCult.Caching.Tests.FirstOwnerResolver), typeof(GameCult.Caching.Tests.SecondOwnerResolver))] + +namespace GameCult.Caching.Tests; + +public sealed class DocumentAssemblyResolverTests +{ + [Test] + public void OptionsFor_CachesOptionsByDocumentAssembly() + { + var first = CultDocumentMessagePackSerialization.OptionsFor(typeof(ResolverDocument)); + var second = CultDocumentMessagePackSerialization.OptionsFor(typeof(ResolverDocument).Assembly); + + Assert.That(second, Is.SameAs(first)); + Assert.That(first, Is.Not.SameAs(CultDocumentMessagePackSerialization.Options)); + } + + [Test] + public void GeneratedDocumentSerialization_UsesOwnerResolversInDeclaredOrder() + { + FirstOwnerResolver.SerializeCount = 0; + SecondOwnerResolver.FormatterRequestCount = 0; + var original = new ResolverDocument { Name = "probe", Value = new ExternalValue(41) }; + + var payload = CultDocumentMessagePackSerialization.SerializeUntyped(original, typeof(ResolverDocument)); + var decoded = (ResolverDocument)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(ResolverDocument), payload); + + Assert.That(decoded.Name, Is.EqualTo("probe")); + Assert.That(decoded.Value.Number, Is.EqualTo(42)); + Assert.That(FirstOwnerResolver.SerializeCount, Is.EqualTo(1)); + Assert.That(SecondOwnerResolver.FormatterRequestCount, Is.Zero); + } +} + +[CultDocument("tests.assembly_resolver", "tests.assembly_resolver.v1")] +public sealed class ResolverDocument +{ + [Key(0)] public string Name { get; set; } = string.Empty; + [Key(1)] public ExternalValue Value { get; set; } +} + +public readonly struct ExternalValue +{ + public ExternalValue(int number) => Number = number; + public int Number { get; } +} + +public sealed class FirstOwnerResolver : IFormatterResolver +{ + public static int SerializeCount; + + public IMessagePackFormatter? GetFormatter() + { + return typeof(T) == typeof(ExternalValue) + ? (IMessagePackFormatter)(object)new FirstExternalValueFormatter() + : null; + } + + public sealed class FirstExternalValueFormatter : IMessagePackFormatter + { + public void Serialize(ref MessagePackWriter writer, ExternalValue value, MessagePackSerializerOptions options) + { + SerializeCount++; + writer.Write(value.Number + 1); + } + + public ExternalValue Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + => new(reader.ReadInt32()); + } +} + +public sealed class SecondOwnerResolver : IFormatterResolver +{ + public static int FormatterRequestCount; + + public IMessagePackFormatter? GetFormatter() + { + if (typeof(T) == typeof(ExternalValue)) FormatterRequestCount++; + return null; + } +} diff --git a/tests/GameCult.Caching.Tests/SoaValueColumnTests.cs b/tests/GameCult.Caching.Tests/SoaValueColumnTests.cs new file mode 100644 index 00000000..e64310a6 --- /dev/null +++ b/tests/GameCult.Caching.Tests/SoaValueColumnTests.cs @@ -0,0 +1,104 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using GameCult.Caching; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + public sealed class SoaValueColumnTests + { + [Test] + public async Task Soa_Preserves_Unmanaged_Value_Members_As_Intact_Columns() + { + var cache = new CultCache(); + var first = new CultRecordHandle(new CultRecordKey("vector:first")); + var second = new CultRecordHandle(new CultRecordKey("vector:second")); + + await cache.UpsertAsync(new VectorDocument + { + Name = "first", + Position = new TestFloat3(1, 2, 3), + Health = 10, + }, first); + await cache.UpsertAsync(new VectorDocument + { + Name = "second", + Position = new TestFloat3(4, 5, 6), + Health = 20, + }, second); + await cache.UpsertAsync(new VectorDocument + { + Name = "first-updated", + Position = new TestFloat3(7, 8, 9), + Health = 30, + }, first); + + var table = cache.Soa(); + + Assert.That(table.Column(nameof(VectorDocument.Position)).Span.ToArray(), Is.EqualTo(new[] + { + new TestFloat3(7, 8, 9), + new TestFloat3(4, 5, 6), + })); + Assert.That(table.Column(nameof(VectorDocument.Health)).Span.ToArray(), Is.EqualTo(new[] { 30, 20 })); + Assert.That(() => table.Column("Position.x"), Throws.TypeOf()); + Assert.That(() => table.Column("x"), Throws.TypeOf()); + + Assert.That(cache.Remove(first.Key), Is.True); + var afterRemoval = cache.Soa(); + Assert.That(afterRemoval.Keys.Select(key => key.Value).ToArray(), Is.EqualTo(new[] { "vector:second" })); + Assert.That( + afterRemoval.Column(nameof(VectorDocument.Position)).Span.ToArray(), + Is.EqualTo(new[] { new TestFloat3(4, 5, 6) })); + } + + [Test] + public async Task Soa_Rejects_Value_Members_Containing_Managed_References() + { + var cache = new CultCache(); + await cache.UpsertAsync(new VectorDocument + { + Name = "managed", + Position = new TestFloat3(1, 2, 3), + ManagedValue = new ManagedValue(42, "not a column"), + }); + + var table = cache.Soa(); + + Assert.That( + () => table.Column(nameof(VectorDocument.ManagedValue)), + Throws.TypeOf()); + } + + [MessagePackObject] + public readonly record struct TestFloat3( + [property: Key(0)] float x, + [property: Key(1)] float y, + [property: Key(2)] float z); + + [MessagePackObject] + public readonly record struct ManagedValue( + [property: Key(0)] int Code, + [property: Key(1)] string Label); + + [CultDocument("tests.soa_value_column", "tests.soa_value_column.v1")] + internal sealed class VectorDocument + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + public TestFloat3 Position; + + [Key(2)] + public int Health; + + [Key(3)] + public ManagedValue ManagedValue; + } + } +} From 364c9cdf48d3bb604d341a4dc1803d83aa13e8c0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 02:58:42 +0100 Subject: [PATCH 05/30] Integrate CultMath vectors into geometry --- ...th-gamecult-geometry-migration-progress.md | 29 ++++- .../CultMathMessagePackResolver.cs | 106 ++++++++++++++++++ .../GameCult.Geometry.csproj | 5 + .../CultMathValueIntegrationTests.cs | 76 +++++++++++++ 4 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 src/GameCult.Geometry/CultMathMessagePackResolver.cs create mode 100644 tests/GameCult.Geometry.Tests/CultMathValueIntegrationTests.cs diff --git a/docs/cultmath-gamecult-geometry-migration-progress.md b/docs/cultmath-gamecult-geometry-migration-progress.md index 0d6db0e6..481b360f 100644 --- a/docs/cultmath-gamecult-geometry-migration-progress.md +++ b/docs/cultmath-gamecult-geometry-migration-progress.md @@ -110,6 +110,26 @@ a broader stage gate. available on the library's `netstandard2.1` target. It was removed rather than papered over; structural managed-reference inspection is the owning eligibility rule. +- CultMath candidate `0.1.0-geometry-migration.1` was packed from commit + `cb481b4c75444426a693781a0e5b35ade08a8938` with build servers disabled. The + 123,307-byte package SHA-256 is + `07C0CE79453AF9BA29910809F8779916E7B8F92DCA7ACEB5E2B8F9D3EBED3E93`; + a local-feed consumer restored, compiled, and executed `CultMath.float3`. +- GameCult.Geometry consumes that candidate as an explicit NuGet dependency; + its sibling `.tools/local-feed` is only an optional restore source, not a + project-reference authority. Geometry owns strict positional formatters for + `float2`, `float3`, `float4`, and `quaternion`. +- `dotnet test tests/GameCult.Geometry.Tests/GameCult.Geometry.Tests.csproj + --no-restore --filter FullyQualifiedName~CultMathValueIntegrationTests + --verbosity minimal --disable-build-servers -p:UseSharedCompilation=false + -m:1` + passed 6/6. Evidence covers exact bytes, round trips, malformed-width + rejection, actual `Column`/`Column` access, and absence of + synthetic component columns. +- GameCult.Geometry candidate `0.1.0-geometry-migration.1` packed successfully; + its nuspec declares exact candidate dependencies on CultMath, CultCache, and + CultCache.MessagePack. The dedicated UPM artifact remains the open Stage 1 + gate. ## Decisions and Deviations @@ -127,8 +147,7 @@ a broader stage gate. ## Next Actions -1. Establish the Geometry -> CultMath dependency through an explicit candidate - package/local-feed workflow rather than an implicit sibling checkout. -2. Add Geometry-owned exact vector/quaternion formatters and byte fixtures. -3. Prove actual `Column` and `Column` access from Geometry, then - inspect the NuGet and UPM artifacts before adopting vectors in v2 schemas. +1. Establish and inspect the dedicated `org.gamecult.geometry` UPM artifact. +2. Record the newly pinned Rust v1 byte/key witnesses and commit their + authoritative submodule change. +3. Begin Stage 2 only after the Unity dependency/layout gate is proven. diff --git a/src/GameCult.Geometry/CultMathMessagePackResolver.cs b/src/GameCult.Geometry/CultMathMessagePackResolver.cs new file mode 100644 index 00000000..6e0075cf --- /dev/null +++ b/src/GameCult.Geometry/CultMathMessagePackResolver.cs @@ -0,0 +1,106 @@ +using CultMath; +using GameCult.Caching.MessagePack; +using MessagePack; +using MessagePack.Formatters; + +[assembly: CultDocumentMessagePackResolvers(typeof(GameCult.Geometry.CultMathMessagePackResolver))] + +namespace GameCult.Geometry; + +/// +/// Owns the persisted positional representation of CultMath values used by +/// GameCult.Geometry documents. CultMath remains serialization-agnostic. +/// +public sealed class CultMathMessagePackResolver : IFormatterResolver +{ + public IMessagePackFormatter? GetFormatter() + { + if (typeof(T) == typeof(float2)) return (IMessagePackFormatter)(object)Float2Formatter.Instance; + if (typeof(T) == typeof(float3)) return (IMessagePackFormatter)(object)Float3Formatter.Instance; + if (typeof(T) == typeof(float4)) return (IMessagePackFormatter)(object)Float4Formatter.Instance; + if (typeof(T) == typeof(quaternion)) return (IMessagePackFormatter)(object)QuaternionFormatter.Instance; + return null; + } + + private static void RequireComponents(ref MessagePackReader reader, int expected, string typeName) + { + var actual = reader.ReadArrayHeader(); + if (actual != expected) + { + throw new MessagePackSerializationException($"{typeName} requires exactly {expected} components; payload contained {actual}."); + } + } + + internal sealed class Float2Formatter : IMessagePackFormatter + { + public static readonly Float2Formatter Instance = new(); + public void Serialize(ref MessagePackWriter writer, float2 value, MessagePackSerializerOptions options) + { + writer.WriteArrayHeader(2); + writer.Write(value.x); + writer.Write(value.y); + } + + public float2 Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + RequireComponents(ref reader, 2, nameof(float2)); + return new float2(reader.ReadSingle(), reader.ReadSingle()); + } + } + + internal sealed class Float3Formatter : IMessagePackFormatter + { + public static readonly Float3Formatter Instance = new(); + public void Serialize(ref MessagePackWriter writer, float3 value, MessagePackSerializerOptions options) + { + writer.WriteArrayHeader(3); + writer.Write(value.x); + writer.Write(value.y); + writer.Write(value.z); + } + + public float3 Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + RequireComponents(ref reader, 3, nameof(float3)); + return new float3(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()); + } + } + + internal sealed class Float4Formatter : IMessagePackFormatter + { + public static readonly Float4Formatter Instance = new(); + public void Serialize(ref MessagePackWriter writer, float4 value, MessagePackSerializerOptions options) + { + writer.WriteArrayHeader(4); + writer.Write(value.x); + writer.Write(value.y); + writer.Write(value.z); + writer.Write(value.w); + } + + public float4 Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + RequireComponents(ref reader, 4, nameof(float4)); + return new float4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()); + } + } + + internal sealed class QuaternionFormatter : IMessagePackFormatter + { + public static readonly QuaternionFormatter Instance = new(); + public void Serialize(ref MessagePackWriter writer, quaternion value, MessagePackSerializerOptions options) + { + writer.WriteArrayHeader(4); + writer.Write(value.x); + writer.Write(value.y); + writer.Write(value.z); + writer.Write(value.w); + } + + public quaternion Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + RequireComponents(ref reader, 4, nameof(quaternion)); + return new quaternion(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()); + } + } +} diff --git a/src/GameCult.Geometry/GameCult.Geometry.csproj b/src/GameCult.Geometry/GameCult.Geometry.csproj index a0192f69..22c991ab 100644 --- a/src/GameCult.Geometry/GameCult.Geometry.csproj +++ b/src/GameCult.Geometry/GameCult.Geometry.csproj @@ -5,10 +5,15 @@ game;geometry;csg;lod;cultcache;cultmesh;vibegeometry;fensalir README.md $(NoWarn);1591 + 0.1.0-geometry-migration.1 + $(MSBuildThisFileDirectory)..\..\..\CultMath\.tools\local-feed + $(RestoreAdditionalProjectSources);$(CultMathCandidateFeed) + + diff --git a/tests/GameCult.Geometry.Tests/CultMathValueIntegrationTests.cs b/tests/GameCult.Geometry.Tests/CultMathValueIntegrationTests.cs new file mode 100644 index 00000000..b76248c0 --- /dev/null +++ b/tests/GameCult.Geometry.Tests/CultMathValueIntegrationTests.cs @@ -0,0 +1,76 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using CultMath; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Geometry.Tests; + +public sealed class CultMathValueIntegrationTests +{ + private static readonly MessagePackSerializerOptions GeometryOptions = + CultDocumentMessagePackSerialization.OptionsFor(typeof(CultGeometryDomainDocument)); + + [TestCaseSource(nameof(ValueEncodingCases))] + public void GeometryResolver_UsesExactPositionalVectorEncoding(object value, Type type, string expectedHex) + { + var payload = MessagePackSerializer.Serialize(type, value, GeometryOptions); + + Assert.That(Convert.ToHexString(payload), Is.EqualTo(expectedHex)); + Assert.That(MessagePackSerializer.Deserialize(type, payload, GeometryOptions), Is.EqualTo(value)); + } + + [Test] + public void GeometryResolver_RejectsWrongVectorWidth() + { + var twoComponents = Convert.FromHexString("92CA3F800000CA40000000"); + + Assert.That( + () => MessagePackSerializer.Deserialize(twoComponents, GeometryOptions), + Throws.TypeOf()); + } + + [Test] + public async Task GeometrySoa_ExposesCultMathVectorsAsIntactColumns() + { + var cache = new CultCache(); + await cache.UpsertAsync(new VectorColumnDocument + { + Name = "probe", + Uv = new float2(0.25f, 0.75f), + Position = new float3(1f, 2f, 3f), + }); + + var table = cache.Soa(); + + Assert.That(table.Column(nameof(VectorColumnDocument.Uv)).Span.ToArray(), + Is.EqualTo(new[] { new float2(0.25f, 0.75f) })); + Assert.That(table.Column(nameof(VectorColumnDocument.Position)).Span.ToArray(), + Is.EqualTo(new[] { new float3(1f, 2f, 3f) })); + Assert.That(() => table.Column("Position.x"), Throws.TypeOf()); + } + + private static object[] ValueEncodingCases => + [ + new object[] { new float2(1f, -2.5f), typeof(float2), "92CA3F800000CAC0200000" }, + new object[] { new float3(1f, -2.5f, 0.5f), typeof(float3), "93CA3F800000CAC0200000CA3F000000" }, + new object[] { new float4(1f, -2.5f, 0.5f, 4f), typeof(float4), "94CA3F800000CAC0200000CA3F000000CA40800000" }, + new object[] { new quaternion(1f, -2.5f, 0.5f, 4f), typeof(quaternion), "94CA3F800000CAC0200000CA3F000000CA40800000" }, + ]; + + [CultDocument("tests.geometry_vector_column", "tests.geometry_vector_column.v1")] + private sealed class VectorColumnDocument + { + [Key(0), CultName] + public string Name { get; set; } = string.Empty; + + [Key(1)] + public float2 Uv { get; set; } + + [Key(2)] + public float3 Position { get; set; } + } +} From 343e03d8a2276a5020b81f7304b5ad5595fd7e7f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 02:59:20 +0100 Subject: [PATCH 06/30] Record geometry wire witnesses --- docs/cultmath-gamecult-geometry-migration-progress.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/cultmath-gamecult-geometry-migration-progress.md b/docs/cultmath-gamecult-geometry-migration-progress.md index 481b360f..d872a6c2 100644 --- a/docs/cultmath-gamecult-geometry-migration-progress.md +++ b/docs/cultmath-gamecult-geometry-migration-progress.md @@ -130,6 +130,11 @@ a broader stage gate. 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. ## Decisions and Deviations @@ -148,6 +153,4 @@ a broader stage gate. ## Next Actions 1. Establish and inspect the dedicated `org.gamecult.geometry` UPM artifact. -2. Record the newly pinned Rust v1 byte/key witnesses and commit their - authoritative submodule change. -3. Begin Stage 2 only after the Unity dependency/layout gate is proven. +2. Begin Stage 2 only after the Unity dependency/layout gate is proven. From 0f16f1441517252ff0e1455d0567f1206eaa97a0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 03:01:08 +0100 Subject: [PATCH 07/30] Add dedicated Geometry Unity package --- ...th-gamecult-geometry-migration-progress.md | 19 +++-- scripts/build-geometry-unity-package.ps1 | 70 +++++++++++++++++++ unity/org.gamecult.geometry/README.md | 12 ++++ .../Runtime/GameCult.Geometry.asmdef | 19 +++++ unity/org.gamecult.geometry/package.json | 15 ++++ 5 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 scripts/build-geometry-unity-package.ps1 create mode 100644 unity/org.gamecult.geometry/README.md create mode 100644 unity/org.gamecult.geometry/Runtime/GameCult.Geometry.asmdef create mode 100644 unity/org.gamecult.geometry/package.json diff --git a/docs/cultmath-gamecult-geometry-migration-progress.md b/docs/cultmath-gamecult-geometry-migration-progress.md index d872a6c2..ab9b8455 100644 --- a/docs/cultmath-gamecult-geometry-migration-progress.md +++ b/docs/cultmath-gamecult-geometry-migration-progress.md @@ -4,7 +4,7 @@ Plan: `docs/cultmath-gamecult-geometry-migration-plan.md` (locked) Started: 2026-07-22 -Status: Stage 0 complete; Stage 1 in progress +Status: Stages 0-1 complete; Stage 2 in progress ## Objective @@ -55,8 +55,8 @@ Demotions: | 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 | In progress | Resolver ownership and exact unmanaged-column support are the first cuts | -| 2. Primitive deletion and v2 schemas | Pending | Requires intact CultMath vector serialization and SoA | +| 1. Destination/dependency/formatters/SoA | Complete | NuGet and dedicated UPM artifacts prove dependency direction and owned vector integration | +| 2. Primitive deletion and v2 schemas | In progress | Delete duplicate vectors first, then publish corrected v2 contracts | | 3. Planetary transfer | Pending | Requires destination packaging and preserved parity corpus | | 4. Mine `vg-csg` | Pending | Requires v1 fixture capture and target Rust package seam | | 5. CultCache/CultNet/CultMesh integration | Pending | Requires current schemas and runtime packages | @@ -135,6 +135,12 @@ a broader stage gate. 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 @@ -152,5 +158,8 @@ a broader stage gate. ## Next Actions -1. Establish and inspect the dedicated `org.gamecult.geometry` UPM artifact. -2. Begin Stage 2 only after the Unity dependency/layout gate is proven. +1. Rewrite Geometry primitives over CultMath and delete `CultVec2`/`CultVec3` + without aliases or forwarding types. +2. Publish v2 geometry documents with typed vectors/quaternion and deliberate + stable-key fixtures; keep v1 only as test evidence. +3. Run repository/package negative searches before beginning planetary transfer. diff --git a/scripts/build-geometry-unity-package.ps1 b/scripts/build-geometry-unity-package.ps1 new file mode 100644 index 00000000..c5665b1e --- /dev/null +++ b/scripts/build-geometry-unity-package.ps1 @@ -0,0 +1,70 @@ +param( + [string] $Configuration = "Release", + [string] $OutputDirectory = "artifacts\unity\org.gamecult.geometry", + [string] $CultMathCandidateFeed = "..\CultMath\.tools\local-feed" +) + +$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" + +if (-not (Test-Path -LiteralPath (Join-Path $candidateFeed "CultMath.0.1.0-geometry-migration.1.nupkg"))) { + throw "CultMath migration candidate 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: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" +} + +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 +Copy-Item -LiteralPath (Join-Path $templateRoot "Runtime\GameCult.Geometry.asmdef") -Destination (Join-Path $outputRoot "Runtime") + +$geometryAssembly = Join-Path $publishRoot "GameCult.Geometry.dll" +if (-not (Test-Path -LiteralPath $geometryAssembly)) { + throw "Geometry Unity package is missing GameCult.Geometry.dll" +} +Copy-Item -LiteralPath $geometryAssembly -Destination $pluginRoot +$geometrySymbols = [System.IO.Path]::ChangeExtension($geometryAssembly, ".pdb") +if (Test-Path -LiteralPath $geometrySymbols) { + Copy-Item -LiteralPath $geometrySymbols -Destination $pluginRoot +} + +$manifest = Get-Content -LiteralPath (Join-Path $outputRoot "package.json") -Raw | ConvertFrom-Json +$stagedAssemblies = @(Get-ChildItem -LiteralPath $pluginRoot -Filter "*.dll") +if ($stagedAssemblies.Count -ne 1 -or $stagedAssemblies[0].Name -ne "GameCult.Geometry.dll") { + throw "Geometry package must stage only its owned assembly; found: $($stagedAssemblies.Name -join ', ')" +} + +Write-Host "GameCult.Geometry Unity package: $outputRoot" +Write-Host "Package: $($manifest.name)@$($manifest.version)" +Write-Host "Dependencies: $($manifest.dependencies.PSObject.Properties.Name -join ', ')" +Write-Host "Managed assemblies: $($stagedAssemblies.Count)" diff --git a/unity/org.gamecult.geometry/README.md b/unity/org.gamecult.geometry/README.md new file mode 100644 index 00000000..7a6e0823 --- /dev/null +++ b/unity/org.gamecult.geometry/README.md @@ -0,0 +1,12 @@ +# GameCult.Geometry for Unity + +This package is the Unity projection of the GameCult.Geometry authority. It +depends on `org.gamecult.cultmath` for numeric primitives and +`org.gamecult.cultlib` for CultCache/CultNet/CultMesh integration. + +Build it with `scripts/build-geometry-unity-package.ps1`. Do not edit generated +plugin assemblies in the package output. + +CultLib-authored files are MIT licensed. Geometry sources transferred from +CultMath retain their MPL-2.0 notices and provenance; those files will be listed +here as they enter this package. No transferred CultMath source is relicensed. diff --git a/unity/org.gamecult.geometry/Runtime/GameCult.Geometry.asmdef b/unity/org.gamecult.geometry/Runtime/GameCult.Geometry.asmdef new file mode 100644 index 00000000..ea9a8c9f --- /dev/null +++ b/unity/org.gamecult.geometry/Runtime/GameCult.Geometry.asmdef @@ -0,0 +1,19 @@ +{ + "name": "GameCult.Geometry", + "rootNamespace": "GameCult.Geometry", + "references": [ + "CultMath", + "GameCult.CultLib" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "GameCult.Geometry.dll" + ], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": true +} diff --git a/unity/org.gamecult.geometry/package.json b/unity/org.gamecult.geometry/package.json new file mode 100644 index 00000000..023fed98 --- /dev/null +++ b/unity/org.gamecult.geometry/package.json @@ -0,0 +1,15 @@ +{ + "name": "org.gamecult.geometry", + "displayName": "GameCult.Geometry", + "version": "0.1.0", + "unity": "2021.3", + "description": "GameCult geometry values, typed state contracts, and runtime algorithms built on CultMath primitives.", + "license": "SEE LICENSES IN README.md", + "author": { + "name": "GameCult" + }, + "dependencies": { + "org.gamecult.cultmath": "0.1.0", + "org.gamecult.cultlib": "1.0.7" + } +} From 317b7d538e07bec7b0ee790e3594259b83a9374d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 03:07:31 +0100 Subject: [PATCH 08/30] Delete duplicate geometry vectors --- ...th-gamecult-geometry-migration-progress.md | 20 ++ packages/cultmesh-ts/test/cultmesh.test.ts | 6 +- .../CultGeometryPrimitives.cs | 184 ++++-------------- src/GameCult.Geometry/README.md | 4 +- .../docs/cross-runtime-primitives-roadmap.md | 4 +- src/GameCult.Mesh/docs/public-api.md | 4 +- .../GeometryDocumentTests.cs | 51 ++--- 7 files changed, 91 insertions(+), 182 deletions(-) diff --git a/docs/cultmath-gamecult-geometry-migration-progress.md b/docs/cultmath-gamecult-geometry-migration-progress.md index ab9b8455..678d98c6 100644 --- a/docs/cultmath-gamecult-geometry-migration-progress.md +++ b/docs/cultmath-gamecult-geometry-migration-progress.md @@ -155,6 +155,26 @@ a broader stage gate. 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. ## Next Actions diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 285ee09d..d7f5e267 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -1454,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 }, ); @@ -2150,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), ], @@ -2163,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); diff --git a/src/GameCult.Geometry/CultGeometryPrimitives.cs b/src/GameCult.Geometry/CultGeometryPrimitives.cs index 99587b8e..4636a3bc 100644 --- a/src/GameCult.Geometry/CultGeometryPrimitives.cs +++ b/src/GameCult.Geometry/CultGeometryPrimitives.cs @@ -1,126 +1,10 @@ using System; using System.Text.Json.Serialization; +using CultMath; using MessagePack; namespace GameCult.Geometry { - /// - /// Deterministic two-dimensional float vector for cross-runtime geometry and query contracts. - /// - [MessagePackObject] - public readonly struct CultVec2 : IEquatable - { - /// Creates a two-dimensional vector. - public CultVec2(float x, float y) - { - X = x; - Y = y; - } - - /// Gets the x component. - [Key(0)] - public float X { get; } - - /// Gets the y component. - [Key(1)] - public float Y { get; } - - /// Gets the zero vector. - [IgnoreMember] - [JsonIgnore] - public static CultVec2 Zero => new(0f, 0f); - - /// Gets the squared vector length. - [IgnoreMember] - [JsonIgnore] - public float LengthSquared => (X * X) + (Y * Y); - - /// Returns whether this vector is equal to another vector. - public bool Equals(CultVec2 other) => X.Equals(other.X) && Y.Equals(other.Y); - - /// - public override bool Equals(object? obj) => obj is CultVec2 other && Equals(other); - - /// - public override int GetHashCode() => HashCode.Combine(X, Y); - - /// - public override string ToString() => $"({X}, {Y})"; - - public static CultVec2 operator +(CultVec2 left, CultVec2 right) => new(left.X + right.X, left.Y + right.Y); - public static CultVec2 operator -(CultVec2 left, CultVec2 right) => new(left.X - right.X, left.Y - right.Y); - public static CultVec2 operator *(CultVec2 value, float scalar) => new(value.X * scalar, value.Y * scalar); - public static CultVec2 operator /(CultVec2 value, float scalar) => new(value.X / scalar, value.Y / scalar); - public static bool operator ==(CultVec2 left, CultVec2 right) => left.Equals(right); - public static bool operator !=(CultVec2 left, CultVec2 right) => !left.Equals(right); - } - - /// - /// Deterministic three-dimensional float vector for cross-runtime geometry and query contracts. - /// - [MessagePackObject] - public readonly struct CultVec3 : IEquatable - { - /// Creates a three-dimensional vector. - public CultVec3(float x, float y, float z) - { - X = x; - Y = y; - Z = z; - } - - /// Gets the x component. - [Key(0)] - public float X { get; } - - /// Gets the y component. - [Key(1)] - public float Y { get; } - - /// Gets the z component. - [Key(2)] - public float Z { get; } - - /// Gets the zero vector. - [IgnoreMember] - [JsonIgnore] - public static CultVec3 Zero => new(0f, 0f, 0f); - - /// Gets the XY plane projection. - [IgnoreMember] - [JsonIgnore] - public CultVec2 Xy => new(X, Y); - - /// Gets the XZ plane projection for Unity compatibility adapters. - [IgnoreMember] - [JsonIgnore] - public CultVec2 Xz => new(X, Z); - - /// Gets the squared vector length. - [IgnoreMember] - [JsonIgnore] - public float LengthSquared => (X * X) + (Y * Y) + (Z * Z); - - /// Returns whether this vector is equal to another vector. - public bool Equals(CultVec3 other) => X.Equals(other.X) && Y.Equals(other.Y) && Z.Equals(other.Z); - - /// - public override bool Equals(object? obj) => obj is CultVec3 other && Equals(other); - - /// - public override int GetHashCode() => HashCode.Combine(X, Y, Z); - - /// - public override string ToString() => $"({X}, {Y}, {Z})"; - - public static CultVec3 operator +(CultVec3 left, CultVec3 right) => new(left.X + right.X, left.Y + right.Y, left.Z + right.Z); - public static CultVec3 operator -(CultVec3 left, CultVec3 right) => new(left.X - right.X, left.Y - right.Y, left.Z - right.Z); - public static CultVec3 operator *(CultVec3 value, float scalar) => new(value.X * scalar, value.Y * scalar, value.Z * scalar); - public static CultVec3 operator /(CultVec3 value, float scalar) => new(value.X / scalar, value.Y / scalar, value.Z / scalar); - public static bool operator ==(CultVec3 left, CultVec3 right) => left.Equals(right); - public static bool operator !=(CultVec3 left, CultVec3 right) => !left.Equals(right); - } - /// /// Axis-aligned two-dimensional rectangle represented by canonical min and max corners. /// @@ -128,52 +12,52 @@ namespace GameCult.Geometry public readonly struct CultRect : IEquatable { /// Creates a rectangle and canonicalizes min/max corners. - public CultRect(CultVec2 min, CultVec2 max) + public CultRect(float2 min, float2 max) { - Min = new CultVec2(Math.Min(min.X, max.X), Math.Min(min.Y, max.Y)); - Max = new CultVec2(Math.Max(min.X, max.X), Math.Max(min.Y, max.Y)); + Min = new float2(Math.Min(min.x, max.x), Math.Min(min.y, max.y)); + Max = new float2(Math.Max(min.x, max.x), Math.Max(min.y, max.y)); } /// Creates a rectangle and canonicalizes min/max corners. public CultRect(float minX, float minY, float maxX, float maxY) - : this(new CultVec2(minX, minY), new CultVec2(maxX, maxY)) + : this(new float2(minX, minY), new float2(maxX, maxY)) { } /// Gets the canonical minimum corner. [Key(0)] - public CultVec2 Min { get; } + public float2 Min { get; } /// Gets the canonical maximum corner. [Key(1)] - public CultVec2 Max { get; } + public float2 Max { get; } /// Gets the rectangle size. [IgnoreMember] [JsonIgnore] - public CultVec2 Size => Max - Min; + public float2 Size => Max - Min; /// Gets the rectangle center. [IgnoreMember] [JsonIgnore] - public CultVec2 Center => (Min + Max) * 0.5f; + public float2 Center => (Min + Max) * 0.5f; /// Returns whether the point lies inside or on the rectangle edge. - public bool Contains(CultVec2 point) + public bool Contains(float2 point) { - return point.X >= Min.X && - point.X <= Max.X && - point.Y >= Min.Y && - point.Y <= Max.Y; + return point.x >= Min.x && + point.x <= Max.x && + point.y >= Min.y && + point.y <= Max.y; } /// Returns whether this rectangle intersects another rectangle. public bool Intersects(CultRect other) { - return Min.X <= other.Max.X && - Max.X >= other.Min.X && - Min.Y <= other.Max.Y && - Max.Y >= other.Min.Y; + return Min.x <= other.Max.x && + Max.x >= other.Min.x && + Min.y <= other.Max.y && + Max.y >= other.Min.y; } /// Returns whether this rectangle is equal to another rectangle. @@ -199,7 +83,7 @@ namespace GameCult.Geometry public readonly struct CultCircle : IEquatable { /// Creates a circle. - public CultCircle(CultVec2 center, float radius) + public CultCircle(float2 center, float radius) { if (radius < 0f) { @@ -212,7 +96,7 @@ namespace GameCult.Geometry /// Gets the circle center. [Key(0)] - public CultVec2 Center { get; } + public float2 Center { get; } /// Gets the circle radius. [Key(1)] @@ -222,24 +106,24 @@ namespace GameCult.Geometry [IgnoreMember] [JsonIgnore] public CultRect Bounds => new( - Center.X - Radius, - Center.Y - Radius, - Center.X + Radius, - Center.Y + Radius); + Center.x - Radius, + Center.y - Radius, + Center.x + Radius, + Center.y + Radius); /// Returns whether the point lies inside or on the circle edge. - public bool Contains(CultVec2 point) + public bool Contains(float2 point) { var delta = point - Center; - return delta.LengthSquared <= Radius * Radius; + return ((delta.x * delta.x) + (delta.y * delta.y)) <= Radius * Radius; } /// Returns whether this circle intersects a rectangle. public bool Intersects(CultRect rect) { - var clampedX = Math.Max(rect.Min.X, Math.Min(Center.X, rect.Max.X)); - var clampedY = Math.Max(rect.Min.Y, Math.Min(Center.Y, rect.Max.Y)); - return Contains(new CultVec2(clampedX, clampedY)); + var clampedX = Math.Max(rect.Min.x, Math.Min(Center.x, rect.Max.x)); + var clampedY = Math.Max(rect.Min.y, Math.Min(Center.y, rect.Max.y)); + return Contains(new float2(clampedX, clampedY)); } /// Returns whether this circle is equal to another circle. @@ -262,7 +146,7 @@ namespace GameCult.Geometry public readonly struct CultSphere : IEquatable { /// Creates a sphere. - public CultSphere(CultVec3 center, float radius) + public CultSphere(float3 center, float radius) { if (radius < 0f) { @@ -275,7 +159,7 @@ namespace GameCult.Geometry /// Gets the sphere center. [Key(0)] - public CultVec3 Center { get; } + public float3 Center { get; } /// Gets the sphere radius. [Key(1)] @@ -284,13 +168,13 @@ namespace GameCult.Geometry /// Gets the XY circle projection. [IgnoreMember] [JsonIgnore] - public CultCircle XyCircle => new(Center.Xy, Radius); + public CultCircle XyCircle => new(Center.xy, Radius); /// Returns whether the point lies inside or on the sphere surface. - public bool Contains(CultVec3 point) + public bool Contains(float3 point) { var delta = point - Center; - return delta.LengthSquared <= Radius * Radius; + return ((delta.x * delta.x) + (delta.y * delta.y) + (delta.z * delta.z)) <= Radius * Radius; } /// Returns whether this sphere is equal to another sphere. diff --git a/src/GameCult.Geometry/README.md b/src/GameCult.Geometry/README.md index 200f14ed..4fb881bc 100644 --- a/src/GameCult.Geometry/README.md +++ b/src/GameCult.Geometry/README.md @@ -20,7 +20,7 @@ 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, +- `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. @@ -72,7 +72,7 @@ cache miss; that is split-brain geometry. ## Documents -- `CultVec2`, `CultVec3`, `CultRect`, `CultCircle`, `CultSphere`: shared value +- `CultMath.float2`, `CultMath.float3`, `CultRect`, `CultCircle`, `CultSphere`: shared value primitives for query and view contracts. - `CultGeometryDomainDocument`: one hierarchical domain tree, suitable for Ragnarok/Fensalir-style feature DSL output. diff --git a/src/GameCult.Mesh/docs/cross-runtime-primitives-roadmap.md b/src/GameCult.Mesh/docs/cross-runtime-primitives-roadmap.md index 93fb3f3c..53db8c7b 100644 --- a/src/GameCult.Mesh/docs/cross-runtime-primitives-roadmap.md +++ b/src/GameCult.Mesh/docs/cross-runtime-primitives-roadmap.md @@ -121,7 +121,7 @@ var viewport = await aetheria.Zone(zoneId) await aetheria.Entity(ravenId) .Pilot() - .MoveAsync(CultVec2.Right, claim: "pilot-control"); + .MoveAsync(new CultMath.float2(1f, 0f), claim: "pilot-control"); ``` Current seed: @@ -134,7 +134,7 @@ var verse = await CultMesh.ConnectVerseAsync( new[] { new CultMeshAuthorityClaim("pilot-control", shardId: "zone:raven") }); var aetheria = verse.Use(context => new AetheriaGeneratedFacade(context)); -await aetheria.Entity(ravenId).Pilot.MoveAsync(CultVec2.Right); +await aetheria.Entity(ravenId).Pilot.MoveAsync(new CultMath.float2(1f, 0f)); ``` ```ts diff --git a/src/GameCult.Mesh/docs/public-api.md b/src/GameCult.Mesh/docs/public-api.md index 512b3e30..ff00e515 100644 --- a/src/GameCult.Mesh/docs/public-api.md +++ b/src/GameCult.Mesh/docs/public-api.md @@ -393,8 +393,8 @@ var view = new CultMeshNativeSliceViewDescriptor( rowCount, new[] { - CultMeshNativeSliceColumn.For("position"), - CultMeshNativeSliceColumn.For("velocity") + CultMeshNativeSliceColumn.For("position"), + CultMeshNativeSliceColumn.For("velocity") }, new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory)); ``` diff --git a/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs b/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs index b39b5182..1c50ab80 100644 --- a/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs +++ b/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs @@ -3,6 +3,7 @@ using GameCult.Caching; using GameCult.Caching.MessagePack; using GameCult.Mesh; using GameCult.Networking; +using CultMath; using MessagePack; using NUnit.Framework; using R3; @@ -21,52 +22,56 @@ namespace GameCult.Geometry.Tests { var viewport = new CultRect(8f, 4f, -8f, -4f); - viewport.Min.Should().Be(new CultVec2(-8f, -4f)); - viewport.Max.Should().Be(new CultVec2(8f, 4f)); - viewport.Center.Should().Be(CultVec2.Zero); - viewport.Contains(new CultVec2(2f, 3f)).Should().BeTrue(); + viewport.Min.Should().Be(new float2(-8f, -4f)); + viewport.Max.Should().Be(new float2(8f, 4f)); + viewport.Center.Should().Be(float2.zero); + viewport.Contains(new float2(2f, 3f)).Should().BeTrue(); viewport.Intersects(new CultRect(7f, 3f, 10f, 5f)).Should().BeTrue(); viewport.Intersects(new CultRect(9f, 5f, 10f, 6f)).Should().BeFalse(); - var gravityBrush = new CultCircle(new CultVec2(10f, 0f), 3f); + var gravityBrush = new CultCircle(new float2(10f, 0f), 3f); gravityBrush.Intersects(viewport).Should().BeTrue(); - gravityBrush.Contains(new CultVec2(12f, 0f)).Should().BeTrue(); - gravityBrush.Contains(new CultVec2(14f, 0f)).Should().BeFalse(); + gravityBrush.Contains(new float2(12f, 0f)).Should().BeTrue(); + gravityBrush.Contains(new float2(14f, 0f)).Should().BeFalse(); } [Test] public void GeometryPrimitives_PreserveWholeVectorsForSoaAndPhysicsQueries() { - var sphere = new CultSphere(new CultVec3(1f, 2f, 3f), 5f); + var sphere = new CultSphere(new float3(1f, 2f, 3f), 5f); - sphere.Contains(new CultVec3(1f, 6f, 3f)).Should().BeTrue(); - sphere.Contains(new CultVec3(1f, 8f, 3f)).Should().BeFalse(); - sphere.Center.Xy.Should().Be(new CultVec2(1f, 2f)); - sphere.Center.Xz.Should().Be(new CultVec2(1f, 3f)); + sphere.Contains(new float3(1f, 6f, 3f)).Should().BeTrue(); + sphere.Contains(new float3(1f, 8f, 3f)).Should().BeFalse(); + sphere.Center.xy.Should().Be(new float2(1f, 2f)); + sphere.Center.xz.Should().Be(new float2(1f, 3f)); sphere.XyCircle.Bounds.Should().Be(new CultRect(-4f, -3f, 6f, 7f)); } [Test] public void GeometryPrimitives_RoundTripThroughMessagePack() { - var rect = new CultRect(new CultVec2(5f, -2f), new CultVec2(-1f, 7f)); - var payload = MessagePackSerializer.Serialize(rect); - var decoded = MessagePackSerializer.Deserialize(payload); + var rect = new CultRect(new float2(5f, -2f), new float2(-1f, 7f)); + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(CultRect)); + var payload = MessagePackSerializer.Serialize(rect, options); + var decoded = MessagePackSerializer.Deserialize(payload, options); decoded.Should().Be(rect); - decoded.Min.Should().Be(new CultVec2(-1f, -2f)); - decoded.Max.Should().Be(new CultVec2(5f, 7f)); + decoded.Min.Should().Be(new float2(-1f, -2f)); + decoded.Max.Should().Be(new float2(5f, 7f)); } [Test] - public void GeometryPrimitives_JsonOmitsComputedConvenienceProperties() + public void GeometryPrimitive_JsonInspectionProjection_UsesLowercaseComponents() { - var json = JsonSerializer.Serialize(new CultVec3(1f, 2f, 3f)); + var value = new float3(1f, 2f, 3f); + var json = JsonSerializer.Serialize(new { value.x, value.y, value.z }); - json.Should().Be("""{"X":1,"Y":2,"Z":3}"""); - json.Should().NotContain("LengthSquared"); - json.Should().NotContain("Xy"); - json.Should().NotContain("Xz"); + json.Should().Contain("\"x\":1"); + json.Should().Contain("\"y\":2"); + json.Should().Contain("\"z\":3"); + json.Should().NotContain("\"X\""); + json.Should().NotContain("\"Y\""); + json.Should().NotContain("\"Z\""); } [Test] From 8a219a479a6ffc4df17f1900fb7f335672a18801 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 03:12:55 +0100 Subject: [PATCH 09/30] Publish typed geometry v2 contracts --- ...th-gamecult-geometry-migration-progress.md | 11 +++ .../CultGeometryDocuments.cs | 59 +++++++----- .../GeometryDocumentTests.cs | 91 +++++++++++-------- 3 files changed, 97 insertions(+), 64 deletions(-) diff --git a/docs/cultmath-gamecult-geometry-migration-progress.md b/docs/cultmath-gamecult-geometry-migration-progress.md index 678d98c6..4f964208 100644 --- a/docs/cultmath-gamecult-geometry-migration-progress.md +++ b/docs/cultmath-gamecult-geometry-migration-progress.md @@ -175,6 +175,17 @@ a broader stage gate. - 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. ## Next Actions diff --git a/src/GameCult.Geometry/CultGeometryDocuments.cs b/src/GameCult.Geometry/CultGeometryDocuments.cs index 306b5849..68fc5514 100644 --- a/src/GameCult.Geometry/CultGeometryDocuments.cs +++ b/src/GameCult.Geometry/CultGeometryDocuments.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Security.Cryptography; using System.Text; using GameCult.Caching; +using CultMath; using MessagePack; using static GameCult.Geometry.CultGeometryDocuments; @@ -14,10 +15,10 @@ namespace GameCult.Geometry /// public static class CultGeometrySchemaVersions { - public const string Domain = "gamecult.geometry.domain.v1"; - public const string BuildRequest = "gamecult.geometry.build_request.v1"; - public const string SelectedCut = "gamecult.geometry.selected_cut.v1"; - public const string ChunkArtifact = "gamecult.geometry.chunk_artifact.v1"; + public const string Domain = "gamecult.geometry.domain.v2"; + public const string BuildRequest = "gamecult.geometry.build_request.v2"; + public const string SelectedCut = "gamecult.geometry.selected_cut.v2"; + public const string ChunkArtifact = "gamecult.geometry.chunk_artifact.v2"; } /// @@ -67,10 +68,10 @@ namespace GameCult.Geometry public string Kind { get; set; } = string.Empty; [Key(2)] - public float[] Translation { get; set; } = Array.Empty(); + public float3 Translation { get; set; } [Key(3)] - public float[] RotationXyzw { get; set; } = Array.Empty(); + public quaternion Rotation { get; set; } = quaternion.identity; [Key(4)] public ulong Seed { get; set; } @@ -88,7 +89,7 @@ namespace GameCult.Geometry Name, Kind, StableVector(Translation), - StableVector(RotationXyzw), + StableVector(Rotation), Seed.ToString(CultureInfo.InvariantCulture) } .Concat(Claims.Select(claim => claim.StableFingerprint())) @@ -109,16 +110,16 @@ namespace GameCult.Geometry public string Name { get; set; } = string.Empty; [Key(1)] - public float[] Translation { get; set; } = Array.Empty(); + public float3 Translation { get; set; } [Key(2)] - public float[] RotationXyzw { get; set; } = Array.Empty(); + public quaternion Rotation { get; set; } = quaternion.identity; [Key(3)] - public float[] SupportCenter { get; set; } = Array.Empty(); + public float3 SupportCenter { get; set; } [Key(4)] - public float[] SupportSize { get; set; } = Array.Empty(); + public float3 SupportSize { get; set; } [Key(5)] public string Kind { get; set; } = string.Empty; @@ -132,7 +133,7 @@ namespace GameCult.Geometry public string StableFingerprint() => StableArray([ Name, StableVector(Translation), - StableVector(RotationXyzw), + StableVector(Rotation), StableVector(SupportCenter), StableVector(SupportSize), Kind, @@ -161,13 +162,13 @@ namespace GameCult.Geometry public string WorkerGroup { get; set; } = string.Empty; [Key(3)] - public float[] CameraPosition { get; set; } = Array.Empty(); + public float3 CameraPosition { get; set; } [Key(4)] - public float[] FrustumMin { get; set; } = Array.Empty(); + public float3 FrustumMin { get; set; } [Key(5)] - public float[] FrustumMax { get; set; } = Array.Empty(); + public float3 FrustumMax { get; set; } [Key(6)] public float ViewportHeightPixels { get; set; } @@ -321,10 +322,10 @@ namespace GameCult.Geometry public string SelectedCutId { get; set; } = string.Empty; [Key(3)] - public float[] BoundsMin { get; set; } = Array.Empty(); + public float3 BoundsMin { get; set; } [Key(4)] - public float[] BoundsMax { get; set; } = Array.Empty(); + public float3 BoundsMax { get; set; } [Key(5)] public string[] SourceDomainKeys { get; set; } = Array.Empty(); @@ -374,13 +375,13 @@ namespace GameCult.Geometry public sealed class CultGeometryTriangleMesh { [Key(0)] - public float[] Positions { get; set; } = Array.Empty(); + public float3[] Positions { get; set; } = Array.Empty(); [Key(1)] - public float[] Normals { get; set; } = Array.Empty(); + public float3[] Normals { get; set; } = Array.Empty(); [Key(2)] - public float[] Uvs { get; set; } = Array.Empty(); + public float2[] Uvs { get; set; } = Array.Empty(); [Key(3)] public uint[] Indices { get; set; } = Array.Empty(); @@ -409,10 +410,20 @@ namespace GameCult.Geometry return string.Concat(bytes.Select(value => value.ToString("x2"))); } - public static string StableVector(float[] values) - { - return string.Join(",", values.Select(StableFloat)); - } + public static string StableVector(float2 value) => + string.Join(",", StableFloat(value.x), StableFloat(value.y)); + + public static string StableVector(float3 value) => + string.Join(",", StableFloat(value.x), StableFloat(value.y), StableFloat(value.z)); + + public static string StableVector(quaternion value) => + string.Join(",", StableFloat(value.x), StableFloat(value.y), StableFloat(value.z), StableFloat(value.w)); + + public static string StableVector(float2[] values) => + string.Join(",", values.SelectMany(value => new[] { value.x, value.y }).Select(StableFloat)); + + public static string StableVector(float3[] values) => + string.Join(",", values.SelectMany(value => new[] { value.x, value.y, value.z }).Select(StableFloat)); public static string StableFloat(float value) { diff --git a/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs b/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs index 1c50ab80..345c270e 100644 --- a/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs +++ b/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs @@ -87,6 +87,15 @@ namespace GameCult.Geometry.Tests .Should().Be(CultGeometrySchemaVersions.SelectedCut); cache.Registry.GetRequired().SchemaVersion .Should().Be(CultGeometrySchemaVersions.ChunkArtifact); + + cache.Registry.GetRequired().SchemaId + .Should().Be("sha256:e3358436f8a07c84cc66b5196380cdfcd6920fa15f15d052d30b42b73762421d"); + cache.Registry.GetRequired().SchemaId + .Should().Be("sha256:2de3162db128fc2c5cc2ea3dae46d0196110a9e5f969997002309e636a3721d3"); + cache.Registry.GetRequired().SchemaId + .Should().Be("sha256:864412d3cf0f6b079ceb0bbd3b36232f11380e63cdf0bfd64d505a710fb49ff8"); + cache.Registry.GetRequired().SchemaId + .Should().Be("sha256:2d28046b7c76244ced70ee6de09bcbb5c64d364aa578e5a4c903d397a7a56156"); } [Test] @@ -98,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, @@ -131,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)); } @@ -152,29 +166,26 @@ namespace GameCult.Geometry.Tests } [Test] - public void RustExportedDomainDocument_DecodesWithStableCultCacheKey() + public void TypedV2Domain_RoundTripsAndPreservesTheV1RecordKey() { - var payload = File.ReadAllBytes(FixturePath("ragnarok-domain.msgpack")); - var domain = CultDocumentMessagePackSerialization.Deserialize(payload); + var domain = SampleDomain(); + var payload = CultDocumentMessagePackSerialization.Serialize(domain); + var decoded = CultDocumentMessagePackSerialization.Deserialize(payload); - domain.DomainId.Should().Be("ragnarok-column"); - domain.Root.Children.Should().NotBeEmpty(); - domain.Root.Children[0].Name.Should().Be("stellarator-column-00"); - CultGeometryDomainDocument.CreateRecordKey(domain).Value - .Should().Be("geometry:domain:02c9b5810977406b0c206f3a3494327a423abb9448192be1b9a1863cd0f2ed95"); + decoded.Root.Rotation.Should().Be(quaternion.identity); + decoded.Root.Children[0].Claims[0].SupportCenter.Should().Be(new float3(0f, 0f, 45f)); + CultGeometryDomainDocument.CreateRecordKey(decoded).Value.Should().Be( + "geometry:domain:175899ea97548da0599e12bcaccd07fa1d6009ed450fadb3de229d47bab04431"); } [Test] - public void RustExportedChunkArtifact_DecodesWithStableCultCacheKey() + public void V2Chunk_DoesNotAcceptTheLegacyFlatMeshPayload() { var payload = File.ReadAllBytes(FixturePath("ragnarok-first-chunk.msgpack")); - var chunk = CultDocumentMessagePackSerialization.Deserialize(payload); - chunk.ChunkId.Should().StartWith("chunk/"); - chunk.RenderMesh.TriangleCount.Should().BeGreaterThan(0); - chunk.SourceDomainKeys.Should().NotBeEmpty(); - CultGeometryChunkArtifact.CreateRecordKey(chunk).Value - .Should().Be("geometry:chunk:b94dcd7d6776df15d0fa5fcf7f4b24f1384876b13b2d38cc57c4468136521af5"); + FluentActions.Invoking(() => + CultDocumentMessagePackSerialization.Deserialize(payload)) + .Should().Throw(); } [Test] @@ -325,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 = @@ -335,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" @@ -359,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 @@ -375,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] }, @@ -393,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); + } } } From 92934716307f1b78ce9b51c057be04000a7212ee Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 03:25:46 +0100 Subject: [PATCH 10/30] Add cross-runtime geometry v2 contract --- .gitignore | 3 + ...th-gamecult-geometry-migration-progress.md | 27 +- packages/gamecult-geometry-rs/Cargo.lock | 194 ++++++ packages/gamecult-geometry-rs/Cargo.toml | 14 + packages/gamecult-geometry-rs/LICENSE | 22 + packages/gamecult-geometry-rs/README.md | 11 + packages/gamecult-geometry-rs/src/lib.rs | 578 ++++++++++++++++++ .../CultGeometryDocuments.cs | 6 +- .../GeometryV2WireParityTests.cs | 153 +++++ 9 files changed, 998 insertions(+), 10 deletions(-) create mode 100644 packages/gamecult-geometry-rs/Cargo.lock create mode 100644 packages/gamecult-geometry-rs/Cargo.toml create mode 100644 packages/gamecult-geometry-rs/LICENSE create mode 100644 packages/gamecult-geometry-rs/README.md create mode 100644 packages/gamecult-geometry-rs/src/lib.rs create mode 100644 tests/GameCult.Geometry.Tests/GeometryV2WireParityTests.cs diff --git a/.gitignore b/.gitignore index d639c709..2e7fdf6c 100644 --- a/.gitignore +++ b/.gitignore @@ -206,6 +206,9 @@ 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/ # except build/, which is used as an MSBuild target. !**/[Pp]ackages/build/ # Uncomment if necessary however generally it will be regenerated when needed diff --git a/docs/cultmath-gamecult-geometry-migration-progress.md b/docs/cultmath-gamecult-geometry-migration-progress.md index 4f964208..3fefd608 100644 --- a/docs/cultmath-gamecult-geometry-migration-progress.md +++ b/docs/cultmath-gamecult-geometry-migration-progress.md @@ -56,8 +56,8 @@ Demotions: | --- | --- | --- | | 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 | In progress | Delete duplicate vectors first, then publish corrected v2 contracts | -| 3. Planetary transfer | Pending | Requires destination packaging and preserved parity corpus | +| 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 | In progress | Transfer C#/tests first, then shaders and runtime projections; delete CultMath owners in the same stage | | 4. Mine `vg-csg` | Pending | Requires v1 fixture capture and target Rust package seam | | 5. CultCache/CultNet/CultMesh integration | Pending | Requires current schemas and runtime packages | | 6. Consumer cutover/release | Pending | Requires local candidate feed and downstream passes | @@ -186,11 +186,24 @@ a broader stage gate. 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. ## Next Actions -1. Rewrite Geometry primitives over CultMath and delete `CultVec2`/`CultVec3` - without aliases or forwarding types. -2. Publish v2 geometry documents with typed vectors/quaternion and deliberate - stable-key fixtures; keep v1 only as test evidence. -3. Run repository/package negative searches before beginning planetary transfer. +1. Establish the Geometry-owned planetary parity harness and move the 13 C# + source/test files while deleting the matching CultMath owners and `rect`. +2. Transfer and rename planetary/erosion HLSL ownership, then remove the + implicit geometry includes and bodies from `CultMath.hlsl`. +3. Transfer the CMPT tool/web decoder, Unity adapters/sample, docs, and license + notices; inspect both packages and run the complete negative gate. diff --git a/packages/gamecult-geometry-rs/Cargo.lock b/packages/gamecult-geometry-rs/Cargo.lock new file mode 100644 index 00000000..ba7661a8 --- /dev/null +++ b/packages/gamecult-geometry-rs/Cargo.lock @@ -0,0 +1,194 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[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 = "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 = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[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 = "gamecult-geometry" +version = "0.1.0" +dependencies = [ + "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 = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[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 = "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 = "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", +] + +[[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 = "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 = "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 = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" diff --git a/packages/gamecult-geometry-rs/Cargo.toml b/packages/gamecult-geometry-rs/Cargo.toml new file mode 100644 index 00000000..4cabb87a --- /dev/null +++ b/packages/gamecult-geometry-rs/Cargo.toml @@ -0,0 +1,14 @@ +[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] +rmp-serde = "1" +serde = { version = "1", features = ["derive"] } +sha2 = "0.10" + diff --git a/packages/gamecult-geometry-rs/LICENSE b/packages/gamecult-geometry-rs/LICENSE new file mode 100644 index 00000000..a431c3ab --- /dev/null +++ b/packages/gamecult-geometry-rs/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2026 GameCult + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/packages/gamecult-geometry-rs/README.md b/packages/gamecult-geometry-rs/README.md new file mode 100644 index 00000000..0b3ec053 --- /dev/null +++ b/packages/gamecult-geometry-rs/README.md @@ -0,0 +1,11 @@ +# GameCult.Geometry for Rust + +Rust projection of the GameCult.Geometry v2 document contract. It owns the +typed MessagePack layouts and stable geometry record identities used at the +Rust boundary; it does not depend on VibeGeometry or `vg-csg`. + +The initial contract and stable-hash implementation were mined from the +MIT-licensed GameCult `vg-csg` crate at commit `8f070f4`. The implementation +was rewritten here under GameCult.Geometry ownership. The original copyright +and MIT terms are preserved in [LICENSE](LICENSE). + diff --git a/packages/gamecult-geometry-rs/src/lib.rs b/packages/gamecult-geometry-rs/src/lib.rs new file mode 100644 index 00000000..7882c457 --- /dev/null +++ b/packages/gamecult-geometry-rs/src/lib.rs @@ -0,0 +1,578 @@ +//! 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`. + +use std::fmt::Write; + +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use sha2::{Digest, Sha256}; + +pub const GEOMETRY_DOMAIN_SCHEMA: &str = "gamecult.geometry.domain.v2"; +pub const GEOMETRY_BUILD_REQUEST_SCHEMA: &str = "gamecult.geometry.build_request.v2"; +pub const GEOMETRY_SELECTED_CUT_SCHEMA: &str = "gamecult.geometry.selected_cut.v2"; +pub const GEOMETRY_CHUNK_ARTIFACT_SCHEMA: &str = "gamecult.geometry.chunk_artifact.v2"; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Float2(pub f32, pub f32); + +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Float3(pub f32, pub f32, pub f32); + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Quaternion(pub f32, pub f32, pub f32, pub f32); + +impl Default for Quaternion { + fn default() -> Self { + Self(0.0, 0.0, 0.0, 1.0) + } +} + +pub trait GeometryDocument: Serialize + DeserializeOwned + Sized { + const SCHEMA_VERSION: &'static str; + fn record_key(&self) -> String; + fn to_msgpack(&self) -> Result, rmp_serde::encode::Error> { + rmp_serde::to_vec(self) + } + fn from_msgpack(payload: &[u8]) -> Result { + rmp_serde::from_slice(payload) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GeometryDomainDocument( + pub String, + pub String, + pub String, + pub GeometryDomainNode, + pub String, +); + +impl GeometryDocument for GeometryDomainDocument { + const SCHEMA_VERSION: &'static str = GEOMETRY_DOMAIN_SCHEMA; + fn record_key(&self) -> String { + format!( + "geometry:domain:{}", + stable_hash(&[&self.1, &self.2, &self.3.stable_fingerprint()]) + ) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GeometryDomainNode( + pub String, + pub String, + pub Float3, + pub Quaternion, + pub u64, + pub Vec, + pub Vec, +); + +impl GeometryDomainNode { + pub fn stable_fingerprint(&self) -> String { + let mut parts = vec![ + self.0.clone(), + self.1.clone(), + stable_float3(self.2), + stable_quaternion(self.3), + self.4.to_string(), + ]; + parts.extend(self.5.iter().map(GeometryFeatureClaim::stable_fingerprint)); + parts.extend(self.6.iter().map(GeometryDomainNode::stable_fingerprint)); + stable_join(&parts) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GeometryFeatureClaim( + pub String, + pub Float3, + pub Quaternion, + pub Float3, + pub Float3, + pub String, + pub u32, + pub String, +); + +impl GeometryFeatureClaim { + pub fn stable_fingerprint(&self) -> String { + stable_join(&[ + self.0.clone(), + stable_float3(self.1), + stable_quaternion(self.2), + stable_float3(self.3), + stable_float3(self.4), + self.5.clone(), + self.6.to_string(), + self.7.clone(), + ]) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GeometryBuildRequest( + pub String, + pub String, + pub String, + pub Float3, + pub Float3, + pub Float3, + pub f32, + pub f32, + pub f32, + pub i32, + pub i32, + pub Vec, + pub Vec, + pub Vec, + pub String, +); + +impl GeometryDocument for GeometryBuildRequest { + const SCHEMA_VERSION: &'static str = GEOMETRY_BUILD_REQUEST_SCHEMA; + fn record_key(&self) -> String { + format!( + "geometry:request:{}", + stable_hash(&[ + &self.1, + &self.2, + &stable_float3(self.3), + &stable_float3(self.4), + &stable_float3(self.5), + &stable_f32(self.6), + &stable_f32(self.7), + &stable_f32(self.8), + &self.9.to_string(), + &self.10.to_string(), + &stable_join(&self.11), + &stable_join(&self.12), + &stable_join(&self.13), + ]) + ) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GeometrySelectedCutManifest( + pub String, + pub String, + pub Vec, + pub Vec, + pub Vec, + pub Vec, +); + +impl GeometryDocument for GeometrySelectedCutManifest { + const SCHEMA_VERSION: &'static str = GEOMETRY_SELECTED_CUT_SCHEMA; + fn record_key(&self) -> String { + format!( + "geometry:cut:{}", + stable_hash(&[ + &self.1, + &self.0, + &stable_join(&self.2), + &stable_join(&self.3), + &stable_join(&self.4), + ]) + ) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GeometryContributionRow( + pub String, + pub String, + pub f32, + pub f32, + pub f32, + pub i32, + pub i32, + pub i32, + pub bool, + pub bool, + pub bool, + pub bool, + pub bool, +); + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GeometryChunkArtifact( + pub String, + pub String, + pub String, + pub Float3, + pub Float3, + pub Vec, + pub Vec, + pub GeometryTriangleMesh, + pub Option, + pub i32, + pub i32, + pub i32, + pub u64, + pub bool, +); + +impl GeometryDocument for GeometryChunkArtifact { + const SCHEMA_VERSION: &'static str = GEOMETRY_CHUNK_ARTIFACT_SCHEMA; + fn record_key(&self) -> String { + format!( + "geometry:chunk:{}", + stable_hash(&[ + &self.1, + &self.0, + &self.2, + &stable_join(&self.5), + &stable_join(&self.6), + &self.7.stable_fingerprint(), + self.8 + .as_ref() + .map(GeometryTriangleMesh::stable_fingerprint) + .as_deref() + .unwrap_or(""), + ]) + ) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct GeometryTriangleMesh( + pub Vec, + pub Vec, + pub Vec, + pub Vec, + pub Vec, +); + +impl GeometryTriangleMesh { + pub fn triangle_count(&self) -> usize { + self.3.len() / 3 + } + pub fn stable_fingerprint(&self) -> String { + stable_hash(&[ + &stable_float3_array(&self.0), + &stable_float3_array(&self.1), + &stable_float2_array(&self.2), + &stable_u32_array(&self.3), + &stable_u32_array(&self.4), + ]) + } +} + +fn stable_hash(parts: &[&str]) -> String { + let mut hasher = Sha256::new(); + hasher.update(parts.join("\u{1f}").as_bytes()); + let mut output = String::with_capacity(64); + for byte in hasher.finalize() { + write!(&mut output, "{byte:02x}").expect("string write"); + } + output +} + +fn stable_join(parts: &[String]) -> String { + parts.join("\u{1e}") +} +fn stable_f32(value: f32) -> String { + format!("{:08x}", value.to_bits()) +} +fn stable_float2(value: Float2) -> String { + format!("{},{}", stable_f32(value.0), stable_f32(value.1)) +} +fn stable_float3(value: Float3) -> String { + format!( + "{},{},{}", + stable_f32(value.0), + stable_f32(value.1), + stable_f32(value.2) + ) +} +fn stable_quaternion(value: Quaternion) -> String { + format!( + "{},{},{},{}", + stable_f32(value.0), + stable_f32(value.1), + stable_f32(value.2), + stable_f32(value.3) + ) +} +fn stable_float2_array(values: &[Float2]) -> String { + values + .iter() + .map(|v| stable_float2(*v)) + .collect::>() + .join(",") +} +fn stable_float3_array(values: &[Float3]) -> String { + values + .iter() + .map(|v| stable_float3(*v)) + .collect::>() + .join(",") +} +fn stable_u32_array(values: &[u32]) -> String { + values + .iter() + .map(u32::to_string) + .collect::>() + .join(",") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn domain() -> GeometryDomainDocument { + GeometryDomainDocument( + "fixture-domain".into(), + "fixture/root".into(), + "gamecult-geometry-rs".into(), + GeometryDomainNode( + "root".into(), + "Root".into(), + Float3(1.0, -2.5, 0.0), + Quaternion::default(), + 42, + vec![], + vec![], + ), + "2026-07-22T00:00:00Z".into(), + ) + } + + fn chunk() -> GeometryChunkArtifact { + GeometryChunkArtifact( + "fixture/chunk".into(), + "geometry:cut:fixture".into(), + "cut-fixture".into(), + Float3(-1.0, -2.0, -3.0), + Float3(1.0, 2.0, 3.0), + vec!["fixture/root".into()], + vec!["fixture/root/claim".into()], + GeometryTriangleMesh( + vec![ + Float3(0.0, 0.0, 0.0), + Float3(1.0, 0.0, 0.0), + Float3(0.0, 1.0, 0.0), + ], + vec![Float3(0.0, 0.0, 1.0); 3], + vec![Float2(0.0, 0.0), Float2(1.0, 0.0), Float2(0.0, 1.0)], + vec![0, 1, 2], + vec![7], + ), + None, + 1, + 0, + 0, + 0x0123_4567_89ab_cdef, + true, + ) + } + + fn request() -> GeometryBuildRequest { + GeometryBuildRequest( + "request-fixture".into(), + domain().record_key(), + "workers".into(), + Float3(1.0, 2.0, 3.0), + Float3(-4.0, -5.0, -6.0), + Float3(4.0, 5.0, 6.0), + 1080.0, + 1.0, + 0.25, + 100, + 50, + vec!["Root".into()], + vec![], + vec![], + "2026-07-22T00:00:01Z".into(), + ) + } + + fn cut() -> GeometrySelectedCutManifest { + GeometrySelectedCutManifest( + "cut-fixture".into(), + "geometry:request:fixture".into(), + vec!["fixture/root".into()], + vec![], + vec![], + vec![], + ) + } + + #[test] + fn schema_versions_are_v2() { + assert_eq!( + GeometryDomainDocument::SCHEMA_VERSION, + "gamecult.geometry.domain.v2" + ); + assert_eq!( + GeometryBuildRequest::SCHEMA_VERSION, + "gamecult.geometry.build_request.v2" + ); + assert_eq!( + GeometrySelectedCutManifest::SCHEMA_VERSION, + "gamecult.geometry.selected_cut.v2" + ); + assert_eq!( + GeometryChunkArtifact::SCHEMA_VERSION, + "gamecult.geometry.chunk_artifact.v2" + ); + } + + #[test] + fn domain_record_key_matches_csharp_v2_fixture() { + let document = GeometryDomainDocument( + "ragnarok-column".into(), + "ragnarok-column".into(), + "vg-csg".into(), + GeometryDomainNode( + "ragnarok-column".into(), + "Root".into(), + Float3::default(), + Quaternion::default(), + 0x5eed, + vec![], + vec![GeometryDomainNode( + "stellarator-column-00".into(), + "Column".into(), + Float3::default(), + Quaternion::default(), + 0xc011_0000, + vec![GeometryFeatureClaim( + "column-support-shell".into(), + Float3::default(), + Quaternion::default(), + Float3(0.0, 0.0, 45.0), + Float3(18.0, 18.0, 96.0), + "SupportShell".into(), + 10, + "RenderAndCollider".into(), + )], + vec![], + )], + ), + "2026-05-29T00:00:00.0000000Z".into(), + ); + + assert_eq!( + document.record_key(), + "geometry:domain:175899ea97548da0599e12bcaccd07fa1d6009ed450fadb3de229d47bab04431" + ); + } + + #[test] + fn exact_v2_messagepack_and_record_key_witnesses() { + let domain = domain(); + let chunk = chunk(); + let request = request(); + let cut = cut(); + assert_eq!( + domain.record_key(), + "geometry:domain:505c94e7393580a4e5b048b169aa4da955da606ffd28c054b766a4b94e50502e" + ); + assert_eq!( + request.record_key(), + "geometry:request:36e25b80c04a6c54c8b3137abb4a8c961468a9b1c0e7dd4d5632cf5a6f847966" + ); + assert_eq!( + cut.record_key(), + "geometry:cut:5614eae3fe2a17dca651d1e32f401c50e1c3b9cf941f5789655990325da64fb3" + ); + assert_eq!( + chunk.record_key(), + "geometry:chunk:ca2874d3638800179c906087ec817ea715c43b70b99ead04edccce2a1b3d6ebb" + ); + assert_eq!( + domain.to_msgpack().unwrap(), + [ + 149, 174, 102, 105, 120, 116, 117, 114, 101, 45, 100, 111, 109, 97, 105, 110, 172, + 102, 105, 120, 116, 117, 114, 101, 47, 114, 111, 111, 116, 180, 103, 97, 109, 101, + 99, 117, 108, 116, 45, 103, 101, 111, 109, 101, 116, 114, 121, 45, 114, 115, 151, + 164, 114, 111, 111, 116, 164, 82, 111, 111, 116, 147, 202, 63, 128, 0, 0, 202, 192, + 32, 0, 0, 202, 0, 0, 0, 0, 148, 202, 0, 0, 0, 0, 202, 0, 0, 0, 0, 202, 0, 0, 0, 0, + 202, 63, 128, 0, 0, 42, 144, 144, 180, 50, 48, 50, 54, 45, 48, 55, 45, 50, 50, 84, + 48, 48, 58, 48, 48, 58, 48, 48, 90 + ] + ); + assert_eq!( + request.to_msgpack().unwrap(), + [ + 159, 175, 114, 101, 113, 117, 101, 115, 116, 45, 102, 105, 120, 116, 117, 114, 101, + 217, 80, 103, 101, 111, 109, 101, 116, 114, 121, 58, 100, 111, 109, 97, 105, 110, + 58, 53, 48, 53, 99, 57, 52, 101, 55, 51, 57, 51, 53, 56, 48, 97, 52, 101, 53, 98, + 48, 52, 56, 98, 49, 54, 57, 97, 97, 52, 100, 97, 57, 53, 53, 100, 97, 54, 48, 54, + 102, 102, 100, 50, 56, 99, 48, 53, 52, 98, 55, 54, 54, 97, 52, 98, 57, 52, 101, 53, + 48, 53, 48, 50, 101, 167, 119, 111, 114, 107, 101, 114, 115, 147, 202, 63, 128, 0, + 0, 202, 64, 0, 0, 0, 202, 64, 64, 0, 0, 147, 202, 192, 128, 0, 0, 202, 192, 160, 0, + 0, 202, 192, 192, 0, 0, 147, 202, 64, 128, 0, 0, 202, 64, 160, 0, 0, 202, 64, 192, + 0, 0, 202, 68, 135, 0, 0, 202, 63, 128, 0, 0, 202, 62, 128, 0, 0, 100, 50, 145, + 164, 82, 111, 111, 116, 144, 144, 180, 50, 48, 50, 54, 45, 48, 55, 45, 50, 50, 84, + 48, 48, 58, 48, 48, 58, 48, 49, 90 + ] + ); + assert_eq!( + cut.to_msgpack().unwrap(), + [ + 150, 171, 99, 117, 116, 45, 102, 105, 120, 116, 117, 114, 101, 184, 103, 101, 111, + 109, 101, 116, 114, 121, 58, 114, 101, 113, 117, 101, 115, 116, 58, 102, 105, 120, + 116, 117, 114, 101, 145, 172, 102, 105, 120, 116, 117, 114, 101, 47, 114, 111, 111, + 116, 144, 144, 144 + ] + ); + assert_eq!( + chunk.to_msgpack().unwrap(), + [ + 158, 173, 102, 105, 120, 116, 117, 114, 101, 47, 99, 104, 117, 110, 107, 180, 103, + 101, 111, 109, 101, 116, 114, 121, 58, 99, 117, 116, 58, 102, 105, 120, 116, 117, + 114, 101, 171, 99, 117, 116, 45, 102, 105, 120, 116, 117, 114, 101, 147, 202, 191, + 128, 0, 0, 202, 192, 0, 0, 0, 202, 192, 64, 0, 0, 147, 202, 63, 128, 0, 0, 202, 64, + 0, 0, 0, 202, 64, 64, 0, 0, 145, 172, 102, 105, 120, 116, 117, 114, 101, 47, 114, + 111, 111, 116, 145, 178, 102, 105, 120, 116, 117, 114, 101, 47, 114, 111, 111, 116, + 47, 99, 108, 97, 105, 109, 149, 147, 147, 202, 0, 0, 0, 0, 202, 0, 0, 0, 0, 202, 0, + 0, 0, 0, 147, 202, 63, 128, 0, 0, 202, 0, 0, 0, 0, 202, 0, 0, 0, 0, 147, 202, 0, 0, + 0, 0, 202, 63, 128, 0, 0, 202, 0, 0, 0, 0, 147, 147, 202, 0, 0, 0, 0, 202, 0, 0, 0, + 0, 202, 63, 128, 0, 0, 147, 202, 0, 0, 0, 0, 202, 0, 0, 0, 0, 202, 63, 128, 0, 0, + 147, 202, 0, 0, 0, 0, 202, 0, 0, 0, 0, 202, 63, 128, 0, 0, 147, 146, 202, 0, 0, 0, + 0, 202, 0, 0, 0, 0, 146, 202, 63, 128, 0, 0, 202, 0, 0, 0, 0, 146, 202, 0, 0, 0, 0, + 202, 63, 128, 0, 0, 147, 0, 1, 2, 145, 7, 192, 1, 0, 0, 207, 1, 35, 69, 103, 137, + 171, 205, 239, 195 + ] + ); + assert_eq!( + GeometryDomainDocument::from_msgpack(&domain.to_msgpack().unwrap()).unwrap(), + domain + ); + assert_eq!( + GeometryChunkArtifact::from_msgpack(&chunk.to_msgpack().unwrap()).unwrap(), + chunk + ); + assert_eq!( + GeometryBuildRequest::from_msgpack(&request.to_msgpack().unwrap()).unwrap(), + request + ); + assert_eq!( + GeometrySelectedCutManifest::from_msgpack(&cut.to_msgpack().unwrap()).unwrap(), + cut + ); + } + + #[test] + fn scalar_bit_identity_is_stable_through_messagepack() { + let mut signed_zero = request(); + signed_zero.6 = -0.0; + signed_zero.7 = f32::from_bits(0x7fc0_1234); + signed_zero.8 = f32::NEG_INFINITY; + assert_eq!( + signed_zero.record_key(), + "geometry:request:978dd0913fc3e12c04766e065320d485d172d60d61be920a9a32637d1c439c81" + ); + let decoded = + GeometryBuildRequest::from_msgpack(&signed_zero.to_msgpack().unwrap()).unwrap(); + assert_eq!(decoded.6.to_bits(), 0x8000_0000); + assert_eq!(decoded.7.to_bits(), 0x7fc0_1234); + assert_eq!(decoded.8.to_bits(), f32::NEG_INFINITY.to_bits()); + } +} diff --git a/src/GameCult.Geometry/CultGeometryDocuments.cs b/src/GameCult.Geometry/CultGeometryDocuments.cs index 68fc5514..afb6d305 100644 --- a/src/GameCult.Geometry/CultGeometryDocuments.cs +++ b/src/GameCult.Geometry/CultGeometryDocuments.cs @@ -206,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), diff --git a/tests/GameCult.Geometry.Tests/GeometryV2WireParityTests.cs b/tests/GameCult.Geometry.Tests/GeometryV2WireParityTests.cs new file mode 100644 index 00000000..4c475683 --- /dev/null +++ b/tests/GameCult.Geometry.Tests/GeometryV2WireParityTests.cs @@ -0,0 +1,153 @@ +using System; +using CultMath; +using FluentAssertions; +using GameCult.Caching.MessagePack; +using NUnit.Framework; + +namespace GameCult.Geometry.Tests; + +public sealed class GeometryV2WireParityTests +{ + private const string DomainHex = "95AE666978747572652D646F6D61696EAC666978747572652F726F6F74B467616D6563756C742D67656F6D657472792D727397A4726F6F74A4526F6F7493CA3F800000CAC0200000CA0000000094CA00000000CA00000000CA00000000CA3F8000002A9090B4323032362D30372D32325430303A30303A30305A"; + private const string RequestHex = "9FAF726571756573742D66697874757265D95067656F6D657472793A646F6D61696E3A35303563393465373339333538306134653562303438623136396161346461393535646136303666666432386330353462373636613462393465353035303265A7776F726B65727393CA3F800000CA40000000CA4040000093CAC0800000CAC0A00000CAC0C0000093CA40800000CA40A00000CA40C00000CA44870000CA3F800000CA3E800000643291A4526F6F749090B4323032362D30372D32325430303A30303A30315A"; + private const string CutHex = "96AB6375742D66697874757265B867656F6D657472793A726571756573743A6669787475726591AC666978747572652F726F6F74909090"; + private const string ChunkHex = "9EAD666978747572652F6368756E6BB467656F6D657472793A6375743A66697874757265AB6375742D6669787475726593CABF800000CAC0000000CAC040000093CA3F800000CA40000000CA4040000091AC666978747572652F726F6F7491B2666978747572652F726F6F742F636C61696D959393CA00000000CA00000000CA0000000093CA3F800000CA00000000CA0000000093CA00000000CA3F800000CA000000009393CA00000000CA00000000CA3F80000093CA00000000CA00000000CA3F80000093CA00000000CA00000000CA3F8000009392CA00000000CA0000000092CA3F800000CA0000000092CA00000000CA3F800000930001029107C0010000CF0123456789ABCDEFC3"; + + [Test] + public void Domain_MatchesRustV2BytesAndKey() + { + var document = Domain(); + AssertWire(document, DomainHex); + CultGeometryDomainDocument.CreateRecordKey(document).Value.Should().Be( + "geometry:domain:505c94e7393580a4e5b048b169aa4da955da606ffd28c054b766a4b94e50502e"); + } + + [Test] + public void BuildRequest_MatchesRustV2BytesAndKey() + { + var document = Request(); + AssertWire(document, RequestHex); + CultGeometryBuildRequest.CreateRecordKey(document).Value.Should().Be( + "geometry:request:36e25b80c04a6c54c8b3137abb4a8c961468a9b1c0e7dd4d5632cf5a6f847966"); + } + + [Test] + public void SelectedCut_MatchesRustV2BytesAndKey() + { + var document = Cut(); + AssertWire(document, CutHex); + CultGeometrySelectedCutManifest.CreateRecordKey(document).Value.Should().Be( + "geometry:cut:5614eae3fe2a17dca651d1e32f401c50e1c3b9cf941f5789655990325da64fb3"); + } + + [Test] + public void Chunk_MatchesRustV2BytesAndKey() + { + var document = Chunk(); + AssertWire(document, ChunkHex); + CultGeometryChunkArtifact.CreateRecordKey(document).Value.Should().Be( + "geometry:chunk:ca2874d3638800179c906087ec817ea715c43b70b99ead04edccce2a1b3d6ebb"); + } + + [Test] + public void BuildRequest_ScalarIdentityUsesExactIeeeBits() + { + var document = Request(); + document.ViewportHeightPixels = -0f; + document.VerticalFovRadians = BitConverter.Int32BitsToSingle(unchecked((int)0x7fc01234)); + document.TargetError = float.NegativeInfinity; + + CultGeometryBuildRequest.CreateRecordKey(document).Value.Should().Be( + "geometry:request:978dd0913fc3e12c04766e065320d485d172d60d61be920a9a32637d1c439c81"); + + var payload = CultDocumentMessagePackSerialization.Serialize(document); + var decoded = CultDocumentMessagePackSerialization.Deserialize(payload); + BitConverter.SingleToInt32Bits(decoded.ViewportHeightPixels).Should().Be(unchecked((int)0x80000000)); + BitConverter.SingleToInt32Bits(decoded.VerticalFovRadians).Should().Be(unchecked((int)0x7fc01234)); + BitConverter.SingleToInt32Bits(decoded.TargetError).Should().Be(unchecked((int)0xff800000)); + } + + private static void AssertWire(T document, string expectedHex) + { + var payload = CultDocumentMessagePackSerialization.Serialize(document); + Convert.ToHexString(payload).Should().Be(expectedHex); + var decoded = CultDocumentMessagePackSerialization.Deserialize(payload); + Convert.ToHexString(CultDocumentMessagePackSerialization.Serialize(decoded)).Should().Be(expectedHex); + + var rustPayload = Convert.FromHexString(expectedHex); + var decodedFromRust = CultDocumentMessagePackSerialization.Deserialize(rustPayload); + Convert.ToHexString(CultDocumentMessagePackSerialization.Serialize(decodedFromRust)).Should().Be(expectedHex); + } + + private static CultGeometryDomainDocument Domain() => new() + { + DomainId = "fixture-domain", + RootKey = "fixture/root", + SourceRuntime = "gamecult-geometry-rs", + Root = new CultGeometryDomainNode + { + Name = "root", + Kind = "Root", + Translation = new float3(1f, -2.5f, 0f), + Rotation = quaternion.identity, + Seed = 42, + Claims = [], + Children = [], + }, + CreatedAt = "2026-07-22T00:00:00Z", + }; + + private static CultGeometryBuildRequest Request() => new() + { + RequestId = "request-fixture", + DomainKey = CultGeometryDomainDocument.CreateRecordKey(Domain()).Value, + WorkerGroup = "workers", + CameraPosition = new float3(1f, 2f, 3f), + FrustumMin = new float3(-4f, -5f, -6f), + FrustumMax = new float3(4f, 5f, 6f), + ViewportHeightPixels = 1080f, + VerticalFovRadians = 1f, + TargetError = 0.25f, + TriangleBudget = 100, + ColliderBudget = 50, + SemanticFilter = ["Root"], + RequestedChunkKeys = [], + DirtyDomainKeys = [], + CreatedAt = "2026-07-22T00:00:01Z", + }; + + private static CultGeometrySelectedCutManifest Cut() => new() + { + CutId = "cut-fixture", + RequestKey = "geometry:request:fixture", + SelectedNodes = ["fixture/root"], + DeferredChildRequests = [], + ParentFallbackNodes = [], + Diagnostics = [], + }; + + private static CultGeometryChunkArtifact Chunk() => new() + { + ChunkId = "fixture/chunk", + CutKey = "geometry:cut:fixture", + SelectedCutId = "cut-fixture", + BoundsMin = new float3(-1f, -2f, -3f), + BoundsMax = new float3(1f, 2f, 3f), + SourceDomainKeys = ["fixture/root"], + SourceClaimKeys = ["fixture/root/claim"], + RenderMesh = new CultGeometryTriangleMesh + { + Positions = [new float3(0f, 0f, 0f), new float3(1f, 0f, 0f), new float3(0f, 1f, 0f)], + Normals = [new float3(0f, 0f, 1f), new float3(0f, 0f, 1f), new float3(0f, 0f, 1f)], + Uvs = [new float2(0f, 0f), new float2(1f, 0f), new float2(0f, 1f)], + Indices = [0, 1, 2], + TriangleMaterials = [7], + }, + ColliderMesh = null, + InputBrushes = 1, + CandidatePairs = 0, + RejectedPairs = 0, + StableClipSeed = 0x0123_4567_89ab_cdef, + SupportsParentChildCoexistence = true, + }; +} From 756f5a333748fc34c4331d5fd097a8cd40445aaa Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 03:47:57 +0100 Subject: [PATCH 11/30] Transfer planetary geometry into GameCult.Geometry --- .gitignore | 10 + CultLib.sln | 15 + Directory.Build.props | 2 + ...th-gamecult-geometry-migration-progress.md | 41 +- package.json | 3 +- packages/gamecult-geometry-ts/package.json | 10 + .../src/planetary-tile.ts | 47 +++ .../planetary-tile.cross-runtime.test.mjs | 16 + .../test/planetary-tile.test.mjs | 24 ++ scripts/build-geometry-unity-package.ps1 | 14 +- scripts/test-geometry-planetary-tile.ps1 | 9 + .../GameCult.Geometry.csproj | 14 +- src/GameCult.Geometry/IsExternalInit.cs | 7 + src/GameCult.Geometry/LICENSES.md | 13 + .../Planetary/AdvancedErosionFilter.cs | 141 +++++++ .../Planetary/ErosionFrequencyBands.cs | 63 +++ .../Planetary/PlanetaryField.cs | 220 ++++++++++ .../Planetary/PlanetaryGpuPages.cs | 60 +++ .../Planetary/PlanetaryMapTileEncoding.cs | 122 ++++++ .../Planetary/PlanetaryPages.cs | 192 +++++++++ .../Planetary/PlanetaryPatch.cs | 50 +++ .../Planetary/PlanetaryProjection.cs | 327 +++++++++++++++ .../Planetary/PlanetaryQueries.cs | 198 +++++++++ .../Planetary/PlanetaryRadialRefinement.cs | 34 ++ .../Planetary/PlanetaryResidency.cs | 153 +++++++ .../Planetary/PlanetaryTopology.cs | 196 +++++++++ .../Planetary/SphericalErosion.cs | 114 +++++ src/GameCult.Geometry/README.md | 35 +- .../Shaders/AdvancedErosionFilter.hlsl | 147 +++++++ .../Shaders/AdvancedErosionFilter.hlsl.meta | 7 + .../Shaders/GameCult.Geometry.hlsl | 14 + .../Shaders/GameCult.Geometry.hlsl.meta | 7 + src/GameCult.Geometry/Shaders/Planetary.hlsl | 392 ++++++++++++++++++ .../Shaders/Planetary.hlsl.meta | 7 + .../Shaders/PlanetaryRadialRefinement.hlsl | 27 ++ .../PlanetaryRadialRefinement.hlsl.meta | 7 + .../Shaders/SphericalErosion.hlsl | 80 ++++ .../Shaders/SphericalErosion.hlsl.meta | 7 + src/GameCult.Geometry/THIRD-PARTY-NOTICES.md | 24 ++ src/GameCult.Geometry/docs/planetary.md | 135 ++++++ .../AdvancedErosionFilterTests.cs | 47 +++ .../ErosionFrequencyBandsTests.cs | 37 ++ .../GameCult.Geometry.Planetary.Tests.csproj | 18 + .../PlanetaryFieldTests.cs | 83 ++++ .../PlanetaryGpuPageTests.cs | 28 ++ .../PlanetaryMapTileEncodingTests.cs | 55 +++ .../PlanetaryPageTests.cs | 60 +++ .../PlanetaryPatchTests.cs | 43 ++ .../PlanetaryProjectionTests.cs | 76 ++++ .../PlanetaryQueryTests.cs | 49 +++ .../PlanetaryRadialRefinementTests.cs | 32 ++ .../PlanetaryResidencyTests.cs | 60 +++ .../PlanetaryTopologyTests.cs | 66 +++ .../SphericalErosionTests.cs | 37 ++ .../GeometryShaderOwnershipTests.cs | 79 ++++ ...eCult.Geometry.PlanetaryTileFixture.csproj | 11 + .../Program.cs | 23 + unity/org.gamecult.geometry/README.md | 9 +- .../Runtime/GameCult.Geometry.Unity.asmdef | 8 + .../Runtime/PlanetaryPageUpload.cs | 29 ++ .../Runtime/PlanetaryPageUpload.cs.meta | 2 + .../Runtime/PlanetaryPatchMeshAdapter.cs | 27 ++ .../Runtime/PlanetaryPatchMeshAdapter.cs.meta | 2 + ...ult.Geometry.PlanetaryViewer.Editor.asmdef | 1 + .../GameCultGeometryPlanetaryViewerMenu.cs | 27 ++ .../Samples~/PlanetaryViewer/README.md | 7 + .../GameCult.Geometry.PlanetaryViewer.asmdef | 1 + .../GameCultGeometryPlanetaryViewer.cs | 141 +++++++ .../GameCultGeometryPlanetaryViewer.shader | 91 ++++ unity/org.gamecult.geometry/package.json | 3 +- 70 files changed, 4105 insertions(+), 31 deletions(-) create mode 100644 packages/gamecult-geometry-ts/package.json create mode 100644 packages/gamecult-geometry-ts/src/planetary-tile.ts create mode 100644 packages/gamecult-geometry-ts/test/planetary-tile.cross-runtime.test.mjs create mode 100644 packages/gamecult-geometry-ts/test/planetary-tile.test.mjs create mode 100644 scripts/test-geometry-planetary-tile.ps1 create mode 100644 src/GameCult.Geometry/IsExternalInit.cs create mode 100644 src/GameCult.Geometry/LICENSES.md create mode 100644 src/GameCult.Geometry/Planetary/AdvancedErosionFilter.cs create mode 100644 src/GameCult.Geometry/Planetary/ErosionFrequencyBands.cs create mode 100644 src/GameCult.Geometry/Planetary/PlanetaryField.cs create mode 100644 src/GameCult.Geometry/Planetary/PlanetaryGpuPages.cs create mode 100644 src/GameCult.Geometry/Planetary/PlanetaryMapTileEncoding.cs create mode 100644 src/GameCult.Geometry/Planetary/PlanetaryPages.cs create mode 100644 src/GameCult.Geometry/Planetary/PlanetaryPatch.cs create mode 100644 src/GameCult.Geometry/Planetary/PlanetaryProjection.cs create mode 100644 src/GameCult.Geometry/Planetary/PlanetaryQueries.cs create mode 100644 src/GameCult.Geometry/Planetary/PlanetaryRadialRefinement.cs create mode 100644 src/GameCult.Geometry/Planetary/PlanetaryResidency.cs create mode 100644 src/GameCult.Geometry/Planetary/PlanetaryTopology.cs create mode 100644 src/GameCult.Geometry/Planetary/SphericalErosion.cs create mode 100644 src/GameCult.Geometry/Shaders/AdvancedErosionFilter.hlsl create mode 100644 src/GameCult.Geometry/Shaders/AdvancedErosionFilter.hlsl.meta create mode 100644 src/GameCult.Geometry/Shaders/GameCult.Geometry.hlsl create mode 100644 src/GameCult.Geometry/Shaders/GameCult.Geometry.hlsl.meta create mode 100644 src/GameCult.Geometry/Shaders/Planetary.hlsl create mode 100644 src/GameCult.Geometry/Shaders/Planetary.hlsl.meta create mode 100644 src/GameCult.Geometry/Shaders/PlanetaryRadialRefinement.hlsl create mode 100644 src/GameCult.Geometry/Shaders/PlanetaryRadialRefinement.hlsl.meta create mode 100644 src/GameCult.Geometry/Shaders/SphericalErosion.hlsl create mode 100644 src/GameCult.Geometry/Shaders/SphericalErosion.hlsl.meta create mode 100644 src/GameCult.Geometry/THIRD-PARTY-NOTICES.md create mode 100644 src/GameCult.Geometry/docs/planetary.md create mode 100644 tests/GameCult.Geometry.Planetary.Tests/AdvancedErosionFilterTests.cs create mode 100644 tests/GameCult.Geometry.Planetary.Tests/ErosionFrequencyBandsTests.cs create mode 100644 tests/GameCult.Geometry.Planetary.Tests/GameCult.Geometry.Planetary.Tests.csproj create mode 100644 tests/GameCult.Geometry.Planetary.Tests/PlanetaryFieldTests.cs create mode 100644 tests/GameCult.Geometry.Planetary.Tests/PlanetaryGpuPageTests.cs create mode 100644 tests/GameCult.Geometry.Planetary.Tests/PlanetaryMapTileEncodingTests.cs create mode 100644 tests/GameCult.Geometry.Planetary.Tests/PlanetaryPageTests.cs create mode 100644 tests/GameCult.Geometry.Planetary.Tests/PlanetaryPatchTests.cs create mode 100644 tests/GameCult.Geometry.Planetary.Tests/PlanetaryProjectionTests.cs create mode 100644 tests/GameCult.Geometry.Planetary.Tests/PlanetaryQueryTests.cs create mode 100644 tests/GameCult.Geometry.Planetary.Tests/PlanetaryRadialRefinementTests.cs create mode 100644 tests/GameCult.Geometry.Planetary.Tests/PlanetaryResidencyTests.cs create mode 100644 tests/GameCult.Geometry.Planetary.Tests/PlanetaryTopologyTests.cs create mode 100644 tests/GameCult.Geometry.Planetary.Tests/SphericalErosionTests.cs create mode 100644 tests/GameCult.Geometry.Tests/GeometryShaderOwnershipTests.cs create mode 100644 tools/GameCult.Geometry.PlanetaryTileFixture/GameCult.Geometry.PlanetaryTileFixture.csproj create mode 100644 tools/GameCult.Geometry.PlanetaryTileFixture/Program.cs create mode 100644 unity/org.gamecult.geometry/Runtime/GameCult.Geometry.Unity.asmdef create mode 100644 unity/org.gamecult.geometry/Runtime/PlanetaryPageUpload.cs create mode 100644 unity/org.gamecult.geometry/Runtime/PlanetaryPageUpload.cs.meta create mode 100644 unity/org.gamecult.geometry/Runtime/PlanetaryPatchMeshAdapter.cs create mode 100644 unity/org.gamecult.geometry/Runtime/PlanetaryPatchMeshAdapter.cs.meta create mode 100644 unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCult.Geometry.PlanetaryViewer.Editor.asmdef create mode 100644 unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCultGeometryPlanetaryViewerMenu.cs create mode 100644 unity/org.gamecult.geometry/Samples~/PlanetaryViewer/README.md create mode 100644 unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCult.Geometry.PlanetaryViewer.asmdef create mode 100644 unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.cs create mode 100644 unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.shader diff --git a/.gitignore b/.gitignore index 2e7fdf6c..2fdb5151 100644 --- a/.gitignore +++ b/.gitignore @@ -209,6 +209,14 @@ PublishScripts/ !/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 @@ -244,6 +252,8 @@ _pkginfo.txt ClientBin/ ~$* *~ +!/unity/org.gamecult.geometry/Samples~/ +!/unity/org.gamecult.geometry/Samples~/** *.dbmdl *.dbproj.schemaview *.jfm diff --git a/CultLib.sln b/CultLib.sln index f6ae25e9..0319071d 100644 --- a/CultLib.sln +++ b/CultLib.sln @@ -29,6 +29,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Geometry", "src\Ga EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Geometry.Tests", "tests\GameCult.Geometry.Tests\GameCult.Geometry.Tests.csproj", "{C95A9A21-34FA-4D82-BE5B-88FBDA836D31}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Geometry.Planetary.Tests", "tests\GameCult.Geometry.Planetary.Tests\GameCult.Geometry.Planetary.Tests.csproj", "{CE447820-B407-48DE-8957-F373E032C4AE}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Mesh.Tests", "tests\GameCult.Mesh.Tests\GameCult.Mesh.Tests.csproj", "{F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}" EndProject Global @@ -173,6 +175,18 @@ Global {C95A9A21-34FA-4D82-BE5B-88FBDA836D31}.Release|x64.Build.0 = Release|Any CPU {C95A9A21-34FA-4D82-BE5B-88FBDA836D31}.Release|x86.ActiveCfg = Release|Any CPU {C95A9A21-34FA-4D82-BE5B-88FBDA836D31}.Release|x86.Build.0 = Release|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Debug|x64.ActiveCfg = Debug|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Debug|x64.Build.0 = Debug|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Debug|x86.ActiveCfg = Debug|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Debug|x86.Build.0 = Debug|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Release|Any CPU.Build.0 = Release|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Release|x64.ActiveCfg = Release|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Release|x64.Build.0 = Release|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Release|x86.ActiveCfg = Release|Any CPU + {CE447820-B407-48DE-8957-F373E032C4AE}.Release|x86.Build.0 = Release|Any CPU {F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}.Debug|Any CPU.Build.0 = Debug|Any CPU {F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -201,6 +215,7 @@ Global {EB15435B-7147-4BD9-BB9C-B0A14318D3D5} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} {94C086DA-3359-424B-9688-29A198806481} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} {C95A9A21-34FA-4D82-BE5B-88FBDA836D31} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} + {CE447820-B407-48DE-8957-F373E032C4AE} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} {F8A5060A-FB0B-4B8B-AE06-5147BB4CA127} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} EndGlobalSection EndGlobal diff --git a/Directory.Build.props b/Directory.Build.props index 519e7a93..e38b66bc 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -15,6 +15,8 @@ © Meta Vens and contributors true 1.0.3 + $(MSBuildThisFileDirectory)..\CultMath\.tools\local-feed + $(RestoreAdditionalProjectSources);$(CultMathCandidateFeed) true diff --git a/docs/cultmath-gamecult-geometry-migration-progress.md b/docs/cultmath-gamecult-geometry-migration-progress.md index 3fefd608..3a882a81 100644 --- a/docs/cultmath-gamecult-geometry-migration-progress.md +++ b/docs/cultmath-gamecult-geometry-migration-progress.md @@ -57,7 +57,7 @@ Demotions: | 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 | In progress | Transfer C#/tests first, then shaders and runtime projections; delete CultMath owners in the same stage | +| 3. Planetary transfer | Complete | Managed, HLSL, CMPT, Unity, web, tool, docs, tests, and package authority moved; CultMath owners deleted | | 4. Mine `vg-csg` | Pending | Requires v1 fixture capture and target Rust package seam | | 5. CultCache/CultNet/CultMesh integration | Pending | Requires current schemas and runtime packages | | 6. Consumer cutover/release | Pending | Requires local candidate feed and downstream passes | @@ -199,11 +199,38 @@ a broader stage gate. 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. + ## Next Actions -1. Establish the Geometry-owned planetary parity harness and move the 13 C# - source/test files while deleting the matching CultMath owners and `rect`. -2. Transfer and rename planetary/erosion HLSL ownership, then remove the - implicit geometry includes and bodies from `CultMath.hlsl`. -3. Transfer the CMPT tool/web decoder, Unity adapters/sample, docs, and license - notices; inspect both packages and run the complete negative gate. +1. Mine convex splitting, brush/CSG tree, domain lowering, dirty-frontier, + selected-cut, and mesh assembly into `gamecult-geometry-rs` in dependency + order, preserving the pinned v1 evidence while emitting v2. +2. Remove `bevy_math` from the public Rust boundary and port earned correctness + and performance fixtures before deleting VibeGeometry build authority. +3. Connect the completed Rust runtime to the v2 CultCache/CultNet/CultMesh path. diff --git a/package.json b/package.json index f1b995a2..a6803b6b 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "workspaces": [ "packages/cultcache-ts", "packages/cultnet-ts", - "packages/cultmesh-ts" + "packages/cultmesh-ts", + "packages/gamecult-geometry-ts" ], "scripts": { "build:ts": "npm --workspaces run build", diff --git a/packages/gamecult-geometry-ts/package.json b/packages/gamecult-geometry-ts/package.json new file mode 100644 index 00000000..ea7f8a32 --- /dev/null +++ b/packages/gamecult-geometry-ts/package.json @@ -0,0 +1,10 @@ +{ + "name": "@gamecult/geometry", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "node --no-warnings --experimental-strip-types --check src/planetary-tile.ts", + "test": "node --no-warnings --experimental-strip-types test/planetary-tile.test.mjs" + } +} diff --git a/packages/gamecult-geometry-ts/src/planetary-tile.ts b/packages/gamecult-geometry-ts/src/planetary-tile.ts new file mode 100644 index 00000000..82849c82 --- /dev/null +++ b/packages/gamecult-geometry-ts/src/planetary-tile.ts @@ -0,0 +1,47 @@ +export const GAMECULT_GEOMETRY_PLANETARY_TILE_VERSION = 1; + +export interface PlanetaryTileHeader { + fieldVersion: bigint; projectionVersion: number; layerId: number; projectionKind: number; + centerLongitude: number; centerLatitude: number; projectionScale: number; + level: number; x: number; y: number; interiorSize: number; borderSize: number; + footprintMeters: number; maximumUnresolvedHeight: number; +} +export interface PlanetaryTileSample { + direction: readonly [number, number, number]; radius: number; radialDisplacement: number; + tangentGradient: readonly [number, number, number]; surfaceNormal: readonly [number, number, number]; + slope: number; ridge: number; gully: number; finestResolvedWavelength: number; unresolvedHeightBound: number; +} +export interface PlanetarySurfaceTile { header: PlanetaryTileHeader; storageSize: number; samples: readonly (PlanetaryTileSample | null)[]; } + +export function decodePlanetarySurfaceTile(bytes: ArrayBuffer): PlanetarySurfaceTile { + const view = new DataView(bytes); let offset = 0; + const u8 = () => view.getUint8(offset++); + const u32 = () => { const value = view.getUint32(offset, true); offset += 4; return value; }; + const i32 = () => { const value = view.getInt32(offset, true); offset += 4; return value; }; + const f32 = () => { const value = view.getFloat32(offset, true); offset += 4; return value; }; + const f64 = () => { const value = view.getFloat64(offset, true); offset += 8; return value; }; + const u64 = () => { const value = view.getBigUint64(offset, true); offset += 8; return value; }; + const vector3 = (): [number, number, number] => [f32(), f32(), f32()]; + const requireRemaining = (count: number) => { if (offset + count > view.byteLength) throw new Error("Truncated GameCult.Geometry planetary tile"); }; + requireRemaining(8); + if (String.fromCharCode(u8(), u8(), u8(), u8()) !== "CMPT") throw new Error("Not a GameCult.Geometry planetary tile"); + const version = u32(); + if (version !== GAMECULT_GEOMETRY_PLANETARY_TILE_VERSION) throw new Error(`Unsupported GameCult.Geometry planetary tile version ${version}`); + requireRemaining(76); + const header: PlanetaryTileHeader = { + fieldVersion: u64(), projectionVersion: u32(), layerId: u32(), projectionKind: i32(), + centerLongitude: f64(), centerLatitude: f64(), projectionScale: f64(), + level: i32(), x: i32(), y: i32(), interiorSize: i32(), borderSize: i32(), + footprintMeters: f32(), maximumUnresolvedHeight: f32(), + }; + const count = i32(); const storageSize = header.interiorSize + header.borderSize * 2; + if (count !== storageSize * storageSize) throw new Error("GameCult.Geometry planetary tile sample count does not match layout"); + const samples: (PlanetaryTileSample | null)[] = new Array(count); + for (let index = 0; index < count; index++) { + requireRemaining(1); if (u8() === 0) { samples[index] = null; continue; } + requireRemaining(64); + samples[index] = { direction: vector3(), radius: f32(), radialDisplacement: f32(), tangentGradient: vector3(), surfaceNormal: vector3(), slope: f32(), ridge: f32(), gully: f32(), finestResolvedWavelength: f32(), unresolvedHeightBound: f32() }; + } + if (offset !== view.byteLength) throw new Error("GameCult.Geometry planetary tile has trailing bytes"); + return { header, storageSize, samples }; +} diff --git a/packages/gamecult-geometry-ts/test/planetary-tile.cross-runtime.test.mjs b/packages/gamecult-geometry-ts/test/planetary-tile.cross-runtime.test.mjs new file mode 100644 index 00000000..04243ce8 --- /dev/null +++ b/packages/gamecult-geometry-ts/test/planetary-tile.cross-runtime.test.mjs @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { decodePlanetarySurfaceTile } from "../src/planetary-tile.ts"; +if (process.argv.length !== 3) throw new Error("Expected a generated CMPT fixture path"); +const bytes = await readFile(process.argv[2]); +assert.equal(bytes.length, 1221); +assert.equal(createHash("sha256").update(bytes).digest("hex"), "8b2bc46e4123b8ac8936b43f8d08a34e3f2e74f38d0bb3f101cf3728cd201962"); +const tile = decodePlanetarySurfaceTile(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)); +assert.equal(tile.header.projectionVersion, 3); assert.equal(tile.header.layerId, 29); +assert.equal(tile.header.projectionKind, 2); assert.equal(tile.header.interiorSize, 5); +assert.equal(tile.header.borderSize, 1); assert.equal(tile.storageSize, 7); assert.equal(tile.samples.length, 49); +assert.ok(tile.samples.some(sample => sample !== null)); +for (const sample of tile.samples) if (sample !== null) { + assert.ok(Number.isFinite(sample.radialDisplacement)); assert.ok(Number.isFinite(sample.unresolvedHeightBound)); +} diff --git a/packages/gamecult-geometry-ts/test/planetary-tile.test.mjs b/packages/gamecult-geometry-ts/test/planetary-tile.test.mjs new file mode 100644 index 00000000..c5c27027 --- /dev/null +++ b/packages/gamecult-geometry-ts/test/planetary-tile.test.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import { decodePlanetarySurfaceTile } from "../src/planetary-tile.ts"; +const bytes = new ArrayBuffer(152); const view = new DataView(bytes); let offset = 0; +const u8 = value => view.setUint8(offset++, value); +const u32 = value => { view.setUint32(offset, value, true); offset += 4; }; +const i32 = value => { view.setInt32(offset, value, true); offset += 4; }; +const u64 = value => { view.setBigUint64(offset, value, true); offset += 8; }; +const f32 = value => { view.setFloat32(offset, value, true); offset += 4; }; +const f64 = value => { view.setFloat64(offset, value, true); offset += 8; }; +for (const character of "CMPT") u8(character.charCodeAt(0)); +u32(1); u64(0x1234n); u32(2); u32(17); i32(0); f64(0); f64(0); f64(1); +i32(0); i32(0); i32(0); i32(2); i32(0); f32(10); f32(0.5); i32(4); u8(1); +for (const value of [1, 0, 0]) f32(value); f32(8.4); f32(0.25); +for (const value of [0, 0.1, 0]) f32(value); +for (const value of [0.99, -0.1, 0]) f32(value); +for (const value of [0.1, 0.8, 0.2, 2.5, 0.05]) f32(value); +u8(0); u8(0); u8(0); assert.equal(offset, bytes.byteLength); +const tile = decodePlanetarySurfaceTile(bytes); +assert.equal(tile.header.fieldVersion, 0x1234n); assert.equal(tile.header.layerId, 17); +assert.equal(tile.storageSize, 2); assert.equal(tile.samples.length, 4); +assert.equal(tile.samples[0]?.radialDisplacement, 0.25); assert.equal(tile.samples[1], null); +assert.throws(() => decodePlanetarySurfaceTile(bytes.slice(0, -1)), /Truncated/); +const trailing = new Uint8Array(bytes.byteLength + 1); trailing.set(new Uint8Array(bytes)); +assert.throws(() => decodePlanetarySurfaceTile(trailing.buffer), /trailing/); diff --git a/scripts/build-geometry-unity-package.ps1 b/scripts/build-geometry-unity-package.ps1 index c5665b1e..8c1b8b1f 100644 --- a/scripts/build-geometry-unity-package.ps1 +++ b/scripts/build-geometry-unity-package.ps1 @@ -22,7 +22,7 @@ $candidateFeed = if ([System.IO.Path]::IsPathRooted($CultMathCandidateFeed)) { } $pluginRoot = Join-Path $outputRoot "Runtime\Plugins" -if (-not (Test-Path -LiteralPath (Join-Path $candidateFeed "CultMath.0.1.0-geometry-migration.1.nupkg"))) { +if (-not (Test-Path -LiteralPath (Join-Path $candidateFeed "CultMath.0.1.0-geometry-migration.2.nupkg"))) { throw "CultMath migration candidate is missing from $candidateFeed. Pack CultMath before staging Geometry." } @@ -47,6 +47,18 @@ 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 Copy-Item -LiteralPath (Join-Path $templateRoot "Runtime\GameCult.Geometry.asmdef") -Destination (Join-Path $outputRoot "Runtime") +Copy-Item -LiteralPath (Join-Path $templateRoot "Runtime\GameCult.Geometry.Unity.asmdef") -Destination (Join-Path $outputRoot "Runtime") +Copy-Item -LiteralPath (Join-Path $templateRoot "Runtime\PlanetaryPatchMeshAdapter.cs") -Destination (Join-Path $outputRoot "Runtime") +Copy-Item -LiteralPath (Join-Path $templateRoot "Runtime\PlanetaryPatchMeshAdapter.cs.meta") -Destination (Join-Path $outputRoot "Runtime") +Copy-Item -LiteralPath (Join-Path $templateRoot "Runtime\PlanetaryPageUpload.cs") -Destination (Join-Path $outputRoot "Runtime") +Copy-Item -LiteralPath (Join-Path $templateRoot "Runtime\PlanetaryPageUpload.cs.meta") -Destination (Join-Path $outputRoot "Runtime") +Copy-Item -LiteralPath (Join-Path $templateRoot "Samples~") -Destination $outputRoot -Recurse +$shaderOutputRoot = Join-Path $outputRoot "Shaders" +New-Item -ItemType Directory -Force -Path $shaderOutputRoot | Out-Null +Copy-Item -Path (Join-Path $repoRoot "src\GameCult.Geometry\Shaders\*.hlsl") -Destination $shaderOutputRoot +Copy-Item -Path (Join-Path $repoRoot "src\GameCult.Geometry\Shaders\*.hlsl.meta") -Destination $shaderOutputRoot -ErrorAction SilentlyContinue +Copy-Item -LiteralPath (Join-Path $repoRoot "src\GameCult.Geometry\LICENSES.md") -Destination $outputRoot +Copy-Item -LiteralPath (Join-Path $repoRoot "src\GameCult.Geometry\THIRD-PARTY-NOTICES.md") -Destination $outputRoot $geometryAssembly = Join-Path $publishRoot "GameCult.Geometry.dll" if (-not (Test-Path -LiteralPath $geometryAssembly)) { diff --git a/scripts/test-geometry-planetary-tile.ps1 b/scripts/test-geometry-planetary-tile.ps1 new file mode 100644 index 00000000..59cc859b --- /dev/null +++ b/scripts/test-geometry-planetary-tile.ps1 @@ -0,0 +1,9 @@ +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +$fixture = Join-Path $root ".tools\planetary-tile\fixture.cmpt" +dotnet run --project (Join-Path $root "tools\GameCult.Geometry.PlanetaryTileFixture\GameCult.Geometry.PlanetaryTileFixture.csproj") -- $fixture +if ($LASTEXITCODE -ne 0) { throw "Planetary tile fixture generation failed." } +node --no-warnings --experimental-strip-types (Join-Path $root "packages\gamecult-geometry-ts\test\planetary-tile.cross-runtime.test.mjs") $fixture +if ($LASTEXITCODE -ne 0) { throw "Planetary tile web decoding failed." } +node --no-warnings --experimental-strip-types (Join-Path $root "packages\gamecult-geometry-ts\test\planetary-tile.test.mjs") +if ($LASTEXITCODE -ne 0) { throw "Planetary tile decoder unit test failed." } diff --git a/src/GameCult.Geometry/GameCult.Geometry.csproj b/src/GameCult.Geometry/GameCult.Geometry.csproj index 22c991ab..32d72d0a 100644 --- a/src/GameCult.Geometry/GameCult.Geometry.csproj +++ b/src/GameCult.Geometry/GameCult.Geometry.csproj @@ -1,13 +1,13 @@ - Typed CultCache geometry domain and chunk artifact documents for VibeGeometry and Fensalir + Spatial values, planetary geometry, queries, and typed geometry state built on CultMath primitives GameCult.Geometry - game;geometry;csg;lod;cultcache;cultmesh;vibegeometry;fensalir + game;geometry;planetary;csg;lod;cultcache;cultmesh README.md + + LICENSES.md $(NoWarn);1591 - 0.1.0-geometry-migration.1 - $(MSBuildThisFileDirectory)..\..\..\CultMath\.tools\local-feed - $(RestoreAdditionalProjectSources);$(CultMathCandidateFeed) + 0.1.0-geometry-migration.2 @@ -20,5 +20,9 @@ + + + + diff --git a/src/GameCult.Geometry/IsExternalInit.cs b/src/GameCult.Geometry/IsExternalInit.cs new file mode 100644 index 00000000..b33e9e1d --- /dev/null +++ b/src/GameCult.Geometry/IsExternalInit.cs @@ -0,0 +1,7 @@ +#if NETSTANDARD2_1 +namespace System.Runtime.CompilerServices; + +internal static class IsExternalInit +{ +} +#endif diff --git a/src/GameCult.Geometry/LICENSES.md b/src/GameCult.Geometry/LICENSES.md new file mode 100644 index 00000000..67455b87 --- /dev/null +++ b/src/GameCult.Geometry/LICENSES.md @@ -0,0 +1,13 @@ +# GameCult.Geometry licenses + +GameCult.Geometry is a mixed-license package. License follows file ownership; +the package as a whole is not relicensed under one blanket expression. + +- CultLib-authored integration, persistence, schema, and packaging files are + licensed under the MIT License published by the CultLib repository. +- Files under `Planetary/` and `Shaders/` transferred from CultMath remain + licensed under the Mozilla Public License 2.0. Their source notices and + provenance are preserved in `THIRD-PARTY-NOTICES.md`. + +Downstream redistributors must preserve the applicable notices for the files +they redistribute. diff --git a/src/GameCult.Geometry/Planetary/AdvancedErosionFilter.cs b/src/GameCult.Geometry/Planetary/AdvancedErosionFilter.cs new file mode 100644 index 00000000..487cdc87 --- /dev/null +++ b/src/GameCult.Geometry/Planetary/AdvancedErosionFilter.cs @@ -0,0 +1,141 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. +// +// Mathematical procedure adapted from Rune Skovbo Johansen's Advanced Terrain +// Erosion Filter (2025): https://www.shadertoy.com/view/wXcfWn + +using CultMath; + +namespace GameCult.Geometry; + +public readonly record struct AdvancedErosionParameters( + float Scale, + float Strength, + float GullyWeight, + float Detail, + float4 Rounding, + float4 Onset, + float2 AssumedSlope, + float CellScale, + float Normalization, + int Octaves, + float Lacunarity, + float Gain) +{ + public static AdvancedErosionParameters Default => new( + 0.15f, 0.22f, 0.5f, 1.5f, + new float4(0.1f, 0.0f, 0.1f, 2.0f), + new float4(1.25f, 1.25f, 2.8f, 1.5f), + new float2(0.7f, 1.0f), + 0.7f, 0.5f, 5, 2.0f, 0.5f); +} + +public readonly record struct AdvancedErosionResult(float3 Delta, float Magnitude, float RidgeMap, float FadeTarget); + +public static class AdvancedErosionFilter +{ + public static AdvancedErosionResult Sample(float2 position, float3 baseHeightAndSlope, float fadeTarget, AdvancedErosionParameters p) + => Sample(position, baseHeightAndSlope, fadeTarget, p, new ErosionBandSelection(p.Octaves, 1.0f, 0.0f, 0.0f)); + + public static AdvancedErosionResult Sample(float2 position, float3 baseHeightAndSlope, float fadeTarget, AdvancedErosionParameters p, ErosionBandSelection band) + { + var strength = p.Strength * p.Scale; + fadeTarget = math.clamp(fadeTarget, -1.0f, 1.0f); + var input = baseHeightAndSlope; + var heightAndSlope = input; + var frequency = 1.0f / math.max(p.Scale * p.CellScale, 1.0e-10f); + var slope = new float2(heightAndSlope.y, heightAndSlope.z); + var slopeLength = math.max(math.length(slope), 1.0e-10f); + var magnitude = 0.0f; + var roundingMultiplier = 1.0f; + + var inputRounding = math.lerp(p.Rounding.y, p.Rounding.x, math.saturate(fadeTarget + 0.5f)) * p.Rounding.z; + var combinedMask = EaseOut(SmoothStart(slopeLength * p.Onset.x, inputRounding * p.Onset.x)); + var ridgeMask = EaseOut(slopeLength * p.Onset.z); + var ridgeFade = fadeTarget; + var gullySlope = math.lerp(slope, slope / slopeLength * p.AssumedSlope.x, p.AssumedSlope.y); + + var octaves = math.min(math.min(math.max(p.Octaves, 0), 8), math.max(band.ActiveOctaves, 0)); + for (var octave = 0; octave < octaves; octave++) + { + var phacelle = Phacelle(position * frequency, SafeNormalize(gullySlope), p.CellScale, 0.25f, p.Normalization); + var derivativeDirection = new float2(phacelle.z, phacelle.w) * -frequency; + var sloping = math.abs(phacelle.y); + gullySlope += math.sign(phacelle.y) * derivativeDirection * strength * p.GullyWeight; + + var gullies = new float3(phacelle.x, phacelle.y * derivativeDirection.x, phacelle.y * derivativeDirection.y); + var faded = math.lerp(new float3(fadeTarget, 0.0f, 0.0f), gullies * p.GullyWeight, combinedMask); + var octaveWeight = octave == octaves - 1 ? math.saturate(band.FinalOctaveWeight) : 1.0f; + heightAndSlope += faded * strength * octaveWeight; + magnitude += strength * octaveWeight; + fadeTarget = faded.x; + + var octaveRounding = math.lerp(p.Rounding.y, p.Rounding.x, math.saturate(phacelle.x + 0.5f)) * roundingMultiplier; + var newMask = EaseOut(SmoothStart(sloping * p.Onset.y, octaveRounding * p.Onset.y)); + combinedMask = PowInverse(combinedMask, p.Detail) * newMask; + + ridgeFade = math.lerp(ridgeFade, math.lerp(ridgeFade, gullies.x, ridgeMask), octaveWeight); + ridgeMask *= math.lerp(1.0f, EaseOut(sloping * p.Onset.w), octaveWeight); + strength *= p.Gain; + frequency *= p.Lacunarity; + roundingMultiplier *= p.Rounding.w; + } + + return new AdvancedErosionResult(heightAndSlope - input, magnitude, ridgeFade * (1.0f - ridgeMask), fadeTarget); + } + + public static float4 Phacelle(float2 position, float2 normalDirection, float frequency, float offsetCycles, float normalization) + { + var sideDirection = new float2(-normalDirection.y, normalDirection.x) * frequency * math.TAU; + var offset = offsetCycles * math.TAU; + var cell = math.floor(position); + var local = math.frac(position); + var phase = float2.zero; + var weightSum = 0.0f; + + for (var x = -1; x <= 2; x++) + for (var y = -1; y <= 2; y++) + { + var gridOffset = new float2(x, y); + var gridPoint = cell + gridOffset; + var randomOffset = Hash2(gridPoint) * 0.5f; + var delta = local - gridOffset - randomOffset; + var weight = math.max(0.0f, math.exp(-math.dot(delta, delta) * 2.0f) - 0.01111f); + weightSum += weight; + var wave = math.dot(delta, sideDirection) + offset; + phase += new float2(math.cos(wave), math.sin(wave)) * weight; + } + + var interpolated = phase / math.max(weightSum, 1.0e-20f); + var magnitude = math.max(1.0f - math.saturate(normalization), math.length(interpolated)); + var normalized = interpolated / math.max(magnitude, 1.0e-20f); + return new float4(normalized, sideDirection.x, sideDirection.y); + } + + private static float2 Hash2(float2 input) + { + var x = (int)input.x; + var y = (int)input.y; + return new float2(HashUnit(x, y, 0x68bc21ebu), HashUnit(x, y, 0x02e5be93u)) * 2.0f - float2.one; + } + + private static float HashUnit(int x, int y, uint salt) + { + var value = unchecked((uint)x * 0x8da6b343u ^ (uint)y * 0xd8163841u ^ salt); + value ^= value >> 16; value *= 0x7feb352du; value ^= value >> 15; value *= 0x846ca68bu; value ^= value >> 16; + return (value & 0x00ffffffu) * (1.0f / 16777216.0f); + } + + private static float2 SafeNormalize(float2 value) + { + var length = math.length(value); + return math.abs(length) > 1.0e-10f ? value / length : value; + } + + private static float PowInverse(float value, float power) => 1.0f - math.pow(1.0f - math.saturate(value), power); + private static float EaseOut(float value) { var inverse = 1.0f - math.saturate(value); return 1.0f - inverse * inverse; } + private static float SmoothStart(float value, float smoothing) => smoothing <= 1.0e-10f || value >= smoothing + ? value - 0.5f * smoothing + : 0.5f * value * value / smoothing; +} diff --git a/src/GameCult.Geometry/Planetary/ErosionFrequencyBands.cs b/src/GameCult.Geometry/Planetary/ErosionFrequencyBands.cs new file mode 100644 index 00000000..8cadbf11 --- /dev/null +++ b/src/GameCult.Geometry/Planetary/ErosionFrequencyBands.cs @@ -0,0 +1,63 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +using CultMath; + +namespace GameCult.Geometry; + +public readonly record struct ErosionBandSelection( + int ActiveOctaves, + float FinalOctaveWeight, + float FinestIncludedWavelength, + float UnresolvedHeightBound) +{ + public bool HasSamples => ActiveOctaves > 0; +} + +public static class ErosionFrequencyBands +{ + public static ErosionBandSelection Select( + float baseWavelength, + float sampleSpacing, + int maximumOctaves, + float lacunarity, + float baseAmplitude, + float gain, + float transitionRatio = 2.0f) + { + baseWavelength = math.max(baseWavelength, 1.0e-6f); + sampleSpacing = math.max(sampleSpacing, 0.0f); + lacunarity = math.max(lacunarity, 1.01f); + transitionRatio = math.max(transitionRatio, 1.01f); + maximumOctaves = math.min(math.max(maximumOctaves, 0), 16); + var nyquistWavelength = sampleSpacing * 2.0f; + var wavelength = baseWavelength; + var active = 0; + var finalWeight = 0.0f; + var finest = 0.0f; + + for (var octave = 0; octave < maximumOctaves; octave++) + { + var weight = nyquistWavelength <= 0.0f + ? 1.0f + : math.smoothstep(nyquistWavelength, nyquistWavelength * transitionRatio, wavelength); + if (weight <= 0.0f) break; + active++; + finalWeight = weight; + finest = wavelength; + wavelength /= lacunarity; + } + + var omittedAmplitude = 0.0f; + var amplitude = math.abs(baseAmplitude); + for (var octave = 0; octave < maximumOctaves; octave++) + { + if (octave >= active) omittedAmplitude += amplitude; + else if (octave == active - 1) omittedAmplitude += amplitude * (1.0f - finalWeight); + amplitude *= math.abs(gain); + } + + return new ErosionBandSelection(active, active > 0 ? finalWeight : 0.0f, finest, omittedAmplitude); + } +} diff --git a/src/GameCult.Geometry/Planetary/PlanetaryField.cs b/src/GameCult.Geometry/Planetary/PlanetaryField.cs new file mode 100644 index 00000000..21f4f805 --- /dev/null +++ b/src/GameCult.Geometry/Planetary/PlanetaryField.cs @@ -0,0 +1,220 @@ +using System; +using CultMath; + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +namespace GameCult.Geometry; + +public readonly record struct PlanetaryQueryScale(float FootprintMeters, float MaximumUnresolvedHeight) +{ + public static PlanetaryQueryScale AtFootprint(float footprintMeters, float maximumUnresolvedHeight = float.PositiveInfinity) + => new PlanetaryQueryScale(footprintMeters, maximumUnresolvedHeight).Validate(); + + public PlanetaryQueryScale Validate() + { + if (!float.IsFinite(FootprintMeters) || FootprintMeters < 0) throw new ArgumentOutOfRangeException(nameof(FootprintMeters)); + if (float.IsNaN(MaximumUnresolvedHeight) || MaximumUnresolvedHeight < 0) throw new ArgumentOutOfRangeException(nameof(MaximumUnresolvedHeight)); + return this; + } +} + +public readonly record struct PlanetaryFieldDefinition( + ulong FieldVersion, + float Radius, + int Seed, + AdvancedErosionParameters Erosion) +{ + public static PlanetaryFieldDefinition Create( + ulong baseFieldVersion, + float radius, + int seed, + AdvancedErosionParameters erosion) + => new PlanetaryFieldDefinition(PlanetaryFieldIdentity.Compute(baseFieldVersion, radius, seed, erosion), radius, seed, erosion).Validate(); + + public PlanetaryFieldDefinition Validate() + { + if (FieldVersion == 0) throw new ArgumentOutOfRangeException(nameof(FieldVersion)); + if (!float.IsFinite(Radius) || Radius <= 0) throw new ArgumentOutOfRangeException(nameof(Radius)); + if (!float.IsFinite(Erosion.Scale) || Erosion.Scale <= 0) throw new ArgumentOutOfRangeException(nameof(Erosion)); + return this; + } +} + +public static class PlanetaryFieldIdentity +{ + public static ulong Compute(ulong baseFieldVersion, float radius, int seed, in AdvancedErosionParameters erosion) + { + if (baseFieldVersion == 0) throw new ArgumentOutOfRangeException(nameof(baseFieldVersion)); + const ulong offset = 14695981039346656037UL; + const ulong prime = 1099511628211UL; + var hash = offset; + Add(baseFieldVersion); + Add((uint)BitConverter.SingleToInt32Bits(radius)); Add((uint)seed); + Add((uint)BitConverter.SingleToInt32Bits(erosion.Scale)); Add((uint)BitConverter.SingleToInt32Bits(erosion.Strength)); + Add((uint)BitConverter.SingleToInt32Bits(erosion.GullyWeight)); Add((uint)BitConverter.SingleToInt32Bits(erosion.Detail)); + AddFloat4(erosion.Rounding); AddFloat4(erosion.Onset); + Add((uint)BitConverter.SingleToInt32Bits(erosion.AssumedSlope.x)); Add((uint)BitConverter.SingleToInt32Bits(erosion.AssumedSlope.y)); + Add((uint)BitConverter.SingleToInt32Bits(erosion.CellScale)); Add((uint)BitConverter.SingleToInt32Bits(erosion.Normalization)); + Add((uint)erosion.Octaves); Add((uint)BitConverter.SingleToInt32Bits(erosion.Lacunarity)); Add((uint)BitConverter.SingleToInt32Bits(erosion.Gain)); + return hash == 0 ? 1 : hash; + + void Add(ulong value) => hash = unchecked((hash ^ value) * prime); + void AddFloat4(float4 value) + { + Add((uint)BitConverter.SingleToInt32Bits(value.x)); Add((uint)BitConverter.SingleToInt32Bits(value.y)); + Add((uint)BitConverter.SingleToInt32Bits(value.z)); Add((uint)BitConverter.SingleToInt32Bits(value.w)); + } + } +} + +/// +/// Application-supplied base evidence at one unit spherical direction. +/// FieldValue and FieldGradient drive erosion. RadialDisplacement and +/// RadialGradient describe the uneroded surface in metres. +/// +public readonly record struct PlanetaryBaseFieldSample( + float RadialDisplacement, + float3 RadialGradient, + float FieldValue, + float3 FieldGradient, + float FadeTarget); + +public readonly record struct PlanetarySurfaceSample( + ulong FieldVersion, + float3 UnitDirection, + float Radius, + float RadialDisplacement, + float3 TangentGradient, + float3 SurfaceNormal, + float Slope, + float Ridge, + float Gully, + float FinestResolvedWavelength, + float UnresolvedHeightBound) +{ + public float SurfaceRadius => Radius + RadialDisplacement; + public float3 PlanetLocalPosition => UnitDirection * SurfaceRadius; + public bool Meets(PlanetaryQueryScale scale) => UnresolvedHeightBound <= scale.MaximumUnresolvedHeight; +} + +public static class PlanetaryField +{ + public static PlanetarySurfaceSample Sample( + in PlanetaryFieldDefinition definition, + float3 unitDirection, + in PlanetaryBaseFieldSample baseSample, + in PlanetaryQueryScale scale) + { + definition.Validate(); + scale.Validate(); + PlanetaryTopology.ValidateDirection(unitDirection); + PlanetaryTopology.ValidateFinite(baseSample.RadialGradient, nameof(baseSample)); + PlanetaryTopology.ValidateFinite(baseSample.FieldGradient, nameof(baseSample)); + if (!float.IsFinite(baseSample.RadialDisplacement) || !float.IsFinite(baseSample.FieldValue) || !float.IsFinite(baseSample.FadeTarget)) + throw new ArgumentOutOfRangeException(nameof(baseSample)); + + var direction = math.normalize(unitDirection); + var radialGradient = Tangent(direction, baseSample.RadialGradient); + var fieldGradient = Tangent(direction, baseSample.FieldGradient); + var p = definition.Erosion; + var band = ErosionFrequencyBands.Select( + p.Scale * p.CellScale, + scale.FootprintMeters, + p.Octaves, + p.Lacunarity, + p.Strength * p.Scale, + p.Gain); + + var powers = new float3( + Pow4(MathF.Abs(direction.x)), + Pow4(MathF.Abs(direction.y)), + Pow4(MathF.Abs(direction.z))); + var powerSum = math.max(powers.x + powers.y + powers.z, 1.0e-6f); + var weights = powers / powerSum; + var world = direction * definition.Radius; + var seed = definition.Seed; + var xy = AdvancedErosionFilter.Sample( + world.xy + new float2(713 + seed * 0.754877666f, -291 + seed * 0.569840296f), + new float3(baseSample.FieldValue, fieldGradient.x, fieldGradient.y), baseSample.FadeTarget, p, band); + var yz = AdvancedErosionFilter.Sample( + world.yz + new float2(-431 + seed * 0.438289027f, 887 + seed * 0.328438163f), + new float3(baseSample.FieldValue, fieldGradient.y, fieldGradient.z), baseSample.FadeTarget, p, band); + var zx = AdvancedErosionFilter.Sample( + new float2(world.z, world.x) + new float2(197 + seed * 0.219783071f, 557 + seed * 0.145898034f), + new float3(baseSample.FieldValue, fieldGradient.z, fieldGradient.x), baseSample.FadeTarget, p, band); + + var erosionHeight = xy.Delta.x * weights.z + yz.Delta.x * weights.x + zx.Delta.x * weights.y; + var erosionGradient = new float3( + xy.Delta.y * weights.z + zx.Delta.z * weights.y, + xy.Delta.z * weights.z + yz.Delta.y * weights.x, + yz.Delta.z * weights.x + zx.Delta.y * weights.y); + var powerGradientX = Tangent(direction, new float3(4 * direction.x * direction.x * direction.x / definition.Radius, 0, 0)); + var powerGradientY = Tangent(direction, new float3(0, 4 * direction.y * direction.y * direction.y / definition.Radius, 0)); + var powerGradientZ = Tangent(direction, new float3(0, 0, 4 * direction.z * direction.z * direction.z / definition.Radius)); + var sumGradient = powerGradientX + powerGradientY + powerGradientZ; + var weightGradientX = (powerGradientX * powerSum - sumGradient * powers.x) / (powerSum * powerSum); + var weightGradientY = (powerGradientY * powerSum - sumGradient * powers.y) / (powerSum * powerSum); + var weightGradientZ = (powerGradientZ * powerSum - sumGradient * powers.z) / (powerSum * powerSum); + erosionGradient += yz.Delta.x * weightGradientX + zx.Delta.x * weightGradientY + xy.Delta.x * weightGradientZ; + var gradient = Tangent(direction, radialGradient + erosionGradient); + var ridgeEvidence = xy.RidgeMap * weights.z + yz.RidgeMap * weights.x + zx.RidgeMap * weights.y; + var ridge = math.saturate(ridgeEvidence * 0.5f + 0.5f); + var gully = math.saturate(0.5f - ridgeEvidence * 0.5f); + var displacement = baseSample.RadialDisplacement + erosionHeight; + + return new( + definition.FieldVersion, + direction, + definition.Radius, + displacement, + gradient, + PlanetaryTopology.SurfaceNormal(direction, gradient), + math.length(gradient), + ridge, + gully, + band.FinestIncludedWavelength, + band.UnresolvedHeightBound); + } + + public static void SampleBatch( + in PlanetaryFieldDefinition definition, + ReadOnlySpan directions, + in PlanetaryQueryScale scale, + TSource source, + Span destination) + where TSource : IPlanetaryBaseField + { + if (destination.Length < directions.Length) throw new ArgumentException("Destination is shorter than directions.", nameof(destination)); + for (var i = 0; i < directions.Length; i++) + { + var direction = math.normalize(directions[i]); + var baseSample = source.Sample(direction); + destination[i] = Sample(definition, direction, baseSample, scale); + } + } + + public static PlanetarySurfaceSample SamplePosition( + in PlanetaryFieldDefinition definition, + double3 planetLocalPosition, + in PlanetaryQueryScale scale, + TSource source) + where TSource : IPlanetaryBaseField + { + if (!double.IsFinite(planetLocalPosition.x) || !double.IsFinite(planetLocalPosition.y) || !double.IsFinite(planetLocalPosition.z)) + throw new ArgumentOutOfRangeException(nameof(planetLocalPosition)); + var length = Math.Sqrt(planetLocalPosition.x * planetLocalPosition.x + planetLocalPosition.y * planetLocalPosition.y + planetLocalPosition.z * planetLocalPosition.z); + if (length < 1.0e-12) throw new ArgumentOutOfRangeException(nameof(planetLocalPosition)); + var direction = new float3((float)(planetLocalPosition.x / length), (float)(planetLocalPosition.y / length), (float)(planetLocalPosition.z / length)); + return Sample(definition, direction, source.Sample(direction), scale); + } + + private static float3 Tangent(float3 direction, float3 gradient) => gradient - direction * math.dot(gradient, direction); + private static float Pow4(float value) { var square = value * value; return square * square; } +} + +public interface IPlanetaryBaseField +{ + PlanetaryBaseFieldSample Sample(float3 unitDirection); +} diff --git a/src/GameCult.Geometry/Planetary/PlanetaryGpuPages.cs b/src/GameCult.Geometry/Planetary/PlanetaryGpuPages.cs new file mode 100644 index 00000000..a6d7f4df --- /dev/null +++ b/src/GameCult.Geometry/Planetary/PlanetaryGpuPages.cs @@ -0,0 +1,60 @@ +using System; +using CultMath; + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +namespace GameCult.Geometry; + +public readonly record struct PlanetaryGpuPageInput(float4 DirectionRadius, float4 Sampling); + +public readonly record struct PlanetaryGpuPageMetadata(float4 Address, float4 Layout, float4 Bounds, float4 State); + +public readonly record struct PlanetaryGpuPageContent( + PlanetaryPageLayout Layout, + PlanetaryGpuPageInput[] Inputs, + float SampleSpacing, + float ParentSampleSpacing); + +public static class PlanetaryGpuPageBuilder +{ + public static PlanetaryGpuPageContent BuildContent(PlanetaryPageLayout layout, float radius) + { + layout.Validate(); + if (!float.IsFinite(radius) || radius <= 0) throw new ArgumentOutOfRangeException(nameof(radius)); + var spacing = PlanetaryPageSampling.NominalAngularTexelSize(layout) * radius; + var parentSpacing = layout.Tile.Level == 0 + ? 0 + : PlanetaryPageSampling.NominalAngularTexelSize(new(layout.Tile.Parent(), layout.InteriorSize, layout.BorderSize)) * radius; + var inputs = new PlanetaryGpuPageInput[layout.StorageSize * layout.StorageSize]; + for (var y = 0; y < layout.StorageSize; y++) + for (var x = 0; x < layout.StorageSize; x++) + { + var direction = PlanetaryPageSampling.Direction(layout, x, y); + inputs[y * layout.StorageSize + x] = new( + new float4(direction, radius), + new float4(spacing, parentSpacing, 0, 0)); + } + return new(layout, inputs, spacing, parentSpacing); + } + + public static PlanetaryGpuPageMetadata Metadata( + in PlanetaryGpuPageContent content, + int outputOffset, + float blend, + float4 bounds = default) + { + content.Layout.Validate(); + if (content.Inputs == null || content.Inputs.Length != content.Layout.StorageSize * content.Layout.StorageSize) + throw new ArgumentException("GPU page content does not match its layout.", nameof(content)); + if (outputOffset < 0) throw new ArgumentOutOfRangeException(nameof(outputOffset)); + if (!float.IsFinite(blend) || blend is < 0 or > 1) throw new ArgumentOutOfRangeException(nameof(blend)); + var tile = content.Layout.Tile; + return new( + new float4((int)tile.Face, tile.Level, tile.X, tile.Y), + new float4(outputOffset, content.Layout.StorageSize, content.Layout.InteriorSize, content.Layout.BorderSize), + bounds, + new float4(1, blend, PlanetaryPageSampling.NominalAngularTexelSize(content.Layout), content.SampleSpacing)); + } +} diff --git a/src/GameCult.Geometry/Planetary/PlanetaryMapTileEncoding.cs b/src/GameCult.Geometry/Planetary/PlanetaryMapTileEncoding.cs new file mode 100644 index 00000000..90c93fee --- /dev/null +++ b/src/GameCult.Geometry/Planetary/PlanetaryMapTileEncoding.cs @@ -0,0 +1,122 @@ +using System; +using System.IO; +using System.Text; +using CultMath; + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +namespace GameCult.Geometry; + +public static class PlanetaryMapTileEncoding +{ + public const uint FormatVersion = 1; + private static readonly byte[] Magic = Encoding.ASCII.GetBytes("CMPT"); + + public static void Write(Stream stream, in PlanetarySurfaceMapTile tile, bool leaveOpen = false) + { + if (stream == null || !stream.CanWrite) throw new ArgumentException("A writable stream is required.", nameof(stream)); + tile.Key.Validate(); + if (tile.Samples == null || tile.Validity == null || tile.Samples.Length != tile.Validity.Length) + throw new ArgumentException("Tile samples and validity must have equal lengths.", nameof(tile)); + var expectedCount = tile.Key.Layout.StorageSize * tile.Key.Layout.StorageSize; + if (tile.Samples.Length != expectedCount) throw new ArgumentException("Tile payload does not match its layout.", nameof(tile)); + + using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen); + writer.Write(Magic); + writer.Write(FormatVersion); + writer.Write(tile.Key.FieldVersion); + writer.Write(tile.Key.ProjectionVersion); + writer.Write(tile.Key.LayerId); + var projection = tile.Key.Layout.Projection; + writer.Write((int)projection.Kind); + writer.Write(projection.CenterLongitude); + writer.Write(projection.CenterLatitude); + writer.Write(projection.Scale); + writer.Write(tile.Key.Layout.Level); + writer.Write(tile.Key.Layout.X); + writer.Write(tile.Key.Layout.Y); + writer.Write(tile.Key.Layout.InteriorSize); + writer.Write(tile.Key.Layout.BorderSize); + writer.Write(tile.Key.QueryScale.FootprintMeters); + writer.Write(tile.Key.QueryScale.MaximumUnresolvedHeight); + writer.Write(tile.Samples.Length); + for (var i = 0; i < tile.Samples.Length; i++) + { + writer.Write(tile.Validity[i]); + if (!tile.Validity[i]) continue; + WriteSample(writer, tile.Samples[i], tile.Key.FieldVersion); + } + } + + public static PlanetarySurfaceMapTile Read(Stream stream, bool leaveOpen = false) + { + if (stream == null || !stream.CanRead) throw new ArgumentException("A readable stream is required.", nameof(stream)); + using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen); + var magic = reader.ReadBytes(Magic.Length); + if (magic.Length != Magic.Length || !magic.AsSpan().SequenceEqual(Magic)) throw new InvalidDataException("Not a CultMath planetary tile."); + var version = reader.ReadUInt32(); + if (version != FormatVersion) throw new InvalidDataException($"Unsupported planetary tile version {version}."); + var fieldVersion = reader.ReadUInt64(); + var projectionVersion = reader.ReadUInt32(); + var layerId = reader.ReadUInt32(); + var kindValue = reader.ReadInt32(); + if (!Enum.IsDefined(typeof(PlanetaryProjectionKind), kindValue)) throw new InvalidDataException("Unknown planetary projection kind."); + var projection = new PlanetaryProjectionParameters( + (PlanetaryProjectionKind)kindValue, + reader.ReadDouble(), reader.ReadDouble(), reader.ReadDouble()).Validate(); + var layout = new PlanetaryMapTileLayout( + projection, + reader.ReadInt32(), reader.ReadInt32(), reader.ReadInt32(), + reader.ReadInt32(), reader.ReadInt32()).Validate(); + var scale = new PlanetaryQueryScale(reader.ReadSingle(), reader.ReadSingle()).Validate(); + var key = new PlanetaryMapTileKey(fieldVersion, projectionVersion, layerId, layout, scale).Validate(); + var count = reader.ReadInt32(); + if (count != layout.StorageSize * layout.StorageSize) throw new InvalidDataException("Planetary tile sample count does not match layout."); + var samples = new PlanetarySurfaceSample[count]; + var validity = new bool[count]; + for (var i = 0; i < count; i++) + { + validity[i] = reader.ReadBoolean(); + if (validity[i]) samples[i] = ReadSample(reader, fieldVersion); + } + if (stream.CanSeek && stream.Position != stream.Length) throw new InvalidDataException("Planetary tile has trailing bytes."); + return new(key, samples, validity); + } + + private static void WriteSample(BinaryWriter writer, PlanetarySurfaceSample sample, ulong fieldVersion) + { + if (sample.FieldVersion != fieldVersion) throw new ArgumentException("Sample field version does not match tile identity."); + Write(writer, sample.UnitDirection); + writer.Write(sample.Radius); + writer.Write(sample.RadialDisplacement); + Write(writer, sample.TangentGradient); + Write(writer, sample.SurfaceNormal); + writer.Write(sample.Slope); + writer.Write(sample.Ridge); + writer.Write(sample.Gully); + writer.Write(sample.FinestResolvedWavelength); + writer.Write(sample.UnresolvedHeightBound); + } + + private static PlanetarySurfaceSample ReadSample(BinaryReader reader, ulong fieldVersion) + { + var direction = ReadFloat3(reader); + var radius = reader.ReadSingle(); + var displacement = reader.ReadSingle(); + var gradient = ReadFloat3(reader); + var normal = ReadFloat3(reader); + return new( + fieldVersion, direction, radius, displacement, gradient, normal, + reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), + reader.ReadSingle(), reader.ReadSingle()); + } + + private static void Write(BinaryWriter writer, float3 value) + { + writer.Write(value.x); writer.Write(value.y); writer.Write(value.z); + } + + private static float3 ReadFloat3(BinaryReader reader) => new(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()); +} diff --git a/src/GameCult.Geometry/Planetary/PlanetaryPages.cs b/src/GameCult.Geometry/Planetary/PlanetaryPages.cs new file mode 100644 index 00000000..919a6320 --- /dev/null +++ b/src/GameCult.Geometry/Planetary/PlanetaryPages.cs @@ -0,0 +1,192 @@ +using System; +using CultMath; + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +namespace GameCult.Geometry; + +public readonly record struct PlanetaryPageLayout(PlanetaryTileAddress Tile, int InteriorSize, int BorderSize) +{ + public int StorageSize => checked(InteriorSize + BorderSize * 2); + + public PlanetaryPageLayout Validate() + { + if (InteriorSize < 2) throw new ArgumentOutOfRangeException(nameof(InteriorSize)); + if (BorderSize < 0 || BorderSize >= InteriorSize) throw new ArgumentOutOfRangeException(nameof(BorderSize)); + return this; + } +} + +public readonly record struct PlanetaryPageSummary( + float MinimumHeight, + float MaximumHeight, + float MaximumSlope, + float UnresolvedHeightBound, + float AngularTexelSize); + +public readonly record struct PlanetaryPageSample(float Height, float3 TangentGradient, float Ridge, float Gully); + +public static class PlanetaryPageSampling +{ + public static float3 Direction(PlanetaryPageLayout page, int storageX, int storageY) + { + page.Validate(); + if ((uint)storageX >= (uint)page.StorageSize || (uint)storageY >= (uint)page.StorageSize) + throw new ArgumentOutOfRangeException(nameof(storageX)); + var localU = (storageX - page.BorderSize) / (double)(page.InteriorSize - 1); + var localV = (storageY - page.BorderSize) / (double)(page.InteriorSize - 1); + return DirectionAtLocal(page, localU, localV); + } + + public static float3 DirectionAtLocal(PlanetaryPageLayout page, double localU, double localV) + { + page.Validate(); + if (!double.IsFinite(localU) || !double.IsFinite(localV)) throw new ArgumentOutOfRangeException(nameof(localU)); + var count = page.Tile.AxisTileCount; + return PlanetaryTopology.Direction(new( + page.Tile.Face, + -1.0 + 2.0 * (page.Tile.X + localU) / count, + -1.0 + 2.0 * (page.Tile.Y + localV) / count)); + } + + public static float AngularTexelSize(PlanetaryPageLayout page) + { + var center = page.BorderSize + (page.InteriorSize - 1) / 2; + var a = Direction(page, center, center); + var b = Direction(page, Math.Min(center + 1, page.StorageSize - 1), center); + return MathF.Acos(math.clamp(math.dot(a, b), -1.0f, 1.0f)); + } + + public static float NominalAngularTexelSize(PlanetaryPageLayout page) + { + page.Validate(); + return MathF.PI * 0.5f / (page.Tile.AxisTileCount * (page.InteriorSize - 1)); + } + + public static PlanetaryPageSample Bilinear( + ReadOnlySpan samples, + PlanetaryPageLayout page, + float2 local) + { + page.Validate(); + if (samples.Length != page.StorageSize * page.StorageSize) throw new ArgumentException("Page sample count does not match layout.", nameof(samples)); + var texel = local * (page.InteriorSize - 1) + page.BorderSize; + var x0 = Math.Clamp((int)MathF.Floor(texel.x), 0, page.StorageSize - 1); + var y0 = Math.Clamp((int)MathF.Floor(texel.y), 0, page.StorageSize - 1); + var x1 = Math.Min(x0 + 1, page.StorageSize - 1); + var y1 = Math.Min(y0 + 1, page.StorageSize - 1); + var tx = math.frac(texel.x); + var ty = math.frac(texel.y); + var a = Lerp(samples[y0 * page.StorageSize + x0], samples[y0 * page.StorageSize + x1], tx); + var b = Lerp(samples[y1 * page.StorageSize + x0], samples[y1 * page.StorageSize + x1], tx); + return Lerp(a, b, ty); + } + + public static PlanetaryPageSummary Summarize(ReadOnlySpan samples, PlanetaryPageLayout page, float unresolvedHeightBound) + { + page.Validate(); + if (samples.Length != page.StorageSize * page.StorageSize) throw new ArgumentException("Page sample count does not match layout.", nameof(samples)); + var min = float.PositiveInfinity; + var max = float.NegativeInfinity; + var maxSlope = 0.0f; + foreach (var sample in samples) + { + min = MathF.Min(min, sample.Height); + max = MathF.Max(max, sample.Height); + maxSlope = MathF.Max(maxSlope, math.length(sample.TangentGradient)); + } + return new(min, max, maxSlope, MathF.Max(unresolvedHeightBound, 0), AngularTexelSize(page)); + } + + private static PlanetaryPageSample Lerp(PlanetaryPageSample a, PlanetaryPageSample b, float t) => new( + math.lerp(a.Height, b.Height, t), + math.lerp(a.TangentGradient, b.TangentGradient, t), + math.lerp(a.Ridge, b.Ridge, t), + math.lerp(a.Gully, b.Gully, t)); +} + +public readonly record struct PlanetaryBakedPage( + PlanetaryPageLayout Layout, + PlanetaryPageSample[] Samples, + PlanetaryPageSummary Summary); + +public static class PlanetaryPageBaker +{ + public static PlanetaryBakedPage Bake( + in PlanetaryFieldDefinition field, + PlanetaryPageLayout layout, + TSource source) + where TSource : IPlanetaryBaseField + { + layout.Validate(); field.Validate(); + var spacing = PlanetaryPageSampling.NominalAngularTexelSize(layout) * field.Radius; + var parentSpacing = layout.Tile.Level == 0 + ? 0 + : PlanetaryPageSampling.NominalAngularTexelSize(new(layout.Tile.Parent(), layout.InteriorSize, layout.BorderSize)) * field.Radius; + var samples = new PlanetaryPageSample[layout.StorageSize * layout.StorageSize]; + var unresolved = 0.0f; + for (var y = 0; y < layout.StorageSize; y++) + for (var x = 0; x < layout.StorageSize; x++) + { + var direction = PlanetaryPageSampling.Direction(layout, x, y); + var baseSample = source.Sample(direction); + var child = PlanetaryField.Sample(field, direction, baseSample, PlanetaryQueryScale.AtFootprint(spacing)); + var height = child.RadialDisplacement; + var gradient = child.TangentGradient; + if (parentSpacing > 0) + { + var parent = PlanetaryField.Sample(field, direction, baseSample, PlanetaryQueryScale.AtFootprint(parentSpacing)); + height -= parent.RadialDisplacement; + gradient -= parent.TangentGradient; + } + samples[y * layout.StorageSize + x] = new(height, gradient, child.Ridge, child.Gully); + unresolved = MathF.Max(unresolved, child.UnresolvedHeightBound); + } + return new(layout, samples, PlanetaryPageSampling.Summarize(samples, layout, unresolved)); + } +} + +public readonly record struct PlanetaryResidentPage(PlanetaryBakedPage Page, float Blend) +{ + public PlanetaryResidentPage Validate() + { + if (!float.IsFinite(Blend) || Blend is < 0 or > 1) throw new ArgumentOutOfRangeException(nameof(Blend)); + Page.Layout.Validate(); + if (Page.Samples.Length != Page.Layout.StorageSize * Page.Layout.StorageSize) throw new ArgumentException("Page payload does not match layout."); + return this; + } +} + +public static class PlanetaryPageSetSampling +{ + public static bool TrySample(ReadOnlySpan pages, float3 direction, out PlanetaryPageSample sample) + { + PlanetaryTopology.ValidateDirection(direction); + var height = 0.0f; + var gradient = float3.zero; + var ridge = 0.0f; + var gully = 0.0f; + var found = false; + var deepestLevel = -1; + foreach (var resident in pages) + { + resident.Validate(); + if (!PlanetaryTopology.TryLocalCoordinate(direction, resident.Page.Layout.Tile, out var local)) continue; + var contribution = PlanetaryPageSampling.Bilinear(resident.Page.Samples, resident.Page.Layout, local); + var blend = math.saturate(resident.Blend); + height += contribution.Height * blend; + gradient += contribution.TangentGradient * blend; + if (resident.Page.Layout.Tile.Level >= deepestLevel) + { + ridge = found ? math.lerp(ridge, contribution.Ridge, blend) : contribution.Ridge; + gully = found ? math.lerp(gully, contribution.Gully, blend) : contribution.Gully; + deepestLevel = resident.Page.Layout.Tile.Level; + } + found = true; + } + sample = new(height, gradient, ridge, gully); + return found; + } +} diff --git a/src/GameCult.Geometry/Planetary/PlanetaryPatch.cs b/src/GameCult.Geometry/Planetary/PlanetaryPatch.cs new file mode 100644 index 00000000..76a3fe92 --- /dev/null +++ b/src/GameCult.Geometry/Planetary/PlanetaryPatch.cs @@ -0,0 +1,50 @@ +using System; +using System.Linq; +using CultMath; + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +namespace GameCult.Geometry; + +public readonly record struct PlanetaryPatchVertex(float2 LocalCoordinate, float3 UnitDirection); + +public readonly record struct PlanetaryPatchMesh( + PlanetaryCubeFace Face, + int CellsPerAxis, + PlanetaryPatchVertex[] Vertices, + int[] Indices); + +public static class PlanetaryPatch +{ + public static PlanetaryPatchMesh CreateFace(PlanetaryCubeFace face, int cellsPerAxis) + { + if (!Enum.IsDefined(typeof(PlanetaryCubeFace), face)) throw new ArgumentOutOfRangeException(nameof(face)); + if (cellsPerAxis < 1 || cellsPerAxis > 4096) throw new ArgumentOutOfRangeException(nameof(cellsPerAxis)); + var axisVertices = cellsPerAxis + 1; + var vertices = new PlanetaryPatchVertex[checked(axisVertices * axisVertices)]; + for (var y = 0; y < axisVertices; y++) + for (var x = 0; x < axisVertices; x++) + { + var local = new float2(x / (float)cellsPerAxis, y / (float)cellsPerAxis); + vertices[y * axisVertices + x] = new(local, PlanetaryTopology.Direction(new(face, local.x * 2 - 1, local.y * 2 - 1))); + } + var indices = new int[checked(cellsPerAxis * cellsPerAxis * 6)]; + var write = 0; + for (var y = 0; y < cellsPerAxis; y++) + for (var x = 0; x < cellsPerAxis; x++) + { + var lowerLeft = y * axisVertices + x; + var lowerRight = lowerLeft + 1; + var upperLeft = lowerLeft + axisVertices; + var upperRight = upperLeft + 1; + indices[write++] = lowerLeft; indices[write++] = lowerRight; indices[write++] = upperRight; + indices[write++] = lowerLeft; indices[write++] = upperRight; indices[write++] = upperLeft; + } + return new(face, cellsPerAxis, vertices, indices); + } + + public static PlanetaryPatchMesh[] CreateCubeSphere(int cellsPerAxis) + => ((PlanetaryCubeFace[])Enum.GetValues(typeof(PlanetaryCubeFace))).Select(face => CreateFace(face, cellsPerAxis)).ToArray(); +} diff --git a/src/GameCult.Geometry/Planetary/PlanetaryProjection.cs b/src/GameCult.Geometry/Planetary/PlanetaryProjection.cs new file mode 100644 index 00000000..5851d367 --- /dev/null +++ b/src/GameCult.Geometry/Planetary/PlanetaryProjection.cs @@ -0,0 +1,327 @@ +using System; +using CultMath; + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +namespace GameCult.Geometry; + +public enum PlanetaryProjectionKind +{ + Equirectangular, + WebMercator, + EqualEarth, + Orthographic, + AzimuthalEquidistant, + AzimuthalEqualArea, + CubeAtlas, + LocalTangent, +} + +public readonly record struct PlanetaryProjectionParameters( + PlanetaryProjectionKind Kind, + double CenterLongitude = 0, + double CenterLatitude = 0, + double Scale = 1) +{ + public PlanetaryProjectionParameters Validate() + { + if (!double.IsFinite(CenterLongitude) || !double.IsFinite(CenterLatitude) || !double.IsFinite(Scale) || Scale <= 0) + throw new ArgumentOutOfRangeException(nameof(Scale)); + if (CenterLatitude is < -Math.PI / 2 or > Math.PI / 2) throw new ArgumentOutOfRangeException(nameof(CenterLatitude)); + return this; + } +} + +public static class PlanetaryProjection +{ + private const double EqualEarthA1 = 1.340264; + private const double EqualEarthA2 = -0.081106; + private const double EqualEarthA3 = 0.000893; + private const double EqualEarthA4 = 0.003796; + private static readonly double EqualEarthM = Math.Sqrt(3) / 2; + private static readonly double EqualEarthXMax = Math.PI / (EqualEarthM * EqualEarthA1); + private static readonly double EqualEarthYMax = EqualEarthY(Math.Asin(EqualEarthM)); + public static readonly double WebMercatorMaximumLatitude = Math.Atan(Math.Sinh(Math.PI)); + + public static bool TryForward(float3 direction, PlanetaryProjectionParameters parameters, out double2 coordinate) + { + parameters.Validate(); + PlanetaryTopology.ValidateDirection(direction); + var d = math.normalize(direction); + if (parameters.Kind == PlanetaryProjectionKind.CubeAtlas) + { + var face = PlanetaryTopology.FaceCoordinate(d); + var index = (int)face.Face; + var column = index % 3; + var row = index / 3; + coordinate = new( + (-1 + (column + (face.U + 1) * 0.5) * (2.0 / 3.0)) / parameters.Scale, + (1 - (row + (face.V + 1) * 0.5)) / parameters.Scale); + return true; + } + + var longitude = Math.Atan2(d.y, d.x); + var latitude = Math.Asin(Math.Clamp(d.z, -1, 1)); + var deltaLongitude = WrapLongitude(longitude - parameters.CenterLongitude); + var centered = CenteredForward(deltaLongitude, latitude, parameters, out coordinate); + coordinate /= parameters.Scale; + return centered; + } + + public static bool TryInverse(double2 coordinate, PlanetaryProjectionParameters parameters, out float3 direction) + { + parameters.Validate(); + if (!double.IsFinite(coordinate.x) || !double.IsFinite(coordinate.y)) + { + direction = float3.zero; + return false; + } + coordinate *= parameters.Scale; + if (parameters.Kind == PlanetaryProjectionKind.CubeAtlas) + return TryInverseCubeAtlas(coordinate, out direction); + + if (!CenteredInverse(coordinate, parameters, out var deltaLongitude, out var latitude)) + { + direction = float3.zero; + return false; + } + var longitude = WrapLongitude(deltaLongitude + parameters.CenterLongitude); + var cosLatitude = Math.Cos(latitude); + direction = math.normalize(new float3( + (float)(cosLatitude * Math.Cos(longitude)), + (float)(cosLatitude * Math.Sin(longitude)), + (float)Math.Sin(latitude))); + return true; + } + + private static bool CenteredForward(double longitude, double latitude, PlanetaryProjectionParameters p, out double2 coordinate) + { + switch (p.Kind) + { + case PlanetaryProjectionKind.Equirectangular: + coordinate = new(longitude / Math.PI, latitude / (Math.PI * 0.5)); + return true; + case PlanetaryProjectionKind.WebMercator: + if (Math.Abs(latitude) > WebMercatorMaximumLatitude) { coordinate = double2.zero; return false; } + coordinate = new(longitude / Math.PI, Math.Asinh(Math.Tan(latitude)) / Math.PI); + return true; + case PlanetaryProjectionKind.EqualEarth: + { + var theta = Math.Asin(EqualEarthM * Math.Sin(latitude)); + var theta2 = theta * theta; + var theta6 = theta2 * theta2 * theta2; + var denominator = EqualEarthM * (EqualEarthA1 + 3 * EqualEarthA2 * theta2 + theta6 * (7 * EqualEarthA3 + 9 * EqualEarthA4 * theta2)); + coordinate = new(longitude * Math.Cos(theta) / denominator / EqualEarthXMax, EqualEarthY(theta) / EqualEarthYMax); + return true; + } + case PlanetaryProjectionKind.Orthographic: + case PlanetaryProjectionKind.AzimuthalEquidistant: + case PlanetaryProjectionKind.AzimuthalEqualArea: + case PlanetaryProjectionKind.LocalTangent: + return ForwardAzimuthal(longitude, latitude, p, out coordinate); + default: + throw new ArgumentOutOfRangeException(nameof(p)); + } + } + + private static bool CenteredInverse(double2 coordinate, PlanetaryProjectionParameters p, out double longitude, out double latitude) + { + longitude = latitude = 0; + switch (p.Kind) + { + case PlanetaryProjectionKind.Equirectangular: + if (Math.Abs(coordinate.y) > 1) return false; + longitude = coordinate.x * Math.PI; latitude = coordinate.y * Math.PI * 0.5; return Math.Abs(coordinate.x) <= 1; + case PlanetaryProjectionKind.WebMercator: + if (Math.Abs(coordinate.x) > 1 || Math.Abs(coordinate.y) > 1) return false; + longitude = coordinate.x * Math.PI; latitude = Math.Atan(Math.Sinh(coordinate.y * Math.PI)); return true; + case PlanetaryProjectionKind.EqualEarth: + { + if (Math.Abs(coordinate.x) > 1 || Math.Abs(coordinate.y) > 1) return false; + var targetY = coordinate.y * EqualEarthYMax; + var theta = targetY / EqualEarthA1; + for (var i = 0; i < 8; i++) + { + var theta2 = theta * theta; + var theta6 = theta2 * theta2 * theta2; + var f = EqualEarthY(theta) - targetY; + var derivative = EqualEarthA1 + 3 * EqualEarthA2 * theta2 + theta6 * (7 * EqualEarthA3 + 9 * EqualEarthA4 * theta2); + theta -= f / derivative; + } + var sinLatitude = Math.Sin(theta) / EqualEarthM; + if (Math.Abs(sinLatitude) > 1.0000000001) return false; + latitude = Math.Asin(Math.Clamp(sinLatitude, -1, 1)); + var thetaSquared = theta * theta; + var thetaSixth = thetaSquared * thetaSquared * thetaSquared; + var denominator = EqualEarthM * (EqualEarthA1 + 3 * EqualEarthA2 * thetaSquared + thetaSixth * (7 * EqualEarthA3 + 9 * EqualEarthA4 * thetaSquared)); + longitude = coordinate.x * EqualEarthXMax * denominator / Math.Cos(theta); + return Math.Abs(longitude) <= Math.PI + 1.0e-10; + } + case PlanetaryProjectionKind.Orthographic: + case PlanetaryProjectionKind.AzimuthalEquidistant: + case PlanetaryProjectionKind.AzimuthalEqualArea: + case PlanetaryProjectionKind.LocalTangent: + return InverseAzimuthal(coordinate, p, out longitude, out latitude); + default: + throw new ArgumentOutOfRangeException(nameof(p)); + } + } + + private static bool ForwardAzimuthal(double longitude, double latitude, PlanetaryProjectionParameters p, out double2 coordinate) + { + var sin0 = Math.Sin(p.CenterLatitude); var cos0 = Math.Cos(p.CenterLatitude); + var sin = Math.Sin(latitude); var cos = Math.Cos(latitude); var cosLon = Math.Cos(longitude); + var cosC = Math.Clamp(sin0 * sin + cos0 * cos * cosLon, -1, 1); + var rawX = cos * Math.Sin(longitude); + var rawY = cos0 * sin - sin0 * cos * cosLon; + double factor; + switch (p.Kind) + { + case PlanetaryProjectionKind.Orthographic: + if (cosC < 0) { coordinate = double2.zero; return false; } + factor = 1; break; + case PlanetaryProjectionKind.AzimuthalEquidistant: + { + var c = Math.Acos(cosC); factor = c < 1.0e-12 ? 1 : c / Math.Sin(c); factor /= Math.PI; break; + } + case PlanetaryProjectionKind.AzimuthalEqualArea: + factor = Math.Sqrt(2 / Math.Max(1 + cosC, 1.0e-20)) * 0.5; break; + case PlanetaryProjectionKind.LocalTangent: + if (cosC <= 0) { coordinate = double2.zero; return false; } + factor = 1 / cosC; break; + default: throw new ArgumentOutOfRangeException(nameof(p)); + } + coordinate = new(rawX * factor, rawY * factor); + return true; + } + + private static bool InverseAzimuthal(double2 coordinate, PlanetaryProjectionParameters p, out double longitude, out double latitude) + { + longitude = latitude = 0; + var rho = Math.Sqrt(coordinate.x * coordinate.x + coordinate.y * coordinate.y); + double c; + switch (p.Kind) + { + case PlanetaryProjectionKind.Orthographic: if (rho > 1) return false; c = Math.Asin(Math.Clamp(rho, 0, 1)); break; + case PlanetaryProjectionKind.AzimuthalEquidistant: if (rho > 1) return false; c = rho * Math.PI; break; + case PlanetaryProjectionKind.AzimuthalEqualArea: if (rho > 1) return false; c = 2 * Math.Asin(Math.Clamp(rho, 0, 1)); break; + case PlanetaryProjectionKind.LocalTangent: c = Math.Atan(rho); break; + default: throw new ArgumentOutOfRangeException(nameof(p)); + } + if (rho < 1.0e-12) { latitude = p.CenterLatitude; return true; } + var sinC = Math.Sin(c); var cosC = Math.Cos(c); + var sin0 = Math.Sin(p.CenterLatitude); var cos0 = Math.Cos(p.CenterLatitude); + latitude = Math.Asin(Math.Clamp(cosC * sin0 + coordinate.y * sinC * cos0 / rho, -1, 1)); + longitude = Math.Atan2(coordinate.x * sinC, rho * cos0 * cosC - coordinate.y * sin0 * sinC); + return true; + } + + private static bool TryInverseCubeAtlas(double2 coordinate, out float3 direction) + { + direction = float3.zero; + if (coordinate.x is < -1 or > 1 || coordinate.y is < -1 or > 1) return false; + var column = Math.Min((int)((coordinate.x + 1) * 1.5), 2); + var row = Math.Min((int)((1 - coordinate.y)), 1); + var u = ((coordinate.x + 1) * 1.5 - column) * 2 - 1; + var v = ((1 - coordinate.y) - row) * 2 - 1; + direction = PlanetaryTopology.Direction(new((PlanetaryCubeFace)(row * 3 + column), u, v)); + return true; + } + + private static double EqualEarthY(double theta) + { + var theta2 = theta * theta; + var theta6 = theta2 * theta2 * theta2; + return theta * (EqualEarthA1 + EqualEarthA2 * theta2 + theta6 * (EqualEarthA3 + EqualEarthA4 * theta2)); + } + + private static double WrapLongitude(double longitude) + { + longitude %= Math.PI * 2; + if (longitude > Math.PI) longitude -= Math.PI * 2; + if (longitude < -Math.PI) longitude += Math.PI * 2; + return longitude; + } +} + +public readonly record struct PlanetaryMapTileLayout( + PlanetaryProjectionParameters Projection, + int Level, + int X, + int Y, + int InteriorSize, + int BorderSize) +{ + public int StorageSize => checked(InteriorSize + BorderSize * 2); + + public PlanetaryMapTileLayout Validate() + { + Projection.Validate(); + if (Level is < 0 or > 30) throw new ArgumentOutOfRangeException(nameof(Level)); + var count = 1 << Level; + if ((uint)X >= (uint)count || (uint)Y >= (uint)count) throw new ArgumentOutOfRangeException(nameof(X)); + if (InteriorSize < 2 || BorderSize < 0 || BorderSize >= InteriorSize) throw new ArgumentOutOfRangeException(nameof(InteriorSize)); + return this; + } +} + +public static class PlanetaryMapTileSampling +{ + public static bool TryDirection(PlanetaryMapTileLayout tile, int storageX, int storageY, out float3 direction) + { + tile.Validate(); + if ((uint)storageX >= (uint)tile.StorageSize || (uint)storageY >= (uint)tile.StorageSize) throw new ArgumentOutOfRangeException(nameof(storageX)); + var count = 1 << tile.Level; + var localU = (storageX - tile.BorderSize) / (double)(tile.InteriorSize - 1); + var localV = (storageY - tile.BorderSize) / (double)(tile.InteriorSize - 1); + var map = new double2(-1 + 2 * (tile.X + localU) / count, 1 - 2 * (tile.Y + localV) / count); + return PlanetaryProjection.TryInverse(map, tile.Projection, out direction); + } +} + +public readonly record struct PlanetaryMapTileKey( + ulong FieldVersion, + uint ProjectionVersion, + uint LayerId, + PlanetaryMapTileLayout Layout, + PlanetaryQueryScale QueryScale) +{ + public PlanetaryMapTileKey Validate() + { + if (FieldVersion == 0 || ProjectionVersion == 0) throw new ArgumentOutOfRangeException(nameof(FieldVersion)); + Layout.Validate(); QueryScale.Validate(); + return this; + } +} + +public readonly record struct PlanetarySurfaceMapTile( + PlanetaryMapTileKey Key, + PlanetarySurfaceSample[] Samples, + bool[] Validity); + +public static class PlanetaryMapTileBaker +{ + public static PlanetarySurfaceMapTile Bake( + in PlanetaryFieldDefinition field, + PlanetaryMapTileKey key, + TSource source) + where TSource : IPlanetaryBaseField + { + field.Validate(); key.Validate(); + if (field.FieldVersion != key.FieldVersion) throw new ArgumentException("Tile field version does not match definition.", nameof(key)); + var count = key.Layout.StorageSize * key.Layout.StorageSize; + var samples = new PlanetarySurfaceSample[count]; + var validity = new bool[count]; + for (var y = 0; y < key.Layout.StorageSize; y++) + for (var x = 0; x < key.Layout.StorageSize; x++) + { + var index = y * key.Layout.StorageSize + x; + if (!PlanetaryMapTileSampling.TryDirection(key.Layout, x, y, out var direction)) continue; + samples[index] = PlanetaryField.Sample(field, direction, source.Sample(direction), key.QueryScale); + validity[index] = true; + } + return new(key, samples, validity); + } +} diff --git a/src/GameCult.Geometry/Planetary/PlanetaryQueries.cs b/src/GameCult.Geometry/Planetary/PlanetaryQueries.cs new file mode 100644 index 00000000..b50b7785 --- /dev/null +++ b/src/GameCult.Geometry/Planetary/PlanetaryQueries.cs @@ -0,0 +1,198 @@ +using System; +using CultMath; + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +namespace GameCult.Geometry; + +public readonly record struct PlanetaryRadialBounds(float MinimumDisplacement, float MaximumDisplacement) +{ + public PlanetaryRadialBounds Validate() + { + if (!float.IsFinite(MinimumDisplacement) || !float.IsFinite(MaximumDisplacement) || MinimumDisplacement > MaximumDisplacement) + throw new ArgumentOutOfRangeException(nameof(MinimumDisplacement)); + return this; + } +} + +public readonly record struct PlanetaryRayHit( + double Distance, + double3 PlanetLocalPosition, + PlanetarySurfaceSample Surface); + +public readonly record struct PlanetaryPathSample(double Distance, PlanetarySurfaceSample Surface); + +public readonly record struct PlanetaryRegionSummary( + float MinimumDisplacement, + float MaximumDisplacement, + float MaximumSlope, + float MaximumUnresolvedHeight); + +public static class PlanetaryQueries +{ + public static bool TryIntersectRay( + in PlanetaryFieldDefinition field, + double3 rayOrigin, + double3 rayDirection, + double maximumDistance, + in PlanetaryQueryScale scale, + in PlanetaryRadialBounds bounds, + TSource source, + out PlanetaryRayHit hit) + where TSource : IPlanetaryBaseField + { + field.Validate(); scale.Validate(); bounds.Validate(); + ValidateFinite(rayOrigin, nameof(rayOrigin)); ValidateFinite(rayDirection, nameof(rayDirection)); + if (!double.IsFinite(maximumDistance) || maximumDistance <= 0) throw new ArgumentOutOfRangeException(nameof(maximumDistance)); + var directionLength = Length(rayDirection); + if (directionLength < 1.0e-15) throw new ArgumentOutOfRangeException(nameof(rayDirection)); + var direction = rayDirection / directionLength; + var outerRadius = field.Radius + bounds.MaximumDisplacement + scale.MaximumUnresolvedHeight; + if (!double.IsFinite(outerRadius)) outerRadius = field.Radius + bounds.MaximumDisplacement; + if (!TrySphereInterval(rayOrigin, direction, outerRadius, out var enter, out var leave)) { hit = default; return false; } + var start = Math.Max(enter, 0); + var end = Math.Min(leave, maximumDistance); + if (start > end) { hit = default; return false; } + + var previousDistance = start; + var previous = SignedSurfaceDistance(field, rayOrigin + direction * previousDistance, scale, source, out var previousSample); + if (previous <= 0) + { + hit = new(previousDistance, rayOrigin + direction * previousDistance, previousSample); + return true; + } + var minimumStep = Math.Max(scale.FootprintMeters * 0.5, field.Radius * 1.0e-7); + for (var iteration = 0; iteration < 512 && previousDistance < end; iteration++) + { + var nextDistance = Math.Min(previousDistance + Math.Max(previous * 0.5, minimumStep), end); + var next = SignedSurfaceDistance(field, rayOrigin + direction * nextDistance, scale, source, out var nextSample); + if (next <= 0) + { + var low = previousDistance; + var high = nextDistance; + var surface = nextSample; + for (var refine = 0; refine < 32; refine++) + { + var middle = (low + high) * 0.5; + var value = SignedSurfaceDistance(field, rayOrigin + direction * middle, scale, source, out var middleSample); + if (value > 0) low = middle; + else { high = middle; surface = middleSample; } + } + var position = rayOrigin + direction * high; + hit = new(high, position, surface); + return true; + } + if (nextDistance >= end) break; + previousDistance = nextDistance; + previous = next; + previousSample = nextSample; + } + hit = default; + return false; + } + + public static double SegmentClearance( + in PlanetaryFieldDefinition field, + double3 start, + double3 end, + double sampleSpacing, + in PlanetaryQueryScale scale, + TSource source) + where TSource : IPlanetaryBaseField + { + ValidateFinite(start, nameof(start)); ValidateFinite(end, nameof(end)); + if (!double.IsFinite(sampleSpacing) || sampleSpacing <= 0) throw new ArgumentOutOfRangeException(nameof(sampleSpacing)); + var delta = end - start; + var distance = Length(delta); + var steps = Math.Max((int)Math.Ceiling(distance / sampleSpacing), 1); + var minimum = double.PositiveInfinity; + for (var i = 0; i <= steps; i++) + { + var position = start + delta * (i / (double)steps); + minimum = Math.Min(minimum, SignedSurfaceDistance(field, position, scale, source, out _)); + } + return minimum; + } + + public static PlanetaryPathSample[] SampleGreatCircle( + in PlanetaryFieldDefinition field, + float3 startDirection, + float3 endDirection, + int segmentCount, + in PlanetaryQueryScale scale, + TSource source) + where TSource : IPlanetaryBaseField + { + if (segmentCount < 1) throw new ArgumentOutOfRangeException(nameof(segmentCount)); + PlanetaryTopology.ValidateDirection(startDirection); PlanetaryTopology.ValidateDirection(endDirection); + var a = math.normalize(startDirection); var b = math.normalize(endDirection); + var angle = MathF.Acos(math.clamp(math.dot(a, b), -1, 1)); + var sinAngle = MathF.Sin(angle); + var result = new PlanetaryPathSample[segmentCount + 1]; + for (var i = 0; i <= segmentCount; i++) + { + var t = i / (float)segmentCount; + var direction = sinAngle < 1.0e-6f + ? math.normalize(math.lerp(a, b, t)) + : math.normalize(a * (MathF.Sin((1 - t) * angle) / sinAngle) + b * (MathF.Sin(t * angle) / sinAngle)); + var sample = PlanetaryField.Sample(field, direction, source.Sample(direction), scale); + result[i] = new(angle * field.Radius * t, sample); + } + return result; + } + + public static PlanetaryRegionSummary Summarize( + in PlanetaryFieldDefinition field, + ReadOnlySpan directions, + in PlanetaryQueryScale scale, + TSource source) + where TSource : IPlanetaryBaseField + { + if (directions.IsEmpty) throw new ArgumentException("At least one direction is required.", nameof(directions)); + var min = float.PositiveInfinity; var max = float.NegativeInfinity; var slope = 0.0f; var unresolved = 0.0f; + foreach (var direction in directions) + { + var normalized = math.normalize(direction); + var sample = PlanetaryField.Sample(field, normalized, source.Sample(normalized), scale); + min = MathF.Min(min, sample.RadialDisplacement); + max = MathF.Max(max, sample.RadialDisplacement); + slope = MathF.Max(slope, sample.Slope); + unresolved = MathF.Max(unresolved, sample.UnresolvedHeightBound); + } + return new(min, max, slope, unresolved); + } + + private static double SignedSurfaceDistance( + in PlanetaryFieldDefinition field, + double3 position, + in PlanetaryQueryScale scale, + TSource source, + out PlanetarySurfaceSample sample) + where TSource : IPlanetaryBaseField + { + var radius = Length(position); + if (radius < 1.0e-15) { sample = default; return double.NegativeInfinity; } + var direction = new float3((float)(position.x / radius), (float)(position.y / radius), (float)(position.z / radius)); + sample = PlanetaryField.Sample(field, direction, source.Sample(direction), scale); + return radius - sample.SurfaceRadius; + } + + private static bool TrySphereInterval(double3 origin, double3 direction, double radius, out double enter, out double leave) + { + var b = Dot(origin, direction); + var c = Dot(origin, origin) - radius * radius; + var discriminant = b * b - c; + if (discriminant < 0) { enter = leave = 0; return false; } + var root = Math.Sqrt(discriminant); + enter = -b - root; leave = -b + root; return leave >= 0; + } + + private static double Dot(double3 a, double3 b) => a.x * b.x + a.y * b.y + a.z * b.z; + private static double Length(double3 value) => Math.Sqrt(Dot(value, value)); + private static void ValidateFinite(double3 value, string name) + { + if (!double.IsFinite(value.x) || !double.IsFinite(value.y) || !double.IsFinite(value.z)) throw new ArgumentOutOfRangeException(name); + } +} diff --git a/src/GameCult.Geometry/Planetary/PlanetaryRadialRefinement.cs b/src/GameCult.Geometry/Planetary/PlanetaryRadialRefinement.cs new file mode 100644 index 00000000..a958bc1a --- /dev/null +++ b/src/GameCult.Geometry/Planetary/PlanetaryRadialRefinement.cs @@ -0,0 +1,34 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +using CultMath; + +namespace GameCult.Geometry; + +public static class PlanetaryRadialRefinement +{ + public static float3 Step( + float3 position, + float3 rayDirection, + float3 center, + float targetRadius, + float minimumDerivative = 0.2f, + float maximumCorrection = 0.08f) + { + var local = position - center; + var radius = math.length(local); + var radial = local / math.max(radius, 0.0001f); + var error = radius - targetRadius; + var derivative = math.dot(rayDirection, radial); + var safeMinimum = math.max(minimumDerivative, 0.0001f); + var safeDerivative = math.abs(derivative) > safeMinimum + ? derivative + : derivative < 0.0f ? -safeMinimum : safeMinimum; + var correction = math.clamp( + -error / safeDerivative, + -math.abs(maximumCorrection), + math.abs(maximumCorrection)); + return position + rayDirection * correction; + } +} diff --git a/src/GameCult.Geometry/Planetary/PlanetaryResidency.cs b/src/GameCult.Geometry/Planetary/PlanetaryResidency.cs new file mode 100644 index 00000000..79acc317 --- /dev/null +++ b/src/GameCult.Geometry/Planetary/PlanetaryResidency.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CultMath; + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +namespace GameCult.Geometry; + +public readonly record struct PlanetaryLodParameters( + int MaximumLevel, + int PageInteriorSize, + int PageBorderSize, + float ViewportHeightPixels, + float MinimumFootprintMeters) +{ + public PlanetaryLodParameters Validate() + { + if (MaximumLevel is < 0 or > PlanetaryTileAddress.MaxLevel) throw new ArgumentOutOfRangeException(nameof(MaximumLevel)); + _ = new PlanetaryPageLayout(new(PlanetaryCubeFace.PositiveX, 0, 0, 0), PageInteriorSize, PageBorderSize).Validate(); + if (!float.IsFinite(ViewportHeightPixels) || ViewportHeightPixels <= 0) throw new ArgumentOutOfRangeException(nameof(ViewportHeightPixels)); + if (!float.IsFinite(MinimumFootprintMeters) || MinimumFootprintMeters < 0) throw new ArgumentOutOfRangeException(nameof(MinimumFootprintMeters)); + return this; + } +} + +public static class PlanetaryLodSelector +{ + public static int SelectLevel( + in PlanetaryFieldDefinition field, + float cameraDistanceFromCenter, + in PlanetaryLodParameters parameters) + { + field.Validate(); parameters.Validate(); + if (!float.IsFinite(cameraDistanceFromCenter) || cameraDistanceFromCenter < 0) throw new ArgumentOutOfRangeException(nameof(cameraDistanceFromCenter)); + var altitude = MathF.Max(cameraDistanceFromCenter - field.Radius, 0.001f); + var footprint = MathF.Max(altitude / parameters.ViewportHeightPixels * 2, parameters.MinimumFootprintMeters); + var erosion = field.Erosion; + for (var level = 0; level <= parameters.MaximumLevel; level++) + { + var page = new PlanetaryPageLayout(new(PlanetaryCubeFace.PositiveX, level, 0, 0), parameters.PageInteriorSize, parameters.PageBorderSize); + var spacing = PlanetaryPageSampling.NominalAngularTexelSize(page) * field.Radius; + var unresolved = ErosionFrequencyBands.Select( + erosion.Scale * erosion.CellScale, spacing, erosion.Octaves, + erosion.Lacunarity, erosion.Strength * erosion.Scale, erosion.Gain).UnresolvedHeightBound; + if (unresolved <= footprint) return level; + } + return parameters.MaximumLevel; + } + + public static PlanetaryTileAddress[] SelectAncestorChain( + in PlanetaryFieldDefinition field, + float3 cameraDirection, + float cameraDistanceFromCenter, + in PlanetaryLodParameters parameters) + { + PlanetaryTopology.ValidateDirection(cameraDirection); + var level = SelectLevel(field, cameraDistanceFromCenter, parameters); + var result = new PlanetaryTileAddress[6 + level]; + for (var face = 0; face < 6; face++) result[face] = new((PlanetaryCubeFace)face, 0, 0, 0); + var leaf = PlanetaryTopology.TileAt(cameraDirection, level); + for (var currentLevel = level; currentLevel >= 1; currentLevel--) + { + result[6 + currentLevel - 1] = leaf; + leaf = leaf.Parent(); + } + return result; + } +} + +public readonly record struct PlanetaryResidentTile(PlanetaryTileAddress Tile, float Blend, bool Departing); + +public readonly record struct PlanetaryResidencySnapshot( + ulong ContentVersion, + ulong PresentationVersion, + PlanetaryResidentTile[] Tiles); + +/// Owns presentation residency only. It never owns field content. +public sealed class PlanetaryResidualResidency +{ + private readonly Dictionary residents = new(); + private readonly Dictionary arrivals = new(); + private readonly Dictionary departures = new(); + private float lastTime = float.NegativeInfinity; + + public void Reset() + { + residents.Clear(); arrivals.Clear(); departures.Clear(); lastTime = float.NegativeInfinity; + } + + public PlanetaryResidencySnapshot Update(ReadOnlySpan desired, float timeSeconds, float transitionSeconds) + { + if (!float.IsFinite(timeSeconds)) throw new ArgumentOutOfRangeException(nameof(timeSeconds)); + if (!float.IsFinite(transitionSeconds) || transitionSeconds < 0) throw new ArgumentOutOfRangeException(nameof(transitionSeconds)); + if (timeSeconds < lastTime) Reset(); + lastTime = timeSeconds; + + var desiredKeys = new HashSet(); + foreach (var tile in desired) + { + var key = tile.StableKey; + desiredKeys.Add(key); + residents[key] = tile; + if (!arrivals.ContainsKey(key)) arrivals[key] = timeSeconds; + departures.Remove(key); + } + + foreach (var pair in residents.ToArray()) + { + if (pair.Value.Level == 0 || desiredKeys.Contains(pair.Key)) continue; + if (!departures.ContainsKey(pair.Key)) + { + var startingBlend = transitionSeconds == 0 + ? 1 + : Math.Clamp((timeSeconds - arrivals[pair.Key]) / transitionSeconds, 0, 1); + departures[pair.Key] = (timeSeconds, startingBlend); + } + if (transitionSeconds > 0 && timeSeconds - departures[pair.Key].Time < transitionSeconds) continue; + residents.Remove(pair.Key); arrivals.Remove(pair.Key); departures.Remove(pair.Key); + } + + var ordered = residents + .OrderBy(pair => pair.Value.Level) + .ThenBy(pair => pair.Value.Face) + .ThenBy(pair => pair.Value.X) + .ThenBy(pair => pair.Value.Y) + .ToArray(); + var tiles = new PlanetaryResidentTile[ordered.Length]; + for (var i = 0; i < ordered.Length; i++) + { + var pair = ordered[i]; + var departing = departures.TryGetValue(pair.Key, out var departure); + var blend = pair.Value.Level == 0 || transitionSeconds == 0 + ? 1 + : departing + ? departure.StartingBlend * Math.Clamp(1 - (timeSeconds - departure.Time) / transitionSeconds, 0, 1) + : Math.Clamp((timeSeconds - arrivals[pair.Key]) / transitionSeconds, 0, 1); + tiles[i] = new(pair.Value, blend, departing); + } + return new(Hash(ordered.Select(pair => pair.Key)), Hash(tiles.Select(tile => tile.Tile.StableKey ^ (uint)BitConverter.SingleToInt32Bits(tile.Blend))), tiles); + } + + private static ulong Hash(IEnumerable values) + { + const ulong offset = 14695981039346656037UL; + const ulong prime = 1099511628211UL; + var hash = offset; + foreach (var value in values) hash = unchecked((hash ^ value) * prime); + return hash == 0 ? 1 : hash; + } +} diff --git a/src/GameCult.Geometry/Planetary/PlanetaryTopology.cs b/src/GameCult.Geometry/Planetary/PlanetaryTopology.cs new file mode 100644 index 00000000..18ff4f31 --- /dev/null +++ b/src/GameCult.Geometry/Planetary/PlanetaryTopology.cs @@ -0,0 +1,196 @@ +using System; +using CultMath; + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +namespace GameCult.Geometry; + +public enum PlanetaryCubeFace +{ + PositiveX, + NegativeX, + PositiveY, + NegativeY, + PositiveZ, + NegativeZ, +} + +public readonly record struct PlanetaryFaceCoordinate(PlanetaryCubeFace Face, double U, double V) +{ + public PlanetaryFaceCoordinate Validate(bool allowOutsideFace = false) + { + if (!double.IsFinite(U) || !double.IsFinite(V)) throw new ArgumentOutOfRangeException(nameof(U)); + if (!allowOutsideFace && (U is < -1.0 or > 1.0 || V is < -1.0 or > 1.0)) + throw new ArgumentOutOfRangeException(nameof(U), "Face coordinates must be in [-1, 1]."); + if (!Enum.IsDefined(typeof(PlanetaryCubeFace), Face)) throw new ArgumentOutOfRangeException(nameof(Face)); + return this; + } +} + +public readonly record struct PlanetaryTileAddress +{ + public const int MaxLevel = 30; + + public PlanetaryTileAddress(PlanetaryCubeFace face, int level, int x, int y) + { + if (!Enum.IsDefined(typeof(PlanetaryCubeFace), face)) throw new ArgumentOutOfRangeException(nameof(face)); + if (level is < 0 or > MaxLevel) throw new ArgumentOutOfRangeException(nameof(level)); + var count = 1 << level; + if ((uint)x >= (uint)count) throw new ArgumentOutOfRangeException(nameof(x)); + if ((uint)y >= (uint)count) throw new ArgumentOutOfRangeException(nameof(y)); + Face = face; + Level = level; + X = x; + Y = y; + } + + public PlanetaryCubeFace Face { get; } + public int Level { get; } + public int X { get; } + public int Y { get; } + public int AxisTileCount => 1 << Level; + + public PlanetaryTileAddress Parent() => Level == 0 + ? throw new InvalidOperationException("Root cube tile has no parent.") + : new(Face, Level - 1, X >> 1, Y >> 1); + + public PlanetaryTileAddress Child(int childX, int childY) + { + if ((uint)childX > 1) throw new ArgumentOutOfRangeException(nameof(childX)); + if ((uint)childY > 1) throw new ArgumentOutOfRangeException(nameof(childY)); + if (Level == MaxLevel) throw new InvalidOperationException($"Level {MaxLevel} cannot be subdivided."); + return new(Face, Level + 1, (X << 1) | childX, (Y << 1) | childY); + } + + public PlanetaryFaceCoordinate PositionAt(double localU, double localV) + { + if (!double.IsFinite(localU) || localU is < 0.0 or > 1.0) throw new ArgumentOutOfRangeException(nameof(localU)); + if (!double.IsFinite(localV) || localV is < 0.0 or > 1.0) throw new ArgumentOutOfRangeException(nameof(localV)); + var count = AxisTileCount; + return new(Face, -1.0 + 2.0 * (X + localU) / count, -1.0 + 2.0 * (Y + localV) / count); + } + + public ulong StableKey + { + get + { + const ulong offset = 14695981039346656037UL; + const ulong prime = 1099511628211UL; + var hash = offset; + hash = unchecked((hash ^ (uint)Face) * prime); + hash = unchecked((hash ^ (uint)Level) * prime); + hash = unchecked((hash ^ (uint)X) * prime); + hash = unchecked((hash ^ (uint)Y) * prime); + return hash == 0 ? 1 : hash; + } + } + public override string ToString() => $"{Face}/L{Level:D2}/{X}/{Y}"; +} + +public static class PlanetaryTopology +{ + private const float QuarterPi = MathF.PI * 0.25f; + + public static float3 Direction(PlanetaryFaceCoordinate coordinate) + { + coordinate.Validate(allowOutsideFace: true); + var u = MathF.Tan((float)coordinate.U * QuarterPi); + var v = MathF.Tan((float)coordinate.V * QuarterPi); + var cube = coordinate.Face switch + { + PlanetaryCubeFace.PositiveX => new float3(1, v, -u), + PlanetaryCubeFace.NegativeX => new float3(-1, v, u), + PlanetaryCubeFace.PositiveY => new float3(u, 1, -v), + PlanetaryCubeFace.NegativeY => new float3(u, -1, v), + PlanetaryCubeFace.PositiveZ => new float3(u, v, 1), + PlanetaryCubeFace.NegativeZ => new float3(-u, v, -1), + _ => throw new ArgumentOutOfRangeException(nameof(coordinate)), + }; + return math.normalize(cube); + } + + public static float3 NormalizedCubeDirection(PlanetaryFaceCoordinate coordinate) + { + coordinate.Validate(allowOutsideFace: true); + var u = (float)coordinate.U; + var v = (float)coordinate.V; + return math.normalize(CubeVector(coordinate.Face, u, v)); + } + + public static PlanetaryFaceCoordinate FaceCoordinate(float3 direction) + { + ValidateDirection(direction); + var d = math.normalize(direction); + var a = math.abs(d); + if (a.x >= a.y && a.x >= a.z) + return d.x >= 0 + ? FromCubeUv(PlanetaryCubeFace.PositiveX, -d.z / a.x, d.y / a.x) + : FromCubeUv(PlanetaryCubeFace.NegativeX, d.z / a.x, d.y / a.x); + if (a.y >= a.z) + return d.y >= 0 + ? FromCubeUv(PlanetaryCubeFace.PositiveY, d.x / a.y, -d.z / a.y) + : FromCubeUv(PlanetaryCubeFace.NegativeY, d.x / a.y, d.z / a.y); + return d.z >= 0 + ? FromCubeUv(PlanetaryCubeFace.PositiveZ, d.x / a.z, d.y / a.z) + : FromCubeUv(PlanetaryCubeFace.NegativeZ, -d.x / a.z, d.y / a.z); + } + + public static PlanetaryTileAddress TileAt(float3 direction, int level) + { + if (level is < 0 or > PlanetaryTileAddress.MaxLevel) throw new ArgumentOutOfRangeException(nameof(level)); + var face = FaceCoordinate(direction); + var count = 1 << level; + var x = Math.Clamp((int)((face.U * 0.5 + 0.5) * count), 0, count - 1); + var y = Math.Clamp((int)((face.V * 0.5 + 0.5) * count), 0, count - 1); + return new(face.Face, level, x, y); + } + + public static bool TryLocalCoordinate(float3 direction, PlanetaryTileAddress tile, out float2 local) + { + var face = FaceCoordinate(direction); + if (face.Face != tile.Face) { local = float2.zero; return false; } + var scaledU = (face.U * 0.5 + 0.5) * tile.AxisTileCount; + var scaledV = (face.V * 0.5 + 0.5) * tile.AxisTileCount; + local = new((float)(scaledU - tile.X), (float)(scaledV - tile.Y)); + return local.x is >= 0 and <= 1 && local.y is >= 0 and <= 1; + } + + public static float3 SurfaceNormal(float3 unitDirection, float3 worldDistanceTangentGradient) + { + ValidateDirection(unitDirection); + ValidateFinite(worldDistanceTangentGradient, nameof(worldDistanceTangentGradient)); + var direction = math.normalize(unitDirection); + var tangent = worldDistanceTangentGradient - direction * math.dot(worldDistanceTangentGradient, direction); + return math.normalize(direction - tangent); + } + + private static PlanetaryFaceCoordinate FromCubeUv(PlanetaryCubeFace face, float cubeU, float cubeV) => new( + face, + MathF.Atan(cubeU) / QuarterPi, + MathF.Atan(cubeV) / QuarterPi); + + private static float3 CubeVector(PlanetaryCubeFace face, float u, float v) => face switch + { + PlanetaryCubeFace.PositiveX => new float3(1, v, -u), + PlanetaryCubeFace.NegativeX => new float3(-1, v, u), + PlanetaryCubeFace.PositiveY => new float3(u, 1, -v), + PlanetaryCubeFace.NegativeY => new float3(u, -1, v), + PlanetaryCubeFace.PositiveZ => new float3(u, v, 1), + PlanetaryCubeFace.NegativeZ => new float3(-u, v, -1), + _ => throw new ArgumentOutOfRangeException(nameof(face)), + }; + + internal static void ValidateDirection(float3 direction) + { + ValidateFinite(direction, nameof(direction)); + if (math.length(direction) < 1.0e-12f) throw new ArgumentOutOfRangeException(nameof(direction)); + } + + internal static void ValidateFinite(float3 value, string name) + { + if (!float.IsFinite(value.x) || !float.IsFinite(value.y) || !float.IsFinite(value.z)) + throw new ArgumentOutOfRangeException(name); + } +} diff --git a/src/GameCult.Geometry/Planetary/SphericalErosion.cs b/src/GameCult.Geometry/Planetary/SphericalErosion.cs new file mode 100644 index 00000000..b20e0f00 --- /dev/null +++ b/src/GameCult.Geometry/Planetary/SphericalErosion.cs @@ -0,0 +1,114 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +using CultMath; + +namespace GameCult.Geometry; + +public readonly record struct SphericalErosionParameters( + float Frequency, + float Amplitude, + float Lacunarity, + float Gain, + float SlopeStrength, + float Detail, + float Seed, + int Octaves) +{ + public static SphericalErosionParameters Default => new(18.0f, 0.035f, 2.03f, 0.52f, 6.0f, 1.35f, 0.0f, 5); +} + +public readonly record struct SphericalErosionSample(float HeightOffset, float Ridge, float Gully, float3 Gradient); + +/// +/// Stateless, point-evaluable erosion-like synthesis on a unit sphere. +/// The matching shader body is gamecult_geometry_spherical_erosion in GameCult.Geometry.hlsl. +/// Position and gradient are planet-space; the gradient is projected tangent +/// internally so cube faces and tile request order never enter the result. +/// +public static class SphericalErosion +{ + public static SphericalErosionSample Sample( + float3 unitPosition, + float baseHeight, + float3 baseGradient, + SphericalErosionParameters parameters) + { + var position = math.normalize(unitPosition); + var gradient = baseGradient - position * math.dot(baseGradient, position); + var slope = math.length(gradient); + var flow = slope > 1.0e-6f ? -gradient / slope : StableTangent(position); + var frequency = math.max(parameters.Frequency, 0.001f); + var amplitude = parameters.Amplitude; + var combiMask = EaseOut(math.saturate(slope * parameters.SlopeStrength)); + var fadeTarget = math.saturate(baseHeight * 0.5f + 0.5f) * 2.0f - 1.0f; + var heightOffset = 0.0f; + var ridge = 0.0f; + var gully = 0.0f; + + var octaveCount = math.min(math.max(parameters.Octaves, 0), 8); + for (var octave = 0; octave < octaveCount; octave++) + { + var phase = DirectionalCellWave(position * frequency, flow, parameters.Seed + octave * 19.19f); + var wave = math.cos(phase); + var waveSlope = math.sin(phase); + var faded = math.lerp(fadeTarget, wave, combiMask); + heightOffset += faded * amplitude; + ridge = math.max(ridge, math.saturate(faded)); + gully = math.max(gully, math.saturate(-faded)); + + var across = math.normalize(math.cross(position, flow)); + gradient += across * (math.sign(waveSlope) * amplitude * frequency * combiMask); + gradient -= position * math.dot(gradient, position); + slope = math.length(gradient); + flow = slope > 1.0e-6f ? -gradient / slope : StableTangent(position); + + var newMask = EaseOut(math.saturate(math.abs(waveSlope) * 1.35f)); + combiMask = PowInverse(combiMask, parameters.Detail) * newMask; + fadeTarget = faded; + frequency *= math.max(parameters.Lacunarity, 1.01f); + amplitude *= math.saturate(parameters.Gain); + } + + return new SphericalErosionSample(heightOffset, ridge, gully, gradient); + } + + private static float DirectionalCellWave(float3 position, float3 flow, float seed) + { + var cell = math.floor(position); + var local = math.frac(position); + var weights = local * local * (3.0f - 2.0f * local); + var x00 = CellWave(position, flow, cell + new float3(0, 0, 0), seed); + var x10 = CellWave(position, flow, cell + new float3(1, 0, 0), seed); + var x01 = CellWave(position, flow, cell + new float3(0, 1, 0), seed); + var x11 = CellWave(position, flow, cell + new float3(1, 1, 0), seed); + var y00 = math.lerp(x00, x10, weights.x); + var y10 = math.lerp(x01, x11, weights.x); + var z0 = math.lerp(y00, y10, weights.y); + var x00z = CellWave(position, flow, cell + new float3(0, 0, 1), seed); + var x10z = CellWave(position, flow, cell + new float3(1, 0, 1), seed); + var x01z = CellWave(position, flow, cell + new float3(0, 1, 1), seed); + var x11z = CellWave(position, flow, cell + new float3(1, 1, 1), seed); + var z1 = math.lerp(math.lerp(x00z, x10z, weights.x), math.lerp(x01z, x11z, weights.x), weights.y); + return math.lerp(z0, z1, weights.z); + } + + private static float CellWave(float3 position, float3 flow, float3 cell, float seed) + { + var pivot = cell + new float3( + math.hash(cell + seed + 11.7f), + math.hash(cell.yzx + seed + 37.1f), + math.hash(cell.zyx + seed + 73.9f)); + return math.dot(position - pivot, flow) * math.TAU + math.hash(cell + seed + 101.3f) * math.TAU; + } + + private static float3 StableTangent(float3 position) + { + var axis = math.abs(position.z) < 0.8f ? new float3(0, 0, 1) : new float3(0, 1, 0); + return math.normalize(math.cross(axis, position)); + } + + private static float EaseOut(float value) => 1.0f - (1.0f - value) * (1.0f - value); + private static float PowInverse(float value, float power) => 1.0f - math.pow(1.0f - math.saturate(value), math.max(power, 0.001f)); +} diff --git a/src/GameCult.Geometry/README.md b/src/GameCult.Geometry/README.md index 4fb881bc..3c1f75bd 100644 --- a/src/GameCult.Geometry/README.md +++ b/src/GameCult.Geometry/README.md @@ -1,8 +1,8 @@ # GameCult.Geometry -`GameCult.Geometry` defines shared geometry values plus CultCache-native -documents for procedural geometry domains, LOD build requests, selected-cut -manifests, and mesh chunk artifacts. +`GameCult.Geometry` owns spatial values, planetary fields and topology, +residual-page residency, map projections and tiles, geometry queries, and the +CultCache-native documents that coordinate geometry production. The package is deliberately substrate-shaped: @@ -20,8 +20,8 @@ The package is deliberately substrate-shaped: The small value layer is for cross-runtime query contracts, physics probes, native slices, and UI surfaces: -- `CultMath.float2` and `CultMath.float3`: 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; @@ -66,18 +66,27 @@ using var chunks = node.Database 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 -- `CultMath.float2`, `CultMath.float3`, `CultRect`, `CultCircle`, `CultSphere`: shared value - primitives for query and view contracts. +- `CultMath.float2`, `CultMath.float3`, `CultRect`, `CultCircle`, and + `CultSphere`: shared value primitives for query and view contracts. - `CultGeometryDomainDocument`: one hierarchical domain tree, suitable for - Ragnarok/Fensalir-style feature DSL output. + hierarchical feature DSL output. - `CultGeometryBuildRequest`: one LOD/frustum/budget request for workers. - `CultGeometrySelectedCutManifest`: the selected domain cut, deferred children, fallback rows, and diagnostic contribution rows. - `CultGeometryChunkArtifact`: render mesh, optional collider mesh, source domain/claim identities, build counters, and transition clipping seed. + +## Planetary geometry + +The planetary field, cube-sphere topology, pages, residency, projections, +queries, and versioned `CMPT` map tiles are described in +[`docs/planetary.md`](docs/planetary.md). Shader consumers include +`Shaders/GameCult.Geometry.hlsl`; Unity consumers use the +`org.gamecult.geometry` package, and browser tile consumers use +`packages/gamecult-geometry-ts`. diff --git a/src/GameCult.Geometry/Shaders/AdvancedErosionFilter.hlsl b/src/GameCult.Geometry/Shaders/AdvancedErosionFilter.hlsl new file mode 100644 index 00000000..219afc86 --- /dev/null +++ b/src/GameCult.Geometry/Shaders/AdvancedErosionFilter.hlsl @@ -0,0 +1,147 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. +// Mathematical procedure adapted from Rune Skovbo Johansen's Advanced Terrain +// Erosion Filter (2025): https://www.shadertoy.com/view/wXcfWn + +#ifndef GAMECULT_GEOMETRY_ADVANCED_EROSION_FILTER_HLSL +#define GAMECULT_GEOMETRY_ADVANCED_EROSION_FILTER_HLSL + +struct GameCultGeometryAdvancedErosionParameters +{ + float scale; float strength; float gully_weight; float detail; + float4 rounding; float4 onset; float2 assumed_slope; + float cell_scale; float normalization; int octaves; float lacunarity; float gain; +}; + +struct GameCultGeometryAdvancedErosionResult +{ + float3 delta; float magnitude; float ridge_map; float fade_target; +}; + +struct GameCultGeometryErosionBandSelection +{ + int active_octaves; float final_octave_weight; float finest_included_wavelength; float unresolved_height_bound; +}; + +GameCultGeometryErosionBandSelection gamecult_geometry_select_erosion_bands(float base_wavelength, float sample_spacing, int maximum_octaves, float lacunarity, float base_amplitude, float gain, float transition_ratio) +{ + base_wavelength=max(base_wavelength,1.0e-6); sample_spacing=max(sample_spacing,0.0); lacunarity=max(lacunarity,1.01); transition_ratio=max(transition_ratio,1.01); maximum_octaves=min(max(maximum_octaves,0),16); + float nyquist=sample_spacing*2.0, wavelength=base_wavelength, final_weight=0.0, finest=0.0; int active=0; + [loop] for(int octave=0; octave=active) omitted+=amplitude; else if(index==active-1) omitted+=amplitude*(1.0-final_weight); amplitude*=abs(gain); + } + GameCultGeometryErosionBandSelection result; result.active_octaves=active; result.final_octave_weight=active>0?final_weight:0.0; result.finest_included_wavelength=finest; result.unresolved_height_bound=omitted; return result; +} + +float gamecult_geometry_erosion_hash_unit(int2 cell, uint salt) +{ + uint value = (uint)cell.x * 0x8da6b343u ^ (uint)cell.y * 0xd8163841u ^ salt; + value ^= value >> 16; value *= 0x7feb352du; value ^= value >> 15; value *= 0x846ca68bu; value ^= value >> 16; + return (value & 0x00ffffffu) * (1.0 / 16777216.0); +} +float2 gamecult_geometry_erosion_hash2(float2 input) +{ + int2 cell = (int2)input; + return float2(gamecult_geometry_erosion_hash_unit(cell, 0x68bc21ebu), gamecult_geometry_erosion_hash_unit(cell, 0x02e5be93u)) * 2.0 - 1.0; +} + +float4 gamecult_geometry_phacelle_noise(float2 position, float2 normal_direction, float frequency, float offset_cycles, float normalization) +{ + float2 side_direction = float2(-normal_direction.y, normal_direction.x) * frequency * 6.28318530718; + float offset = offset_cycles * 6.28318530718; + float2 cell = floor(position); + float2 local = frac(position); + float2 phase = 0.0; + float weight_sum = 0.0; + [unroll] for (int x = -1; x <= 2; x++) + [unroll] for (int y = -1; y <= 2; y++) + { + float2 grid_offset = float2(x, y); + float2 grid_point = cell + grid_offset; + float2 random_offset = gamecult_geometry_erosion_hash2(grid_point) * 0.5; + float2 delta = local - grid_offset - random_offset; + float weight = max(0.0, exp(-dot(delta, delta) * 2.0) - 0.01111); + weight_sum += weight; + float wave = dot(delta, side_direction) + offset; + phase += float2(cos(wave), sin(wave)) * weight; + } + float2 interpolated = phase / max(weight_sum, 1.0e-20); + float magnitude = max(1.0 - saturate(normalization), length(interpolated)); + return float4(interpolated / max(magnitude, 1.0e-20), side_direction); +} + +float gamecult_geometry_erosion_pow_inverse(float value, float power) { return 1.0 - pow(1.0 - saturate(value), power); } +float gamecult_geometry_erosion_ease_out(float value) { float inverse = 1.0 - saturate(value); return 1.0 - inverse * inverse; } +float gamecult_geometry_erosion_smooth_start(float value, float smoothing) +{ + return smoothing <= 1.0e-10 || value >= smoothing ? value - 0.5 * smoothing : 0.5 * value * value / smoothing; +} +float2 gamecult_geometry_erosion_safe_normalize(float2 value) +{ + float value_length = length(value); + return abs(value_length) > 1.0e-10 ? value / value_length : value; +} + +GameCultGeometryAdvancedErosionResult gamecult_geometry_advanced_erosion_filter_banded(float2 position, float3 base_height_and_slope, float fade_target, GameCultGeometryAdvancedErosionParameters p, GameCultGeometryErosionBandSelection band) +{ + float strength = p.strength * p.scale; + fade_target = clamp(fade_target, -1.0, 1.0); + float3 input_value = base_height_and_slope; + float3 height_and_slope = input_value; + float frequency = 1.0 / max(p.scale * p.cell_scale, 1.0e-10); + float2 slope = height_and_slope.yz; + float slope_length = max(length(slope), 1.0e-10); + float magnitude = 0.0; + float rounding_multiplier = 1.0; + float input_rounding = lerp(p.rounding.y, p.rounding.x, saturate(fade_target + 0.5)) * p.rounding.z; + float combined_mask = gamecult_geometry_erosion_ease_out(gamecult_geometry_erosion_smooth_start(slope_length * p.onset.x, input_rounding * p.onset.x)); + float ridge_mask = gamecult_geometry_erosion_ease_out(slope_length * p.onset.z); + float ridge_fade = fade_target; + float2 gully_slope = lerp(slope, slope / slope_length * p.assumed_slope.x, p.assumed_slope.y); + + int octave_count=min(min(max(p.octaves,0),8),max(band.active_octaves,0)); + [loop] for (int octave = 0; octave < octave_count; octave++) + { + float4 phacelle = gamecult_geometry_phacelle_noise(position * frequency, gamecult_geometry_erosion_safe_normalize(gully_slope), p.cell_scale, 0.25, p.normalization); + float2 derivative_direction = phacelle.zw * -frequency; + float sloping = abs(phacelle.y); + gully_slope += sign(phacelle.y) * derivative_direction * strength * p.gully_weight; + float3 gullies = float3(phacelle.x, phacelle.y * derivative_direction.x, phacelle.y * derivative_direction.y); + float3 faded = lerp(float3(fade_target, 0.0, 0.0), gullies * p.gully_weight, combined_mask); + float octave_weight=octave==octave_count-1?saturate(band.final_octave_weight):1.0; + height_and_slope += faded * strength * octave_weight; + magnitude += strength * octave_weight; + fade_target = faded.x; + float octave_rounding = lerp(p.rounding.y, p.rounding.x, saturate(phacelle.x + 0.5)) * rounding_multiplier; + float new_mask = gamecult_geometry_erosion_ease_out(gamecult_geometry_erosion_smooth_start(sloping * p.onset.y, octave_rounding * p.onset.y)); + combined_mask = gamecult_geometry_erosion_pow_inverse(combined_mask, p.detail) * new_mask; + ridge_fade = lerp(ridge_fade, lerp(ridge_fade, gullies.x, ridge_mask), octave_weight); + ridge_mask *= lerp(1.0, gamecult_geometry_erosion_ease_out(sloping * p.onset.w), octave_weight); + strength *= p.gain; + frequency *= p.lacunarity; + rounding_multiplier *= p.rounding.w; + } + + GameCultGeometryAdvancedErosionResult result; + result.delta = height_and_slope - input_value; + result.magnitude = magnitude; + result.ridge_map = ridge_fade * (1.0 - ridge_mask); + result.fade_target = fade_target; + return result; +} + +GameCultGeometryAdvancedErosionResult gamecult_geometry_advanced_erosion_filter(float2 position, float3 base_height_and_slope, float fade_target, GameCultGeometryAdvancedErosionParameters p) +{ + GameCultGeometryErosionBandSelection band; band.active_octaves=p.octaves; band.final_octave_weight=1.0; band.finest_included_wavelength=0.0; band.unresolved_height_bound=0.0; + return gamecult_geometry_advanced_erosion_filter_banded(position, base_height_and_slope, fade_target, p, band); +} + +#endif diff --git a/src/GameCult.Geometry/Shaders/AdvancedErosionFilter.hlsl.meta b/src/GameCult.Geometry/Shaders/AdvancedErosionFilter.hlsl.meta new file mode 100644 index 00000000..015c51c2 --- /dev/null +++ b/src/GameCult.Geometry/Shaders/AdvancedErosionFilter.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3252ac02e8853ef4b9d1fbc5204b5c6b +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/GameCult.Geometry/Shaders/GameCult.Geometry.hlsl b/src/GameCult.Geometry/Shaders/GameCult.Geometry.hlsl new file mode 100644 index 00000000..fb159077 --- /dev/null +++ b/src/GameCult.Geometry/Shaders/GameCult.Geometry.hlsl @@ -0,0 +1,14 @@ +#ifndef GAMECULT_GEOMETRY_HLSL +#define GAMECULT_GEOMETRY_HLSL + +#ifndef GAMECULT_GEOMETRY_CULTMATH_INCLUDE +#define GAMECULT_GEOMETRY_CULTMATH_INCLUDE "CultMath.hlsl" +#endif + +#include GAMECULT_GEOMETRY_CULTMATH_INCLUDE +#include "AdvancedErosionFilter.hlsl" +#include "Planetary.hlsl" +#include "SphericalErosion.hlsl" +#include "PlanetaryRadialRefinement.hlsl" + +#endif diff --git a/src/GameCult.Geometry/Shaders/GameCult.Geometry.hlsl.meta b/src/GameCult.Geometry/Shaders/GameCult.Geometry.hlsl.meta new file mode 100644 index 00000000..d685ccce --- /dev/null +++ b/src/GameCult.Geometry/Shaders/GameCult.Geometry.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b253fa50ddd34bf3b69fb323fe6e00e8 +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/GameCult.Geometry/Shaders/Planetary.hlsl b/src/GameCult.Geometry/Shaders/Planetary.hlsl new file mode 100644 index 00000000..8e16bfff --- /dev/null +++ b/src/GameCult.Geometry/Shaders/Planetary.hlsl @@ -0,0 +1,392 @@ +#ifndef GAMECULT_GEOMETRY_PLANETARY_HLSL +#define GAMECULT_GEOMETRY_PLANETARY_HLSL + +static const float GAMECULT_GEOMETRY_PLANETARY_QUARTER_PI = 0.78539816339744830962; + +float3 gamecult_geometry_planetary_face_direction(int face, float2 coordinate) +{ + float2 tangent = tan(coordinate * GAMECULT_GEOMETRY_PLANETARY_QUARTER_PI); + float3 cube = face == 0 ? float3(1.0, tangent.y, -tangent.x) + : face == 1 ? float3(-1.0, tangent.y, tangent.x) + : face == 2 ? float3(tangent.x, 1.0, -tangent.y) + : face == 3 ? float3(tangent.x, -1.0, tangent.y) + : face == 4 ? float3(tangent.x, tangent.y, 1.0) + : float3(-tangent.x, tangent.y, -1.0); + return cultmath_normalize(cube); +} + +float2 gamecult_geometry_planetary_face_coordinate(float3 direction, out int face) +{ + float3 d = cultmath_normalize(direction); + float3 a = abs(d); + float2 cube_uv; + if (a.x >= a.y && a.x >= a.z) + { + if (d.x >= 0.0) { face = 0; cube_uv = float2(-d.z, d.y) / a.x; } + else { face = 1; cube_uv = float2(d.z, d.y) / a.x; } + } + else if (a.y >= a.z) + { + if (d.y >= 0.0) { face = 2; cube_uv = float2(d.x, -d.z) / a.y; } + else { face = 3; cube_uv = float2(d.x, d.z) / a.y; } + } + else + { + if (d.z >= 0.0) { face = 4; cube_uv = d.xy / a.z; } + else { face = 5; cube_uv = float2(-d.x, d.y) / a.z; } + } + return atan(cube_uv) / GAMECULT_GEOMETRY_PLANETARY_QUARTER_PI; +} + +bool gamecult_geometry_planetary_page_local(float3 direction, float4 tile_address, float active, out float2 local) +{ + int face; + float2 uv = gamecult_geometry_planetary_face_coordinate(direction, face); + local = 0.0; + if (active < 0.5 || abs((float)face - tile_address.x) > 0.25) return false; + float axis_tiles = exp2(tile_address.y); + float2 scaled = (uv * 0.5 + 0.5) * axis_tiles; + float2 canonical = floor(min(scaled, axis_tiles - 1.0e-5)); + if (any(abs(canonical - tile_address.zw) > 0.25)) return false; + local = scaled - tile_address.zw; + return all(local >= 0.0) && all(local <= 1.0); +} + +float3 gamecult_geometry_planetary_surface_normal(float3 direction, float3 world_distance_tangent_gradient) +{ + direction = cultmath_normalize(direction); + float3 tangent_gradient = world_distance_tangent_gradient - direction * dot(world_distance_tangent_gradient, direction); + return cultmath_normalize(direction - tangent_gradient); +} + +float2 gamecult_geometry_planetary_equirectangular_forward(float3 direction) +{ + direction = cultmath_normalize(direction); + return float2(atan2(direction.y, direction.x) / CULTMATH_PI, asin(clamp(direction.z, -1.0, 1.0)) / CULTMATH_HALF_PI); +} + +float3 gamecult_geometry_planetary_equirectangular_inverse(float2 coordinate) +{ + float longitude = coordinate.x * CULTMATH_PI; + float latitude = coordinate.y * CULTMATH_HALF_PI; + float cos_latitude = cos(latitude); + return cultmath_normalize(float3(cos_latitude * cos(longitude), cos_latitude * sin(longitude), sin(latitude))); +} + +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_A1 = 1.340264; +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_A2 = -0.081106; +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_A3 = 0.000893; +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_A4 = 0.003796; +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_M = 0.8660254037844386; +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_X_MAX = 2.706629; +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_Y_MAX = 1.3173628; + +float gamecult_geometry_equal_earth_y(float theta) +{ + float theta2 = theta * theta; + float theta6 = theta2 * theta2 * theta2; + return theta * (GAMECULT_GEOMETRY_EQUAL_EARTH_A1 + GAMECULT_GEOMETRY_EQUAL_EARTH_A2 * theta2 + theta6 * (GAMECULT_GEOMETRY_EQUAL_EARTH_A3 + GAMECULT_GEOMETRY_EQUAL_EARTH_A4 * theta2)); +} + +float2 gamecult_geometry_planetary_equal_earth_forward(float3 direction) +{ + direction = cultmath_normalize(direction); + float longitude = atan2(direction.y, direction.x); + float latitude = asin(clamp(direction.z, -1.0, 1.0)); + float theta = asin(GAMECULT_GEOMETRY_EQUAL_EARTH_M * sin(latitude)); + float theta2 = theta * theta; + float theta6 = theta2 * theta2 * theta2; + float denominator = GAMECULT_GEOMETRY_EQUAL_EARTH_M * (GAMECULT_GEOMETRY_EQUAL_EARTH_A1 + 3.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A2 * theta2 + theta6 * (7.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A3 + 9.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A4 * theta2)); + return float2(longitude * cos(theta) / denominator / GAMECULT_GEOMETRY_EQUAL_EARTH_X_MAX, gamecult_geometry_equal_earth_y(theta) / GAMECULT_GEOMETRY_EQUAL_EARTH_Y_MAX); +} + +float3 gamecult_geometry_planetary_equal_earth_inverse(float2 coordinate) +{ + float target_y = coordinate.y * GAMECULT_GEOMETRY_EQUAL_EARTH_Y_MAX; + float theta = target_y / GAMECULT_GEOMETRY_EQUAL_EARTH_A1; + [unroll] for (int iteration = 0; iteration < 8; iteration++) + { + float theta2 = theta * theta; + float theta6 = theta2 * theta2 * theta2; + float derivative = GAMECULT_GEOMETRY_EQUAL_EARTH_A1 + 3.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A2 * theta2 + theta6 * (7.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A3 + 9.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A4 * theta2); + theta -= (gamecult_geometry_equal_earth_y(theta) - target_y) / derivative; + } + float latitude = asin(clamp(sin(theta) / GAMECULT_GEOMETRY_EQUAL_EARTH_M, -1.0, 1.0)); + float theta2 = theta * theta; + float theta6 = theta2 * theta2 * theta2; + float denominator = GAMECULT_GEOMETRY_EQUAL_EARTH_M * (GAMECULT_GEOMETRY_EQUAL_EARTH_A1 + 3.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A2 * theta2 + theta6 * (7.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A3 + 9.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A4 * theta2)); + float longitude = coordinate.x * GAMECULT_GEOMETRY_EQUAL_EARTH_X_MAX * denominator / cos(theta); + float cos_latitude = cos(latitude); + return cultmath_normalize(float3(cos_latitude * cos(longitude), cos_latitude * sin(longitude), sin(latitude))); +} + +float gamecult_geometry_planetary_wrap_longitude(float longitude) +{ + longitude = fmod(longitude, CULTMATH_TAU); + if (longitude > CULTMATH_PI) longitude -= CULTMATH_TAU; + if (longitude < -CULTMATH_PI) longitude += CULTMATH_TAU; + return longitude; +} + +float3 gamecult_geometry_planetary_lon_lat_direction(float longitude, float latitude) +{ + float cos_latitude = cos(latitude); + return cultmath_normalize(float3(cos_latitude * cos(longitude), cos_latitude * sin(longitude), sin(latitude))); +} + +float gamecult_geometry_planetary_asinh(float value) { return log(value + sqrt(value * value + 1.0)); } +float gamecult_geometry_planetary_sinh(float value) { return 0.5 * (exp(value) - exp(-value)); } + +bool gamecult_geometry_planetary_azimuthal_forward(float3 direction, float2 center_lon_lat, int kind, out float2 coordinate) +{ + direction = cultmath_normalize(direction); + float longitude = gamecult_geometry_planetary_wrap_longitude(atan2(direction.y, direction.x) - center_lon_lat.x); + float latitude = asin(clamp(direction.z, -1.0, 1.0)); + float sin0 = sin(center_lon_lat.y), cos0 = cos(center_lon_lat.y); + float sin_latitude = sin(latitude), cos_latitude = cos(latitude), cos_longitude = cos(longitude); + float cos_c = clamp(sin0 * sin_latitude + cos0 * cos_latitude * cos_longitude, -1.0, 1.0); + float2 raw = float2(cos_latitude * sin(longitude), cos0 * sin_latitude - sin0 * cos_latitude * cos_longitude); + float factor = 1.0; + if (kind == 3) + { + if (cos_c < 0.0) { coordinate = 0.0; return false; } + } + else if (kind == 4) + { + float c = acos(cos_c); factor = (c < 1.0e-6 ? 1.0 : c / sin(c)) / CULTMATH_PI; + } + else if (kind == 5) factor = sqrt(2.0 / max(1.0 + cos_c, 1.0e-20)) * 0.5; + else + { + if (cos_c <= 0.0) { coordinate = 0.0; return false; } + factor = 1.0 / cos_c; + } + coordinate = raw * factor; + return true; +} + +bool gamecult_geometry_planetary_azimuthal_inverse(float2 coordinate, float2 center_lon_lat, int kind, out float3 direction) +{ + float rho = length(coordinate); + float c; + if (kind == 3) { if (rho > 1.0) { direction = 0.0; return false; } c = asin(clamp(rho, 0.0, 1.0)); } + else if (kind == 4) { if (rho > 1.0) { direction = 0.0; return false; } c = rho * CULTMATH_PI; } + else if (kind == 5) { if (rho > 1.0) { direction = 0.0; return false; } c = 2.0 * asin(clamp(rho, 0.0, 1.0)); } + else c = atan(rho); + if (rho < 1.0e-6) { direction = gamecult_geometry_planetary_lon_lat_direction(center_lon_lat.x, center_lon_lat.y); return true; } + float sin_c = sin(c), cos_c = cos(c), sin0 = sin(center_lon_lat.y), cos0 = cos(center_lon_lat.y); + float latitude = asin(clamp(cos_c * sin0 + coordinate.y * sin_c * cos0 / rho, -1.0, 1.0)); + float delta_longitude = atan2(coordinate.x * sin_c, rho * cos0 * cos_c - coordinate.y * sin0 * sin_c); + direction = gamecult_geometry_planetary_lon_lat_direction(gamecult_geometry_planetary_wrap_longitude(delta_longitude + center_lon_lat.x), latitude); + return true; +} + +bool gamecult_geometry_planetary_cube_atlas_forward(float3 direction, out float2 coordinate) +{ + int face; + float2 face_coordinate = gamecult_geometry_planetary_face_coordinate(direction, face); + int column = face % 3; + int row = face / 3; + coordinate = float2(-1.0 + (column + (face_coordinate.x + 1.0) * 0.5) * (2.0 / 3.0), 1.0 - (row + (face_coordinate.y + 1.0) * 0.5)); + return true; +} + +bool gamecult_geometry_planetary_cube_atlas_inverse(float2 coordinate, out float3 direction) +{ + if (any(coordinate < -1.0) || any(coordinate > 1.0)) { direction = 0.0; return false; } + int column = min((int)((coordinate.x + 1.0) * 1.5), 2); + int row = min((int)(1.0 - coordinate.y), 1); + float u = ((coordinate.x + 1.0) * 1.5 - column) * 2.0 - 1.0; + float v = ((1.0 - coordinate.y) - row) * 2.0 - 1.0; + direction = gamecult_geometry_planetary_face_direction(row * 3 + column, float2(u, v)); + return true; +} + +bool gamecult_geometry_planetary_projection_forward(float3 direction, int kind, float3 center_lon_lat_scale, out float2 coordinate) +{ + float scale = max(center_lon_lat_scale.z, 1.0e-20); + if (kind == 6) + { + bool valid = gamecult_geometry_planetary_cube_atlas_forward(direction, coordinate); + coordinate /= scale; return valid; + } + if (kind >= 3) + { + bool valid = gamecult_geometry_planetary_azimuthal_forward(direction, center_lon_lat_scale.xy, kind, coordinate); + coordinate /= scale; return valid; + } + direction = cultmath_normalize(direction); + float longitude = gamecult_geometry_planetary_wrap_longitude(atan2(direction.y, direction.x) - center_lon_lat_scale.x); + float latitude = asin(clamp(direction.z, -1.0, 1.0)); + if (kind == 0) coordinate = float2(longitude / CULTMATH_PI, latitude / CULTMATH_HALF_PI); + else if (kind == 1) + { + const float maximum_latitude = 1.4844222297453324; + if (abs(latitude) > maximum_latitude) { coordinate = 0.0; return false; } + coordinate = float2(longitude / CULTMATH_PI, gamecult_geometry_planetary_asinh(tan(latitude)) / CULTMATH_PI); + } + else + { + float3 centered = gamecult_geometry_planetary_lon_lat_direction(longitude, latitude); + coordinate = gamecult_geometry_planetary_equal_earth_forward(centered); + } + coordinate /= scale; + return true; +} + +bool gamecult_geometry_planetary_projection_inverse(float2 coordinate, int kind, float3 center_lon_lat_scale, out float3 direction) +{ + coordinate *= max(center_lon_lat_scale.z, 1.0e-20); + if (kind == 6) return gamecult_geometry_planetary_cube_atlas_inverse(coordinate, direction); + if (kind >= 3) return gamecult_geometry_planetary_azimuthal_inverse(coordinate, center_lon_lat_scale.xy, kind, direction); + if (any(abs(coordinate) > 1.0)) { direction = 0.0; return false; } + float longitude, latitude; + if (kind == 0) { longitude = coordinate.x * CULTMATH_PI; latitude = coordinate.y * CULTMATH_HALF_PI; } + else if (kind == 1) { longitude = coordinate.x * CULTMATH_PI; latitude = atan(gamecult_geometry_planetary_sinh(coordinate.y * CULTMATH_PI)); } + else + { + float3 centered = gamecult_geometry_planetary_equal_earth_inverse(coordinate); + longitude = atan2(centered.y, centered.x); latitude = asin(clamp(centered.z, -1.0, 1.0)); + } + direction = gamecult_geometry_planetary_lon_lat_direction(gamecult_geometry_planetary_wrap_longitude(longitude + center_lon_lat_scale.x), latitude); + return true; +} + +struct GameCultGeometryPlanetaryFieldDefinition +{ + float radius; + int seed; + GameCultGeometryAdvancedErosionParameters erosion; +}; + +struct GameCultGeometryPlanetaryBaseFieldSample +{ + float radial_displacement; + float3 radial_gradient; + float field_value; + float3 field_gradient; + float fade_target; +}; + +struct GameCultGeometryPlanetarySurfaceSample +{ + float radial_displacement; + float3 tangent_gradient; + float3 surface_normal; + float slope; + float ridge; + float gully; + float finest_resolved_wavelength; + float unresolved_height_bound; +}; + +struct GameCultGeometryPlanetaryPageSample +{ + float4 height_gradient; + float2 masks; +}; + +GameCultGeometryPlanetaryPageSample gamecult_geometry_planetary_page_lerp( + GameCultGeometryPlanetaryPageSample a, + GameCultGeometryPlanetaryPageSample b, + GameCultGeometryPlanetaryPageSample c, + GameCultGeometryPlanetaryPageSample d, + float2 fraction) +{ + GameCultGeometryPlanetaryPageSample result; + result.height_gradient = lerp(lerp(a.height_gradient, b.height_gradient, fraction.x), lerp(c.height_gradient, d.height_gradient, fraction.x), fraction.y); + result.masks = lerp(lerp(a.masks, b.masks, fraction.x), lerp(c.masks, d.masks, fraction.x), fraction.y); + return result; +} + +GameCultGeometryPlanetaryPageSample gamecult_geometry_planetary_residual_sample( + GameCultGeometryPlanetarySurfaceSample child, + GameCultGeometryPlanetarySurfaceSample parent, + bool has_parent) +{ + GameCultGeometryPlanetaryPageSample result; + result.height_gradient = float4(child.radial_displacement, child.tangent_gradient); + if (has_parent) result.height_gradient -= float4(parent.radial_displacement, parent.tangent_gradient); + result.masks = float2(child.ridge, child.gully); + return result; +} + +float4 gamecult_geometry_planetary_summary_empty() +{ + return float4(3.402823466e+38, -3.402823466e+38, 0.0, 0.0); +} + +float4 gamecult_geometry_planetary_summary_accumulate(float4 summary, float4 height_gradient, float unresolved_height) +{ + summary.x = min(summary.x, height_gradient.x); + summary.y = max(summary.y, height_gradient.x); + summary.z = max(summary.z, length(height_gradient.yzw)); + summary.w = max(summary.w, max(unresolved_height, 0.0)); + return summary; +} + +float gamecult_geometry_planetary_pow4(float value) { float square = value * value; return square * square; } + +GameCultGeometryPlanetarySurfaceSample gamecult_geometry_planetary_field_sample( + GameCultGeometryPlanetaryFieldDefinition definition, + float3 unit_direction, + GameCultGeometryPlanetaryBaseFieldSample base_sample, + float footprint_meters) +{ + float3 direction = cultmath_normalize(unit_direction); + float3 radial_gradient = base_sample.radial_gradient - direction * dot(base_sample.radial_gradient, direction); + float3 field_gradient = base_sample.field_gradient - direction * dot(base_sample.field_gradient, direction); + GameCultGeometryAdvancedErosionParameters p = definition.erosion; + GameCultGeometryErosionBandSelection band = gamecult_geometry_select_erosion_bands( + p.scale * p.cell_scale, footprint_meters, p.octaves, p.lacunarity, + p.strength * p.scale, p.gain, 2.0); + float3 powers = float3( + gamecult_geometry_planetary_pow4(abs(direction.x)), + gamecult_geometry_planetary_pow4(abs(direction.y)), + gamecult_geometry_planetary_pow4(abs(direction.z))); + float power_sum = max(powers.x + powers.y + powers.z, 1.0e-6); + float3 weights = powers / power_sum; + float3 world = direction * definition.radius; + float seed = (float)definition.seed; + GameCultGeometryAdvancedErosionResult xy = gamecult_geometry_advanced_erosion_filter_banded( + world.xy + float2(713.0 + seed * 0.754877666, -291.0 + seed * 0.569840296), + float3(base_sample.field_value, field_gradient.x, field_gradient.y), base_sample.fade_target, p, band); + GameCultGeometryAdvancedErosionResult yz = gamecult_geometry_advanced_erosion_filter_banded( + world.yz + float2(-431.0 + seed * 0.438289027, 887.0 + seed * 0.328438163), + float3(base_sample.field_value, field_gradient.y, field_gradient.z), base_sample.fade_target, p, band); + GameCultGeometryAdvancedErosionResult zx = gamecult_geometry_advanced_erosion_filter_banded( + world.zx + float2(197.0 + seed * 0.219783071, 557.0 + seed * 0.145898034), + float3(base_sample.field_value, field_gradient.z, field_gradient.x), base_sample.fade_target, p, band); + float erosion_height = xy.delta.x * weights.z + yz.delta.x * weights.x + zx.delta.x * weights.y; + float3 erosion_gradient = float3( + xy.delta.y * weights.z + zx.delta.z * weights.y, + xy.delta.z * weights.z + yz.delta.y * weights.x, + yz.delta.z * weights.x + zx.delta.y * weights.y); + float3 power_gradient_x = float3(4.0 * direction.x * direction.x * direction.x / definition.radius, 0.0, 0.0); + float3 power_gradient_y = float3(0.0, 4.0 * direction.y * direction.y * direction.y / definition.radius, 0.0); + float3 power_gradient_z = float3(0.0, 0.0, 4.0 * direction.z * direction.z * direction.z / definition.radius); + power_gradient_x -= direction * dot(power_gradient_x, direction); + power_gradient_y -= direction * dot(power_gradient_y, direction); + power_gradient_z -= direction * dot(power_gradient_z, direction); + float3 power_sum_gradient = power_gradient_x + power_gradient_y + power_gradient_z; + float inverse_power_sum_squared = 1.0 / (power_sum * power_sum); + float3 weight_gradient_x = (power_gradient_x * power_sum - power_sum_gradient * powers.x) * inverse_power_sum_squared; + float3 weight_gradient_y = (power_gradient_y * power_sum - power_sum_gradient * powers.y) * inverse_power_sum_squared; + float3 weight_gradient_z = (power_gradient_z * power_sum - power_sum_gradient * powers.z) * inverse_power_sum_squared; + erosion_gradient += yz.delta.x * weight_gradient_x + zx.delta.x * weight_gradient_y + xy.delta.x * weight_gradient_z; + float3 gradient = radial_gradient + erosion_gradient; + gradient -= direction * dot(gradient, direction); + float ridge_evidence = xy.ridge_map * weights.z + yz.ridge_map * weights.x + zx.ridge_map * weights.y; + GameCultGeometryPlanetarySurfaceSample result; + result.radial_displacement = base_sample.radial_displacement + erosion_height; + result.tangent_gradient = gradient; + result.surface_normal = gamecult_geometry_planetary_surface_normal(direction, gradient); + result.slope = length(gradient); + result.ridge = saturate(ridge_evidence * 0.5 + 0.5); + result.gully = saturate(0.5 - ridge_evidence * 0.5); + result.finest_resolved_wavelength = band.finest_included_wavelength; + result.unresolved_height_bound = band.unresolved_height_bound; + return result; +} + +#endif diff --git a/src/GameCult.Geometry/Shaders/Planetary.hlsl.meta b/src/GameCult.Geometry/Shaders/Planetary.hlsl.meta new file mode 100644 index 00000000..009c2989 --- /dev/null +++ b/src/GameCult.Geometry/Shaders/Planetary.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: bb055ad5328888e479e90ae3fe62c3fe +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/GameCult.Geometry/Shaders/PlanetaryRadialRefinement.hlsl b/src/GameCult.Geometry/Shaders/PlanetaryRadialRefinement.hlsl new file mode 100644 index 00000000..20f3e2e5 --- /dev/null +++ b/src/GameCult.Geometry/Shaders/PlanetaryRadialRefinement.hlsl @@ -0,0 +1,27 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +#ifndef GAMECULT_GEOMETRY_PLANETARY_RADIAL_REFINEMENT_HLSL +#define GAMECULT_GEOMETRY_PLANETARY_RADIAL_REFINEMENT_HLSL + +float3 gamecult_geometry_planetary_radial_refinement_step( + float3 position, + float3 ray_direction, + float3 center, + float target_radius, + float minimum_derivative, + float maximum_correction) +{ + float3 local=position-center; + float radius=length(local); + float3 radial=local/max(radius,0.0001); + float error=radius-target_radius; + float derivative=dot(ray_direction,radial); + float safe_minimum=max(minimum_derivative,0.0001); + float safe_derivative=abs(derivative)>safe_minimum?derivative:(derivative<0?-safe_minimum:safe_minimum); + float correction=clamp(-error/safe_derivative,-abs(maximum_correction),abs(maximum_correction)); + return position+ray_direction*correction; +} + +#endif diff --git a/src/GameCult.Geometry/Shaders/PlanetaryRadialRefinement.hlsl.meta b/src/GameCult.Geometry/Shaders/PlanetaryRadialRefinement.hlsl.meta new file mode 100644 index 00000000..28d629b1 --- /dev/null +++ b/src/GameCult.Geometry/Shaders/PlanetaryRadialRefinement.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 356389371b854e12b9d419f7ee310d81 +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/GameCult.Geometry/Shaders/SphericalErosion.hlsl b/src/GameCult.Geometry/Shaders/SphericalErosion.hlsl new file mode 100644 index 00000000..107ba24b --- /dev/null +++ b/src/GameCult.Geometry/Shaders/SphericalErosion.hlsl @@ -0,0 +1,80 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +#ifndef GAMECULT_GEOMETRY_SPHERICAL_EROSION_HLSL +#define GAMECULT_GEOMETRY_SPHERICAL_EROSION_HLSL + +struct GameCultGeometrySphericalErosionParameters +{ + float frequency; float amplitude; float lacunarity; float gain; + float slope_strength; float detail; float seed; int octaves; +}; + +struct GameCultGeometrySphericalErosionSample +{ + float height_offset; float ridge; float gully; float3 gradient; +}; + +float gamecult_geometry_spherical_cell_wave(float3 position, float3 flow, float3 cell, float seed) +{ + float3 pivot = cell + float3(cultmath_hash(cell + seed + 11.7), cultmath_hash(cell.yzx + seed + 37.1), cultmath_hash(cell.zyx + seed + 73.9)); + return dot(position - pivot, flow) * 6.28318530718 + cultmath_hash(cell + seed + 101.3) * 6.28318530718; +} + +float gamecult_geometry_spherical_directional_wave(float3 position, float3 flow, float seed) +{ + float3 cell = floor(position); + float3 local = cultmath_frac(position); + float3 weights = local * local * (3.0 - 2.0 * local); + float z0 = cultmath_lerp( + cultmath_lerp(gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(0,0,0), seed), gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(1,0,0), seed), weights.x), + cultmath_lerp(gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(0,1,0), seed), gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(1,1,0), seed), weights.x), weights.y); + float z1 = cultmath_lerp( + cultmath_lerp(gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(0,0,1), seed), gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(1,0,1), seed), weights.x), + cultmath_lerp(gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(0,1,1), seed), gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(1,1,1), seed), weights.x), weights.y); + return cultmath_lerp(z0, z1, weights.z); +} + +float3 gamecult_geometry_spherical_stable_tangent(float3 position) +{ + float3 axis = abs(position.z) < 0.8 ? float3(0,0,1) : float3(0,1,0); + return cultmath_normalize(cross(axis, position)); +} + +GameCultGeometrySphericalErosionSample gamecult_geometry_spherical_erosion(float3 unit_position, float base_height, float3 base_gradient, GameCultGeometrySphericalErosionParameters p) +{ + float3 position = cultmath_normalize(unit_position); + float3 gradient = base_gradient - position * dot(base_gradient, position); + float slope = length(gradient); + float3 flow = slope > 1.0e-6 ? -gradient / slope : gamecult_geometry_spherical_stable_tangent(position); + float frequency = max(p.frequency, 0.001); + float amplitude = p.amplitude; + float combi_mask = 1.0 - pow(1.0 - cultmath_saturate(slope * p.slope_strength), 2.0); + float fade_target = cultmath_saturate(base_height * 0.5 + 0.5) * 2.0 - 1.0; + GameCultGeometrySphericalErosionSample result = (GameCultGeometrySphericalErosionSample)0; + [loop] for (int octave = 0; octave < min(max(p.octaves, 0), 8); octave++) + { + float phase = gamecult_geometry_spherical_directional_wave(position * frequency, flow, p.seed + octave * 19.19); + float wave = cos(phase); + float wave_slope = sin(phase); + float faded = cultmath_lerp(fade_target, wave, combi_mask); + result.height_offset += faded * amplitude; + result.ridge = max(result.ridge, cultmath_saturate(faded)); + result.gully = max(result.gully, cultmath_saturate(-faded)); + float3 across = cultmath_normalize(cross(position, flow)); + gradient += across * (sign(wave_slope) * amplitude * frequency * combi_mask); + gradient -= position * dot(gradient, position); + slope = length(gradient); + flow = slope > 1.0e-6 ? -gradient / slope : gamecult_geometry_spherical_stable_tangent(position); + float new_mask = 1.0 - pow(1.0 - cultmath_saturate(abs(wave_slope) * 1.35), 2.0); + combi_mask = (1.0 - pow(1.0 - cultmath_saturate(combi_mask), max(p.detail, 0.001))) * new_mask; + fade_target = faded; + frequency *= max(p.lacunarity, 1.01); + amplitude *= cultmath_saturate(p.gain); + } + result.gradient = gradient; + return result; +} + +#endif diff --git a/src/GameCult.Geometry/Shaders/SphericalErosion.hlsl.meta b/src/GameCult.Geometry/Shaders/SphericalErosion.hlsl.meta new file mode 100644 index 00000000..7b7ddc79 --- /dev/null +++ b/src/GameCult.Geometry/Shaders/SphericalErosion.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 43d458fc5b6b441a941e37001df919e3 +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/GameCult.Geometry/THIRD-PARTY-NOTICES.md b/src/GameCult.Geometry/THIRD-PARTY-NOTICES.md new file mode 100644 index 00000000..3388b601 --- /dev/null +++ b/src/GameCult.Geometry/THIRD-PARTY-NOTICES.md @@ -0,0 +1,24 @@ +# GameCult.Geometry source provenance + +## CultMath planetary geometry + +The C# files under `Planetary/` and the HLSL files under `Shaders/` are licensed +under the Mozilla Public License 2.0 and retain MPL-2.0 notices. CultMath owns +the numeric primitives used by these geometry systems; GameCult.Geometry owns +their spatial meaning. + +## Advanced Terrain Erosion Filter + +`Planetary/AdvancedErosionFilter.cs` and `Shaders/AdvancedErosionFilter.hlsl` +adapt the mathematical procedure from +Rune Skovbo Johansen's **Advanced Terrain Erosion Filter**. + +- Copyright 2025 Rune Skovbo Johansen. +- Upstream: +- Explanation: +- License: Mozilla Public License 2.0, + +The implementation includes C#/HLSL type lowering, bounded octave counts, +finite zero-rounding behavior, and a shared integer avalanche hash for CPU/GPU +cell-selection parity. The technique builds on earlier work credited upstream +to Fewes and Clay John; the hash form follows public shader math by Inigo Quilez. diff --git a/src/GameCult.Geometry/docs/planetary.md b/src/GameCult.Geometry/docs/planetary.md new file mode 100644 index 00000000..94eab553 --- /dev/null +++ b/src/GameCult.Geometry/docs/planetary.md @@ -0,0 +1,135 @@ +# Planetary Field Runtime + +GameCult.Geometry owns the deterministic, renderer-independent planetary field. The same +definition can be queried by a CPU simulation, baked into residual pages, +sampled through a map projection, or evaluated by HLSL. Render resources and +gameplay interpretation remain consumer responsibilities. + +## Authority + +- `PlanetaryFieldDefinition` identifies radius, seed, erosion parameters, and a + version that includes the consumer-owned base-field version. +- `IPlanetaryBaseField` supplies deterministic application-authored base + evidence without making Geometry depend on an application. +- `PlanetaryQueryScale` makes the physical footprint and accepted unresolved + error explicit. There is deliberately no scale-free authoritative + `HeightAt` call. +- `PlanetarySurfaceSample` carries displacement, tangent gradient, normal, + slope, ridge/gully evidence, resolved wavelength, and unresolved bound. +- pages, residency, patches, and map tiles are disposable lowerings of that + contract. + +The C# surface targets .NET and Unity-compatible `netstandard2.1`. The matching +HLSL lives in `Shaders/Planetary.hlsl`. Consumers include +`Shaders/GameCult.Geometry.hlsl`, which imports CultMath's numeric mirror before +the geometry kernels. + +## Point and batch queries + +```csharp +var erosion = AdvancedErosionParameters.Default with +{ + Scale = radius * 0.075f, + Strength = 0.12f, + Octaves = 7, +}; +var field = PlanetaryFieldDefinition.Create( + baseFieldVersion: 17, + radius, + seed: 42, + erosion); +var scale = PlanetaryQueryScale.AtFootprint( + footprintMeters: 10, + maximumUnresolvedHeight: 0.5f); + +PlanetaryBaseFieldSample basis = source.Sample(direction); +PlanetarySurfaceSample sample = PlanetaryField.Sample( + field, direction, basis, scale); +``` + +`PlanetaryField.SampleBatch` writes into a caller-owned span. `SamplePosition` +accepts `double3`, reduces the astronomical position to a stable unit direction, +and then evaluates the same float field contract. + +Higher-level CPU queries include bounded ray intersection, segment clearance, +great-circle profiles, and region summaries. Their answers remain field queries; +only a consuming simulation can turn them into committed game consequences. + +## Cube-sphere topology and pages + +`PlanetaryTopology` owns the tangent/QSC cube-sphere mapping, inverse face +coordinates, tile selection, local page coordinates, and radial-graph surface +normal. `PlanetaryPageSampling` owns bordered directions, nominal spacing, +bilinear sampling, and summaries. + +`PlanetaryPageBaker` evaluates a root page at its physical sample spacing. A +child stores `child filtered field - parent filtered field`. A +`PlanetaryPageSetSampling` query adds every containing ancestor contribution +and applies transition weights only to residuals. + +`PlanetaryLodSelector` selects six roots plus one camera-facing ancestor chain +from the unresolved-height error. `PlanetaryResidualResidency` owns arrival and +departure presentation weights, content and presentation versions, and nothing +about field truth. + +## Patches + +`PlanetaryPatch.CreateFace` and `CreateCubeSphere` produce renderer-neutral +coarse meshes from the canonical topology. HLSL calls +`gamecult_geometry_planetary_face_direction` for procedural vertices and +`gamecult_geometry_planetary_radial_refinement_step` for bounded pixel refinement. + +Renderers still own cameras, clip space, depth, materials, buffers, dispatch, +and draw submission. + +`PlanetaryGpuPageBuilder` owns the renderer-neutral page input and metadata +payload. Host adapters may convert its `float4` values to native vector types, +but do not recalculate directions, spacing, addressing, or transition state. + +The `GameCult.Geometry.Unity` assembly in `org.gamecult.geometry` converts a canonical face patch into a +Unity `Mesh` and converts the shared GPU payload into Unity-native structs. It +owns no field parameters, page selection, or simulation state. The package's +Planetary Field Viewer sample renders the shared HLSL field and reports matching +CPU queries. Unity render code remains responsible for allocating/binding +graphics buffers and dispatching application-specific base-field entry points. +The host Unity project must set `-langversion:latest` in `Assets/csc.rsp`. + +## Maps + +`PlanetaryProjection` supplies forward and inverse transforms for: + +- equirectangular; +- Web Mercator with an explicit polar cutoff; +- Equal Earth; +- orthographic; +- azimuthal equidistant and equal-area; +- cube atlas; +- local tangent/gnomonic views. + +`PlanetaryMapTileBaker` maps each valid tile sample through the inverse +projection and canonical field query. `PlanetaryMapTileKey` includes field, +projection, layer, layout, and query-scale identity. Palettes, contours, labels, +and strategic overlays do not enter GameCult.Geometry's canonical field. + +`PlanetaryMapTileEncoding` writes the resulting evidence as a versioned +little-endian `CMPT` binary asset. +`packages/gamecult-geometry-ts/src/planetary-tile.ts` decodes that exact format +into browser-native values without defining another terrain generator. +Cross-runtime fixtures are generated by +`tools/GameCult.Geometry.PlanetaryTileFixture`. +Typed CultMesh documents should carry the tile key and asset reference; the +binary payload is derived CDN/cache data, not committed game state. + +## Numeric contract + +Integer hashes and published field versions are exact. Basic arithmetic follows +CultMath's matched C#/HLSL semantics. Hardware transcendental functions such as +`atan` and `tan` use measured tolerances rather than fictional bit identity. +Every filtered terrain query carries an unresolved-height bound so consumers can +reject insufficient fidelity explicitly. + +Current D3D12 parity evidence covers the advanced erosion kernel, QSC topology, +and forward/inverse transforms for every supported map projection, including +center and scale parameters. Production page and patch shaders use the common +functions and retain their seam, residual, summary, and radial-hit integration +probes in Fensalir. diff --git a/tests/GameCult.Geometry.Planetary.Tests/AdvancedErosionFilterTests.cs b/tests/GameCult.Geometry.Planetary.Tests/AdvancedErosionFilterTests.cs new file mode 100644 index 00000000..de5ccfb0 --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/AdvancedErosionFilterTests.cs @@ -0,0 +1,47 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class AdvancedErosionFilterTests +{ + [Fact] + public void DefaultReferenceFixtureIsStable() + { + var result = AdvancedErosionFilter.Sample( + new float2(0.42f, 0.31f), + new float3(0.5f, 0.1f, -0.2f), + 0.0f, + AdvancedErosionParameters.Default); + + Assert.Equal(0.0639375f, result.Magnitude, precision: 7); + Assert.True(float.IsFinite(result.Delta.x)); + Assert.True(float.IsFinite(result.Delta.y)); + Assert.True(float.IsFinite(result.Delta.z)); + Assert.InRange(result.RidgeMap, -1.0f, 1.0f); + } + + [Fact] + public void FlatInputAndZeroRoundingRemainFinite() + { + var parameters = AdvancedErosionParameters.Default with { Rounding = float4.zero }; + var result = AdvancedErosionFilter.Sample(new float2(0.25f, 0.75f), float3.zero, 0.0f, parameters); + + Assert.True(float.IsFinite(result.Delta.x)); + Assert.True(float.IsFinite(result.RidgeMap)); + Assert.True(float.IsFinite(result.FadeTarget)); + } + + [Fact] + public void PhacellePartialNormalizationIsFiniteAtEndpoints() + { + foreach (var normalization in new[] { 0.0f, 0.5f, 1.0f }) + { + var sample = AdvancedErosionFilter.Phacelle(new float2(1.7f, -3.2f), new float2(0.6f, -0.8f), 0.7f, 0.25f, normalization); + Assert.True(float.IsFinite(sample.x)); + Assert.True(float.IsFinite(sample.y)); + } + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/ErosionFrequencyBandsTests.cs b/tests/GameCult.Geometry.Planetary.Tests/ErosionFrequencyBandsTests.cs new file mode 100644 index 00000000..b17a2292 --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/ErosionFrequencyBandsTests.cs @@ -0,0 +1,37 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class ErosionFrequencyBandsTests +{ + [Fact] + public void RadiusIndependentWorldWavelengthSelectsSameBands() + { + var small = ErosionFrequencyBands.Select(1_000.0f, 100.0f, 8, 2.0f, 20.0f, 0.5f); + var large = ErosionFrequencyBands.Select(1_000.0f, 100.0f, 8, 2.0f, 20.0f, 0.5f); + Assert.Equal(small, large); + Assert.Equal(3, small.ActiveOctaves); + } + + [Fact] + public void TransitionPartiallyWeightsLastResolvableOctave() + { + var selection = ErosionFrequencyBands.Select(1_000.0f, 50.0f, 8, 2.0f, 1.0f, 0.5f); + Assert.Equal(4, selection.ActiveOctaves); + Assert.InRange(selection.FinalOctaveWeight, 0.0f, 1.0f); + Assert.True(selection.UnresolvedHeightBound > 0.0f); + } + + [Fact] + public void BandedFilterPreservesLowOctaveResultExactly() + { + var p = AdvancedErosionParameters.Default; + var band = new ErosionBandSelection(2, 1.0f, 0.0f, 0.0f); + var banded = AdvancedErosionFilter.Sample(new float2(0.42f, 0.31f), new float3(0.5f, 0.1f, -0.2f), 0.0f, p, band); + var truncated = AdvancedErosionFilter.Sample(new float2(0.42f, 0.31f), new float3(0.5f, 0.1f, -0.2f), 0.0f, p with { Octaves = 2 }); + Assert.Equal(truncated, banded); + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/GameCult.Geometry.Planetary.Tests.csproj b/tests/GameCult.Geometry.Planetary.Tests/GameCult.Geometry.Planetary.Tests.csproj new file mode 100644 index 00000000..b2511182 --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/GameCult.Geometry.Planetary.Tests.csproj @@ -0,0 +1,18 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + diff --git a/tests/GameCult.Geometry.Planetary.Tests/PlanetaryFieldTests.cs b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryFieldTests.cs new file mode 100644 index 00000000..ba934f33 --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryFieldTests.cs @@ -0,0 +1,83 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class PlanetaryFieldTests +{ + private static readonly PlanetaryFieldDefinition Definition = new( + 0x7f59_6d11_02ab_9c31UL, + 8.4f, + 42, + AdvancedErosionParameters.Default with { Scale = 0.63f, Strength = 0.12f, Octaves = 7 }); + + [Fact] + public void SampleIsDeterministicScaleAwareAndTangent() + { + var direction = math.normalize(new float3(0.31f, -0.72f, 0.61f)); + var source = new FixtureField(); + var coarse = PlanetaryField.Sample(Definition, direction, source.Sample(direction), PlanetaryQueryScale.AtFootprint(0.1f)); + var fine = PlanetaryField.Sample(Definition, direction, source.Sample(direction), PlanetaryQueryScale.AtFootprint(0.01f)); + var repeated = PlanetaryField.Sample(Definition, direction, source.Sample(direction), PlanetaryQueryScale.AtFootprint(0.01f)); + + Assert.Equal(fine, repeated); + Assert.Equal(Definition.FieldVersion, fine.FieldVersion); + Assert.True(fine.UnresolvedHeightBound <= coarse.UnresolvedHeightBound); + Assert.True(fine.FinestResolvedWavelength <= coarse.FinestResolvedWavelength); + Assert.InRange(MathF.Abs(math.dot(fine.UnitDirection, fine.TangentGradient)), 0, 2.0e-5f); + Assert.Equal(1, math.length(fine.SurfaceNormal), 5); + } + + [Fact] + public void BatchAndDoublePositionUseThePointContract() + { + var directions = new[] + { + math.normalize(new float3(1, 2, 3)), + math.normalize(new float3(-2, 0.5f, 1)), + math.normalize(new float3(0.1f, -0.2f, -1)), + }; + var output = new PlanetarySurfaceSample[directions.Length]; + var source = new FixtureField(); + var scale = PlanetaryQueryScale.AtFootprint(0.05f); + PlanetaryField.SampleBatch(Definition, directions, scale, source, output); + + for (var i = 0; i < directions.Length; i++) + { + var normalized = math.normalize(directions[i]); + var expected = PlanetaryField.Sample(Definition, normalized, source.Sample(normalized), scale); + Assert.InRange(MathF.Abs(expected.RadialDisplacement - output[i].RadialDisplacement), 0, 1.0e-6f); + Assert.InRange(math.distance(expected.TangentGradient, output[i].TangentGradient), 0, 1.0e-6f); + } + + var d = directions[0]; + var position = new double3(d.x * 8.0e11, d.y * 8.0e11, d.z * 8.0e11); + var positioned = PlanetaryField.SamplePosition(Definition, position, scale, source); + Assert.InRange(math.distance(positioned.UnitDirection, d), 0, 2.0e-6f); + } + + [Fact] + public void FieldIdentityChangesWithEveryLoadBearingInput() + { + var erosion = AdvancedErosionParameters.Default; + var baseline = PlanetaryFieldDefinition.Create(3, 8.4f, 7, erosion); + Assert.Equal(baseline.FieldVersion, PlanetaryFieldDefinition.Create(3, 8.4f, 7, erosion).FieldVersion); + Assert.NotEqual(baseline.FieldVersion, PlanetaryFieldDefinition.Create(4, 8.4f, 7, erosion).FieldVersion); + Assert.NotEqual(baseline.FieldVersion, PlanetaryFieldDefinition.Create(3, 8.5f, 7, erosion).FieldVersion); + Assert.NotEqual(baseline.FieldVersion, PlanetaryFieldDefinition.Create(3, 8.4f, 8, erosion).FieldVersion); + Assert.NotEqual(baseline.FieldVersion, PlanetaryFieldDefinition.Create(3, 8.4f, 7, erosion with { Gain = 0.49f }).FieldVersion); + } + + private readonly struct FixtureField : IPlanetaryBaseField + { + public PlanetaryBaseFieldSample Sample(float3 direction) + { + var value = direction.x * 0.6f + direction.y * direction.z * 0.3f; + var fieldGradient = new float3(0.6f, direction.z * 0.3f, direction.y * 0.3f); + fieldGradient -= direction * math.dot(fieldGradient, direction); + return new(value * 0.1f, fieldGradient * 0.1f, value, fieldGradient, math.clamp(value, -1, 1)); + } + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/PlanetaryGpuPageTests.cs b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryGpuPageTests.cs new file mode 100644 index 00000000..1a8f96ef --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryGpuPageTests.cs @@ -0,0 +1,28 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class PlanetaryGpuPageTests +{ + [Fact] + public void UploadContentUsesCanonicalDirectionsSpacingAndMetadata() + { + var layout = new PlanetaryPageLayout(new(PlanetaryCubeFace.NegativeY, 3, 4, 2), 9, 2); + var content = PlanetaryGpuPageBuilder.BuildContent(layout, 8.4f); + Assert.Equal(layout.StorageSize * layout.StorageSize, content.Inputs.Length); + Assert.True(content.ParentSampleSpacing > content.SampleSpacing); + var first = content.Inputs[0]; + Assert.Equal(PlanetaryPageSampling.Direction(layout, 0, 0), first.DirectionRadius.xyz); + Assert.Equal(8.4f, first.DirectionRadius.w); + Assert.Equal(content.SampleSpacing, first.Sampling.x); + Assert.Equal(content.ParentSampleSpacing, first.Sampling.y); + + var metadata = PlanetaryGpuPageBuilder.Metadata(content, 37, 0.25f); + Assert.Equal(new float4((int)layout.Tile.Face, layout.Tile.Level, layout.Tile.X, layout.Tile.Y), metadata.Address); + Assert.Equal(new float4(37, layout.StorageSize, layout.InteriorSize, layout.BorderSize), metadata.Layout); + Assert.Equal(0.25f, metadata.State.y); + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/PlanetaryMapTileEncodingTests.cs b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryMapTileEncodingTests.cs new file mode 100644 index 00000000..5b7f2397 --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryMapTileEncodingTests.cs @@ -0,0 +1,55 @@ +using System.IO; +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class PlanetaryMapTileEncodingTests +{ + [Fact] + public void BinaryTileRoundTripsIdentityValidityAndSurfaceEvidence() + { + var field = PlanetaryFieldDefinition.Create(5, 10, 3, AdvancedErosionParameters.Default); + var layout = new PlanetaryMapTileLayout(new(PlanetaryProjectionKind.EqualEarth), 1, 1, 0, 5, 1); + var key = new PlanetaryMapTileKey(field.FieldVersion, 2, 17, layout, PlanetaryQueryScale.AtFootprint(0.25f, 0.1f)); + var tile = PlanetaryMapTileBaker.Bake(field, key, new FlatField()); + using var bytes = new MemoryStream(); + PlanetaryMapTileEncoding.Write(bytes, tile, leaveOpen: true); + bytes.Position = 0; + var decoded = PlanetaryMapTileEncoding.Read(bytes); + + Assert.Equal(tile.Key, decoded.Key); + Assert.Equal(tile.Validity, decoded.Validity); + for (var i = 0; i < tile.Samples.Length; i++) + if (tile.Validity[i]) Assert.Equal(tile.Samples[i], decoded.Samples[i]); + } + + [Fact] + public void DecoderRejectsForeignMagicAndVersions() + { + Assert.Throws(() => PlanetaryMapTileEncoding.Read(new MemoryStream(new byte[] { 1, 2, 3, 4, 1, 0, 0, 0 }))); + var bytes = new byte[] { (byte)'C', (byte)'M', (byte)'P', (byte)'T', 99, 0, 0, 0 }; + Assert.Throws(() => PlanetaryMapTileEncoding.Read(new MemoryStream(bytes))); + } + + [Fact] + public void DecoderRejectsTrailingBytes() + { + var field = PlanetaryFieldDefinition.Create(2, 1, 0, AdvancedErosionParameters.Default); + var layout = new PlanetaryMapTileLayout(new(PlanetaryProjectionKind.Equirectangular), 0, 0, 0, 2, 0); + var key = new PlanetaryMapTileKey(field.FieldVersion, 1, 0, layout, PlanetaryQueryScale.AtFootprint(1)); + var tile = PlanetaryMapTileBaker.Bake(field, key, new FlatField()); + using var bytes = new MemoryStream(); + PlanetaryMapTileEncoding.Write(bytes, tile, leaveOpen: true); + bytes.WriteByte(0xff); + bytes.Position = 0; + Assert.Throws(() => PlanetaryMapTileEncoding.Read(bytes)); + } + + private readonly struct FlatField : IPlanetaryBaseField + { + public PlanetaryBaseFieldSample Sample(float3 direction) => new(0, float3.zero, 0, float3.zero, 0); + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/PlanetaryPageTests.cs b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryPageTests.cs new file mode 100644 index 00000000..88ae15c1 --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryPageTests.cs @@ -0,0 +1,60 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class PlanetaryPageTests +{ + [Fact] + public void BilinearSamplingAndSummaryUseOnePageContract() + { + var layout = new PlanetaryPageLayout(new(PlanetaryCubeFace.PositiveZ, 0, 0, 0), 3, 1); + var samples = new PlanetaryPageSample[layout.StorageSize * layout.StorageSize]; + for (var y = 0; y < layout.StorageSize; y++) + for (var x = 0; x < layout.StorageSize; x++) + samples[y * layout.StorageSize + x] = new(x + y * 10, new float3(x, y, 0), x / 10.0f, y / 10.0f); + + var sample = PlanetaryPageSampling.Bilinear(samples, layout, new float2(0.25f, 0.75f)); + Assert.Equal(26.5f, sample.Height, 4); + Assert.Equal(new float3(1.5f, 2.5f, 0), sample.TangentGradient); + var summary = PlanetaryPageSampling.Summarize(samples, layout, 0.125f); + Assert.Equal(0, summary.MinimumHeight); + Assert.Equal(44, summary.MaximumHeight); + Assert.Equal(0.125f, summary.UnresolvedHeightBound); + Assert.True(summary.MaximumSlope > 0); + } + + [Fact] + public void ChildPageStoresOnlyResidualAndComposesBackToDirectField() + { + var field = new PlanetaryFieldDefinition(7, 8.4f, 3, AdvancedErosionParameters.Default with + { + Scale = 0.63f, + Strength = 0.12f, + Octaves = 7, + }); + var source = new FixtureField(); + var root = PlanetaryPageBaker.Bake(field, new(new(PlanetaryCubeFace.PositiveZ, 0, 0, 0), 17, 2), source); + var child = PlanetaryPageBaker.Bake(field, new(new(PlanetaryCubeFace.PositiveZ, 1, 1, 0), 17, 2), source); + var direction = PlanetaryPageSampling.DirectionAtLocal(child.Layout, 0.5, 0.5); + var pages = new[] { new PlanetaryResidentPage(root, 1), new PlanetaryResidentPage(child, 1) }; + Assert.True(PlanetaryPageSetSampling.TrySample(pages, direction, out var composed)); + var spacing = PlanetaryPageSampling.NominalAngularTexelSize(child.Layout) * field.Radius; + var direct = PlanetaryField.Sample(field, direction, source.Sample(direction), PlanetaryQueryScale.AtFootprint(spacing)); + Assert.InRange(MathF.Abs(composed.Height - direct.RadialDisplacement), 0, 2.0e-5f); + Assert.InRange(math.distance(composed.TangentGradient, direct.TangentGradient), 0, 3.0e-5f); + } + + private readonly struct FixtureField : IPlanetaryBaseField + { + public PlanetaryBaseFieldSample Sample(float3 direction) + { + var value = direction.x * 0.4f - direction.y * 0.2f + direction.z * 0.5f; + var gradient = new float3(0.4f, -0.2f, 0.5f); + gradient -= direction * math.dot(gradient, direction); + return new(value * 0.08f, gradient * 0.08f, value, gradient, value); + } + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/PlanetaryPatchTests.cs b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryPatchTests.cs new file mode 100644 index 00000000..bea83edf --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryPatchTests.cs @@ -0,0 +1,43 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class PlanetaryPatchTests +{ + [Fact] + public void CubeSpherePatchMeshUsesCanonicalTopology() + { + var meshes = PlanetaryPatch.CreateCubeSphere(8); + Assert.Equal(6, meshes.Length); + foreach (var mesh in meshes) + { + Assert.Equal(81, mesh.Vertices.Length); + Assert.Equal(8 * 8 * 6, mesh.Indices.Length); + Assert.All(mesh.Vertices, vertex => Assert.Equal(1, math.length(vertex.UnitDirection), 5)); + foreach (var index in mesh.Indices) Assert.InRange(index, 0, mesh.Vertices.Length - 1); + } + } + + [Fact] + public void NeighborFaceCornersAreSingleValued() + { + var directions = PlanetaryPatch.CreateCubeSphere(1) + .SelectMany(mesh => mesh.Vertices.Select(vertex => vertex.UnitDirection)) + .ToArray(); + var groups = directions.GroupBy(direction => new + { + X = Math.Sign(direction.x), + Y = Math.Sign(direction.y), + Z = Math.Sign(direction.z), + }); + Assert.Equal(8, groups.Count()); + Assert.All(groups, group => + { + var first = group.First(); + Assert.All(group, direction => Assert.InRange(math.distance(first, direction), 0, 1.0e-6f)); + }); + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/PlanetaryProjectionTests.cs b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryProjectionTests.cs new file mode 100644 index 00000000..55f5676b --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryProjectionTests.cs @@ -0,0 +1,76 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class PlanetaryProjectionTests +{ + [Theory] + [InlineData(PlanetaryProjectionKind.Equirectangular)] + [InlineData(PlanetaryProjectionKind.WebMercator)] + [InlineData(PlanetaryProjectionKind.EqualEarth)] + [InlineData(PlanetaryProjectionKind.Orthographic)] + [InlineData(PlanetaryProjectionKind.AzimuthalEquidistant)] + [InlineData(PlanetaryProjectionKind.AzimuthalEqualArea)] + [InlineData(PlanetaryProjectionKind.LocalTangent)] + public void ProjectionRoundTripsVisibleDirections(PlanetaryProjectionKind kind) + { + var parameters = new PlanetaryProjectionParameters(kind, 0.31, -0.22); + foreach (var longitude in new[] { -0.4, -0.1, 0.2, 0.65 }) + foreach (var latitude in new[] { -0.6, -0.15, 0.3, 0.7 }) + { + var direction = Direction(longitude + parameters.CenterLongitude, latitude); + if (!PlanetaryProjection.TryForward(direction, parameters, out var map)) continue; + Assert.True(PlanetaryProjection.TryInverse(map, parameters, out var roundTrip)); + Assert.InRange(math.distance(direction, roundTrip), 0, 3.0e-5f); + } + } + + [Fact] + public void CubeAtlasRoundTripsEveryFaceInterior() + { + var projection = new PlanetaryProjectionParameters(PlanetaryProjectionKind.CubeAtlas); + foreach (var face in Enum.GetValues()) + { + var direction = PlanetaryTopology.Direction(new(face, 0.23, -0.41)); + Assert.True(PlanetaryProjection.TryForward(direction, projection, out var map)); + Assert.True(PlanetaryProjection.TryInverse(map, projection, out var roundTrip)); + Assert.InRange(math.distance(direction, roundTrip), 0, 2.0e-6f); + } + } + + [Fact] + public void MercatorRejectsPolarSingularity() + { + var northPole = new float3(0, 0, 1); + Assert.False(PlanetaryProjection.TryForward(northPole, new(PlanetaryProjectionKind.WebMercator), out _)); + } + + [Fact] + public void MapTileBakerCarriesFieldIdentityAndProjectionValidity() + { + var field = new PlanetaryFieldDefinition(99, 10, 0, AdvancedErosionParameters.Default); + var layout = new PlanetaryMapTileLayout( + new(PlanetaryProjectionKind.Orthographic), 0, 0, 0, 9, 1); + var key = new PlanetaryMapTileKey(field.FieldVersion, 1, 7, layout, PlanetaryQueryScale.AtFootprint(0.1f)); + var tile = PlanetaryMapTileBaker.Bake(field, key, new FlatField()); + Assert.Equal(key, tile.Key); + Assert.Equal(layout.StorageSize * layout.StorageSize, tile.Samples.Length); + Assert.Contains(tile.Validity, valid => valid); + Assert.Contains(tile.Validity, valid => !valid); + Assert.All(tile.Samples.Where((_, index) => tile.Validity[index]), sample => Assert.Equal(field.FieldVersion, sample.FieldVersion)); + } + + private static float3 Direction(double longitude, double latitude) + { + var cos = Math.Cos(latitude); + return new((float)(cos * Math.Cos(longitude)), (float)(cos * Math.Sin(longitude)), (float)Math.Sin(latitude)); + } + + private readonly struct FlatField : IPlanetaryBaseField + { + public PlanetaryBaseFieldSample Sample(float3 unitDirection) => new(0, float3.zero, 0, float3.zero, 0); + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/PlanetaryQueryTests.cs b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryQueryTests.cs new file mode 100644 index 00000000..17636165 --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryQueryTests.cs @@ -0,0 +1,49 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class PlanetaryQueryTests +{ + private static readonly PlanetaryFieldDefinition Field = new(12, 10, 0, AdvancedErosionParameters.Default with { Strength = 0, Octaves = 0 }); + private static readonly PlanetaryQueryScale Scale = PlanetaryQueryScale.AtFootprint(0.1f, 0); + + [Fact] + public void RayIntersectionAndClearanceUseTheCpuSurfaceContract() + { + var source = new BulgedSphere(); + Assert.True(PlanetaryQueries.TryIntersectRay( + Field, new double3(0, 0, 20), new double3(0, 0, -1), 30, + Scale, new PlanetaryRadialBounds(-1, 1), source, out var hit)); + Assert.InRange(hit.Distance, 8.99999, 9.00001); + Assert.InRange(hit.PlanetLocalPosition.z, 10.99999, 11.00001); + var clearance = PlanetaryQueries.SegmentClearance( + Field, new double3(-2, 0, 12), new double3(2, 0, 12), 0.1, Scale, source); + Assert.True(clearance > 0.8); + } + + [Fact] + public void GreatCircleAndRegionQueriesReturnVersionedSamples() + { + var source = new BulgedSphere(); + var path = PlanetaryQueries.SampleGreatCircle(Field, new float3(1, 0, 0), new float3(0, 1, 0), 8, Scale, source); + Assert.Equal(9, path.Length); + Assert.Equal(MathF.PI * 5, path[^1].Distance, 4); + Assert.All(path, point => Assert.Equal(Field.FieldVersion, point.Surface.FieldVersion)); + var summary = PlanetaryQueries.Summarize(Field, path.Select(point => point.Surface.UnitDirection).ToArray(), Scale, source); + Assert.True(summary.MaximumDisplacement >= summary.MinimumDisplacement); + Assert.True(summary.MaximumSlope >= 0); + } + + private readonly struct BulgedSphere : IPlanetaryBaseField + { + public PlanetaryBaseFieldSample Sample(float3 direction) + { + var displacement = direction.z; + var gradient = new float3(0, 0, 1) - direction * direction.z; + return new(displacement, gradient, 0, float3.zero, 0); + } + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/PlanetaryRadialRefinementTests.cs b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryRadialRefinementTests.cs new file mode 100644 index 00000000..18d8529b --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryRadialRefinementTests.cs @@ -0,0 +1,32 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class PlanetaryRadialRefinementTests +{ + [Fact] + public void FourBoundedStepsConvergeFromTheRasterCaptureShell() + { + var center=new float3(0.3f,-0.2f,0.7f); + var radial=math.normalize(new float3(0.2f,-0.4f,0.9f)); + var targetRadius=3.85f; + var target=center+radial*targetRadius; + var origin=target+math.normalize(radial+new float3(0.12f,-0.05f,0.02f))*6.0f; + var position=center+radial*(targetRadius+0.30f); + var ray=math.normalize(position-origin); + for(var step=0;step<4;step++) + position=PlanetaryRadialRefinement.Step(position,ray,center,targetRadius); + Assert.InRange(math.abs(math.length(position-center)-targetRadius),0.0f,1.0e-4f); + } + + [Fact] + public void StepClampsWeakDerivativeCorrections() + { + var position=new float3(0,0,4.1f); + var result=PlanetaryRadialRefinement.Step(position,new float3(1,0,0),float3.zero,3.8f); + Assert.InRange(math.length(result-position),0.07999f,0.08001f); + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/PlanetaryResidencyTests.cs b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryResidencyTests.cs new file mode 100644 index 00000000..b8ed63f5 --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryResidencyTests.cs @@ -0,0 +1,60 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class PlanetaryResidencyTests +{ + private static readonly PlanetaryFieldDefinition Field = new( + 1, 8.4f, 0, AdvancedErosionParameters.Default with { Scale = 0.63f, Strength = 0.12f, Octaves = 7 }); + private static readonly PlanetaryLodParameters Lod = new(5, 17, 2, 720, 8.4f / 65536); + + [Fact] + public void SelectorReturnsSixRootsAndOneAncestorChain() + { + var direction = math.normalize(new float3(-0.2f, 0.91f, 0.37f)); + var selected = PlanetaryLodSelector.SelectAncestorChain(Field, direction, 8.45f, Lod); + Assert.Equal(6, selected.Count(tile => tile.Level == 0)); + var descendants = selected.Where(tile => tile.Level > 0).OrderBy(tile => tile.Level).ToArray(); + for (var i = 1; i < descendants.Length; i++) Assert.Equal(descendants[i - 1], descendants[i].Parent()); + if (descendants.Length > 0) Assert.Equal(PlanetaryTopology.TileAt(direction, descendants[^1].Level), descendants[^1]); + } + + [Fact] + public void ResidencyFadesResidualsWithoutChangingContentDuringPresentation() + { + var root = new PlanetaryTileAddress(PlanetaryCubeFace.PositiveX, 0, 0, 0); + var child = root.Child(1, 0); + var residency = new PlanetaryResidualResidency(); + var arrival = residency.Update(new[] { root, child }, 0, 1); + var midpoint = residency.Update(new[] { root, child }, 0.5f, 1); + var settled = residency.Update(new[] { root, child }, 1, 1); + Assert.Equal(arrival.ContentVersion, midpoint.ContentVersion); + Assert.Equal(midpoint.ContentVersion, settled.ContentVersion); + Assert.Equal(0, arrival.Tiles.Single(tile => tile.Tile == child).Blend); + Assert.Equal(0.5f, midpoint.Tiles.Single(tile => tile.Tile == child).Blend); + Assert.Equal(1, settled.Tiles.Single(tile => tile.Tile == child).Blend); + + var leaving = residency.Update(new[] { root }, 1.25f, 1); + Assert.True(leaving.Tiles.Single(tile => tile.Tile == child).Departing); + Assert.Equal(1, leaving.Tiles.Single(tile => tile.Tile == child).Blend); + var gone = residency.Update(new[] { root }, 2.25f, 1); + Assert.DoesNotContain(gone.Tiles, tile => tile.Tile == child); + } + + [Fact] + public void ReversingAnArrivalDepartsFromItsCurrentWeightWithoutJumping() + { + var root = new PlanetaryTileAddress(PlanetaryCubeFace.PositiveX, 0, 0, 0); + var child = root.Child(0, 1); + var residency = new PlanetaryResidualResidency(); + _ = residency.Update(new[] { root, child }, 0, 1); + var arriving = residency.Update(new[] { root, child }, 0.25f, 1); + var departing = residency.Update(new[] { root }, 0.25f, 1); + Assert.Equal(0.25f, arriving.Tiles.Single(tile => tile.Tile == child).Blend); + Assert.Equal(0.25f, departing.Tiles.Single(tile => tile.Tile == child).Blend); + Assert.True(departing.Tiles.Single(tile => tile.Tile == child).Departing); + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/PlanetaryTopologyTests.cs b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryTopologyTests.cs new file mode 100644 index 00000000..27bb4cef --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/PlanetaryTopologyTests.cs @@ -0,0 +1,66 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class PlanetaryTopologyTests +{ + [Fact] + public void FaceDirectionRoundTripsAcrossEveryFace() + { + foreach (var face in Enum.GetValues()) + foreach (var u in new[] { -0.9, -0.25, 0.0, 0.4, 0.9 }) + foreach (var v in new[] { -0.85, -0.1, 0.3, 0.88 }) + { + var direction = PlanetaryTopology.Direction(new(face, u, v)); + var roundTrip = PlanetaryTopology.FaceCoordinate(direction); + Assert.Equal(face, roundTrip.Face); + Assert.Equal(u, roundTrip.U, 5); + Assert.Equal(v, roundTrip.V, 5); + } + } + + [Fact] + public void SiblingPagesShareBitStableBoundaryDirections() + { + var left = new PlanetaryPageLayout(new(PlanetaryCubeFace.PositiveZ, 1, 0, 0), 17, 2); + var right = new PlanetaryPageLayout(new(PlanetaryCubeFace.PositiveZ, 1, 1, 0), 17, 2); + for (var y = 0; y < left.InteriorSize; y++) + { + var a = PlanetaryPageSampling.Direction(left, left.BorderSize + left.InteriorSize - 1, left.BorderSize + y); + var b = PlanetaryPageSampling.Direction(right, right.BorderSize, right.BorderSize + y); + Assert.Equal(a, b); + } + } + + [Fact] + public void DirectionSelectsContainingTileAndLocalCoordinate() + { + var expected = new PlanetaryTileAddress(PlanetaryCubeFace.NegativeY, 5, 19, 7); + var direction = PlanetaryPageSampling.DirectionAtLocal(new(expected, 17, 2), 0.37, 0.61); + Assert.Equal(expected, PlanetaryTopology.TileAt(direction, expected.Level)); + Assert.True(PlanetaryTopology.TryLocalCoordinate(direction, expected, out var local)); + Assert.Equal(0.37f, local.x, 4); + Assert.Equal(0.61f, local.y, 4); + } + + [Fact] + public void BorderSamplesDoNotRequireNeighborResidency() + { + var page = new PlanetaryPageLayout(new(PlanetaryCubeFace.PositiveX, 0, 0, 0), 17, 3); + var edge = PlanetaryPageSampling.Direction(page, page.BorderSize + page.InteriorSize - 1, page.BorderSize + 8); + var border = PlanetaryPageSampling.Direction(page, page.StorageSize - 1, page.BorderSize + 8); + Assert.NotEqual(edge, border); + Assert.Equal(1, math.length(border), 5); + } + + [Fact] + public void NormalizedCubeBaselineSharesFaceEdges() + { + var a = PlanetaryTopology.NormalizedCubeDirection(new(PlanetaryCubeFace.PositiveZ, 1, -0.4)); + var b = PlanetaryTopology.NormalizedCubeDirection(new(PlanetaryCubeFace.PositiveX, -1, -0.4)); + Assert.InRange(math.distance(a, b), 0, 1.0e-6f); + } +} diff --git a/tests/GameCult.Geometry.Planetary.Tests/SphericalErosionTests.cs b/tests/GameCult.Geometry.Planetary.Tests/SphericalErosionTests.cs new file mode 100644 index 00000000..adf7ea0e --- /dev/null +++ b/tests/GameCult.Geometry.Planetary.Tests/SphericalErosionTests.cs @@ -0,0 +1,37 @@ +using Xunit; + +using CultMath; +using GameCult.Geometry; + +namespace GameCult.Geometry.Planetary.Tests; + +public sealed class SphericalErosionTests +{ + [Fact] + public void SampleIsDeterministicAndScaleInvariant() + { + var parameters = SphericalErosionParameters.Default with { Seed = 42.0f }; + var a = SphericalErosion.Sample(new float3(0.31f, -0.74f, 0.59f), 0.2f, new float3(0.4f, -0.1f, 0.2f), parameters); + var repeated = SphericalErosion.Sample(new float3(0.31f, -0.74f, 0.59f), 0.2f, new float3(0.4f, -0.1f, 0.2f), parameters); + var b = SphericalErosion.Sample(new float3(3.1f, -7.4f, 5.9f), 0.2f, new float3(0.4f, -0.1f, 0.2f), parameters); + + Assert.Equal(a, repeated); + Assert.InRange(math.abs(a.HeightOffset-b.HeightOffset),0.0f,1.0e-6f); + Assert.InRange(math.abs(a.Ridge-b.Ridge),0.0f,1.0e-6f); + Assert.InRange(math.abs(a.Gully-b.Gully),0.0f,5.0e-5f); + Assert.InRange(math.length(a.Gradient-b.Gradient),0.0f,5.0e-6f); + Assert.InRange(a.HeightOffset, -0.08f, 0.08f); + Assert.InRange(a.Ridge, 0.0f, 1.0f); + Assert.InRange(a.Gully, 0.0f, 1.0f); + } + + [Fact] + public void NearbySamplesRemainContinuousAcrossAChartBoundary() + { + var parameters = SphericalErosionParameters.Default; + var left = SphericalErosion.Sample(math.normalize(new float3(1.0f, 0.99999f, 0.2f)), 0.1f, new float3(0.2f, -0.1f, 0.05f), parameters); + var right = SphericalErosion.Sample(math.normalize(new float3(0.99999f, 1.0f, 0.2f)), 0.1f, new float3(0.2f, -0.1f, 0.05f), parameters); + + Assert.InRange(math.abs(left.HeightOffset - right.HeightOffset), 0.0f, 0.0002f); + } +} diff --git a/tests/GameCult.Geometry.Tests/GeometryShaderOwnershipTests.cs b/tests/GameCult.Geometry.Tests/GeometryShaderOwnershipTests.cs new file mode 100644 index 00000000..ba5bcf64 --- /dev/null +++ b/tests/GameCult.Geometry.Tests/GeometryShaderOwnershipTests.cs @@ -0,0 +1,79 @@ +using System; +using System.IO; +using NUnit.Framework; + +namespace GameCult.Geometry.Tests; + +public sealed class GeometryShaderOwnershipTests +{ + [Test] + public void GeometryUmbrellaOwnsTheCompleteShaderSurface() + { + var shaders = Path.Combine(GetRepositoryRoot(), "src", "GameCult.Geometry", "Shaders"); + var umbrella = File.ReadAllText(Path.Combine(shaders, "GameCult.Geometry.hlsl")); + var erosion = File.ReadAllText(Path.Combine(shaders, "AdvancedErosionFilter.hlsl")); + var planetary = File.ReadAllText(Path.Combine(shaders, "Planetary.hlsl")); + var spherical = File.ReadAllText(Path.Combine(shaders, "SphericalErosion.hlsl")); + var refinement = File.ReadAllText(Path.Combine(shaders, "PlanetaryRadialRefinement.hlsl")); + + Assert.That(umbrella, Does.Contain("#define GAMECULT_GEOMETRY_CULTMATH_INCLUDE \"CultMath.hlsl\"") + .And.Contain("#include GAMECULT_GEOMETRY_CULTMATH_INCLUDE") + .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_CULTMATH_INCLUDE \"Packages/org.gamecult.cultmath/shaders/CultMath.hlsl\"") + .And.Contain("#include \"Packages/org.gamecult.geometry/Shaders/GameCult.Geometry.hlsl\"") + .And.Contain("gamecult_geometry_planetary_field_sample") + .And.Not.Contain("cultmath_planetary_") + .And.Not.Contain("CultMathPlanetary")); + } + + private static string GetRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "CultLib.sln"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new InvalidOperationException("Could not locate CultLib repository root."); + } +} diff --git a/tools/GameCult.Geometry.PlanetaryTileFixture/GameCult.Geometry.PlanetaryTileFixture.csproj b/tools/GameCult.Geometry.PlanetaryTileFixture/GameCult.Geometry.PlanetaryTileFixture.csproj new file mode 100644 index 00000000..21a2c04b --- /dev/null +++ b/tools/GameCult.Geometry.PlanetaryTileFixture/GameCult.Geometry.PlanetaryTileFixture.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + enable + + + + + diff --git a/tools/GameCult.Geometry.PlanetaryTileFixture/Program.cs b/tools/GameCult.Geometry.PlanetaryTileFixture/Program.cs new file mode 100644 index 00000000..c9ce965b --- /dev/null +++ b/tools/GameCult.Geometry.PlanetaryTileFixture/Program.cs @@ -0,0 +1,23 @@ +using CultMath; +using GameCult.Geometry; + +if (args.Length != 1) throw new ArgumentException("Expected one output path."); +var field = GameCult.Geometry.PlanetaryFieldDefinition.Create(0x55aa, 10, 7, GameCult.Geometry.AdvancedErosionParameters.Default); +var layout = new GameCult.Geometry.PlanetaryMapTileLayout(new(GameCult.Geometry.PlanetaryProjectionKind.EqualEarth), 0, 0, 0, 5, 1); +var key = new GameCult.Geometry.PlanetaryMapTileKey(field.FieldVersion, 3, 29, layout, GameCult.Geometry.PlanetaryQueryScale.AtFootprint(0.25f, 0.125f)); +var tile = GameCult.Geometry.PlanetaryMapTileBaker.Bake(field, key, new FixtureField()); +var path = Path.GetFullPath(args[0]); +Directory.CreateDirectory(Path.GetDirectoryName(path)!); +using var stream = File.Create(path); +GameCult.Geometry.PlanetaryMapTileEncoding.Write(stream, tile); + +readonly struct FixtureField : GameCult.Geometry.IPlanetaryBaseField +{ + public GameCult.Geometry.PlanetaryBaseFieldSample Sample(float3 direction) + { + var value = direction.x * 0.2f + direction.z * 0.4f; + var gradient = new float3(0.2f, 0, 0.4f); + gradient -= direction * math.dot(gradient, direction); + return new(value, gradient, value, gradient * 0.1f, value); + } +} diff --git a/unity/org.gamecult.geometry/README.md b/unity/org.gamecult.geometry/README.md index 7a6e0823..3fe2f10e 100644 --- a/unity/org.gamecult.geometry/README.md +++ b/unity/org.gamecult.geometry/README.md @@ -7,6 +7,9 @@ depends on `org.gamecult.cultmath` for numeric primitives and Build it with `scripts/build-geometry-unity-package.ps1`. Do not edit generated plugin assemblies in the package output. -CultLib-authored files are MIT licensed. Geometry sources transferred from -CultMath retain their MPL-2.0 notices and provenance; those files will be listed -here as they enter this package. No transferred CultMath source is relicensed. +CultLib-authored integration files are MIT licensed. The planetary C# compiled +into `GameCult.Geometry.dll` and the geometry HLSL under `Shaders` are +MPL-2.0 and retain their source notices and provenance. See `LICENSES.md` and +`THIRD-PARTY-NOTICES.md` in the staged package. No MPL-covered source is +relicensed. The package manifest therefore uses `SEE LICENSES IN README.md` +rather than claiming one license for every file. diff --git a/unity/org.gamecult.geometry/Runtime/GameCult.Geometry.Unity.asmdef b/unity/org.gamecult.geometry/Runtime/GameCult.Geometry.Unity.asmdef new file mode 100644 index 00000000..3a0a02ee --- /dev/null +++ b/unity/org.gamecult.geometry/Runtime/GameCult.Geometry.Unity.asmdef @@ -0,0 +1,8 @@ +{ + "name": "GameCult.Geometry.Unity", + "rootNamespace": "GameCult.Geometry.Unity", + "references": ["CultMath", "GameCult.Geometry"], + "includePlatforms": [], "excludePlatforms": [], "allowUnsafeCode": false, + "overrideReferences": false, "precompiledReferences": [], "autoReferenced": true, + "defineConstraints": [], "versionDefines": [], "noEngineReferences": false +} diff --git a/unity/org.gamecult.geometry/Runtime/PlanetaryPageUpload.cs b/unity/org.gamecult.geometry/Runtime/PlanetaryPageUpload.cs new file mode 100644 index 00000000..8e0b431e --- /dev/null +++ b/unity/org.gamecult.geometry/Runtime/PlanetaryPageUpload.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using CultMath; +using UnityEngine; + +namespace GameCult.Geometry.Unity; + +[StructLayout(LayoutKind.Sequential)] public readonly struct PlanetaryPageInputGpu { public PlanetaryPageInputGpu(Vector4 directionRadius, Vector4 sampling) { DirectionRadius = directionRadius; Sampling = sampling; } public readonly Vector4 DirectionRadius; public readonly Vector4 Sampling; } +[StructLayout(LayoutKind.Sequential)] public readonly struct PlanetaryPageMetadataGpu { public PlanetaryPageMetadataGpu(Vector4 address, Vector4 layout, Vector4 bounds, Vector4 state) { Address = address; Layout = layout; Bounds = bounds; State = state; } public readonly Vector4 Address; public readonly Vector4 Layout; public readonly Vector4 Bounds; public readonly Vector4 State; } +public readonly struct PlanetaryPageUpload { public PlanetaryPageUpload(PlanetaryPageInputGpu[] inputs, PlanetaryPageMetadataGpu[] metadata, int outputSampleCount, ulong contentVersion, ulong presentationVersion) { Inputs = inputs; Metadata = metadata; OutputSampleCount = outputSampleCount; ContentVersion = contentVersion; PresentationVersion = presentationVersion; } public PlanetaryPageInputGpu[] Inputs { get; } public PlanetaryPageMetadataGpu[] Metadata { get; } public int OutputSampleCount { get; } public ulong ContentVersion { get; } public ulong PresentationVersion { get; } } +public static class PlanetaryPageUploadBuilder +{ + public static PlanetaryPageUpload Build(in PlanetaryResidencySnapshot snapshot, int interiorSize, int borderSize, float radius) + { + if (!float.IsFinite(radius) || radius <= 0) throw new ArgumentOutOfRangeException(nameof(radius)); + var inputs = new List(); var metadata = new PlanetaryPageMetadataGpu[snapshot.Tiles.Length]; + foreach (var (resident, pageIndex) in snapshot.Tiles.Select((resident, index) => (resident, index))) + { + var content = PlanetaryGpuPageBuilder.BuildContent(new PlanetaryPageLayout(resident.Tile, interiorSize, borderSize).Validate(), radius); var outputOffset = inputs.Count; + foreach (var input in content.Inputs) inputs.Add(new(ToUnity(input.DirectionRadius), ToUnity(input.Sampling))); + var common = PlanetaryGpuPageBuilder.Metadata(content, outputOffset, resident.Blend); + metadata[pageIndex] = new(ToUnity(common.Address), ToUnity(common.Layout), ToUnity(common.Bounds), ToUnity(common.State)); + } + return new(inputs.ToArray(), metadata, inputs.Count, snapshot.ContentVersion, snapshot.PresentationVersion); + } + private static Vector4 ToUnity(float4 value) => new(value.x, value.y, value.z, value.w); +} diff --git a/unity/org.gamecult.geometry/Runtime/PlanetaryPageUpload.cs.meta b/unity/org.gamecult.geometry/Runtime/PlanetaryPageUpload.cs.meta new file mode 100644 index 00000000..904f752e --- /dev/null +++ b/unity/org.gamecult.geometry/Runtime/PlanetaryPageUpload.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7edd3bb2edb2e6448a2d54fc082c27dc diff --git a/unity/org.gamecult.geometry/Runtime/PlanetaryPatchMeshAdapter.cs b/unity/org.gamecult.geometry/Runtime/PlanetaryPatchMeshAdapter.cs new file mode 100644 index 00000000..8e758426 --- /dev/null +++ b/unity/org.gamecult.geometry/Runtime/PlanetaryPatchMeshAdapter.cs @@ -0,0 +1,27 @@ +using System; +using CultMath; +using UnityEngine; +using UnityEngine.Rendering; + +namespace GameCult.Geometry.Unity; + +public static class PlanetaryPatchMeshAdapter +{ + public static Mesh CreateFaceMesh(PlanetaryCubeFace face, int cellsPerAxis, float radius = 1) + { + if (!float.IsFinite(radius) || radius <= 0) throw new ArgumentOutOfRangeException(nameof(radius)); + var source = PlanetaryPatch.CreateFace(face, cellsPerAxis); + var vertices = new Vector3[source.Vertices.Length]; + var normals = new Vector3[source.Vertices.Length]; + var uv = new Vector2[source.Vertices.Length]; + for (var i = 0; i < source.Vertices.Length; i++) + { + var direction = source.Vertices[i].UnitDirection; + var normal = new Vector3(direction.x, direction.y, direction.z); + vertices[i] = normal * radius; normals[i] = normal; + uv[i] = new Vector2(source.Vertices[i].LocalCoordinate.x, source.Vertices[i].LocalCoordinate.y); + } + var mesh = new Mesh { name = $"GameCult.Geometry Planetary {face} {cellsPerAxis}x{cellsPerAxis}", indexFormat = vertices.Length > ushort.MaxValue ? IndexFormat.UInt32 : IndexFormat.UInt16, vertices = vertices, normals = normals, uv = uv, triangles = source.Indices }; + mesh.RecalculateBounds(); return mesh; + } +} diff --git a/unity/org.gamecult.geometry/Runtime/PlanetaryPatchMeshAdapter.cs.meta b/unity/org.gamecult.geometry/Runtime/PlanetaryPatchMeshAdapter.cs.meta new file mode 100644 index 00000000..4a2d9754 --- /dev/null +++ b/unity/org.gamecult.geometry/Runtime/PlanetaryPatchMeshAdapter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9ad7b581e14ef774d8435069bb77ca6a diff --git a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCult.Geometry.PlanetaryViewer.Editor.asmdef b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCult.Geometry.PlanetaryViewer.Editor.asmdef new file mode 100644 index 00000000..7f842880 --- /dev/null +++ b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCult.Geometry.PlanetaryViewer.Editor.asmdef @@ -0,0 +1 @@ +{"name":"GameCult.Geometry.PlanetaryViewer.Editor","rootNamespace":"GameCult.Geometry.Unity.Samples.Editor","references":["GameCult.Geometry.PlanetaryViewer"],"includePlatforms":["Editor"],"excludePlatforms":[],"allowUnsafeCode":false,"overrideReferences":false,"precompiledReferences":[],"autoReferenced":true,"defineConstraints":[],"versionDefines":[],"noEngineReferences":false} diff --git a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCultGeometryPlanetaryViewerMenu.cs b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCultGeometryPlanetaryViewerMenu.cs new file mode 100644 index 00000000..e2a32d17 --- /dev/null +++ b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCultGeometryPlanetaryViewerMenu.cs @@ -0,0 +1,27 @@ +using UnityEditor; +using UnityEngine; +using GameCult.Geometry.Unity.Samples; + +namespace GameCult.Geometry.Unity.Samples.Editor; + +public static class GameCultGeometryPlanetaryViewerMenu +{ + [MenuItem("GameObject/GameCult.Geometry/Planetary Field Viewer", false, 10)] + public static void CreateViewer() + { + var root = new GameObject("GameCult.Geometry Planetary Field Viewer"); + Undo.RegisterCreatedObjectUndo(root, "Create GameCult.Geometry Planetary Viewer"); + root.AddComponent(); + Selection.activeGameObject = root; + + if (Camera.main != null) return; + var cameraObject = new GameObject("Planetary Viewer Camera"); + Undo.RegisterCreatedObjectUndo(cameraObject, "Create Planetary Viewer Camera"); + cameraObject.tag = "MainCamera"; + cameraObject.transform.position = new Vector3(0, 0, -24); + cameraObject.transform.LookAt(root.transform); + var camera = cameraObject.AddComponent(); + camera.nearClipPlane = 0.01f; + camera.farClipPlane = 1000; + } +} diff --git a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/README.md b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/README.md new file mode 100644 index 00000000..85bb4585 --- /dev/null +++ b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/README.md @@ -0,0 +1,7 @@ +# Planetary Field Viewer + +Import this sample through Unity Package Manager, then choose +`GameObject > GameCult.Geometry > Planetary Field Viewer`. + +The six-face mesh and planetary field are owned by GameCult.Geometry and use +CultMath numeric primitives. This is a renderer fixture, not gameplay state. diff --git a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCult.Geometry.PlanetaryViewer.asmdef b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCult.Geometry.PlanetaryViewer.asmdef new file mode 100644 index 00000000..ff41a80b --- /dev/null +++ b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCult.Geometry.PlanetaryViewer.asmdef @@ -0,0 +1 @@ +{"name":"GameCult.Geometry.PlanetaryViewer","rootNamespace":"GameCult.Geometry.Unity.Samples","references":["CultMath","GameCult.Geometry","GameCult.Geometry.Unity"],"includePlatforms":[],"excludePlatforms":[],"allowUnsafeCode":false,"overrideReferences":false,"precompiledReferences":[],"autoReferenced":true,"defineConstraints":[],"versionDefines":[],"noEngineReferences":false} diff --git a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.cs b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.cs new file mode 100644 index 00000000..708d7423 --- /dev/null +++ b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using CultMath; +using GameCult.Geometry; +using UnityEngine; + +namespace GameCult.Geometry.Unity.Samples; + +[ExecuteAlways] +public sealed class GameCultGeometryPlanetaryViewer : MonoBehaviour +{ + [Min(0.001f)] public float Radius = 8.4f; + [Range(1, 256)] public int CellsPerFace = 64; + public int Seed = 42; + [Min(0.0001f)] public float SampleFootprint = 0.01f; + public Shader? PlanetShader; + public Vector3 LightDirection = new(-0.4f, 0.7f, -0.6f); + public Color LowColor = new(0.055f, 0.16f, 0.09f, 1); + public Color HighColor = new(0.62f, 0.48f, 0.28f, 1); + public Color RidgeColor = new(0.88f, 0.83f, 0.7f, 1); + + private readonly List meshes = new(); + private Material? material; + private int builtCells; + + public PlanetaryFieldDefinition Definition => PlanetaryFieldDefinition.Create( + 1, + Radius, + Seed, + ViewerErosion(Radius)); + + public PlanetarySurfaceSample Sample(float3 direction) + { + var source = new ViewerBaseField(Radius); + var normalized = math.normalize(direction); + return PlanetaryField.Sample(Definition, normalized, source.Sample(normalized), PlanetaryQueryScale.AtFootprint(SampleFootprint)); + } + + private void OnEnable() => Rebuild(); + + private void OnValidate() + { + Radius = Mathf.Max(Radius, 0.001f); + CellsPerFace = Mathf.Clamp(CellsPerFace, 1, 256); + SampleFootprint = Mathf.Max(SampleFootprint, 0.0001f); + if (isActiveAndEnabled) Rebuild(); + } + + private void Update() + { + if (builtCells != CellsPerFace || material == null) Rebuild(); + ApplyMaterial(); + } + + private void Rebuild() + { + ClearGenerated(); + PlanetShader ??= Shader.Find("GameCult.Geometry/Planetary Field Viewer"); + if (PlanetShader == null) return; + material = new Material(PlanetShader) { name = "CultMath Planetary Viewer Material", hideFlags = HideFlags.HideAndDontSave }; + foreach (var face in (PlanetaryCubeFace[])Enum.GetValues(typeof(PlanetaryCubeFace))) + { + var mesh = PlanetaryPatchMeshAdapter.CreateFaceMesh(face, CellsPerFace); + mesh.hideFlags = HideFlags.HideAndDontSave; + meshes.Add(mesh); + var child = new GameObject(face.ToString()) { hideFlags = HideFlags.DontSave }; + child.transform.SetParent(transform, false); + child.AddComponent().sharedMesh = mesh; + child.AddComponent().sharedMaterial = material; + } + builtCells = CellsPerFace; + ApplyMaterial(); + } + + private void ApplyMaterial() + { + if (material == null) return; + var p = ViewerErosion(Radius); + material.SetFloat("_Radius", Radius); + material.SetFloat("_SampleFootprint", SampleFootprint); + material.SetInt("_Seed", Seed); + material.SetFloat("_ErosionScale", p.Scale); + material.SetFloat("_ErosionStrength", p.Strength); + material.SetFloat("_GullyWeight", p.GullyWeight); + material.SetFloat("_Detail", p.Detail); + material.SetVector("_Rounding", new(p.Rounding.x, p.Rounding.y, p.Rounding.z, p.Rounding.w)); + material.SetVector("_Onset", new(p.Onset.x, p.Onset.y, p.Onset.z, p.Onset.w)); + material.SetVector("_AssumedSlope", new(p.AssumedSlope.x, p.AssumedSlope.y, 0, 0)); + material.SetFloat("_CellScale", p.CellScale); + material.SetFloat("_Normalization", p.Normalization); + material.SetInt("_Octaves", p.Octaves); + material.SetFloat("_Lacunarity", p.Lacunarity); + material.SetFloat("_Gain", p.Gain); + material.SetVector("_LightDirection", LightDirection.normalized); + material.SetColor("_LowColor", LowColor); + material.SetColor("_HighColor", HighColor); + material.SetColor("_RidgeColor", RidgeColor); + } + + private void OnDisable() => ClearGenerated(); + private void OnDestroy() => ClearGenerated(); + + private void ClearGenerated() + { + for (var i = transform.childCount - 1; i >= 0; i--) DestroyGenerated(transform.GetChild(i).gameObject); + foreach (var mesh in meshes) DestroyGenerated(mesh); + meshes.Clear(); + if (material != null) DestroyGenerated(material); + material = null; + builtCells = 0; + } + + private static void DestroyGenerated(UnityEngine.Object value) + { + if (Application.isPlaying) Destroy(value); + else DestroyImmediate(value); + } + + private static AdvancedErosionParameters ViewerErosion(float radius) => new( + radius * 0.075f, 0.12f, 0.58f, 1.45f, + new float4(0.1f, 0.015f, 0.1f, 2), + new float4(1.25f, 1.25f, 2.8f, 1.5f), + new float2(0.7f, 0.85f), 0.7f, 0.5f, 7, 2, 0.5f); + + private readonly struct ViewerBaseField : IPlanetaryBaseField + { + private readonly float radius; + public ViewerBaseField(float radius) => this.radius = radius; + + public PlanetaryBaseFieldSample Sample(float3 direction) + { + var value = direction.x * 0.6f + direction.y * direction.z * 0.3f; + var angularGradient = new float3(0.6f, direction.z * 0.3f, direction.y * 0.3f); + angularGradient -= direction * math.dot(angularGradient, direction); + var fieldGradient = angularGradient / radius; + var radialDisplacement = value * radius * 0.035f; + var radialGradient = angularGradient * 0.035f; + return new(radialDisplacement, radialGradient, value, fieldGradient, math.clamp(value, -1, 1)); + } + } +} diff --git a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.shader b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.shader new file mode 100644 index 00000000..850e1dd5 --- /dev/null +++ b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.shader @@ -0,0 +1,91 @@ +Shader "GameCult.Geometry/Planetary Field Viewer" +{ + Properties + { + _LowColor ("Low Color", Color) = (0.055, 0.16, 0.09, 1) + _HighColor ("High Color", Color) = (0.62, 0.48, 0.28, 1) + _RidgeColor ("Ridge Color", Color) = (0.88, 0.83, 0.7, 1) + } + SubShader + { + Tags { "RenderType"="Opaque" "Queue"="Geometry" } + Pass + { + Cull Back + ZWrite On + HLSLPROGRAM + #pragma target 5.0 + #pragma vertex vert + #pragma fragment frag + #include "UnityCG.cginc" + #define GAMECULT_GEOMETRY_CULTMATH_INCLUDE "Packages/org.gamecult.cultmath/shaders/CultMath.hlsl" + #include "Packages/org.gamecult.geometry/Shaders/GameCult.Geometry.hlsl" + + float _Radius; + float _SampleFootprint; + int _Seed; + float _ErosionScale, _ErosionStrength, _GullyWeight, _Detail; + float4 _Rounding, _Onset, _AssumedSlope; + float _CellScale, _Normalization, _Lacunarity, _Gain; + int _Octaves; + float3 _LightDirection; + float4 _LowColor, _HighColor, _RidgeColor; + + struct Attributes { float4 vertex : POSITION; }; + struct Varyings + { + float4 position : SV_POSITION; + float3 worldNormal : TEXCOORD0; + float height : TEXCOORD1; + float ridge : TEXCOORD2; + }; + + GameCultGeometryPlanetarySurfaceSample viewer_sample(float3 direction) + { + direction = normalize(direction); + float value = direction.x * 0.6 + direction.y * direction.z * 0.3; + float3 angular_gradient = float3(0.6, direction.z * 0.3, direction.y * 0.3); + angular_gradient -= direction * dot(angular_gradient, direction); + GameCultGeometryPlanetaryBaseFieldSample base_sample; + base_sample.radial_displacement = value * _Radius * 0.035; + base_sample.radial_gradient = angular_gradient * 0.035; + base_sample.field_value = value; + base_sample.field_gradient = angular_gradient / _Radius; + base_sample.fade_target = clamp(value, -1.0, 1.0); + GameCultGeometryAdvancedErosionParameters erosion; + erosion.scale = _ErosionScale; erosion.strength = _ErosionStrength; + erosion.gully_weight = _GullyWeight; erosion.detail = _Detail; + erosion.rounding = _Rounding; erosion.onset = _Onset; + erosion.assumed_slope = _AssumedSlope.xy; erosion.cell_scale = _CellScale; + erosion.normalization = _Normalization; erosion.octaves = _Octaves; + erosion.lacunarity = _Lacunarity; erosion.gain = _Gain; + GameCultGeometryPlanetaryFieldDefinition definition; + definition.radius = _Radius; definition.seed = _Seed; definition.erosion = erosion; + return gamecult_geometry_planetary_field_sample(definition, direction, base_sample, _SampleFootprint); + } + + Varyings vert(Attributes input) + { + float3 direction = normalize(input.vertex.xyz); + GameCultGeometryPlanetarySurfaceSample sample = viewer_sample(direction); + float3 local = direction * (_Radius + sample.radial_displacement); + Varyings output; + output.position = UnityObjectToClipPos(float4(local, 1)); + output.worldNormal = UnityObjectToWorldNormal(sample.surface_normal); + output.height = sample.radial_displacement / max(_Radius * 0.1, 1.0e-5); + output.ridge = sample.ridge; + return output; + } + + float4 frag(Varyings input) : SV_Target + { + float height = saturate(input.height * 0.5 + 0.5); + float3 albedo = lerp(_LowColor.rgb, _HighColor.rgb, height); + albedo = lerp(albedo, _RidgeColor.rgb, saturate((input.ridge - 0.55) * 2.5)); + float diffuse = saturate(dot(normalize(input.worldNormal), normalize(-_LightDirection))) * 0.82 + 0.08; + return float4(albedo * diffuse, 1); + } + ENDHLSL + } + } +} diff --git a/unity/org.gamecult.geometry/package.json b/unity/org.gamecult.geometry/package.json index 023fed98..7ac223b5 100644 --- a/unity/org.gamecult.geometry/package.json +++ b/unity/org.gamecult.geometry/package.json @@ -11,5 +11,6 @@ "dependencies": { "org.gamecult.cultmath": "0.1.0", "org.gamecult.cultlib": "1.0.7" - } + }, + "samples": [{"displayName":"Planetary Field Viewer","description":"Planetary geometry CPU/HLSL parity viewer.","path":"Samples~/PlanetaryViewer"}] } From 99c893bd646caac1796a6788c2034fb0a7e06aeb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 03:52:56 +0100 Subject: [PATCH 12/30] Fix remaining planetary ownership labels --- src/GameCult.Geometry/Planetary/PlanetaryMapTileEncoding.cs | 2 +- .../PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/GameCult.Geometry/Planetary/PlanetaryMapTileEncoding.cs b/src/GameCult.Geometry/Planetary/PlanetaryMapTileEncoding.cs index 90c93fee..ed07e7ba 100644 --- a/src/GameCult.Geometry/Planetary/PlanetaryMapTileEncoding.cs +++ b/src/GameCult.Geometry/Planetary/PlanetaryMapTileEncoding.cs @@ -55,7 +55,7 @@ public static class PlanetaryMapTileEncoding 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 CultMath planetary tile."); + 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(); diff --git a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.cs b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.cs index 708d7423..2cfb0ce5 100644 --- a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.cs +++ b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.cs @@ -57,7 +57,7 @@ public sealed class GameCultGeometryPlanetaryViewer : MonoBehaviour ClearGenerated(); PlanetShader ??= Shader.Find("GameCult.Geometry/Planetary Field Viewer"); if (PlanetShader == null) return; - material = new Material(PlanetShader) { name = "CultMath Planetary Viewer Material", hideFlags = HideFlags.HideAndDontSave }; + material = new Material(PlanetShader) { name = "GameCult.Geometry Planetary Viewer Material", hideFlags = HideFlags.HideAndDontSave }; foreach (var face in (PlanetaryCubeFace[])Enum.GetValues(typeof(PlanetaryCubeFace))) { var mesh = PlanetaryPatchMeshAdapter.CreateFaceMesh(face, CellsPerFace); From 6b44f969a973d3acfac1d5a4cdf507a3b252ad83 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 03:56:28 +0100 Subject: [PATCH 13/30] Absorb CSG kernel into GameCult.Geometry --- ...th-gamecult-geometry-migration-progress.md | 38 +- packages/gamecult-geometry-rs/Cargo.lock | 777 +++++++- packages/gamecult-geometry-rs/Cargo.toml | 3 + .../gamecult-geometry-rs/src/assembler.rs | 1509 ++++++++++++++++ packages/gamecult-geometry-rs/src/brush.rs | 370 ++++ packages/gamecult-geometry-rs/src/convex.rs | 905 ++++++++++ packages/gamecult-geometry-rs/src/domain.rs | 1560 +++++++++++++++++ packages/gamecult-geometry-rs/src/dsl.rs | 164 ++ packages/gamecult-geometry-rs/src/frontier.rs | 321 ++++ packages/gamecult-geometry-rs/src/lib.rs | 117 ++ packages/gamecult-geometry-rs/src/mesh.rs | 103 ++ .../gamecult-geometry-rs/src/primitives.rs | 173 ++ .../src/realtime_csg_parity.rs | 166 ++ packages/gamecult-geometry-rs/src/tree.rs | 402 +++++ 14 files changed, 6598 insertions(+), 10 deletions(-) create mode 100644 packages/gamecult-geometry-rs/src/assembler.rs create mode 100644 packages/gamecult-geometry-rs/src/brush.rs create mode 100644 packages/gamecult-geometry-rs/src/convex.rs create mode 100644 packages/gamecult-geometry-rs/src/domain.rs create mode 100644 packages/gamecult-geometry-rs/src/dsl.rs create mode 100644 packages/gamecult-geometry-rs/src/frontier.rs create mode 100644 packages/gamecult-geometry-rs/src/mesh.rs create mode 100644 packages/gamecult-geometry-rs/src/primitives.rs create mode 100644 packages/gamecult-geometry-rs/src/realtime_csg_parity.rs create mode 100644 packages/gamecult-geometry-rs/src/tree.rs diff --git a/docs/cultmath-gamecult-geometry-migration-progress.md b/docs/cultmath-gamecult-geometry-migration-progress.md index 3a882a81..ace7653e 100644 --- a/docs/cultmath-gamecult-geometry-migration-progress.md +++ b/docs/cultmath-gamecult-geometry-migration-progress.md @@ -4,7 +4,7 @@ Plan: `docs/cultmath-gamecult-geometry-migration-plan.md` (locked) Started: 2026-07-22 -Status: Stages 0-1 complete; Stage 2 in progress +Status: Stages 0-4 complete; Stage 5 in progress ## Objective @@ -58,8 +58,8 @@ Demotions: | 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` | Pending | Requires v1 fixture capture and target Rust package seam | -| 5. CultCache/CultNet/CultMesh integration | Pending | Requires current schemas and runtime packages | +| 4. Mine `vg-csg` | Complete | Geometry-owned Rust kernel passes correctness, parity, performance, formatting, and warning-denied lint gates | +| 5. CultCache/CultNet/CultMesh integration | In progress | Provider/dataflow mapped; typed commit-path smoke remains | | 6. Consumer cutover/release | Pending | Requires local candidate feed and downstream passes | | 7. Delete obsolete authorities/audit | Pending | Requires every prior stage gate | @@ -226,11 +226,31 @@ a broader stage gate. 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 + recorded after this progress update is committed. + ## Next Actions -1. Mine convex splitting, brush/CSG tree, domain lowering, dirty-frontier, - selected-cut, and mesh assembly into `gamecult-geometry-rs` in dependency - order, preserving the pinned v1 evidence while emitting v2. -2. Remove `bevy_math` from the public Rust boundary and port earned correctness - and performance fixtures before deleting VibeGeometry build authority. -3. Connect the completed Rust runtime to the v2 CultCache/CultNet/CultMesh path. +1. Add the Geometry-owned worker provider with `CultNetDatabase.PutAsync` as + the single commit/publication primitive for direct and operation-handle use. +2. Prove domain/request to selected-cut/artifact persistence, raw envelope + replication, worker-state observation, and typed dev-probe agreement. +3. Add exact C#/Rust raw-envelope parity without growing a second networking + stack or a load-bearing JSON projection. diff --git a/packages/gamecult-geometry-rs/Cargo.lock b/packages/gamecult-geometry-rs/Cargo.lock index ba7661a8..986160f0 100644 --- a/packages/gamecult-geometry-rs/Cargo.lock +++ b/packages/gamecult-geometry-rs/Cargo.lock @@ -2,12 +2,147 @@ # 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" @@ -17,6 +152,32 @@ 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" @@ -32,6 +193,12 @@ 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" @@ -42,6 +209,27 @@ dependencies = [ "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" @@ -52,10 +240,86 @@ dependencies = [ "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", @@ -71,12 +335,120 @@ dependencies = [ "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" @@ -84,6 +456,66 @@ 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]] @@ -104,6 +536,66 @@ 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" @@ -123,6 +615,33 @@ dependencies = [ "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" @@ -150,7 +669,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -164,6 +683,47 @@ dependencies = [ "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" @@ -175,6 +735,62 @@ dependencies = [ "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" @@ -187,8 +803,167 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "variadics_please" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b6d82be61465f97d42bd1d15bf20f3b0a3a0905018f38f9d6f6962055b0b5c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wgpu-types" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca7a8d8af57c18f57d393601a1fb159ace8b2328f1b6b5f80893f7d672c9ae2" +dependencies = [ + "bitflags", + "bytemuck", + "js-sys", + "log", + "serde", + "thiserror", + "web-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/packages/gamecult-geometry-rs/Cargo.toml b/packages/gamecult-geometry-rs/Cargo.toml index 4cabb87a..b05c0246 100644 --- a/packages/gamecult-geometry-rs/Cargo.toml +++ b/packages/gamecult-geometry-rs/Cargo.toml @@ -8,7 +8,10 @@ repository = "https://github.com/GameCult/CultLib" readme = "README.md" [dependencies] +bevy_math = "0.17" rmp-serde = "1" serde = { version = "1", features = ["derive"] } sha2 = "0.10" +[dev-dependencies] +approx = "0.5" diff --git a/packages/gamecult-geometry-rs/src/assembler.rs b/packages/gamecult-geometry-rs/src/assembler.rs new file mode 100644 index 00000000..078a07b5 --- /dev/null +++ b/packages/gamecult-geometry-rs/src/assembler.rs @@ -0,0 +1,1509 @@ +#![allow(dead_code)] +use std::cell::RefCell; + +use bevy_math::Vec3; + +use crate::{ + Aabb, Brush, BrushId, BrushOp, ConvexPolygon, ConvexSolid, DirtyDemandFrontier, MaterialId, + PolygonRouteScratch, Primitive, TriangleMesh, append_cylinder_z, append_dome_cap_z, + append_floret_arm, + primitives::{DomeCapZSpec, FloretArmSpec}, +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BuildWarning { + SubtractIgnoredForNonBox { brush: String }, + IntersectIgnoredForNonBox { brush: String }, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct BuildReport { + pub input_brushes: usize, + pub emitted_convex_fragments: usize, + pub operator_brushes: usize, + pub candidate_pairs: usize, + pub rejected_pairs: usize, + pub reused_mesh: bool, + pub used_checkpoint: bool, + pub checkpoint_rebuild_from: Option, + pub direct_dirty_rebuild: bool, + pub warnings: Vec, +} + +#[derive(Clone, Debug)] +pub struct BuildOutput { + pub mesh: TriangleMesh, + pub report: BuildReport, + pub generation: u64, +} + +#[derive(Clone, Debug, Default)] +pub struct Assembler { + brushes: Vec, + compiled: Vec, + cache: RefCell>, + box_cache: RefCell>, + general_cache: RefCell>, + first_dirty_index: RefCell>, + next_id: u32, + generation: u64, +} + +#[derive(Clone, Debug)] +struct CompiledBrush { + op: BrushOp, + material: MaterialId, + bounds: Aabb, + geometry: CompiledGeometry, + name: String, +} + +#[derive(Clone, Debug)] +enum CompiledGeometry { + Box(Aabb), + Convex(ConvexSolid), + CylinderZ { + center: Vec3, + radius: f32, + depth: f32, + segments: usize, + }, + DomeCapZ { + center: Vec3, + radius: f32, + height: f32, + rings: usize, + segments: usize, + }, + FloretArm { + anchor: Vec3, + direction: Vec3, + length: f32, + root_width: f32, + tip_width: f32, + thickness: f32, + tip_lift: f32, + }, +} + +#[derive(Clone, Debug, Default)] +struct BoxBuildCache { + generation: u64, + valid_checkpoint_index: usize, + checkpoints: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq)] +struct BoxBuildState { + boxes: Vec<(Aabb, MaterialId)>, + report: BuildReport, +} + +#[derive(Clone, Debug, Default)] +struct GeneralBuildCache { + generation: u64, + valid_checkpoint_index: usize, + checkpoints: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq)] +struct GeneralBuildState { + mesh: TriangleMesh, + solids: Vec, + report: BuildReport, +} + +impl Assembler { + pub fn new() -> Self { + Self::default() + } + + pub fn brushes(&self) -> &[Brush] { + &self.brushes + } + + pub fn generation(&self) -> u64 { + self.generation + } + + pub fn brush_index(&self, id: BrushId) -> Option { + self.brushes.iter().position(|brush| brush.id == id) + } + + pub fn dirty_frontier_for_indices( + &self, + dirty_indices: impl IntoIterator, + ) -> DirtyDemandFrontier { + DirtyDemandFrontier::from_dirty_indices(&self.brushes, dirty_indices) + } + + pub fn dirty_frontier_for_brushes( + &self, + dirty_brushes: impl IntoIterator, + ) -> DirtyDemandFrontier { + let indices = dirty_brushes + .into_iter() + .filter_map(|id| self.brush_index(id)) + .collect::>(); + self.dirty_frontier_for_indices(indices) + } + + pub fn add_brush( + &mut self, + name: impl Into, + op: BrushOp, + primitive: Primitive, + material: MaterialId, + ) -> BrushId { + let id = BrushId(self.next_id); + self.next_id += 1; + let index = self.brushes.len(); + let brush = Brush::new(id, name, op, primitive, material); + self.compiled.push(CompiledBrush::from_brush(&brush)); + self.brushes.push(brush); + self.generation = self.generation.wrapping_add(1); + self.mark_dirty_index(index); + id + } + + pub fn set_brush_primitive(&mut self, id: BrushId, primitive: Primitive) -> bool { + let Some(index) = self.brush_index(id) else { + return false; + }; + self.brushes[index].primitive = primitive; + self.brushes[index].set_dirty(); + self.compiled[index] = CompiledBrush::from_brush(&self.brushes[index]); + self.generation = self.generation.wrapping_add(1); + self.mark_dirty_index(index); + true + } + + pub fn set_brush_op(&mut self, id: BrushId, op: BrushOp) -> bool { + let Some(index) = self.brush_index(id) else { + return false; + }; + self.brushes[index].op = op; + self.brushes[index].set_dirty(); + self.compiled[index] = CompiledBrush::from_brush(&self.brushes[index]); + self.generation = self.generation.wrapping_add(1); + self.mark_dirty_index(index); + true + } + + pub fn solid_box( + &mut self, + name: impl Into, + bounds: Aabb, + material: MaterialId, + ) -> BrushId { + self.add_brush(name, BrushOp::Add, Primitive::Box { bounds }, material) + } + + pub fn cut_box(&mut self, name: impl Into, bounds: Aabb) -> BrushId { + self.add_brush( + name, + BrushOp::Subtract, + Primitive::Box { bounds }, + MaterialId::default(), + ) + } + + pub fn solid_oriented_box( + &mut self, + name: impl Into, + center: Vec3, + size: Vec3, + rotation: bevy_math::Quat, + material: MaterialId, + ) -> BrushId { + self.add_brush( + name, + BrushOp::Add, + Primitive::OrientedBox { + center, + size, + rotation, + }, + material, + ) + } + + pub fn cut_oriented_box( + &mut self, + name: impl Into, + center: Vec3, + size: Vec3, + rotation: bevy_math::Quat, + ) -> BrushId { + self.add_brush( + name, + BrushOp::Subtract, + Primitive::OrientedBox { + center, + size, + rotation, + }, + MaterialId::default(), + ) + } + + pub fn build(&self) -> BuildOutput { + if let Some(output) = self + .cache + .borrow() + .as_ref() + .filter(|output| output.generation == self.generation) + { + return output.clone(); + } + + let output = self.build_with_checkpoints(); + *self.cache.borrow_mut() = Some(output.clone()); + *self.first_dirty_index.borrow_mut() = None; + output + } + + pub fn build_incremental(&self) -> BuildOutput { + if let Some(output) = self + .cache + .borrow() + .as_ref() + .filter(|output| output.generation == self.generation) + { + return output.clone(); + } + + let first_dirty_index = *self.first_dirty_index.borrow(); + let output = self + .try_build_axis_aligned_boxes_from_cache(first_dirty_index) + .unwrap_or_else(|| self.build_general_from_cache(first_dirty_index)); + *self.cache.borrow_mut() = Some(output.clone()); + *self.first_dirty_index.borrow_mut() = None; + output + } + + pub fn rebuild_incremental_for_indices( + &self, + dirty_indices: impl IntoIterator, + ) -> BuildOutput { + let first_dirty_index = self + .dirty_frontier_for_indices(dirty_indices) + .first_dirty_index; + self.try_build_axis_aligned_boxes_from_cache(first_dirty_index) + .unwrap_or_else(|| self.build_general_from_cache(first_dirty_index)) + } + + pub fn rebuild_incremental_for_brushes( + &self, + dirty_brushes: impl IntoIterator, + ) -> BuildOutput { + let indices = dirty_brushes + .into_iter() + .filter_map(|id| self.brush_index(id)) + .collect::>(); + self.rebuild_incremental_for_indices(indices) + } + + pub fn rebuild(&self) -> BuildOutput { + self.build_uncached() + } + + pub fn rebuild_routed_surfaces(&self) -> BuildOutput { + self.try_build_routed_surfaces() + .unwrap_or_else(|| self.build_uncached()) + } + + pub fn supports_routed_surfaces(&self) -> bool { + self.supports_routed_surface_subset() + } + + fn mark_dirty_index(&self, index: usize) { + let mut first_dirty_index = self.first_dirty_index.borrow_mut(); + *first_dirty_index = Some(first_dirty_index.map_or(index, |dirty| dirty.min(index))); + } + + fn try_build_routed_surfaces(&self) -> Option { + if !self.supports_routed_surface_subset() { + return None; + } + + let mut report = BuildReport { + input_brushes: self.brushes.len(), + ..BuildReport::default() + }; + let mut source = None::; + let mut surfaces = Vec::::new(); + let mut previous_cutters = Vec::::new(); + let mut scratch = PolygonRouteScratch::default(); + + for brush in &self.compiled { + match brush.op { + BrushOp::Add => { + if source.is_some() { + return None; + } + let solid = brush.convex_cutter()?; + surfaces = solid.polygons.clone(); + source = Some(solid); + } + BrushOp::Subtract => { + report.operator_brushes += 1; + let source_solid = source.as_ref()?; + if !source_solid.bounds.intersects(brush.bounds) { + report.rejected_pairs += 1; + continue; + } + let cutter = brush.convex_cutter()?; + report.candidate_pairs += 1; + surfaces = scratch.route_outside_of(surfaces, &cutter); + + let mut caps = scratch.route_inside_of(cutter.polygons.clone(), source_solid); + for previous in &previous_cutters { + caps = scratch.route_outside_of(caps, previous); + } + for cap in &mut caps { + cap.material = source_solid.material; + cap.reversed = !cap.reversed; + } + surfaces.extend(caps); + previous_cutters.push(cutter); + } + BrushOp::Intersect => return None, + } + } + + let mut mesh = TriangleMesh::new(); + ConvexSolid::append_polygons_to_mesh(&surfaces, &mut mesh); + report.emitted_convex_fragments = surfaces.len(); + + Some(BuildOutput { + mesh, + report, + generation: self.generation, + }) + } + + fn supports_routed_surface_subset(&self) -> bool { + let mut source = None::; + let mut candidate_subtracts = 0usize; + + for brush in &self.compiled { + match brush.op { + BrushOp::Add => { + if source.is_some() || brush.convex_cutter().is_none() { + return false; + } + source = Some(brush.bounds); + } + BrushOp::Subtract => { + let Some(source_bounds) = source else { + return false; + }; + if brush.convex_cutter().is_none() { + return false; + } + if source_bounds.intersects(brush.bounds) { + candidate_subtracts += 1; + if candidate_subtracts > 1 { + return false; + } + } + } + BrushOp::Intersect => return false, + } + } + + source.is_some() && candidate_subtracts == 1 + } + + fn build_uncached(&self) -> BuildOutput { + if self + .compiled + .iter() + .all(|brush| matches!(brush.geometry, CompiledGeometry::Box(_))) + { + return self.build_axis_aligned_boxes_direct(); + } + + self.build_general_direct() + } + + fn build_with_checkpoints(&self) -> BuildOutput { + self.try_build_axis_aligned_boxes_from_cache(Some(0)) + .unwrap_or_else(|| self.build_general_from_cache(Some(0))) + } + + fn build_general_direct(&self) -> BuildOutput { + let mut state = GeneralBuildState::default(); + for brush in &self.compiled { + state.apply_compiled_brush(brush); + } + state.into_output(self.brushes.len(), self.generation) + } + + fn build_axis_aligned_boxes_direct(&self) -> BuildOutput { + let mut state = BoxBuildState::default(); + for brush in &self.compiled { + state.apply_compiled_box(brush); + } + state.report.direct_dirty_rebuild = true; + state.into_output_with_mesh(self.brushes.len(), self.generation, None) + } + + fn try_build_axis_aligned_boxes_from_cache( + &self, + first_dirty_index: Option, + ) -> Option { + if !self + .compiled + .iter() + .all(|brush| matches!(brush.geometry, CompiledGeometry::Box(_))) + { + return None; + } + + let brush_count = self.compiled.len(); + let previous_output = self.cache.borrow().as_ref().cloned(); + if let Some(first_dirty_index) = first_dirty_index + && first_dirty_index > 0 + { + return Some(self.build_axis_aligned_boxes_direct()); + } + let rebuild_from = match first_dirty_index { + Some(index) => index.min(brush_count), + None => self + .box_cache + .borrow() + .as_ref() + .filter(|cache| { + cache.generation == self.generation + && cache.valid_checkpoint_index == brush_count + && cache.checkpoints.len() == brush_count + 1 + }) + .map_or(0, |_| brush_count), + }; + let cached_state = self + .box_cache + .borrow() + .as_ref() + .filter(|cache| rebuild_from <= cache.valid_checkpoint_index) + .and_then(|cache| cache.checkpoints.get(rebuild_from).cloned()); + let (mut state, start_index) = if let Some(state) = cached_state { + (state, rebuild_from) + } else { + (BoxBuildState::default(), 0) + }; + if start_index > 0 { + state.report.used_checkpoint = true; + state.report.checkpoint_rebuild_from = Some(start_index); + } + + let mut checkpoints = (start_index == 0).then(|| vec![BoxBuildState::default()]); + + let mut changed_geometry = start_index == 0; + for brush in &self.compiled[start_index..] { + changed_geometry |= state.apply_compiled_box(brush); + if let Some(checkpoints) = &mut checkpoints { + checkpoints.push(state.clone()); + } + } + + if let Some(checkpoints) = checkpoints { + *self.box_cache.borrow_mut() = Some(BoxBuildCache { + generation: self.generation, + valid_checkpoint_index: brush_count, + checkpoints, + }); + } else if let Some(cache) = self.box_cache.borrow_mut().as_mut() { + cache.generation = self.generation; + cache.valid_checkpoint_index = cache.valid_checkpoint_index.min(start_index); + } + + let reused_mesh = (!changed_geometry) + .then_some(previous_output) + .flatten() + .map(|output| output.mesh); + Some(state.into_output_with_mesh(self.brushes.len(), self.generation, reused_mesh)) + } + + fn build_general_from_cache(&self, first_dirty_index: Option) -> BuildOutput { + let brush_count = self.compiled.len(); + let previous_output = self.cache.borrow().as_ref().cloned(); + let rebuild_from = match first_dirty_index { + Some(index) => index.min(brush_count), + None => self + .general_cache + .borrow() + .as_ref() + .filter(|cache| { + cache.generation == self.generation + && cache.valid_checkpoint_index == brush_count + && cache.checkpoints.len() == brush_count + 1 + }) + .map_or(0, |_| brush_count), + }; + let cached_state = self + .general_cache + .borrow() + .as_ref() + .filter(|cache| rebuild_from <= cache.valid_checkpoint_index) + .and_then(|cache| cache.checkpoints.get(rebuild_from).cloned()); + let (mut state, start_index) = if let Some(state) = cached_state { + (state, rebuild_from) + } else { + (GeneralBuildState::default(), 0) + }; + if start_index > 0 { + state.report.used_checkpoint = true; + state.report.checkpoint_rebuild_from = Some(start_index); + } + + let mut checkpoints = (start_index == 0).then(|| vec![GeneralBuildState::default()]); + + let mut changed_geometry = start_index == 0; + for brush in &self.compiled[start_index..] { + changed_geometry |= state.apply_compiled_brush(brush); + if let Some(checkpoints) = &mut checkpoints { + checkpoints.push(state.clone()); + } + } + + if let Some(checkpoints) = checkpoints { + *self.general_cache.borrow_mut() = Some(GeneralBuildCache { + generation: self.generation, + valid_checkpoint_index: brush_count, + checkpoints, + }); + } else if let Some(cache) = self.general_cache.borrow_mut().as_mut() { + cache.generation = self.generation; + cache.valid_checkpoint_index = cache.valid_checkpoint_index.min(start_index); + } + + let reused_mesh = (!changed_geometry) + .then_some(previous_output) + .flatten() + .map(|output| output.mesh); + state.into_output_with_mesh(self.brushes.len(), self.generation, reused_mesh) + } + + pub fn sample_room_with_door() -> Self { + let wall = MaterialId(1); + let floor = MaterialId(2); + let mut asm = Self::new(); + asm.solid_box( + "floor", + Aabb::from_center_size(Vec3::new(0.0, 0.0, -0.1), Vec3::new(8.0, 8.0, 0.2)), + floor, + ); + asm.solid_box( + "north wall", + Aabb::from_center_size(Vec3::new(0.0, 4.0, 1.5), Vec3::new(8.0, 0.25, 3.0)), + wall, + ); + asm.cut_box( + "door void", + Aabb::from_center_size(Vec3::new(0.0, 4.0, 1.0), Vec3::new(1.2, 0.5, 2.0)), + ); + asm + } +} + +impl CompiledBrush { + fn from_brush(brush: &Brush) -> Self { + let bounds = brush.bounds(); + let geometry = match &brush.primitive { + Primitive::Box { bounds } => CompiledGeometry::Box(*bounds), + Primitive::OrientedBox { + center, + size, + rotation, + } => CompiledGeometry::Convex(ConvexSolid::box_from_center_size( + *center, + *size, + *rotation, + brush.material, + )), + Primitive::CylinderZ { + center, + radius, + depth, + segments, + } => CompiledGeometry::CylinderZ { + center: *center, + radius: *radius, + depth: *depth, + segments: *segments, + }, + Primitive::DomeCapZ { + center, + radius, + height, + rings, + segments, + } => CompiledGeometry::DomeCapZ { + center: *center, + radius: *radius, + height: *height, + rings: *rings, + segments: *segments, + }, + Primitive::FloretArm { + anchor, + direction, + length, + root_width, + tip_width, + thickness, + tip_lift, + } => CompiledGeometry::FloretArm { + anchor: *anchor, + direction: *direction, + length: *length, + root_width: *root_width, + tip_width: *tip_width, + thickness: *thickness, + tip_lift: *tip_lift, + }, + }; + + Self { + op: brush.op, + material: brush.material, + bounds, + geometry, + name: brush.name.clone(), + } + } + + fn convex_cutter(&self) -> Option { + match &self.geometry { + CompiledGeometry::Box(bounds) => Some(ConvexSolid::from_aabb(*bounds, self.material)), + CompiledGeometry::Convex(solid) => Some(solid.clone()), + _ => None, + } + } +} + +impl BoxBuildState { + fn apply_compiled_box(&mut self, brush: &CompiledBrush) -> bool { + let CompiledGeometry::Box(bounds) = brush.geometry else { + unreachable!("box build state received a non-box brush"); + }; + + match brush.op { + BrushOp::Add => { + self.boxes.push((bounds, brush.material)); + true + } + BrushOp::Subtract => { + self.report.operator_brushes += 1; + if !bounds.intersects_any(self.boxes.iter().map(|(bounds, _)| *bounds)) { + self.report.rejected_pairs += self.boxes.len(); + return false; + } + + let mut out = Vec::with_capacity(self.boxes.len() + 4); + for (solid, material) in self.boxes.drain(..) { + if solid.intersects(bounds) { + self.report.candidate_pairs += 1; + out.extend( + solid + .subtract_box(bounds) + .into_iter() + .map(|piece| (piece, material)), + ); + } else { + self.report.rejected_pairs += 1; + out.push((solid, material)); + } + } + self.boxes = out; + true + } + BrushOp::Intersect => { + self.report.operator_brushes += 1; + if !bounds.intersects_any(self.boxes.iter().map(|(bounds, _)| *bounds)) { + let changed = !self.boxes.is_empty(); + self.report.rejected_pairs += self.boxes.len(); + self.boxes.clear(); + return changed; + } + + self.boxes = self + .boxes + .drain(..) + .filter_map(|(solid, material)| { + if let Some(hit) = solid.intersection(bounds) { + self.report.candidate_pairs += 1; + Some((hit, material)) + } else { + self.report.rejected_pairs += 1; + None + } + }) + .collect(); + true + } + } + } + + fn into_output_with_mesh( + mut self, + input_brushes: usize, + generation: u64, + reused_mesh: Option, + ) -> BuildOutput { + let mut mesh = TriangleMesh::new(); + self.report.input_brushes = input_brushes; + self.report.emitted_convex_fragments = self.boxes.len(); + if let Some(reused_mesh) = reused_mesh { + self.report.reused_mesh = true; + mesh = reused_mesh; + } else { + for (bounds, material) in self.boxes { + mesh.append_box(bounds, material); + } + } + + BuildOutput { + mesh, + report: self.report, + generation, + } + } +} + +impl GeneralBuildState { + fn apply_compiled_brush(&mut self, brush: &CompiledBrush) -> bool { + match brush.op { + BrushOp::Add => { + self.add_brush_geometry(brush); + true + } + BrushOp::Subtract => { + self.report.operator_brushes += 1; + if !brush + .bounds + .intersects_any(self.solids.iter().map(|solid| solid.bounds)) + { + self.report.rejected_pairs += self.solids.len(); + return false; + } + if let Some(cutter) = brush.convex_cutter() { + self.solids = subtract_from_solids( + std::mem::take(&mut self.solids), + &cutter, + &mut self.report, + ); + true + } else { + let has_candidate = + record_solid_pairs(&self.solids, brush.bounds, &mut self.report); + if has_candidate { + self.report + .warnings + .push(BuildWarning::SubtractIgnoredForNonBox { + brush: brush.name.clone(), + }); + } + false + } + } + BrushOp::Intersect => { + self.report.operator_brushes += 1; + if !brush + .bounds + .intersects_any(self.solids.iter().map(|solid| solid.bounds)) + { + let changed = !self.solids.is_empty(); + self.report.rejected_pairs += self.solids.len(); + self.solids.clear(); + return changed; + } + if let Some(cutter) = brush.convex_cutter() { + self.solids = intersect_solids( + std::mem::take(&mut self.solids), + &cutter, + &mut self.report, + ); + true + } else { + let has_candidate = + record_solid_pairs(&self.solids, brush.bounds, &mut self.report); + let changed = !self.solids.is_empty(); + self.solids.clear(); + if has_candidate { + self.report + .warnings + .push(BuildWarning::IntersectIgnoredForNonBox { + brush: brush.name.clone(), + }); + } + changed + } + } + } + } + + fn add_brush_geometry(&mut self, brush: &CompiledBrush) { + match &brush.geometry { + CompiledGeometry::Box(bounds) => { + self.solids + .push(ConvexSolid::from_aabb(*bounds, brush.material)); + } + CompiledGeometry::Convex(solid) => self.solids.push(solid.clone()), + CompiledGeometry::CylinderZ { + center, + radius, + depth, + segments, + } => append_cylinder_z( + &mut self.mesh, + *center, + *radius, + *depth, + *segments, + brush.material, + ), + CompiledGeometry::DomeCapZ { + center, + radius, + height, + rings, + segments, + } => append_dome_cap_z( + &mut self.mesh, + DomeCapZSpec { + center: *center, + radius: *radius, + height: *height, + rings: *rings, + segments: *segments, + material: brush.material, + }, + ), + CompiledGeometry::FloretArm { + anchor, + direction, + length, + root_width, + tip_width, + thickness, + tip_lift, + } => append_floret_arm( + &mut self.mesh, + FloretArmSpec { + anchor: *anchor, + direction: *direction, + length: *length, + root_width: *root_width, + tip_width: *tip_width, + thickness: *thickness, + tip_lift: *tip_lift, + material: brush.material, + }, + ), + } + } + + fn into_output(self, input_brushes: usize, generation: u64) -> BuildOutput { + self.into_output_with_mesh(input_brushes, generation, None) + } + + fn into_output_with_mesh( + mut self, + input_brushes: usize, + generation: u64, + reused_mesh: Option, + ) -> BuildOutput { + self.report.input_brushes = input_brushes; + self.report.emitted_convex_fragments = self.solids.len(); + if let Some(reused_mesh) = reused_mesh { + self.report.reused_mesh = true; + self.mesh = reused_mesh; + } else { + for solid in self.solids { + solid.append_to_mesh(&mut self.mesh); + } + } + + BuildOutput { + mesh: self.mesh, + report: self.report, + generation, + } + } +} + +fn subtract_from_solids( + solids: Vec, + cutter: &ConvexSolid, + report: &mut BuildReport, +) -> Vec { + let mut out = Vec::with_capacity(solids.len() + 4); + for solid in solids { + if solid.bounds.intersects(cutter.bounds) { + report.candidate_pairs += 1; + out.extend(solid.subtract_convex_owned(cutter)); + } else { + report.rejected_pairs += 1; + out.push(solid); + } + } + out +} + +fn intersect_solids( + solids: Vec, + cutter: &ConvexSolid, + report: &mut BuildReport, +) -> Vec { + let mut out = Vec::with_capacity(solids.len()); + for solid in solids { + if !solid.bounds.intersects(cutter.bounds) { + report.rejected_pairs += 1; + continue; + } + report.candidate_pairs += 1; + if let Some(fragment) = solid.intersect_convex_owned(cutter) { + out.push(fragment); + } + } + out +} + +fn record_solid_pairs(solids: &[ConvexSolid], bounds: Aabb, report: &mut BuildReport) -> bool { + let mut has_candidate = false; + for solid in solids { + if solid.bounds.intersects(bounds) { + report.candidate_pairs += 1; + has_candidate = true; + } else { + report.rejected_pairs += 1; + } + } + has_candidate +} + +#[cfg(test)] +mod tests { + use bevy_math::Vec3; + + use super::*; + + #[test] + fn centered_subtract_splits_box_into_six_fragments() { + let source = Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)); + let cutter = Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)); + let pieces = source.subtract_box(cutter); + + assert_eq!(pieces.len(), 6); + assert!(pieces.iter().all(|piece| piece.is_valid())); + assert!(pieces.iter().all(|piece| !piece.intersects(cutter))); + } + + #[test] + fn door_cut_leaves_no_box_fragment_inside_void() { + let output = Assembler::sample_room_with_door().build(); + let cutter = Aabb::from_center_size(Vec3::new(0.0, 4.0, 1.0), Vec3::new(1.2, 0.5, 2.0)); + + assert!(output.report.emitted_convex_fragments > 2); + for tri in output.mesh.indices.chunks_exact(3) { + let center = (output.mesh.positions[tri[0] as usize] + + output.mesh.positions[tri[1] as usize] + + output.mesh.positions[tri[2] as usize]) + / 3.0; + assert!(!cutter.contains_point_strict(center, 1.0e-4)); + } + } + + #[test] + fn rotated_door_cut_produces_diagonal_portal_faces() { + let mut asm = Assembler::new(); + asm.solid_box( + "wall", + Aabb::from_center_size(Vec3::new(0.0, 0.0, 1.5), Vec3::new(5.0, 0.5, 3.0)), + MaterialId(1), + ); + asm.cut_oriented_box( + "angled void", + Vec3::new(0.0, 0.0, 1.5), + Vec3::new(1.5, 2.0, 2.0), + bevy_math::Quat::from_rotation_z(std::f32::consts::FRAC_PI_4), + ); + + let output = asm.build(); + assert!(output.report.emitted_convex_fragments > 2); + assert!(output.mesh.normals.iter().any(|normal| { + normal.x.abs() > 0.2 && normal.y.abs() > 0.2 && normal.z.abs() < 0.1 + })); + } + + #[test] + fn mesh_box_emits_twelve_triangles() { + let mut mesh = TriangleMesh::new(); + mesh.append_box(Aabb::from_center_size(Vec3::ZERO, Vec3::ONE), MaterialId(7)); + + assert_eq!(mesh.triangle_count(), 12); + assert_eq!(mesh.triangle_materials.len(), 12); + assert_eq!(mesh.triangle_materials[0], MaterialId(7)); + } + + #[test] + fn distant_subtract_skips_convex_split_and_keeps_source_solid() { + let mut asm = Assembler::new(); + asm.solid_box( + "source", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)), + MaterialId(1), + ); + asm.cut_box( + "far void", + Aabb::from_center_size(Vec3::splat(10.0), Vec3::splat(1.0)), + ); + + let output = asm.build(); + assert_eq!(output.report.emitted_convex_fragments, 1); + assert_eq!(output.report.operator_brushes, 1); + assert_eq!(output.report.candidate_pairs, 0); + assert_eq!(output.report.rejected_pairs, 1); + assert_eq!(output.mesh.triangle_count(), 12); + } + + #[test] + fn intersecting_box_keeps_common_convex_region() { + let mut asm = Assembler::new(); + asm.solid_box( + "a", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + MaterialId(1), + ); + asm.add_brush( + "b", + BrushOp::Intersect, + Primitive::Box { + bounds: Aabb::from_center_size(Vec3::X, Vec3::splat(4.0)), + }, + MaterialId(1), + ); + + let output = asm.build(); + assert_eq!(output.report.emitted_convex_fragments, 1); + assert_eq!(output.report.operator_brushes, 1); + assert_eq!(output.report.candidate_pairs, 1); + assert_eq!(output.report.rejected_pairs, 0); + assert_eq!(output.report.warnings.len(), 0); + assert_eq!(output.mesh.triangle_count(), 12); + } + + #[test] + fn cached_build_and_dirty_rebuild_emit_same_output() { + let mut asm = Assembler::new(); + asm.solid_box( + "slab", + Aabb::from_center_size(Vec3::ZERO, Vec3::new(8.0, 8.0, 2.0)), + MaterialId(1), + ); + asm.cut_box( + "center void", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)), + ); + asm.cut_oriented_box( + "angled void", + Vec3::new(1.0, 1.0, 0.0), + Vec3::new(1.25, 4.0, 2.5), + bevy_math::Quat::from_rotation_z(0.35), + ); + + let stable = asm.build(); + let cached = asm.build(); + let dirty = asm.rebuild(); + + assert_eq!( + stable.report.emitted_convex_fragments, + dirty.report.emitted_convex_fragments + ); + assert_eq!( + stable.report.operator_brushes, + dirty.report.operator_brushes + ); + assert_eq!(stable.report.candidate_pairs, dirty.report.candidate_pairs); + assert_eq!(stable.report.rejected_pairs, dirty.report.rejected_pairs); + assert_eq!(stable.report.warnings, dirty.report.warnings); + assert_eq!(stable.mesh.triangle_count(), dirty.mesh.triangle_count()); + assert_eq!(stable.mesh.vertex_count(), dirty.mesh.vertex_count()); + assert_eq!(stable.mesh.positions, dirty.mesh.positions); + assert_eq!(stable.mesh.indices, dirty.mesh.indices); + assert_eq!(cached.mesh.positions, stable.mesh.positions); + assert_eq!(cached.mesh.indices, stable.mesh.indices); + } + + #[test] + fn routed_surface_rebuild_emits_clean_center_cut_boundary() { + let mut asm = Assembler::new(); + let cutter = Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)); + asm.solid_box( + "source", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + MaterialId(1), + ); + asm.cut_box("void", cutter); + + let routed = asm.rebuild_routed_surfaces(); + + assert_eq!(routed.report.operator_brushes, 1); + assert_eq!(routed.report.candidate_pairs, 1); + assert_eq!(routed.report.rejected_pairs, 0); + assert_eq!(routed.report.emitted_convex_fragments, 24); + assert!(routed.mesh.triangle_count() < asm.rebuild().mesh.triangle_count()); + for tri in routed.mesh.indices.chunks_exact(3) { + let center = (routed.mesh.positions[tri[0] as usize] + + routed.mesh.positions[tri[1] as usize] + + routed.mesh.positions[tri[2] as usize]) + / 3.0; + assert!(!cutter.contains_point_strict(center, 1.0e-4)); + } + } + + #[test] + fn routed_surface_rebuild_falls_back_for_intersections() { + let mut asm = Assembler::new(); + asm.solid_box( + "source", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + MaterialId(1), + ); + asm.add_brush( + "common", + BrushOp::Intersect, + Primitive::Box { + bounds: Aabb::from_center_size(Vec3::X, Vec3::splat(4.0)), + }, + MaterialId(1), + ); + + let stable = asm.rebuild(); + let routed = asm.rebuild_routed_surfaces(); + + assert_eq!( + routed.report.emitted_convex_fragments, + stable.report.emitted_convex_fragments + ); + assert_eq!(routed.mesh.triangle_count(), stable.mesh.triangle_count()); + assert_eq!(routed.mesh.positions, stable.mesh.positions); + assert_eq!(routed.mesh.indices, stable.mesh.indices); + } + + #[test] + fn routed_surface_rebuild_falls_back_for_multiple_candidate_cutters() { + let mut asm = Assembler::new(); + asm.solid_box( + "source", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + MaterialId(1), + ); + asm.cut_oriented_box( + "a", + Vec3::new(-0.5, 0.0, 0.0), + Vec3::new(1.0, 5.0, 5.0), + bevy_math::Quat::from_rotation_z(0.25), + ); + asm.cut_oriented_box( + "b", + Vec3::new(0.5, 0.0, 0.0), + Vec3::new(1.0, 5.0, 5.0), + bevy_math::Quat::from_rotation_z(-0.25), + ); + + let stable = asm.rebuild(); + let routed = asm.rebuild_routed_surfaces(); + + assert_eq!( + routed.report.emitted_convex_fragments, + stable.report.emitted_convex_fragments + ); + assert_eq!(routed.mesh.triangle_count(), stable.mesh.triangle_count()); + assert_eq!(routed.mesh.positions, stable.mesh.positions); + assert_eq!(routed.mesh.indices, stable.mesh.indices); + } + + #[test] + fn routed_surface_rebuild_falls_back_when_no_cutter_touches_source() { + let mut asm = Assembler::new(); + asm.solid_box( + "source", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + MaterialId(1), + ); + asm.cut_box( + "far", + Aabb::from_center_size(Vec3::splat(20.0), Vec3::splat(2.0)), + ); + + let stable = asm.rebuild(); + let routed = asm.rebuild_routed_surfaces(); + + assert_eq!( + routed.report.emitted_convex_fragments, + stable.report.emitted_convex_fragments + ); + assert_eq!(routed.mesh.triangle_count(), stable.mesh.triangle_count()); + assert_eq!(routed.mesh.positions, stable.mesh.positions); + assert_eq!(routed.mesh.indices, stable.mesh.indices); + } + + #[test] + fn mutating_brush_primitive_invalidates_cache_and_exposes_dirty_frontier() { + let mut asm = Assembler::new(); + let source = asm.solid_box( + "source", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + MaterialId(1), + ); + let cutter = asm.cut_box("void", Aabb::from_center_size(Vec3::ZERO, Vec3::splat(1.0))); + let before = asm.build(); + + assert!(asm.set_brush_primitive( + cutter, + Primitive::Box { + bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)), + }, + )); + let frontier = asm.dirty_frontier_for_brushes([cutter]); + let after = asm.build(); + + assert_eq!(frontier.first_dirty_index, Some(1)); + assert_eq!(frontier.operator_brushes, 1); + assert_eq!(frontier.candidate_pairs(), 1); + assert_eq!(frontier.rejected_pairs, 0); + assert!(after.generation > before.generation); + assert_ne!(after.mesh.positions, before.mesh.positions); + assert_eq!(asm.brush_index(source), Some(0)); + assert_eq!(asm.brush_index(cutter), Some(1)); + } + + #[test] + fn incremental_box_rebuild_reuses_prefix_and_matches_full_rebuild() { + let mut asm = Assembler::new(); + asm.solid_box( + "source", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(8.0)), + MaterialId(1), + ); + asm.cut_box( + "early", + Aabb::from_center_size(Vec3::new(-2.0, 0.0, 0.0), Vec3::splat(2.0)), + ); + let dirty = asm.cut_box( + "dirty", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)), + ); + asm.cut_box( + "late", + Aabb::from_center_size(Vec3::new(2.0, 0.0, 0.0), Vec3::splat(2.0)), + ); + let before = asm.build(); + + assert!(asm.set_brush_primitive( + dirty, + Primitive::Box { + bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(3.0)), + }, + )); + let incremental = asm.build_incremental(); + let full = asm.rebuild(); + + assert!(!incremental.report.used_checkpoint); + assert!(incremental.report.direct_dirty_rebuild); + assert_eq!(incremental.mesh.positions, full.mesh.positions); + assert_eq!(incremental.mesh.indices, full.mesh.indices); + assert_eq!( + incremental.report.candidate_pairs, + full.report.candidate_pairs + ); + assert_eq!( + incremental.report.rejected_pairs, + full.report.rejected_pairs + ); + assert_eq!(incremental.generation, full.generation); + assert_ne!(incremental.mesh.positions, before.mesh.positions); + } + + #[test] + fn explicit_incremental_box_rebuild_matches_full_rebuild() { + let mut asm = Assembler::new(); + asm.solid_box( + "source", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(8.0)), + MaterialId(1), + ); + asm.cut_box( + "early", + Aabb::from_center_size(Vec3::new(-2.0, 0.0, 0.0), Vec3::splat(2.0)), + ); + let dirty = asm.cut_box( + "dirty", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)), + ); + asm.cut_box( + "late", + Aabb::from_center_size(Vec3::new(2.0, 0.0, 0.0), Vec3::splat(2.0)), + ); + asm.build_incremental(); + + assert!(asm.set_brush_primitive( + dirty, + Primitive::Box { + bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(3.0)), + }, + )); + let incremental = asm.rebuild_incremental_for_brushes([dirty]); + let full = asm.rebuild(); + + assert_eq!(incremental.mesh.positions, full.mesh.positions); + assert_eq!(incremental.mesh.indices, full.mesh.indices); + assert_eq!( + incremental.report.candidate_pairs, + full.report.candidate_pairs + ); + assert_eq!( + incremental.report.rejected_pairs, + full.report.rejected_pairs + ); + } + + #[test] + fn incremental_distant_tail_edit_reuses_previous_mesh() { + let mut asm = Assembler::new(); + asm.solid_box( + "source", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(8.0)), + MaterialId(1), + ); + let far = asm.cut_oriented_box( + "far", + Vec3::splat(100.0), + Vec3::splat(1.0), + bevy_math::Quat::from_rotation_z(0.2), + ); + let before = asm.build_incremental(); + + assert!(asm.set_brush_primitive( + far, + Primitive::OrientedBox { + center: Vec3::splat(120.0), + size: Vec3::splat(1.0), + rotation: bevy_math::Quat::from_rotation_z(0.2), + }, + )); + let after = asm.build_incremental(); + + assert_eq!(after.mesh, before.mesh); + assert!(after.report.reused_mesh); + assert!(after.report.used_checkpoint); + assert_eq!(after.report.checkpoint_rebuild_from, Some(1)); + assert!(!after.report.direct_dirty_rebuild); + assert_eq!(after.report.candidate_pairs, 0); + assert_eq!(after.report.rejected_pairs, 1); + } + + #[test] + fn incremental_convex_rebuild_matches_full_rebuild() { + let mut asm = Assembler::new(); + asm.solid_box( + "slab", + Aabb::from_center_size(Vec3::ZERO, Vec3::new(16.0, 16.0, 4.0)), + MaterialId(1), + ); + + let mut tail = BrushId(0); + for index in 0..12 { + let angle = index as f32 * 0.217; + let center = Vec3::new(angle.cos() * 4.0, angle.sin() * 4.0, 0.0); + tail = asm.cut_oriented_box( + format!("rotated_void_{index}"), + center, + Vec3::new(1.0, 6.0, 5.0), + bevy_math::Quat::from_rotation_z(angle), + ); + } + asm.build_incremental(); + + assert!(asm.set_brush_primitive( + tail, + Primitive::OrientedBox { + center: Vec3::new(3.0, 0.0, 0.0), + size: Vec3::new(1.25, 6.0, 5.0), + rotation: bevy_math::Quat::from_rotation_z(0.5), + }, + )); + let incremental = asm.build_incremental(); + let full = asm.rebuild(); + + assert!(incremental.report.used_checkpoint); + assert_eq!(incremental.report.checkpoint_rebuild_from, Some(12)); + assert!(!incremental.report.direct_dirty_rebuild); + assert_eq!(incremental.mesh.positions, full.mesh.positions); + assert_eq!(incremental.mesh.indices, full.mesh.indices); + assert_eq!( + incremental.report.candidate_pairs, + full.report.candidate_pairs + ); + assert_eq!( + incremental.report.rejected_pairs, + full.report.rejected_pairs + ); + } + + #[test] + fn mutating_brush_operation_changes_ordered_output() { + let mut asm = Assembler::new(); + let _source = asm.solid_box( + "source", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + MaterialId(1), + ); + let other = asm.solid_box( + "other", + Aabb::from_center_size(Vec3::new(0.5, 0.0, 0.0), Vec3::splat(3.0)), + MaterialId(1), + ); + let before = asm.rebuild(); + + assert!(asm.set_brush_op(other, BrushOp::Intersect)); + let after = asm.rebuild(); + + assert_eq!(after.report.operator_brushes, 1); + assert_eq!(after.report.candidate_pairs, 1); + assert!(after.mesh.triangle_count() < before.mesh.triangle_count()); + } + + #[test] + fn rotated_cut_stack_regression_preserves_ordered_output_shape() { + let mut asm = Assembler::new(); + asm.solid_box( + "slab", + Aabb::from_center_size(Vec3::ZERO, Vec3::new(32.0, 32.0, 4.0)), + MaterialId(1), + ); + + for index in 0..64 { + let angle = index as f32 * 0.173; + let radius = 11.0 + (index % 7) as f32 * 0.35; + let center = Vec3::new(angle.cos() * radius, angle.sin() * radius, 0.0); + asm.cut_oriented_box( + format!("rotated_void_{index}"), + center, + Vec3::new(1.0 + (index % 3) as f32 * 0.3, 8.0, 5.0), + bevy_math::Quat::from_rotation_z(angle), + ); + } + + let output = asm.rebuild(); + + assert_eq!(output.report.emitted_convex_fragments, 280); + assert_eq!(output.mesh.triangle_count(), 3404); + assert_eq!(output.report.candidate_pairs, 804); + assert_eq!(output.report.rejected_pairs, 9417); + } +} diff --git a/packages/gamecult-geometry-rs/src/brush.rs b/packages/gamecult-geometry-rs/src/brush.rs new file mode 100644 index 00000000..314c84d0 --- /dev/null +++ b/packages/gamecult-geometry-rs/src/brush.rs @@ -0,0 +1,370 @@ +#![allow(dead_code)] +use bevy_math::{Quat, Vec3}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct BrushId(pub u32); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] +pub struct MaterialId(pub u32); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BrushOp { + Add, + Subtract, + Intersect, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum PolygonCategory { + Inside, + Outside, + Aligned, + ReverseAligned, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Aabb { + pub min: Vec3, + pub max: Vec3, +} + +impl Aabb { + pub fn empty() -> Self { + Self { + min: Vec3::splat(f32::INFINITY), + max: Vec3::splat(f32::NEG_INFINITY), + } + } + + pub fn from_center_size(center: Vec3, size: Vec3) -> Self { + let half = size * 0.5; + Self { + min: center - half, + max: center + half, + } + } + + pub fn new(min: Vec3, max: Vec3) -> Self { + Self { min, max } + } + + pub fn from_points(points: &[Vec3]) -> Self { + let mut bounds = Self::empty(); + for point in points { + bounds.include_point(*point); + } + bounds + } + + pub fn include_point(&mut self, point: Vec3) { + self.min = self.min.min(point); + self.max = self.max.max(point); + } + + pub fn union(self, other: Self) -> Self { + if !self.has_finite_extents() { + return other; + } + if !other.has_finite_extents() { + return self; + } + Self { + min: self.min.min(other.min), + max: self.max.max(other.max), + } + } + + pub fn size(self) -> Vec3 { + self.max - self.min + } + + pub fn center(self) -> Vec3 { + (self.min + self.max) * 0.5 + } + + pub fn is_valid(self) -> bool { + self.max.x > self.min.x && self.max.y > self.min.y && self.max.z > self.min.z + } + + pub fn has_finite_extents(self) -> bool { + self.min.is_finite() && self.max.is_finite() + } + + pub fn intersects(self, other: Self) -> bool { + self.min.x < other.max.x + && self.max.x > other.min.x + && self.min.y < other.max.y + && self.max.y > other.min.y + && self.min.z < other.max.z + && self.max.z > other.min.z + } + + pub fn intersects_any(self, others: impl IntoIterator) -> bool { + others.into_iter().any(|other| self.intersects(other)) + } + + pub fn intersection(self, other: Self) -> Option { + if !self.intersects(other) { + return None; + } + + let min = self.min.max(other.min); + let max = self.max.min(other.max); + let hit = Self { min, max }; + hit.is_valid().then_some(hit) + } + + pub fn contains_point(self, point: Vec3) -> bool { + point.x >= self.min.x + && point.x <= self.max.x + && point.y >= self.min.y + && point.y <= self.max.y + && point.z >= self.min.z + && point.z <= self.max.z + } + + pub fn contains_point_strict(self, point: Vec3, epsilon: f32) -> bool { + point.x > self.min.x + epsilon + && point.x < self.max.x - epsilon + && point.y > self.min.y + epsilon + && point.y < self.max.y - epsilon + && point.z > self.min.z + epsilon + && point.z < self.max.z - epsilon + } + + pub fn subtract_box(self, cutter: Self) -> Vec { + let Some(hit) = self.intersection(cutter) else { + return vec![self]; + }; + + let mut pieces = Vec::with_capacity(6); + + push_valid( + &mut pieces, + Self::new(self.min, Vec3::new(hit.min.x, self.max.y, self.max.z)), + ); + push_valid( + &mut pieces, + Self::new(Vec3::new(hit.max.x, self.min.y, self.min.z), self.max), + ); + + let x_min = self.min.x.max(hit.min.x); + let x_max = self.max.x.min(hit.max.x); + + push_valid( + &mut pieces, + Self::new( + Vec3::new(x_min, self.min.y, self.min.z), + Vec3::new(x_max, hit.min.y, self.max.z), + ), + ); + push_valid( + &mut pieces, + Self::new( + Vec3::new(x_min, hit.max.y, self.min.z), + Vec3::new(x_max, self.max.y, self.max.z), + ), + ); + + let y_min = self.min.y.max(hit.min.y); + let y_max = self.max.y.min(hit.max.y); + + push_valid( + &mut pieces, + Self::new( + Vec3::new(x_min, y_min, self.min.z), + Vec3::new(x_max, y_max, hit.min.z), + ), + ); + push_valid( + &mut pieces, + Self::new( + Vec3::new(x_min, y_min, hit.max.z), + Vec3::new(x_max, y_max, self.max.z), + ), + ); + + pieces + } +} + +fn push_valid(pieces: &mut Vec, bounds: Aabb) { + if bounds.is_valid() { + pieces.push(bounds); + } +} + +#[derive(Clone, Debug)] +pub enum Primitive { + Box { + bounds: Aabb, + }, + OrientedBox { + center: Vec3, + size: Vec3, + rotation: Quat, + }, + CylinderZ { + center: Vec3, + radius: f32, + depth: f32, + segments: usize, + }, + DomeCapZ { + center: Vec3, + radius: f32, + height: f32, + rings: usize, + segments: usize, + }, + FloretArm { + anchor: Vec3, + direction: Vec3, + length: f32, + root_width: f32, + tip_width: f32, + thickness: f32, + tip_lift: f32, + }, +} + +#[derive(Clone, Debug)] +pub struct Brush { + pub id: BrushId, + pub name: String, + pub op: BrushOp, + pub primitive: Primitive, + pub material: MaterialId, + pub rotation: Quat, + pub generation: u64, +} + +impl Brush { + pub fn new( + id: BrushId, + name: impl Into, + op: BrushOp, + primitive: Primitive, + material: MaterialId, + ) -> Self { + Self { + id, + name: name.into(), + op, + primitive, + material, + rotation: Quat::IDENTITY, + generation: 0, + } + } + + pub fn set_dirty(&mut self) { + self.generation = self.generation.wrapping_add(1); + } + + pub fn as_box(&self) -> Option { + match self.primitive { + Primitive::Box { bounds } => Some(bounds), + _ => None, + } + } + + pub fn bounds(&self) -> Aabb { + self.primitive.bounds() + } +} + +impl Primitive { + pub fn bounds(&self) -> Aabb { + match *self { + Self::Box { bounds } => bounds, + Self::OrientedBox { + center, + size, + rotation, + } => oriented_box_bounds(center, size, rotation), + Self::CylinderZ { + center, + radius, + depth, + .. + } => Aabb::new( + center - Vec3::new(radius, radius, depth * 0.5), + center + Vec3::new(radius, radius, depth * 0.5), + ), + Self::DomeCapZ { + center, + radius, + height, + .. + } => Aabb::new( + center - Vec3::new(radius, radius, 0.0), + center + Vec3::new(radius, radius, height), + ), + Self::FloretArm { + anchor, + direction, + length, + root_width, + tip_width, + thickness, + tip_lift, + } => floret_bounds( + anchor, direction, length, root_width, tip_width, thickness, tip_lift, + ), + } + } +} + +fn oriented_box_bounds(center: Vec3, size: Vec3, rotation: Quat) -> Aabb { + let half = size * 0.5; + let local_corners = [ + Vec3::new(-half.x, -half.y, -half.z), + Vec3::new(half.x, -half.y, -half.z), + Vec3::new(half.x, half.y, -half.z), + Vec3::new(-half.x, half.y, -half.z), + Vec3::new(-half.x, -half.y, half.z), + Vec3::new(half.x, -half.y, half.z), + Vec3::new(half.x, half.y, half.z), + Vec3::new(-half.x, half.y, half.z), + ]; + let corners = local_corners.map(|point| center + rotation * point); + Aabb::from_points(&corners) +} + +fn floret_bounds( + anchor: Vec3, + direction: Vec3, + length: f32, + root_width: f32, + tip_width: f32, + thickness: f32, + tip_lift: f32, +) -> Aabb { + let forward = direction.normalize_or_zero(); + let forward = if forward.length_squared() == 0.0 { + Vec3::X + } else { + forward + }; + let mut side = forward.cross(Vec3::Z).normalize_or_zero(); + if side.length_squared() == 0.0 { + side = Vec3::Y; + } + + let root_center = anchor; + let tip_center = anchor + forward * length + Vec3::Z * tip_lift; + let z = Vec3::Z * (thickness * 0.5); + let r = side * (root_width * 0.5); + let t = side * (tip_width * 0.5); + Aabb::from_points(&[ + root_center - r - z, + root_center + r - z, + root_center - r + z, + root_center + r + z, + tip_center - t - z, + tip_center + t - z, + tip_center - t + z, + tip_center + t + z, + ]) +} diff --git a/packages/gamecult-geometry-rs/src/convex.rs b/packages/gamecult-geometry-rs/src/convex.rs new file mode 100644 index 00000000..7c8d78b6 --- /dev/null +++ b/packages/gamecult-geometry-rs/src/convex.rs @@ -0,0 +1,905 @@ +#![allow(dead_code)] +use bevy_math::{Quat, Vec2, Vec3}; + +use crate::{Aabb, MaterialId, PolygonCategory, TriangleMesh}; + +const EPSILON: f32 = 1.0e-5; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Plane { + pub normal: Vec3, + pub distance: f32, +} + +impl Plane { + pub fn from_point_normal(point: Vec3, normal: Vec3) -> Self { + let normal = normal.normalize_or_zero(); + Self { + normal, + distance: -normal.dot(point), + } + } + + pub fn signed_distance(self, point: Vec3) -> f32 { + self.normal.dot(point) + self.distance + } + + pub fn is_coplanar_with(self, other: Self) -> bool { + self.normal.dot(other.normal).abs() > 1.0 - EPSILON + && (self.distance - other.distance).abs() <= EPSILON + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ConvexPolygon { + pub vertices: Vec, + pub normal: Vec3, + pub material: MaterialId, + pub category: PolygonCategory, + pub visible: bool, + pub reversed: bool, + pub bounds: Aabb, +} + +impl ConvexPolygon { + pub fn new(vertices: Vec, normal: Vec3, material: MaterialId) -> Self { + Self { + bounds: Aabb::from_points(&vertices), + vertices, + normal: normal.normalize_or_zero(), + material, + category: PolygonCategory::Aligned, + visible: true, + reversed: false, + } + } + + pub fn centroid(&self) -> Vec3 { + if self.vertices.is_empty() { + return Vec3::ZERO; + } + self.vertices.iter().copied().sum::() / self.vertices.len() as f32 + } + + fn with_vertices(&self, vertices: Vec) -> Self { + Self { + bounds: Aabb::from_points(&vertices), + vertices, + normal: self.normal, + material: self.material, + category: self.category, + visible: self.visible, + reversed: self.reversed, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ConvexSolid { + pub polygons: Vec, + pub clip_planes: Vec, + pub material: MaterialId, + pub bounds: Aabb, +} + +#[derive(Clone, Debug, Default)] +pub struct CategorizedPolygons { + pub inside: Vec, + pub outside: Vec, + pub aligned: Vec, + pub reverse_aligned: Vec, +} + +#[derive(Default)] +pub struct PolygonRouteScratch { + pieces: Vec, + inside_pieces: Vec, + cap_points: Vec, +} + +impl ConvexSolid { + pub fn from_aabb(bounds: Aabb, material: MaterialId) -> Self { + Self::box_from_center_size(bounds.center(), bounds.size(), Quat::IDENTITY, material) + } + + pub fn box_from_center_size( + center: Vec3, + size: Vec3, + rotation: Quat, + material: MaterialId, + ) -> Self { + let half = size * 0.5; + let local_corners = [ + Vec3::new(-half.x, -half.y, -half.z), + Vec3::new(half.x, -half.y, -half.z), + Vec3::new(half.x, half.y, -half.z), + Vec3::new(-half.x, half.y, -half.z), + Vec3::new(-half.x, -half.y, half.z), + Vec3::new(half.x, -half.y, half.z), + Vec3::new(half.x, half.y, half.z), + Vec3::new(-half.x, half.y, half.z), + ]; + let corners = local_corners.map(|point| center + rotation * point); + let faces = [ + ([0, 3, 2, 1], Vec3::NEG_Z), + ([4, 5, 6, 7], Vec3::Z), + ([0, 1, 5, 4], Vec3::NEG_Y), + ([3, 7, 6, 2], Vec3::Y), + ([0, 4, 7, 3], Vec3::NEG_X), + ([1, 2, 6, 5], Vec3::X), + ]; + + let mut polygons = Vec::with_capacity(6); + let mut clip_planes = Vec::with_capacity(6); + for (indices, local_normal) in faces { + let normal = rotation * local_normal; + let vertices = indices.map(|index| corners[index]).to_vec(); + clip_planes.push(Plane::from_point_normal(vertices[0], normal)); + polygons.push(ConvexPolygon::new(vertices, normal, material)); + } + + Self { + bounds: Aabb::from_points(&corners), + polygons, + clip_planes, + material, + } + } + + pub fn subtract_convex(&self, cutter: &Self) -> Vec { + self.clone().subtract_convex_owned(cutter) + } + + pub fn subtract_convex_owned(self, cutter: &Self) -> Vec { + let mut fragments = Vec::new(); + let mut remainder = Some(self); + + for plane in &cutter.clip_planes { + let Some(current) = remainder.take() else { + break; + }; + + match current.split_owned(*plane) { + SplitResult::Front(front) => { + fragments.push(front); + break; + } + SplitResult::Back(back) => { + remainder = Some(back); + } + SplitResult::Both { front, back } => { + fragments.push(front); + remainder = Some(back); + } + SplitResult::Coplanar(solid) => { + remainder = Some(solid); + } + } + } + + fragments + } + + pub fn intersect_convex(&self, cutter: &Self) -> Option { + self.clone().intersect_convex_owned(cutter) + } + + pub fn intersect_convex_owned(self, cutter: &Self) -> Option { + if cutter.contains_solid(&self) { + return Some(self); + } + if self.contains_solid(cutter) { + return Some(cutter.clone_with_material(self.material)); + } + + let mut remainder = self; + + for plane in &cutter.clip_planes { + remainder = match remainder.split_owned(*plane) { + SplitResult::Front(_) => return None, + SplitResult::Back(back) | SplitResult::Coplanar(back) => back, + SplitResult::Both { back, .. } => back, + }; + } + + Some(remainder) + } + + pub fn contains_point(&self, point: Vec3) -> bool { + self.clip_planes + .iter() + .all(|plane| plane.signed_distance(point) <= EPSILON) + } + + pub fn contains_solid(&self, other: &Self) -> bool { + other + .polygons + .iter() + .flat_map(|polygon| polygon.vertices.iter()) + .all(|point| self.contains_point(*point)) + } + + pub fn clone_with_material(&self, material: MaterialId) -> Self { + let mut solid = self.clone(); + solid.material = material; + for polygon in &mut solid.polygons { + polygon.material = material; + } + solid + } + + pub fn append_to_mesh(&self, mesh: &mut TriangleMesh) { + for polygon in &self.polygons { + if !polygon.visible || polygon.vertices.len() < 3 { + continue; + } + let base = polygon.vertices[0]; + for i in 1..polygon.vertices.len() - 1 { + let triangle = if polygon.reversed { + [base, polygon.vertices[i + 1], polygon.vertices[i]] + } else { + [base, polygon.vertices[i], polygon.vertices[i + 1]] + }; + let normal = if polygon.reversed { + -polygon.normal + } else { + polygon.normal + }; + mesh.append_triangle( + triangle, + normal, + [Vec2::ZERO, Vec2::X, Vec2::Y], + polygon.material, + ); + } + } + } + + pub fn categorize_whole_polygons_against(&mut self, cutter: &Self) { + for polygon in &mut self.polygons { + if let Some(category) = whole_polygon_category_against(polygon, cutter) { + polygon.category = category; + } + } + } + + pub fn categorize_polygons_against(&self, cutter: &Self) -> CategorizedPolygons { + PolygonRouteScratch::default().categorize_against(&self.polygons, cutter) + } + + pub fn route_polygons_outside_of( + polygons: Vec, + cutter: &Self, + ) -> Vec { + let categorized = PolygonRouteScratch::default().categorize_against(&polygons, cutter); + categorized.outside + } + + pub fn route_polygons_inside_of( + polygons: Vec, + cutter: &Self, + ) -> Vec { + let categorized = PolygonRouteScratch::default().categorize_against(&polygons, cutter); + let mut inside = categorized.inside; + inside.extend(categorized.aligned); + inside.extend(categorized.reverse_aligned); + inside + } + + pub fn subtract_convex_routed_polygons(&self, cutter: &Self) -> Vec { + let mut polygons = Vec::new(); + let source = self.categorize_polygons_against(cutter); + polygons.extend(source.outside); + + let cutter_inside_source = cutter.categorize_polygons_against(self); + for mut polygon in cutter_inside_source.inside { + polygon.reversed = !polygon.reversed; + polygons.push(polygon); + } + for mut polygon in cutter_inside_source.aligned { + polygon.reversed = !polygon.reversed; + polygons.push(polygon); + } + for mut polygon in cutter_inside_source.reverse_aligned { + polygon.reversed = !polygon.reversed; + polygons.push(polygon); + } + + polygons + } + + pub fn append_polygons_to_mesh(polygons: &[ConvexPolygon], mesh: &mut TriangleMesh) { + for polygon in polygons { + if !polygon.visible || polygon.vertices.len() < 3 { + continue; + } + let base = polygon.vertices[0]; + for i in 1..polygon.vertices.len() - 1 { + let triangle = if polygon.reversed { + [base, polygon.vertices[i + 1], polygon.vertices[i]] + } else { + [base, polygon.vertices[i], polygon.vertices[i + 1]] + }; + let normal = if polygon.reversed { + -polygon.normal + } else { + polygon.normal + }; + mesh.append_triangle( + triangle, + normal, + [Vec2::ZERO, Vec2::X, Vec2::Y], + polygon.material, + ); + } + } + } + + fn split_owned(self, plane: Plane) -> SplitResult { + let mut saw_front = false; + let mut saw_back = false; + for polygon in &self.polygons { + for vertex in &polygon.vertices { + let distance = plane.signed_distance(*vertex); + saw_front |= distance > EPSILON; + saw_back |= distance < -EPSILON; + } + } + + match (saw_front, saw_back) { + (true, false) => return SplitResult::Front(self), + (false, true) => return SplitResult::Back(self), + (false, false) => return SplitResult::Coplanar(self), + (true, true) => {} + } + + let mut front_polygons = Vec::with_capacity(self.polygons.len() + 1); + let mut back_polygons = Vec::with_capacity(self.polygons.len() + 1); + let mut cap_points = Vec::with_capacity(self.polygons.len() * 2); + + for polygon in &self.polygons { + let front = clip_polygon(&polygon.vertices, plane, KeepSide::Front, &mut cap_points); + if front.len() >= 3 { + front_polygons.push(polygon.with_vertices(front)); + } + + let back = clip_polygon(&polygon.vertices, plane, KeepSide::Back, &mut cap_points); + if back.len() >= 3 { + back_polygons.push(polygon.with_vertices(back)); + } + } + + let cap_points = unique_points_on_plane(cap_points); + if cap_points.len() >= 3 { + front_polygons.push(make_cap_polygon(&cap_points, -plane.normal, self.material)); + back_polygons.push(make_cap_polygon(&cap_points, plane.normal, self.material)); + } + + SplitResult::Both { + front: Self { + bounds: bounds_from_polygons(&front_polygons), + clip_planes: planes_from_polygons(&front_polygons), + polygons: front_polygons, + material: self.material, + }, + back: Self { + bounds: bounds_from_polygons(&back_polygons), + clip_planes: planes_from_polygons(&back_polygons), + polygons: back_polygons, + material: self.material, + }, + } + } +} + +impl PolygonRouteScratch { + pub fn categorize_against( + &mut self, + polygons: &[ConvexPolygon], + cutter: &ConvexSolid, + ) -> CategorizedPolygons { + let mut categorized = CategorizedPolygons::default(); + + for polygon in polygons { + self.pieces.clear(); + self.pieces.push(CategorizationPiece { + polygon: polygon.clone(), + aligned_category: None, + }); + + for plane in &cutter.clip_planes { + self.inside_pieces.clear(); + for piece in self.pieces.drain(..) { + classify_piece_against_plane( + piece, + *plane, + &mut self.inside_pieces, + &mut categorized, + &mut self.cap_points, + ); + } + std::mem::swap(&mut self.pieces, &mut self.inside_pieces); + } + + for piece in self.pieces.drain(..) { + match piece.aligned_category { + Some(PolygonCategory::Aligned) => categorized.aligned.push(piece.polygon), + Some(PolygonCategory::ReverseAligned) => { + categorized.reverse_aligned.push(piece.polygon); + } + _ => categorized.inside.push(piece.polygon), + } + } + } + + categorized + } + + pub fn route_outside_of( + &mut self, + polygons: Vec, + cutter: &ConvexSolid, + ) -> Vec { + self.categorize_against(&polygons, cutter).outside + } + + pub fn route_inside_of( + &mut self, + polygons: Vec, + cutter: &ConvexSolid, + ) -> Vec { + let categorized = self.categorize_against(&polygons, cutter); + let mut inside = categorized.inside; + inside.extend(categorized.aligned); + inside.extend(categorized.reverse_aligned); + inside + } +} + +enum SplitResult { + Front(ConvexSolid), + Back(ConvexSolid), + Both { + front: ConvexSolid, + back: ConvexSolid, + }, + Coplanar(ConvexSolid), +} + +struct CategorizationPiece { + polygon: ConvexPolygon, + aligned_category: Option, +} + +#[derive(Clone, Copy)] +enum KeepSide { + Front, + Back, +} + +fn classify_piece_against_plane( + mut piece: CategorizationPiece, + plane: Plane, + inside_pieces: &mut Vec, + categorized: &mut CategorizedPolygons, + cap_points: &mut Vec, +) { + let piece_plane = piece + .polygon + .vertices + .first() + .map(|point| Plane::from_point_normal(*point, piece.polygon.normal)); + + if piece_plane.is_some_and(|piece_plane| piece_plane.is_coplanar_with(plane)) { + piece.aligned_category = Some(if piece.polygon.normal.dot(plane.normal) >= 0.0 { + PolygonCategory::Aligned + } else { + PolygonCategory::ReverseAligned + }); + inside_pieces.push(piece); + return; + } + + let mut saw_front = false; + let mut saw_back = false; + for vertex in &piece.polygon.vertices { + let distance = plane.signed_distance(*vertex); + saw_front |= distance > EPSILON; + saw_back |= distance < -EPSILON; + } + + match (saw_front, saw_back) { + (true, false) => { + let mut polygon = piece.polygon; + polygon.category = PolygonCategory::Outside; + categorized.outside.push(polygon); + } + (false, true) | (false, false) => inside_pieces.push(piece), + (true, true) => { + cap_points.clear(); + let outside = clip_polygon(&piece.polygon.vertices, plane, KeepSide::Front, cap_points); + if outside.len() >= 3 { + let mut outside_polygon = piece.polygon.with_vertices(outside); + outside_polygon.category = PolygonCategory::Outside; + categorized.outside.push(outside_polygon); + } + + let inside = clip_polygon(&piece.polygon.vertices, plane, KeepSide::Back, cap_points); + if inside.len() >= 3 { + inside_pieces.push(CategorizationPiece { + polygon: piece.polygon.with_vertices(inside), + aligned_category: piece.aligned_category, + }); + } + } + } +} + +fn clip_polygon( + vertices: &[Vec3], + plane: Plane, + keep: KeepSide, + cap_points: &mut Vec, +) -> Vec { + let mut output = Vec::with_capacity(vertices.len() + 1); + if vertices.is_empty() { + return output; + } + + let mut previous = *vertices.last().expect("checked non-empty"); + let mut previous_distance = plane.signed_distance(previous); + let mut previous_inside = inside(previous_distance, keep); + + for ¤t in vertices { + let current_distance = plane.signed_distance(current); + let current_inside = inside(current_distance, keep); + + if current_inside != previous_inside { + let t = previous_distance / (previous_distance - current_distance); + let intersection = previous + (current - previous) * t; + output.push(intersection); + cap_points.push(intersection); + } + + if current_inside { + output.push(current); + } + + previous = current; + previous_distance = current_distance; + previous_inside = current_inside; + } + + output +} + +fn inside(distance: f32, keep: KeepSide) -> bool { + match keep { + KeepSide::Front => distance >= -EPSILON, + KeepSide::Back => distance <= EPSILON, + } +} + +fn make_cap_polygon(points: &[Vec3], normal: Vec3, material: MaterialId) -> ConvexPolygon { + let center = points.iter().copied().sum::() / points.len() as f32; + let axis = if normal.z.abs() < 0.9 { + Vec3::Z + } else { + Vec3::Y + }; + let tangent = normal.cross(axis).normalize_or_zero(); + let bitangent = normal.cross(tangent).normalize_or_zero(); + let mut vertices = points.to_vec(); + + vertices.sort_by(|a, b| { + let da = *a - center; + let db = *b - center; + let aa = da.dot(bitangent).atan2(da.dot(tangent)); + let ab = db.dot(bitangent).atan2(db.dot(tangent)); + aa.total_cmp(&ab) + }); + + if polygon_normal(&vertices).dot(normal) < 0.0 { + vertices.reverse(); + } + + ConvexPolygon::new(vertices, normal, material) +} + +fn unique_points_on_plane(points: Vec) -> Vec { + let mut unique = Vec::new(); + 'outer: for point in points { + for seen in &unique { + let delta: Vec3 = point - *seen; + if delta.length_squared() < EPSILON * EPSILON { + continue 'outer; + } + } + unique.push(point); + } + unique +} + +fn planes_from_polygons(polygons: &[ConvexPolygon]) -> Vec { + polygons + .iter() + .filter_map(|polygon| { + polygon + .vertices + .first() + .map(|point| Plane::from_point_normal(*point, polygon.normal)) + }) + .collect() +} + +fn bounds_from_polygons(polygons: &[ConvexPolygon]) -> Aabb { + polygons.iter().fold(Aabb::empty(), |bounds, polygon| { + bounds.union(polygon.bounds) + }) +} + +fn whole_polygon_category_against( + polygon: &ConvexPolygon, + cutter: &ConvexSolid, +) -> Option { + let polygon_plane = polygon + .vertices + .first() + .map(|point| Plane::from_point_normal(*point, polygon.normal))?; + + for cutter_plane in &cutter.clip_planes { + if polygon_plane.is_coplanar_with(*cutter_plane) + && polygon + .vertices + .iter() + .all(|vertex| cutter_plane.signed_distance(*vertex).abs() <= EPSILON) + { + return Some(if polygon.normal.dot(cutter_plane.normal) >= 0.0 { + PolygonCategory::Aligned + } else { + PolygonCategory::ReverseAligned + }); + } + } + + let mut fully_inside = true; + for cutter_plane in &cutter.clip_planes { + let mut all_outside_this_plane = true; + for vertex in &polygon.vertices { + let distance = cutter_plane.signed_distance(*vertex); + if distance > EPSILON { + fully_inside = false; + } else { + all_outside_this_plane = false; + } + } + if all_outside_this_plane { + return Some(PolygonCategory::Outside); + } + } + + fully_inside.then_some(PolygonCategory::Inside) +} + +fn polygon_normal(vertices: &[Vec3]) -> Vec3 { + if vertices.len() < 3 { + return Vec3::ZERO; + } + (vertices[1] - vertices[0]) + .cross(vertices[2] - vertices[0]) + .normalize_or_zero() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_box_parity(solid: &ConvexSolid, material: MaterialId) { + assert_eq!(solid.polygons.len(), 6); + assert_eq!(solid.clip_planes.len(), 6); + assert!( + solid + .polygons + .iter() + .all(|polygon| polygon.vertices.len() == 4) + ); + assert!( + solid + .polygons + .iter() + .all(|polygon| polygon.material == material) + ); + } + + #[test] + fn parity_box_brush_from_planes_has_six_quad_polygons() { + let solid = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(2.0), + Quat::IDENTITY, + MaterialId(9), + ); + + assert_box_parity(&solid, MaterialId(9)); + } + + #[test] + fn subtract_center_box_emits_six_convex_fragments() { + let source = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(4.0), + Quat::IDENTITY, + MaterialId(1), + ); + let cutter = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(2.0), + Quat::IDENTITY, + MaterialId(0), + ); + + let fragments = source.subtract_convex(&cutter); + assert_eq!(fragments.len(), 6); + assert!( + fragments + .iter() + .all(|fragment| !fragment.polygons.is_empty()) + ); + } + + #[test] + fn intersect_overlapping_boxes_emits_single_common_solid() { + let source = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(4.0), + Quat::IDENTITY, + MaterialId(1), + ); + let cutter = ConvexSolid::box_from_center_size( + Vec3::X, + Vec3::splat(4.0), + Quat::IDENTITY, + MaterialId(0), + ); + + let common = source.intersect_convex(&cutter).expect("intersection"); + + assert!(common.bounds.is_valid()); + assert_eq!(common.polygons.len(), 6); + assert!(common.bounds.contains_point(Vec3::ZERO)); + assert!(common.bounds.contains_point(Vec3::new(2.0, 0.0, 0.0))); + assert!(!common.bounds.contains_point(Vec3::new(-2.0, 0.0, 0.0))); + } + + #[test] + fn categorize_crossing_coplanar_face_splits_visible_categories() { + let source = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(4.0), + Quat::IDENTITY, + MaterialId(1), + ); + let cutter = ConvexSolid::box_from_center_size( + Vec3::new(0.0, 0.0, 1.0), + Vec3::new(2.0, 2.0, 2.0), + Quat::IDENTITY, + MaterialId(0), + ); + + let categorized = source.categorize_polygons_against(&cutter); + + assert_eq!(categorized.aligned.len(), 1); + assert!(categorized.outside.len() > 6); + assert!(categorized.inside.is_empty()); + assert!(categorized.reverse_aligned.is_empty()); + assert!( + categorized + .aligned + .iter() + .all(|polygon| polygon.category == PolygonCategory::Aligned) + ); + assert!( + categorized + .outside + .iter() + .all(|polygon| polygon.category == PolygonCategory::Outside) + ); + } + + #[test] + fn subtract_rotated_box_creates_non_axis_aligned_faces() { + let source = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(4.0), + Quat::IDENTITY, + MaterialId(1), + ); + let cutter = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::new(2.0, 5.0, 2.0), + Quat::from_rotation_z(std::f32::consts::FRAC_PI_4), + MaterialId(0), + ); + + let fragments = source.subtract_convex(&cutter); + assert!(fragments.len() > 2); + assert!(fragments.iter().any(|fragment| { + fragment + .polygons + .iter() + .any(|polygon| polygon.normal.x.abs() > 0.01 && polygon.normal.y.abs() > 0.01) + })); + } + + #[test] + fn routed_subtraction_emits_source_shell_and_reversed_cutter_shell() { + let source = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(4.0), + Quat::IDENTITY, + MaterialId(1), + ); + let cutter = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(2.0), + Quat::IDENTITY, + MaterialId(0), + ); + + let routed = source.subtract_convex_routed_polygons(&cutter); + + assert_eq!(routed.len(), 24); + assert_eq!( + routed + .iter() + .filter(|polygon| polygon.material == MaterialId(1)) + .count(), + 18 + ); + assert_eq!( + routed + .iter() + .filter(|polygon| polygon.material == MaterialId(0)) + .count(), + 6 + ); + assert_eq!( + routed + .iter() + .filter(|polygon| polygon.material == MaterialId(0) && polygon.reversed) + .count(), + 6 + ); + } + + #[test] + fn routed_subtraction_clips_crossing_cutter_surfaces_to_source() { + let source = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(4.0), + Quat::IDENTITY, + MaterialId(1), + ); + let cutter = ConvexSolid::box_from_center_size( + Vec3::new(1.5, 0.0, 0.0), + Vec3::splat(2.0), + Quat::IDENTITY, + MaterialId(0), + ); + + let routed = source.subtract_convex_routed_polygons(&cutter); + + assert!(!routed.is_empty()); + assert!( + routed + .iter() + .flat_map(|polygon| polygon.vertices.iter()) + .all(|vertex| source.bounds.contains_point(*vertex)) + ); + assert!( + routed + .iter() + .any(|polygon| polygon.material == MaterialId(0) && polygon.reversed) + ); + } +} diff --git a/packages/gamecult-geometry-rs/src/domain.rs b/packages/gamecult-geometry-rs/src/domain.rs new file mode 100644 index 00000000..a946305e --- /dev/null +++ b/packages/gamecult-geometry-rs/src/domain.rs @@ -0,0 +1,1560 @@ +#![allow(dead_code)] +use bevy_math::{Quat, Vec3}; + +use serde::{Deserialize, Serialize}; + +use crate::{ + Aabb, BrushOp, BuildReport, CsgBranchOp, CsgNodeId, CsgOperationType, CsgTreeArena, MaterialId, + Primitive, TriangleMesh, +}; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct DomainKey(pub String); + +impl DomainKey { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn child(&self, name: impl AsRef) -> Self { + Self(format!("{}/{}", self.0, name.as_ref())) + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DomainFrame { + pub translation: Vec3, + pub rotation: Quat, +} + +impl DomainFrame { + pub const IDENTITY: Self = Self { + translation: Vec3::ZERO, + rotation: Quat::IDENTITY, + }; + + pub fn translated(translation: Vec3) -> Self { + Self { + translation, + rotation: Quat::IDENTITY, + } + } + + pub fn rotated_z(translation: Vec3, radians: f32) -> Self { + Self { + translation, + rotation: Quat::from_rotation_z(radians), + } + } + + pub fn transform_point(self, point: Vec3) -> Vec3 { + self.translation + self.rotation * point + } + + pub fn transform_bounds(self, bounds: Aabb) -> Aabb { + let min = bounds.min; + let max = bounds.max; + let corners = [ + Vec3::new(min.x, min.y, min.z), + Vec3::new(max.x, min.y, min.z), + Vec3::new(max.x, max.y, min.z), + Vec3::new(min.x, max.y, min.z), + Vec3::new(min.x, min.y, max.z), + Vec3::new(max.x, min.y, max.z), + Vec3::new(max.x, max.y, max.z), + Vec3::new(min.x, max.y, max.z), + ]; + let world = corners.map(|corner| self.transform_point(corner)); + Aabb::from_points(&world) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum DomainKind { + Root, + Column, + AltitudeBand, + RoadSpine, + BranchRoad, + Roundabout, + SupportMass, + ClearanceVolume, + Chunk, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum FieldLayer { + Form, + Appearance, + Transport, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum FieldEncoding { + Mesh, + Sdf3d, + Occupancy, + Material, + Confidence, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum FeatureClaimKind { + SolidBrush, + VoidBrush, + RoadSurfaceSlab, + ClearanceVolume, + SupportShell, + ColliderProxy, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum FeatureLoweringPolicy { + RenderOnly, + ColliderOnly, + RenderAndCollider, + BooleanOperator, +} + +impl FeatureLoweringPolicy { + pub fn default_for_kind(kind: FeatureClaimKind) -> Self { + match kind { + FeatureClaimKind::VoidBrush | FeatureClaimKind::ClearanceVolume => { + Self::BooleanOperator + } + FeatureClaimKind::ColliderProxy => Self::ColliderOnly, + FeatureClaimKind::SolidBrush + | FeatureClaimKind::RoadSurfaceSlab + | FeatureClaimKind::SupportShell => Self::RenderAndCollider, + } + } + + pub fn emits_to(self, target: ClaimLoweringTarget) -> bool { + match self { + Self::RenderOnly => target == ClaimLoweringTarget::Render, + Self::ColliderOnly => target == ClaimLoweringTarget::Collider, + Self::RenderAndCollider | Self::BooleanOperator => true, + } + } +} + +impl FeatureClaimKind { + pub fn field_layer(self) -> FieldLayer { + match self { + Self::SolidBrush + | Self::VoidBrush + | Self::RoadSurfaceSlab + | Self::ClearanceVolume + | Self::SupportShell + | Self::ColliderProxy => FieldLayer::Form, + } + } + + pub fn field_encoding(self) -> FieldEncoding { + match self { + Self::VoidBrush | Self::ClearanceVolume => FieldEncoding::Sdf3d, + Self::ColliderProxy => FieldEncoding::Occupancy, + _ => FieldEncoding::Mesh, + } + } + + pub fn brush_op(self) -> BrushOp { + match self { + Self::VoidBrush | Self::ClearanceVolume => BrushOp::Subtract, + _ => BrushOp::Add, + } + } + + pub fn emits_render(self) -> bool { + !matches!(self, Self::ColliderProxy | Self::ClearanceVolume) + } + + pub fn emits_collider(self) -> bool { + matches!( + self, + Self::SolidBrush | Self::RoadSurfaceSlab | Self::SupportShell | Self::ColliderProxy + ) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct FeatureClaim { + pub key: String, + pub domain_key: DomainKey, + pub frame: DomainFrame, + pub support: Aabb, + pub kind: FeatureClaimKind, + pub material: MaterialId, + pub lowering: FeatureLoweringPolicy, +} + +impl FeatureClaim { + pub fn world_bounds(&self) -> Aabb { + self.frame.transform_bounds(self.support) + } + + pub fn primitive(&self) -> Primitive { + Primitive::OrientedBox { + center: self.frame.transform_point(self.support.center()), + size: self.support.size(), + rotation: self.frame.rotation, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct DomainSummary { + pub bounds: Aabb, + pub estimated_triangle_cost: usize, + pub estimated_brush_cost: usize, + pub projected_error: f32, + pub contribution_weight: f32, + pub has_children: bool, + pub fallback_claims: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct DomainNode { + pub key: DomainKey, + pub parent: Option, + pub kind: DomainKind, + pub frame: DomainFrame, + pub bounds: Aabb, + pub seed: u64, + pub summary: DomainSummary, + pub claims: Vec, + pub children: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct FeatureClaimSpec { + pub name: String, + pub frame: DomainFrame, + pub support: Aabb, + pub kind: FeatureClaimKind, + pub material: MaterialId, + pub lowering: FeatureLoweringPolicy, +} + +impl FeatureClaimSpec { + pub fn new( + name: impl Into, + frame: DomainFrame, + support: Aabb, + kind: FeatureClaimKind, + material: MaterialId, + ) -> Self { + Self { + name: name.into(), + frame, + support, + kind, + material, + lowering: FeatureLoweringPolicy::default_for_kind(kind), + } + } + + pub fn with_lowering_policy(mut self, lowering: FeatureLoweringPolicy) -> Self { + self.lowering = lowering; + self + } + + pub fn compile(&self, domain_key: &DomainKey) -> FeatureClaim { + FeatureClaim { + key: format!("{}/claim/{}", domain_key.0, self.name), + domain_key: domain_key.clone(), + frame: self.frame, + support: self.support, + kind: self.kind, + material: self.material, + lowering: self.lowering, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct DomainNodeSpec { + pub name: String, + pub kind: DomainKind, + pub frame: DomainFrame, + pub seed: u64, + pub claims: Vec, + pub children: Vec, +} + +impl DomainNodeSpec { + pub fn new(name: impl Into, kind: DomainKind, frame: DomainFrame, seed: u64) -> Self { + Self { + name: name.into(), + kind, + frame, + seed, + claims: Vec::new(), + children: Vec::new(), + } + } + + pub fn with_claim(mut self, claim: FeatureClaimSpec) -> Self { + self.claims.push(claim); + self + } + + pub fn with_child(mut self, child: DomainNodeSpec) -> Self { + self.children.push(child); + self + } + + pub fn compile_root(&self) -> DomainNode { + self.compile(DomainKey::new(self.name.clone()), None) + } + + fn compile(&self, key: DomainKey, parent: Option) -> DomainNode { + let claims = self + .claims + .iter() + .map(|claim| claim.compile(&key)) + .collect::>(); + let children = self + .children + .iter() + .map(|child| child.compile(key.child(&child.name), Some(key.clone()))) + .collect::>(); + DomainNode::new( + key, parent, self.kind, self.frame, self.seed, claims, children, + ) + } +} + +impl DomainNode { + pub fn new( + key: DomainKey, + parent: Option, + kind: DomainKind, + frame: DomainFrame, + seed: u64, + claims: Vec, + children: Vec, + ) -> Self { + let claim_bounds = claims + .iter() + .map(FeatureClaim::world_bounds) + .fold(Aabb::empty(), Aabb::union); + let child_bounds = children + .iter() + .map(|child| child.summary.bounds) + .fold(Aabb::empty(), Aabb::union); + let bounds = claim_bounds.union(child_bounds); + let has_children = !children.is_empty(); + let estimated_brush_cost = claims.len() + + children + .iter() + .map(|child| child.summary.estimated_brush_cost) + .sum::(); + let estimated_triangle_cost = claims.len().max(1) * 12 + + children + .iter() + .map(|child| child.summary.estimated_triangle_cost) + .sum::(); + let contribution_weight = domain_priority(kind) * bounds.size().length().max(1.0); + let fallback_claims = if claims.is_empty() { + fallback_box_claim(&key, kind, frame, bounds) + } else { + claims.clone() + }; + + Self { + key, + parent, + kind, + frame, + bounds, + seed, + summary: DomainSummary { + bounds, + estimated_triangle_cost, + estimated_brush_cost, + projected_error: bounds.size().length().max(1.0), + contribution_weight, + has_children, + fallback_claims, + }, + claims, + children, + } + } + + pub fn find(&self, key: &DomainKey) -> Option<&Self> { + if &self.key == key { + return Some(self); + } + self.children.iter().find_map(|child| child.find(key)) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct DomainQuery { + pub camera_position: Vec3, + pub frustum: Aabb, + pub viewport_height_px: f32, + pub vertical_fov_radians: f32, + pub target_error: f32, + pub triangle_budget: usize, + pub collider_budget: usize, + pub semantic_filter: Vec, + pub requested_chunk_keys: Vec, + pub dirty_domain_keys: Vec, +} + +impl DomainQuery { + pub fn accepts_kind(&self, kind: DomainKind) -> bool { + self.semantic_filter.is_empty() || self.semantic_filter.contains(&kind) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ContributionRow { + pub domain_key: DomainKey, + pub kind: DomainKind, + pub contribution: f32, + pub projected_screen_error: f32, + pub semantic_priority: f32, + pub estimated_triangle_cost: usize, + pub child_cost: usize, + pub remaining_triangle_budget: usize, + pub requested: bool, + pub dirty: bool, + pub selected: bool, + pub used_fallback: bool, + pub deferred_by_budget: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SelectedCut { + pub id: String, + pub selected_nodes: Vec, + pub emitted_claims: Vec, + pub deferred_children: Vec, + pub fallback_nodes: Vec, + pub diagnostics: Vec, +} + +#[derive(Clone, Debug)] +pub struct DomainChunkBuild { + pub cut: SelectedCut, + pub chunks: Vec, + pub manifest: SelectedCutManifest, +} + +impl DomainChunkBuild {} + +#[derive(Clone, Debug)] +pub struct TriangleChunk { + pub key: DomainKey, + pub selected_cut_id: String, + pub bounds: Aabb, + pub mesh: TriangleMesh, + pub collider_mesh: Option, + pub source_domain_keys: Vec, + pub source_claim_keys: Vec, + pub report: BuildReport, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TriangleChunkManifest { + pub key: String, + pub selected_cut_id: String, + pub bounds_min: [f32; 3], + pub bounds_max: [f32; 3], + pub render_vertices: usize, + pub render_triangles: usize, + pub collider_triangles: usize, + pub source_domain_keys: Vec, + pub source_claim_keys: Vec, + pub input_brushes: usize, + pub candidate_pairs: usize, + pub rejected_pairs: usize, + pub transition_hint: ChunkTransitionHint, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SelectedCutManifest { + pub id: String, + pub selected_nodes: Vec, + pub deferred_child_requests: Vec, + pub parent_fallback_nodes: Vec, + pub diagnostics: Vec, + pub chunks: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ContributionManifest { + pub domain_key: String, + pub kind: DomainKind, + pub contribution: f32, + pub projected_screen_error: f32, + pub semantic_priority: f32, + pub estimated_triangle_cost: usize, + pub child_cost: usize, + pub remaining_triangle_budget: usize, + pub requested: bool, + pub dirty: bool, + pub selected: bool, + pub used_fallback: bool, + pub deferred_by_budget: bool, +} + +impl SelectedCut { + pub fn manifest(&self, chunks: &[TriangleChunk]) -> SelectedCutManifest { + SelectedCutManifest { + id: self.id.clone(), + selected_nodes: self + .selected_nodes + .iter() + .map(|key| key.0.clone()) + .collect(), + deferred_child_requests: self + .deferred_children + .iter() + .map(|key| key.0.clone()) + .collect(), + parent_fallback_nodes: self + .fallback_nodes + .iter() + .map(|key| key.0.clone()) + .collect(), + diagnostics: self + .diagnostics + .iter() + .map(ContributionManifest::from_row) + .collect(), + chunks: chunks.iter().map(TriangleChunk::manifest).collect(), + } + } +} + +impl ContributionManifest { + pub fn from_row(row: &ContributionRow) -> Self { + Self { + domain_key: row.domain_key.0.clone(), + kind: row.kind, + contribution: row.contribution, + projected_screen_error: row.projected_screen_error, + semantic_priority: row.semantic_priority, + estimated_triangle_cost: row.estimated_triangle_cost, + child_cost: row.child_cost, + remaining_triangle_budget: row.remaining_triangle_budget, + requested: row.requested, + dirty: row.dirty, + selected: row.selected, + used_fallback: row.used_fallback, + deferred_by_budget: row.deferred_by_budget, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChunkTransitionHint { + pub stable_clip_seed: u64, + pub supports_parent_child_coexistence: bool, +} + +impl TriangleChunk { + pub fn manifest(&self) -> TriangleChunkManifest { + TriangleChunkManifest { + key: self.key.0.clone(), + selected_cut_id: self.selected_cut_id.clone(), + bounds_min: vec3_to_array(self.bounds.min), + bounds_max: vec3_to_array(self.bounds.max), + render_vertices: self.mesh.vertex_count(), + render_triangles: self.mesh.triangle_count(), + collider_triangles: self + .collider_mesh + .as_ref() + .map_or(0, TriangleMesh::triangle_count), + source_domain_keys: self + .source_domain_keys + .iter() + .map(|key| key.0.clone()) + .collect(), + source_claim_keys: self.source_claim_keys.clone(), + input_brushes: self.report.input_brushes, + candidate_pairs: self.report.candidate_pairs, + rejected_pairs: self.report.rejected_pairs, + transition_hint: ChunkTransitionHint { + stable_clip_seed: stable_str_hash(&self.key.0), + supports_parent_child_coexistence: true, + }, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ClaimLoweringTarget { + Render, + Collider, +} + +#[derive(Clone, Debug)] +pub struct CsgClaimLowering { + pub arena: CsgTreeArena, + pub root: Option, + pub bounds: Aabb, + pub source_domain_keys: Vec, + pub source_claim_keys: Vec, +} + +pub fn select_domain_cut(root: &DomainNode, query: &DomainQuery) -> SelectedCut { + let mut state = CutState { + query, + selected: Vec::new(), + claims: Vec::new(), + deferred: Vec::new(), + fallback: Vec::new(), + diagnostics: Vec::new(), + remaining_triangles: query.triangle_budget, + remaining_colliders: query.collider_budget, + }; + state.visit(root, true); + SelectedCut { + id: stable_cut_id(&state.selected), + selected_nodes: state.selected, + emitted_claims: state.claims, + deferred_children: state.deferred, + fallback_nodes: state.fallback, + diagnostics: state.diagnostics, + } +} + +pub fn build_domain_chunks(root: &DomainNode, query: &DomainQuery) -> DomainChunkBuild { + let cut = select_domain_cut(root, query); + let chunks = lower_selected_cut_chunks(&cut); + let manifest = cut.manifest(&chunks); + DomainChunkBuild { + cut, + chunks, + manifest, + } +} + +pub fn lower_selected_cut(cut: &SelectedCut) -> TriangleChunk { + lower_claims_to_chunk( + DomainKey::new(format!("chunk/{}", cut.id)), + &cut.id, + &cut.emitted_claims, + ) +} + +pub fn lower_selected_cut_chunks(cut: &SelectedCut) -> Vec { + let mut chunks = Vec::new(); + for domain_key in &cut.selected_nodes { + let claims = cut + .emitted_claims + .iter() + .filter(|claim| &claim.domain_key == domain_key) + .cloned() + .collect::>(); + if claims.is_empty() { + continue; + } + chunks.push(lower_claims_to_chunk( + DomainKey::new(format!("chunk/{}/{}", cut.id, domain_key.0)), + &cut.id, + &claims, + )); + } + chunks +} + +pub fn lower_feature_claims_to_csg_tree( + claims: &[FeatureClaim], + target: ClaimLoweringTarget, +) -> CsgClaimLowering { + let mut arena = CsgTreeArena::new(); + let mut additive_nodes = Vec::new(); + let mut subtractive_nodes = Vec::new(); + let mut source_domain_keys = Vec::::new(); + let mut source_claim_keys = Vec::::new(); + let mut bounds = Aabb::empty(); + + for claim in claims.iter().filter(|claim| claim_in_target(claim, target)) { + source_claim_keys.push(claim.key.clone()); + if !source_domain_keys.contains(&claim.domain_key) { + source_domain_keys.push(claim.domain_key.clone()); + } + bounds = bounds.union(claim.world_bounds()); + let brush = arena.generate_brush( + claim.key.clone(), + CsgOperationType::Additive, + claim.primitive(), + claim.material, + ); + match claim.kind.brush_op() { + BrushOp::Add => additive_nodes.push(brush.node), + BrushOp::Subtract => subtractive_nodes.push(brush.node), + BrushOp::Intersect => additive_nodes.push(brush.node), + } + } + + let root = build_claim_tree(&mut arena, additive_nodes, subtractive_nodes); + CsgClaimLowering { + arena, + root, + bounds, + source_domain_keys, + source_claim_keys, + } +} + +fn lower_claims_to_chunk( + key: DomainKey, + selected_cut_id: &str, + claims: &[FeatureClaim], +) -> TriangleChunk { + let render = lower_feature_claims_to_csg_tree(claims, ClaimLoweringTarget::Render); + let collider = lower_feature_claims_to_csg_tree(claims, ClaimLoweringTarget::Collider); + let (source_domain_keys, source_claim_keys) = claim_metadata(claims); + + let output = render + .root + .map(|root| { + render + .arena + .compile_tree_to_assembler(render.arena.generate_tree(root)) + .build() + }) + .unwrap_or_else(|| crate::Assembler::new().build()); + let collider_output = collider.root.map(|root| { + collider + .arena + .compile_tree_to_assembler(collider.arena.generate_tree(root)) + .build() + }); + + TriangleChunk { + key, + selected_cut_id: selected_cut_id.to_owned(), + bounds: render.bounds.union(collider.bounds), + mesh: output.mesh, + collider_mesh: collider_output.map(|output| output.mesh), + source_domain_keys, + source_claim_keys, + report: output.report, + } +} + +fn claim_metadata(claims: &[FeatureClaim]) -> (Vec, Vec) { + let mut source_domain_keys = Vec::::new(); + let mut source_claim_keys = Vec::::new(); + for claim in claims { + source_claim_keys.push(claim.key.clone()); + if !source_domain_keys.contains(&claim.domain_key) { + source_domain_keys.push(claim.domain_key.clone()); + } + } + (source_domain_keys, source_claim_keys) +} + +fn claim_in_target(claim: &FeatureClaim, target: ClaimLoweringTarget) -> bool { + claim.lowering.emits_to(target) +} + +fn build_claim_tree( + arena: &mut CsgTreeArena, + additive_nodes: Vec, + subtractive_nodes: Vec, +) -> Option { + let additive_root = match additive_nodes.len() { + 0 => None, + 1 => additive_nodes.first().copied(), + _ => Some( + arena + .generate_branch( + "domain-additive-claims", + CsgBranchOp::Addition, + additive_nodes, + ) + .node, + ), + }?; + + if subtractive_nodes.is_empty() { + return Some(additive_root); + } + + let subtractive_root = match subtractive_nodes.len() { + 0 => unreachable!(), + 1 => subtractive_nodes[0], + _ => { + arena + .generate_branch( + "domain-subtractive-claims", + CsgBranchOp::Addition, + subtractive_nodes, + ) + .node + } + }; + + Some( + arena + .generate_branch( + "domain-claims", + CsgBranchOp::Subtraction, + [additive_root, subtractive_root], + ) + .node, + ) +} + +pub fn ragnarok_column_fixture() -> DomainNode { + ragnarok_column_spec().compile_root() +} + +pub fn ragnarok_column_spec() -> DomainNodeSpec { + let mut column = DomainNodeSpec::new( + "stellarator-column-00", + DomainKind::Column, + DomainFrame::IDENTITY, + 0xC011_0000, + ) + .with_claim(claim_spec( + "column-support-shell", + DomainFrame::IDENTITY, + Aabb::from_center_size(Vec3::new(0.0, 0.0, 45.0), Vec3::new(18.0, 18.0, 96.0)), + FeatureClaimKind::SupportShell, + MaterialId(10), + )) + .with_claim(claim_spec( + "column-core-clearance", + DomainFrame::IDENTITY, + Aabb::from_center_size(Vec3::new(0.0, 0.0, 45.0), Vec3::new(7.0, 7.0, 100.0)), + FeatureClaimKind::ClearanceVolume, + MaterialId(0), + )); + for index in 0..3 { + column = column.with_child(ragnarok_band(index)); + } + + DomainNodeSpec::new( + "ragnarok-column", + DomainKind::Root, + DomainFrame::IDENTITY, + 0x5EED, + ) + .with_child(column) +} + +fn ragnarok_band(index: usize) -> DomainNodeSpec { + let z = 15.0 + index as f32 * 30.0; + let frame = DomainFrame::translated(Vec3::new(0.0, 0.0, z)); + let mut band = DomainNodeSpec::new( + format!("altitude-band-{index}"), + DomainKind::AltitudeBand, + frame, + 0xBADD_0000 + index as u64, + ) + .with_claim(claim_spec( + "coarse-ring-road", + frame, + Aabb::from_center_size(Vec3::ZERO, Vec3::new(34.0, 5.0, 1.0)), + FeatureClaimKind::RoadSurfaceSlab, + MaterialId(20 + index as u32), + )); + for lane in 0..2 { + band = band.with_child(ragnarok_branch(index, lane)); + } + band.with_child(ragnarok_roundabout(index)) +} + +fn ragnarok_branch(band: usize, lane: usize) -> DomainNodeSpec { + let angle = band as f32 * 0.41 + lane as f32 * std::f32::consts::PI; + let radius = 16.0 + lane as f32 * 5.0; + let frame = DomainFrame::rotated_z( + Vec3::new(angle.cos() * radius, angle.sin() * radius, 0.0), + angle, + ); + let mut branch = DomainNodeSpec::new( + format!("branch-road-{lane}"), + DomainKind::BranchRoad, + frame, + 0xA11E_0000 + (band * 10 + lane) as u64, + ) + .with_claim(claim_spec( + "road-slab", + frame, + Aabb::from_center_size(Vec3::new(7.0, 0.0, 0.0), Vec3::new(18.0, 4.0, 0.8)), + FeatureClaimKind::RoadSurfaceSlab, + MaterialId(40 + band as u32), + )) + .with_claim(claim_spec( + "hover-clearance", + frame, + Aabb::from_center_size(Vec3::new(7.0, 0.0, 2.2), Vec3::new(17.0, 3.0, 2.0)), + FeatureClaimKind::ClearanceVolume, + MaterialId(0), + )) + .with_claim(claim_spec( + "coarse-support-rib", + frame, + Aabb::from_center_size(Vec3::new(7.0, 0.0, -1.0), Vec3::new(18.5, 1.0, 1.5)), + FeatureClaimKind::SupportShell, + MaterialId(12), + )); + for segment in 0..3 { + branch = branch.with_child(ragnarok_road_chunk(band, lane, segment, frame)); + } + branch +} + +fn ragnarok_roundabout(band: usize) -> DomainNodeSpec { + let frame = DomainFrame::translated(Vec3::ZERO); + let mut roundabout = DomainNodeSpec::new( + "roundabout", + DomainKind::Roundabout, + frame, + 0xF00D_0000 + band as u64, + ) + .with_claim(claim_spec( + "roundabout-east-west", + frame, + Aabb::from_center_size(Vec3::ZERO, Vec3::new(26.0, 4.5, 0.9)), + FeatureClaimKind::RoadSurfaceSlab, + MaterialId(60 + band as u32), + )) + .with_claim(claim_spec( + "roundabout-north-south", + DomainFrame::rotated_z(Vec3::ZERO, std::f32::consts::FRAC_PI_2), + Aabb::from_center_size(Vec3::ZERO, Vec3::new(26.0, 4.5, 0.9)), + FeatureClaimKind::RoadSurfaceSlab, + MaterialId(60 + band as u32), + )); + for quadrant in 0..4 { + roundabout = roundabout.with_child(ragnarok_roundabout_chunk(band, quadrant)); + } + roundabout +} + +fn ragnarok_road_chunk( + band: usize, + lane: usize, + segment: usize, + frame: DomainFrame, +) -> DomainNodeSpec { + let x = 1.5 + segment as f32 * 5.5; + DomainNodeSpec::new( + format!("chunk-{segment}"), + DomainKind::Chunk, + frame, + 0xC40B_0000 + (band * 100 + lane * 10 + segment) as u64, + ) + .with_claim(claim_spec( + "road-slab", + frame, + Aabb::from_center_size(Vec3::new(x, 0.0, 0.0), Vec3::new(6.5, 4.0, 0.75)), + FeatureClaimKind::RoadSurfaceSlab, + MaterialId(80 + band as u32), + )) + .with_claim(claim_spec( + "hover-clearance", + frame, + Aabb::from_center_size(Vec3::new(x, 0.0, 2.1), Vec3::new(6.0, 3.0, 1.9)), + FeatureClaimKind::ClearanceVolume, + MaterialId(0), + )) + .with_claim(claim_spec( + "support-rib", + frame, + Aabb::from_center_size(Vec3::new(x, 0.0, -1.05), Vec3::new(6.8, 0.9, 1.4)), + FeatureClaimKind::SupportShell, + MaterialId(90 + lane as u32), + )) + .with_claim(claim_spec( + "collider-proxy", + frame, + Aabb::from_center_size(Vec3::new(x, 0.0, 0.35), Vec3::new(6.8, 4.2, 0.35)), + FeatureClaimKind::ColliderProxy, + MaterialId(0), + )) +} + +fn ragnarok_roundabout_chunk(band: usize, quadrant: usize) -> DomainNodeSpec { + let angle = quadrant as f32 * std::f32::consts::FRAC_PI_2; + let frame = DomainFrame::rotated_z(Vec3::ZERO, angle); + DomainNodeSpec::new( + format!("chunk-{quadrant}"), + DomainKind::Chunk, + frame, + 0xC10C_0000 + (band * 10 + quadrant) as u64, + ) + .with_claim(claim_spec( + "arc-road-slab", + frame, + Aabb::from_center_size(Vec3::new(5.0, 5.0, 0.0), Vec3::new(12.0, 4.0, 0.8)), + FeatureClaimKind::RoadSurfaceSlab, + MaterialId(100 + band as u32), + )) + .with_claim(claim_spec( + "arc-clearance", + frame, + Aabb::from_center_size(Vec3::new(5.0, 5.0, 2.1), Vec3::new(11.0, 3.0, 1.8)), + FeatureClaimKind::ClearanceVolume, + MaterialId(0), + )) + .with_claim(claim_spec( + "arc-support", + frame, + Aabb::from_center_size(Vec3::new(5.0, 5.0, -1.0), Vec3::new(12.5, 0.8, 1.2)), + FeatureClaimKind::SupportShell, + MaterialId(90), + )) +} + +fn claim_spec( + name: &str, + frame: DomainFrame, + support: Aabb, + kind: FeatureClaimKind, + material: MaterialId, +) -> FeatureClaimSpec { + FeatureClaimSpec::new(name, frame, support, kind, material) +} + +fn vec3_to_array(value: Vec3) -> [f32; 3] { + [value.x, value.y, value.z] +} + +fn array_to_vec3(value: [f32; 3]) -> Vec3 { + Vec3::new(value[0], value[1], value[2]) +} + +fn quat_to_array(value: Quat) -> [f32; 4] { + [value.x, value.y, value.z, value.w] +} + +fn array_to_quat(value: [f32; 4]) -> Quat { + Quat::from_xyzw(value[0], value[1], value[2], value[3]) +} + +fn fallback_box_claim( + key: &DomainKey, + kind: DomainKind, + frame: DomainFrame, + bounds: Aabb, +) -> Vec { + if !bounds.is_valid() { + return Vec::new(); + } + let kind = match kind { + DomainKind::ClearanceVolume => FeatureClaimKind::ClearanceVolume, + DomainKind::RoadSpine | DomainKind::BranchRoad | DomainKind::Roundabout => { + FeatureClaimKind::RoadSurfaceSlab + } + _ => FeatureClaimKind::SupportShell, + }; + vec![FeatureClaim { + key: format!("{}/claim/fallback-summary", key.0), + domain_key: key.clone(), + frame, + support: Aabb::from_center_size(Vec3::ZERO, bounds.size()), + kind, + material: MaterialId(1), + lowering: FeatureLoweringPolicy::default_for_kind(kind), + }] +} + +fn domain_priority(kind: DomainKind) -> f32 { + match kind { + DomainKind::Root => 0.5, + DomainKind::Column => 1.0, + DomainKind::AltitudeBand => 1.4, + DomainKind::RoadSpine | DomainKind::BranchRoad | DomainKind::Roundabout => 2.0, + DomainKind::SupportMass => 1.1, + DomainKind::ClearanceVolume => 1.8, + DomainKind::Chunk => 1.0, + } +} + +fn contribution(node: &DomainNode, query: &DomainQuery) -> f32 { + let projected_error = projected_screen_error(node, query); + let stale_multiplier = if query.dirty_domain_keys.contains(&node.key) { + 1.5 + } else { + 1.0 + }; + let cost_pressure = selection_triangle_cost(node).max(1) as f32; + (projected_error * domain_priority(node.kind) * stale_multiplier) / cost_pressure.sqrt() +} + +fn projected_screen_error(node: &DomainNode, query: &DomainQuery) -> f32 { + let center = node.summary.bounds.center(); + let distance = center.distance(query.camera_position).max(1.0); + let radius = node.summary.bounds.size().length() * 0.5; + let focal_pixels = query.viewport_height_px.max(1.0) + / (2.0 * (query.vertical_fov_radians.max(0.01) * 0.5).tan()); + (radius / distance) * focal_pixels +} + +fn stable_cut_id(keys: &[DomainKey]) -> String { + let mut hash = FNV_OFFSET_BASIS; + for key in keys { + hash = stable_hash_bytes(hash, key.0.as_bytes()); + } + format!("cut-{hash:016x}") +} + +const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; +const FNV_PRIME: u64 = 0x1000_0000_01b3; + +fn stable_str_hash(value: &str) -> u64 { + stable_hash_bytes(FNV_OFFSET_BASIS, value.as_bytes()) +} + +fn stable_hash_bytes(mut hash: u64, bytes: &[u8]) -> u64 { + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(FNV_PRIME); + } + hash +} + +struct CutState<'a> { + query: &'a DomainQuery, + selected: Vec, + claims: Vec, + deferred: Vec, + fallback: Vec, + diagnostics: Vec, + remaining_triangles: usize, + remaining_colliders: usize, +} + +impl CutState<'_> { + fn visit(&mut self, node: &DomainNode, force_visit: bool) { + let requested_self = self.query.requested_chunk_keys.contains(&node.key); + let requested_descendant = has_requested_descendant(node, &self.query.requested_chunk_keys); + let dirty_descendant = has_requested_descendant(node, &self.query.dirty_domain_keys); + if !force_visit + && !requested_self + && !requested_descendant + && !dirty_descendant + && !node.summary.bounds.intersects(self.query.frustum) + { + return; + } + let score = contribution(node, self.query); + let projected_error = projected_screen_error(node, self.query); + let semantic_priority = domain_priority(node.kind); + let dirty = self.query.dirty_domain_keys.contains(&node.key); + let child_cost = node + .children + .iter() + .map(selection_triangle_cost) + .sum::(); + let child_collider_cost = node + .children + .iter() + .map(selection_collider_cost) + .sum::(); + let budget_allows_children = child_cost <= self.remaining_triangles + && child_collider_cost <= self.remaining_colliders; + let descend_for_detail = score >= self.query.target_error && budget_allows_children; + let descend_for_request = requested_descendant && !requested_self; + let descend_for_dirty = dirty_descendant && budget_allows_children; + let should_descend = node.summary.has_children + && !requested_self + && (descend_for_request || descend_for_dirty || descend_for_detail); + self.diagnostics.push(ContributionRow { + domain_key: node.key.clone(), + kind: node.kind, + contribution: score, + projected_screen_error: projected_error, + semantic_priority, + estimated_triangle_cost: node.summary.estimated_triangle_cost, + child_cost, + remaining_triangle_budget: self.remaining_triangles, + requested: requested_self, + dirty, + selected: !should_descend && self.query.accepts_kind(node.kind), + used_fallback: !should_descend && node.summary.has_children, + deferred_by_budget: node.summary.has_children + && !requested_self + && score >= self.query.target_error + && !budget_allows_children, + }); + + if should_descend { + if budget_allows_children { + self.remaining_triangles = self.remaining_triangles.saturating_sub(child_cost); + self.remaining_colliders = + self.remaining_colliders.saturating_sub(child_collider_cost); + } + for child in &node.children { + let child_requested = self.query.requested_chunk_keys.contains(&child.key) + || has_requested_descendant(child, &self.query.requested_chunk_keys); + let child_dirty = self.query.dirty_domain_keys.contains(&child.key) + || has_requested_descendant(child, &self.query.dirty_domain_keys); + self.visit(child, child_requested || child_dirty); + } + return; + } + + for child in &node.children { + self.deferred.push(child.key.clone()); + } + if self.query.accepts_kind(node.kind) { + self.selected.push(node.key.clone()); + let claims = if node.summary.has_children { + self.fallback.push(node.key.clone()); + &node.summary.fallback_claims + } else { + &node.claims + }; + self.claims.extend(claims.iter().cloned()); + } + } +} + +fn selection_triangle_cost(node: &DomainNode) -> usize { + let claim_count = if node.summary.has_children { + node.summary.fallback_claims.len() + } else { + node.claims.len() + }; + claim_count.max(1) * 12 +} + +fn selection_collider_cost(node: &DomainNode) -> usize { + let claims = if node.summary.has_children { + &node.summary.fallback_claims + } else { + &node.claims + }; + claims + .iter() + .filter(|claim| claim.lowering.emits_to(ClaimLoweringTarget::Collider)) + .count() + .max(1) + * 12 +} + +fn has_requested_descendant(node: &DomainNode, requested: &[DomainKey]) -> bool { + node.children + .iter() + .any(|child| requested.contains(&child.key) || has_requested_descendant(child, requested)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn query(target_error: f32, triangle_budget: usize) -> DomainQuery { + DomainQuery { + camera_position: Vec3::new(36.0, -42.0, 30.0), + frustum: Aabb::from_center_size(Vec3::new(0.0, 0.0, 45.0), Vec3::splat(150.0)), + viewport_height_px: 1080.0, + vertical_fov_radians: std::f32::consts::FRAC_PI_3, + target_error, + triangle_budget, + collider_budget: triangle_budget, + semantic_filter: Vec::new(), + requested_chunk_keys: Vec::new(), + dirty_domain_keys: Vec::new(), + } + } + + #[test] + fn ragnarok_domain_keys_are_stable() { + let a = ragnarok_column_fixture(); + let b = ragnarok_column_fixture(); + assert_eq!(a.key, b.key); + assert_eq!(a.children[0].children[1].key, b.children[0].children[1].key); + } + + #[test] + fn domain_tree_spec_compiles_stable_parent_claim_identity() { + let spec = DomainNodeSpec::new("root", DomainKind::Root, DomainFrame::IDENTITY, 1) + .with_child( + DomainNodeSpec::new("road", DomainKind::BranchRoad, DomainFrame::IDENTITY, 2) + .with_claim(FeatureClaimSpec::new( + "slab", + DomainFrame::IDENTITY, + Aabb::from_center_size(Vec3::ZERO, Vec3::ONE), + FeatureClaimKind::RoadSurfaceSlab, + MaterialId(1), + )), + ); + let domain = spec.compile_root(); + let road = &domain.children[0]; + assert_eq!(road.key, DomainKey::new("root/road")); + assert_eq!(road.parent, Some(domain.key.clone())); + assert_eq!(road.claims[0].key, "root/road/claim/slab"); + assert_eq!(road.claims[0].domain_key, road.key); + assert_eq!( + road.claims[0].lowering, + FeatureLoweringPolicy::RenderAndCollider + ); + } + + #[test] + fn domain_summary_contains_child_bounds() { + let fixture = ragnarok_column_fixture(); + let column = &fixture.children[0]; + for band in &column.children { + assert!( + column + .summary + .bounds + .contains_point(band.summary.bounds.center()) + ); + } + } + + #[test] + fn selected_cut_chooses_parent_under_tight_budget_and_children_when_relaxed() { + let fixture = ragnarok_column_fixture(); + let tight = select_domain_cut(&fixture, &query(10_000.0, 100)); + let relaxed = select_domain_cut(&fixture, &query(0.01, 10_000)); + assert!(tight.selected_nodes.len() < relaxed.selected_nodes.len()); + assert!( + tight + .fallback_nodes + .iter() + .any(|key| key.0.contains("ragnarok-column")) + ); + assert!( + relaxed + .selected_nodes + .iter() + .any(|key| key.0.contains("branch-road")) + ); + } + + #[test] + fn selected_cut_honors_collider_budget() { + let fixture = ragnarok_column_fixture(); + let mut collider_tight = query(0.01, 10_000); + collider_tight.collider_budget = 1; + let cut = select_domain_cut(&fixture, &collider_tight); + assert_eq!(cut.selected_nodes, vec![fixture.key.clone()]); + assert!(cut.diagnostics[0].deferred_by_budget); + } + + #[test] + fn selected_cut_reports_projected_error_and_dirty_pressure() { + let fixture = ragnarok_column_fixture(); + let dirty_key = fixture.key.clone(); + let mut request = query(10_000.0, 100); + request.dirty_domain_keys.push(dirty_key.clone()); + let cut = select_domain_cut(&fixture, &request); + let row = cut + .diagnostics + .iter() + .find(|row| row.domain_key == dirty_key) + .unwrap(); + assert!(row.projected_screen_error > 0.0); + assert_eq!(row.semantic_priority, domain_priority(row.kind)); + assert!(row.dirty); + } + + #[test] + fn dirty_descendant_pulls_selection_down_when_budget_allows() { + let fixture = ragnarok_column_fixture(); + let dirty_key = fixture.children[0].children[0].children[0].children[0] + .key + .clone(); + let mut request = query(10_000.0, 10_000); + request.dirty_domain_keys.push(dirty_key.clone()); + let cut = select_domain_cut(&fixture, &request); + assert!(cut.selected_nodes.contains(&dirty_key)); + assert!( + cut.diagnostics + .iter() + .any(|row| row.domain_key == dirty_key && row.dirty) + ); + } + + #[test] + fn missing_children_degrade_to_parent_fallback_claims() { + let fixture = ragnarok_column_fixture(); + let cut = select_domain_cut(&fixture, &query(10_000.0, 100)); + assert!(!cut.fallback_nodes.is_empty()); + assert!(!cut.emitted_claims.is_empty()); + } + + #[test] + fn feature_claims_lower_deterministically_to_triangle_chunks() { + let fixture = ragnarok_column_fixture(); + let cut = select_domain_cut(&fixture, &query(0.01, 10_000)); + let a = lower_selected_cut(&cut); + let b = lower_selected_cut(&cut); + assert_eq!(a.source_claim_keys, b.source_claim_keys); + assert_eq!(a.mesh.positions, b.mesh.positions); + assert_eq!(a.mesh.indices, b.mesh.indices); + } + + #[test] + fn triangle_chunks_preserve_source_domain_and_claim_ids() { + let fixture = ragnarok_column_fixture(); + let cut = select_domain_cut(&fixture, &query(0.01, 10_000)); + let chunk = lower_selected_cut(&cut); + assert!(!chunk.source_domain_keys.is_empty()); + assert!(!chunk.source_claim_keys.is_empty()); + assert!(chunk.collider_mesh.is_some()); + } + + #[test] + fn feature_claims_lower_through_csg_tree_branches() { + let fixture = ragnarok_column_fixture(); + let cut = select_domain_cut(&fixture, &query(0.01, 10_000)); + let lowering = + lower_feature_claims_to_csg_tree(&cut.emitted_claims, ClaimLoweringTarget::Render); + assert!(lowering.root.is_some()); + assert!(lowering.arena.brush_count() > 0); + assert!(lowering.arena.branch_count() > 0); + } + + #[test] + fn lowering_policy_controls_render_and_collider_streams() { + let fixture = ragnarok_column_fixture(); + let cut = select_domain_cut(&fixture, &query(0.01, 10_000)); + let render = + lower_feature_claims_to_csg_tree(&cut.emitted_claims, ClaimLoweringTarget::Render); + let collider = + lower_feature_claims_to_csg_tree(&cut.emitted_claims, ClaimLoweringTarget::Collider); + assert!( + !render + .source_claim_keys + .iter() + .any(|key| key.ends_with("/claim/collider-proxy")) + ); + assert!( + collider + .source_claim_keys + .iter() + .any(|key| key.ends_with("/claim/collider-proxy")) + ); + assert!( + render + .source_claim_keys + .iter() + .any(|key| key.ends_with("/claim/hover-clearance")) + ); + } + + #[test] + fn selected_cut_chunks_emit_per_selected_domain() { + let fixture = ragnarok_column_fixture(); + let cut = select_domain_cut(&fixture, &query(0.01, 10_000)); + let chunks = lower_selected_cut_chunks(&cut); + assert_eq!(chunks.len(), cut.selected_nodes.len()); + for chunk in chunks { + assert_eq!(chunk.source_domain_keys.len(), 1); + assert!(cut.selected_nodes.contains(&chunk.source_domain_keys[0])); + assert!(chunk.mesh.triangle_count() > 0); + } + } + + #[test] + fn triangle_chunk_manifest_preserves_streaming_metadata() { + let fixture = ragnarok_column_fixture(); + let cut = select_domain_cut(&fixture, &query(0.01, 10_000)); + let chunks = lower_selected_cut_chunks(&cut); + let manifest = chunks[0].manifest(); + assert_eq!(manifest.selected_cut_id, cut.id); + assert!(manifest.render_triangles > 0); + assert!(!manifest.source_domain_keys.is_empty()); + assert!(manifest.transition_hint.supports_parent_child_coexistence); + assert_ne!(manifest.transition_hint.stable_clip_seed, 0); + } + + #[test] + fn selected_cut_manifest_carries_worker_artifact_context() { + let fixture = ragnarok_column_fixture(); + let cut = select_domain_cut(&fixture, &query(0.01, 10_000)); + let chunks = lower_selected_cut_chunks(&cut); + let manifest = cut.manifest(&chunks); + assert_eq!(manifest.id, cut.id); + assert_eq!(manifest.chunks.len(), chunks.len()); + assert_eq!(manifest.selected_nodes.len(), cut.selected_nodes.len()); + assert!(!manifest.diagnostics.is_empty()); + } + + #[test] + fn build_domain_chunks_runs_full_runtime_pipeline() { + let fixture = ragnarok_column_fixture(); + let build = build_domain_chunks(&fixture, &query(0.01, 10_000)); + assert_eq!(build.manifest.id, build.cut.id); + assert_eq!(build.manifest.chunks.len(), build.chunks.len()); + assert!(!build.chunks.is_empty()); + assert!( + build + .chunks + .iter() + .all(|chunk| !chunk.mesh.indices.is_empty()) + ); + } + + #[test] + fn requested_chunk_bypasses_frustum_and_budget() { + let fixture = ragnarok_column_fixture(); + let requested = fixture.children[0].children[2].children[0].children[1] + .key + .clone(); + let mut request = query(0.01, 1); + request.frustum = + Aabb::from_center_size(Vec3::new(10_000.0, 10_000.0, 10_000.0), Vec3::splat(1.0)); + request.requested_chunk_keys.push(requested.clone()); + let cut = select_domain_cut(&fixture, &request); + assert_eq!(cut.selected_nodes, vec![requested.clone()]); + assert!( + cut.diagnostics + .iter() + .any(|row| row.domain_key == requested && row.requested) + ); + } + + #[test] + fn ragnarok_fixture_emits_lod_chunks_and_preserves_parent_for_transition() { + let fixture = ragnarok_column_fixture(); + let tight_cut = select_domain_cut(&fixture, &query(10_000.0, 100)); + let medium_cut = select_domain_cut(&fixture, &query(0.01, 560)); + let relaxed_cut = select_domain_cut(&fixture, &query(0.01, 10_000)); + let tight = lower_selected_cut(&tight_cut); + let medium = lower_selected_cut(&medium_cut); + let relaxed = lower_selected_cut(&relaxed_cut); + assert!(tight.mesh.triangle_count() > 0); + assert!(medium.mesh.triangle_count() > tight.mesh.triangle_count()); + assert!(relaxed.mesh.triangle_count() > medium.mesh.triangle_count()); + assert!(medium_cut.selected_nodes.len() > tight_cut.selected_nodes.len()); + assert!(relaxed_cut.selected_nodes.len() > medium_cut.selected_nodes.len()); + assert!(!tight_cut.fallback_nodes.is_empty()); + assert!(!relaxed_cut.selected_nodes.is_empty()); + assert!( + relaxed_cut + .selected_nodes + .iter() + .any(|key| key.0.contains("/chunk-")) + ); + } + + #[test] + fn clearance_voids_leave_no_triangle_centers_inside_strict_bounds() { + let fixture = ragnarok_column_fixture(); + let cut = select_domain_cut(&fixture, &query(0.01, 10_000)); + let chunk = lower_selected_cut(&cut); + for clearance in cut + .emitted_claims + .iter() + .filter(|claim| claim.kind == FeatureClaimKind::ClearanceVolume) + { + let bounds = clearance.world_bounds(); + for tri in chunk.mesh.indices.chunks_exact(3) { + let center = (chunk.mesh.positions[tri[0] as usize] + + chunk.mesh.positions[tri[1] as usize] + + chunk.mesh.positions[tri[2] as usize]) + / 3.0; + assert!(!bounds.contains_point_strict(center, 1.0e-4)); + } + } + } +} diff --git a/packages/gamecult-geometry-rs/src/dsl.rs b/packages/gamecult-geometry-rs/src/dsl.rs new file mode 100644 index 00000000..77e31e68 --- /dev/null +++ b/packages/gamecult-geometry-rs/src/dsl.rs @@ -0,0 +1,164 @@ +#![allow(dead_code)] +use bevy_math::{Quat, Vec3}; + +use crate::{ + Aabb, Assembler, BrushId, BrushOp, BuildOutput, DomeCapZSpec, FloretArmSpec, MaterialId, + Primitive, +}; + +#[derive(Clone, Debug, Default)] +pub struct LevelDsl { + assembler: Assembler, +} + +impl LevelDsl { + pub fn new() -> Self { + Self::default() + } + + pub fn solid_box( + &mut self, + name: impl Into, + center: Vec3, + size: Vec3, + material: MaterialId, + ) -> BrushId { + self.assembler + .solid_box(name, Aabb::from_center_size(center, size), material) + } + + pub fn cut_box(&mut self, name: impl Into, center: Vec3, size: Vec3) -> BrushId { + self.assembler + .cut_box(name, Aabb::from_center_size(center, size)) + } + + pub fn solid_oriented_box( + &mut self, + name: impl Into, + center: Vec3, + size: Vec3, + rotation: Quat, + material: MaterialId, + ) -> BrushId { + self.assembler + .solid_oriented_box(name, center, size, rotation, material) + } + + pub fn cut_oriented_box( + &mut self, + name: impl Into, + center: Vec3, + size: Vec3, + rotation: Quat, + ) -> BrushId { + self.assembler + .cut_oriented_box(name, center, size, rotation) + } + + pub fn cylinder_z( + &mut self, + name: impl Into, + center: Vec3, + radius: f32, + depth: f32, + segments: usize, + material: MaterialId, + ) -> BrushId { + self.assembler.add_brush( + name, + BrushOp::Add, + Primitive::CylinderZ { + center, + radius, + depth, + segments, + }, + material, + ) + } + + pub fn dome_cap_z(&mut self, name: impl Into, spec: DomeCapZSpec) -> BrushId { + self.assembler.add_brush( + name, + BrushOp::Add, + Primitive::DomeCapZ { + center: spec.center, + radius: spec.radius, + height: spec.height, + rings: spec.rings, + segments: spec.segments, + }, + spec.material, + ) + } + + pub fn floret_arm(&mut self, name: impl Into, spec: FloretArmSpec) -> BrushId { + self.assembler.add_brush( + name, + BrushOp::Add, + Primitive::FloretArm { + anchor: spec.anchor, + direction: spec.direction, + length: spec.length, + root_width: spec.root_width, + tip_width: spec.tip_width, + thickness: spec.thickness, + tip_lift: spec.tip_lift, + }, + spec.material, + ) + } + + pub fn assemble(&self) -> BuildOutput { + self.assembler.build() + } + + pub fn into_assembler(self) -> Assembler { + self.assembler + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dsl_builds_room_and_habitat_primitives() { + let mut dsl = LevelDsl::new(); + dsl.solid_box( + "wall", + Vec3::new(0.0, 0.0, 1.5), + Vec3::new(5.0, 0.25, 3.0), + MaterialId(1), + ); + dsl.cut_box("door", Vec3::new(0.0, 0.0, 1.0), Vec3::new(1.0, 0.5, 2.0)); + dsl.dome_cap_z( + "city dome", + DomeCapZSpec { + center: Vec3::new(0.0, 0.0, 0.0), + radius: 3.0, + height: 1.5, + rings: 4, + segments: 16, + material: MaterialId(2), + }, + ); + dsl.floret_arm( + "solar petal", + FloretArmSpec { + anchor: Vec3::new(3.0, 0.0, 0.0), + direction: Vec3::X, + length: 12.0, + root_width: 1.2, + tip_width: 3.0, + thickness: 0.08, + tip_lift: 0.6, + material: MaterialId(3), + }, + ); + + let output = dsl.assemble(); + assert!(output.mesh.triangle_count() > 12); + assert!(output.report.warnings.is_empty()); + } +} diff --git a/packages/gamecult-geometry-rs/src/frontier.rs b/packages/gamecult-geometry-rs/src/frontier.rs new file mode 100644 index 00000000..e48af14f --- /dev/null +++ b/packages/gamecult-geometry-rs/src/frontier.rs @@ -0,0 +1,321 @@ +#![allow(dead_code)] +use crate::{Brush, BrushOp}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DemandPair { + pub source_index: usize, + pub operator_index: usize, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct DemandFrontier { + pub pairs: Vec, + pub rejected_pairs: usize, + pub operator_brushes: usize, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct DirtyDemandFrontier { + pub first_dirty_index: Option, + pub pairs: Vec, + pub rejected_pairs: usize, + pub operator_brushes: usize, + pub skipped_prefix_pairs: usize, +} + +impl DemandFrontier { + pub fn from_ordered_brushes(brushes: &[Brush]) -> Self { + let mut frontier = Self::default(); + let mut active_sources = Vec::::new(); + + for (operator_index, brush) in brushes.iter().enumerate() { + match brush.op { + BrushOp::Add => active_sources.push(operator_index), + BrushOp::Subtract => { + frontier.operator_brushes += 1; + frontier.collect_pairs(brushes, &active_sources, operator_index); + } + BrushOp::Intersect => { + frontier.operator_brushes += 1; + let retained = frontier.collect_pairs(brushes, &active_sources, operator_index); + active_sources = retained; + } + } + } + + frontier + } + + pub fn candidate_pairs(&self) -> usize { + self.pairs.len() + } + + fn collect_pairs( + &mut self, + brushes: &[Brush], + active_sources: &[usize], + operator_index: usize, + ) -> Vec { + let operator_bounds = brushes[operator_index].bounds(); + let mut retained = Vec::new(); + + for &source_index in active_sources { + if brushes[source_index].bounds().intersects(operator_bounds) { + self.pairs.push(DemandPair { + source_index, + operator_index, + }); + retained.push(source_index); + } else { + self.rejected_pairs += 1; + } + } + + retained + } +} + +impl DirtyDemandFrontier { + pub fn from_dirty_indices( + brushes: &[Brush], + dirty_indices: impl IntoIterator, + ) -> Self { + let first_dirty_index = dirty_indices + .into_iter() + .filter(|index| *index < brushes.len()) + .min(); + let Some(first_dirty_index) = first_dirty_index else { + return Self::default(); + }; + + let mut frontier = Self { + first_dirty_index: Some(first_dirty_index), + ..Self::default() + }; + let mut active_sources = Vec::::new(); + + for (operator_index, brush) in brushes.iter().enumerate() { + match brush.op { + BrushOp::Add => active_sources.push(operator_index), + BrushOp::Subtract => { + frontier.operator_brushes += usize::from(operator_index >= first_dirty_index); + frontier.collect_pairs( + brushes, + &active_sources, + operator_index, + first_dirty_index, + ); + } + BrushOp::Intersect => { + frontier.operator_brushes += usize::from(operator_index >= first_dirty_index); + let retained = frontier.collect_pairs( + brushes, + &active_sources, + operator_index, + first_dirty_index, + ); + active_sources = retained; + } + } + } + + frontier + } + + pub fn candidate_pairs(&self) -> usize { + self.pairs.len() + } + + fn collect_pairs( + &mut self, + brushes: &[Brush], + active_sources: &[usize], + operator_index: usize, + first_dirty_index: usize, + ) -> Vec { + let operator_bounds = brushes[operator_index].bounds(); + let mut retained = Vec::new(); + + for &source_index in active_sources { + if brushes[source_index].bounds().intersects(operator_bounds) { + if operator_index >= first_dirty_index { + self.pairs.push(DemandPair { + source_index, + operator_index, + }); + } else { + self.skipped_prefix_pairs += 1; + } + retained.push(source_index); + } else if operator_index >= first_dirty_index { + self.rejected_pairs += 1; + } else { + self.skipped_prefix_pairs += 1; + } + } + + retained + } +} + +#[cfg(test)] +mod tests { + use bevy_math::{Quat, Vec3}; + + use super::*; + use crate::{Aabb, Brush, MaterialId, Primitive}; + + fn brush(name: &str, op: BrushOp, bounds: Aabb) -> Brush { + Brush::new( + crate::BrushId(0), + name, + op, + Primitive::Box { bounds }, + MaterialId(1), + ) + } + + #[test] + fn frontier_keeps_only_touching_ordered_pairs() { + let brushes = vec![ + brush( + "source", + BrushOp::Add, + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + ), + brush( + "far cutter", + BrushOp::Subtract, + Aabb::from_center_size(Vec3::splat(20.0), Vec3::ONE), + ), + brush( + "near cutter", + BrushOp::Subtract, + Aabb::from_center_size(Vec3::ZERO, Vec3::ONE), + ), + ]; + + let frontier = DemandFrontier::from_ordered_brushes(&brushes); + + assert_eq!(frontier.operator_brushes, 2); + assert_eq!(frontier.rejected_pairs, 1); + assert_eq!( + frontier.pairs, + vec![DemandPair { + source_index: 0, + operator_index: 2, + }] + ); + } + + #[test] + fn intersection_frontier_reduces_active_sources() { + let brushes = vec![ + brush( + "a", + BrushOp::Add, + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)), + ), + brush( + "b", + BrushOp::Add, + Aabb::from_center_size(Vec3::new(20.0, 0.0, 0.0), Vec3::splat(2.0)), + ), + Brush::new( + crate::BrushId(2), + "common", + BrushOp::Intersect, + Primitive::OrientedBox { + center: Vec3::ZERO, + size: Vec3::splat(3.0), + rotation: Quat::IDENTITY, + }, + MaterialId(1), + ), + brush( + "cut", + BrushOp::Subtract, + Aabb::from_center_size(Vec3::ZERO, Vec3::ONE), + ), + ]; + + let frontier = DemandFrontier::from_ordered_brushes(&brushes); + + assert_eq!(frontier.operator_brushes, 2); + assert_eq!(frontier.rejected_pairs, 1); + assert_eq!( + frontier.pairs, + vec![ + DemandPair { + source_index: 0, + operator_index: 2, + }, + DemandPair { + source_index: 0, + operator_index: 3, + }, + ] + ); + } + + #[test] + fn dirty_frontier_preserves_prefix_before_first_dirty_brush() { + let brushes = vec![ + brush( + "source", + BrushOp::Add, + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(8.0)), + ), + brush( + "early", + BrushOp::Subtract, + Aabb::from_center_size(Vec3::new(-2.0, 0.0, 0.0), Vec3::splat(2.0)), + ), + brush( + "dirty", + BrushOp::Subtract, + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)), + ), + brush( + "later", + BrushOp::Subtract, + Aabb::from_center_size(Vec3::new(2.0, 0.0, 0.0), Vec3::splat(2.0)), + ), + ]; + + let frontier = DirtyDemandFrontier::from_dirty_indices(&brushes, [2]); + + assert_eq!(frontier.first_dirty_index, Some(2)); + assert_eq!(frontier.operator_brushes, 2); + assert_eq!(frontier.skipped_prefix_pairs, 1); + assert_eq!(frontier.candidate_pairs(), 2); + assert_eq!( + frontier.pairs, + vec![ + DemandPair { + source_index: 0, + operator_index: 2, + }, + DemandPair { + source_index: 0, + operator_index: 3, + }, + ] + ); + } + + #[test] + fn dirty_frontier_ignores_out_of_range_dirty_indices() { + let brushes = vec![brush( + "source", + BrushOp::Add, + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)), + )]; + + let frontier = DirtyDemandFrontier::from_dirty_indices(&brushes, [99]); + + assert_eq!(frontier.first_dirty_index, None); + assert_eq!(frontier.candidate_pairs(), 0); + assert_eq!(frontier.operator_brushes, 0); + } +} diff --git a/packages/gamecult-geometry-rs/src/lib.rs b/packages/gamecult-geometry-rs/src/lib.rs index 7882c457..04727ed1 100644 --- a/packages/gamecult-geometry-rs/src/lib.rs +++ b/packages/gamecult-geometry-rs/src/lib.rs @@ -4,6 +4,50 @@ //! 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}; @@ -40,6 +84,54 @@ pub trait GeometryDocument: Serialize + DeserializeOwned + Sized { } } +/// Geometry-owned CSG facade. The implementation uses an internal math kernel, +/// but no `bevy_math` type crosses this public boundary. +#[derive(Default)] +pub struct GeometryBrushAssembler(Assembler); + +impl GeometryBrushAssembler { + pub fn new() -> Self { + Self(Assembler::new()) + } + pub fn add_box( + &mut self, + name: impl Into, + center: Float3, + size: Float3, + material: u32, + ) { + self.0 + .solid_box(name, internal_aabb(center, size), MaterialId(material)); + } + pub fn subtract_box(&mut self, name: impl Into, center: Float3, size: Float3) { + self.0.cut_box(name, internal_aabb(center, size)); + } + pub fn build(&self) -> GeometryTriangleMesh { + internal_mesh(&self.0.build().mesh) + } +} + +fn internal_aabb(center: Float3, size: Float3) -> Aabb { + use bevy_math::Vec3; + Aabb::from_center_size( + Vec3::new(center.0, center.1, center.2), + Vec3::new(size.0, size.1, size.2), + ) +} + +fn internal_mesh(mesh: &TriangleMesh) -> GeometryTriangleMesh { + GeometryTriangleMesh( + mesh.positions + .iter() + .map(|v| Float3(v.x, v.y, v.z)) + .collect(), + mesh.normals.iter().map(|v| Float3(v.x, v.y, v.z)).collect(), + mesh.uvs.iter().map(|v| Float2(v.x, v.y)).collect(), + mesh.indices.clone(), + mesh.triangle_materials.iter().map(|v| v.0).collect(), + ) +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct GeometryDomainDocument( pub String, @@ -463,6 +555,31 @@ mod tests { ); } + #[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(); diff --git a/packages/gamecult-geometry-rs/src/mesh.rs b/packages/gamecult-geometry-rs/src/mesh.rs new file mode 100644 index 00000000..9e23b7ab --- /dev/null +++ b/packages/gamecult-geometry-rs/src/mesh.rs @@ -0,0 +1,103 @@ +#![allow(dead_code)] +use bevy_math::{Vec2, Vec3}; + +use crate::{Aabb, MaterialId}; + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct TriangleMesh { + pub positions: Vec, + pub normals: Vec, + pub uvs: Vec, + pub indices: Vec, + pub triangle_materials: Vec, +} + +impl TriangleMesh { + pub fn new() -> Self { + Self::default() + } + + pub fn triangle_count(&self) -> usize { + self.indices.len() / 3 + } + + pub fn vertex_count(&self) -> usize { + self.positions.len() + } + + pub fn append_box(&mut self, bounds: Aabb, material: MaterialId) { + let min = bounds.min; + let max = bounds.max; + let corners = [ + Vec3::new(min.x, min.y, min.z), + Vec3::new(max.x, min.y, min.z), + Vec3::new(max.x, max.y, min.z), + Vec3::new(min.x, max.y, min.z), + Vec3::new(min.x, min.y, max.z), + Vec3::new(max.x, min.y, max.z), + Vec3::new(max.x, max.y, max.z), + Vec3::new(min.x, max.y, max.z), + ]; + + self.append_quad( + [corners[0], corners[3], corners[2], corners[1]], + Vec3::NEG_Z, + material, + ); + self.append_quad( + [corners[4], corners[5], corners[6], corners[7]], + Vec3::Z, + material, + ); + self.append_quad( + [corners[0], corners[1], corners[5], corners[4]], + Vec3::NEG_Y, + material, + ); + self.append_quad( + [corners[3], corners[7], corners[6], corners[2]], + Vec3::Y, + material, + ); + self.append_quad( + [corners[0], corners[4], corners[7], corners[3]], + Vec3::NEG_X, + material, + ); + self.append_quad( + [corners[1], corners[2], corners[6], corners[5]], + Vec3::X, + material, + ); + } + + pub fn append_quad(&mut self, points: [Vec3; 4], normal: Vec3, material: MaterialId) { + let base = self.positions.len() as u32; + self.positions.extend(points); + self.normals.extend([normal; 4]); + self.uvs.extend([ + Vec2::new(0.0, 0.0), + Vec2::new(1.0, 0.0), + Vec2::new(1.0, 1.0), + Vec2::new(0.0, 1.0), + ]); + self.indices + .extend([base, base + 1, base + 2, base, base + 2, base + 3]); + self.triangle_materials.extend([material; 2]); + } + + pub fn append_triangle( + &mut self, + points: [Vec3; 3], + normal: Vec3, + uvs: [Vec2; 3], + material: MaterialId, + ) { + let base = self.positions.len() as u32; + self.positions.extend(points); + self.normals.extend([normal; 3]); + self.uvs.extend(uvs); + self.indices.extend([base, base + 1, base + 2]); + self.triangle_materials.push(material); + } +} diff --git a/packages/gamecult-geometry-rs/src/primitives.rs b/packages/gamecult-geometry-rs/src/primitives.rs new file mode 100644 index 00000000..bc100e59 --- /dev/null +++ b/packages/gamecult-geometry-rs/src/primitives.rs @@ -0,0 +1,173 @@ +#![allow(dead_code)] +use bevy_math::{Vec2, Vec3}; + +use crate::{MaterialId, TriangleMesh}; + +#[derive(Clone, Copy, Debug)] +pub struct DomeCapZSpec { + pub center: Vec3, + pub radius: f32, + pub height: f32, + pub rings: usize, + pub segments: usize, + pub material: MaterialId, +} + +#[derive(Clone, Copy, Debug)] +pub struct FloretArmSpec { + pub anchor: Vec3, + pub direction: Vec3, + pub length: f32, + pub root_width: f32, + pub tip_width: f32, + pub thickness: f32, + pub tip_lift: f32, + pub material: MaterialId, +} + +pub fn append_cylinder_z( + mesh: &mut TriangleMesh, + center: Vec3, + radius: f32, + depth: f32, + segments: usize, + material: MaterialId, +) { + let segments = segments.max(3); + let half = depth * 0.5; + let bottom_center = center - Vec3::Z * half; + let top_center = center + Vec3::Z * half; + + for i in 0..segments { + let a0 = ring_angle(i, segments); + let a1 = ring_angle(i + 1, segments); + let p0 = center + Vec3::new(a0.cos() * radius, a0.sin() * radius, -half); + let p1 = center + Vec3::new(a1.cos() * radius, a1.sin() * radius, -half); + let p2 = center + Vec3::new(a1.cos() * radius, a1.sin() * radius, half); + let p3 = center + Vec3::new(a0.cos() * radius, a0.sin() * radius, half); + let normal = Vec3::new(((a0 + a1) * 0.5).cos(), ((a0 + a1) * 0.5).sin(), 0.0); + mesh.append_quad([p0, p1, p2, p3], normal, material); + mesh.append_triangle( + [bottom_center, p1, p0], + Vec3::NEG_Z, + [Vec2::ZERO, Vec2::X, Vec2::Y], + material, + ); + mesh.append_triangle( + [top_center, p3, p2], + Vec3::Z, + [Vec2::ZERO, Vec2::X, Vec2::Y], + material, + ); + } +} + +pub fn append_dome_cap_z(mesh: &mut TriangleMesh, spec: DomeCapZSpec) { + let rings = spec.rings.max(2); + let segments = spec.segments.max(6); + let top = spec.center + Vec3::Z * spec.height; + + for ring in 0..rings { + let t0 = ring as f32 / rings as f32; + let t1 = (ring + 1) as f32 / rings as f32; + let r0 = spec.radius * (1.0 - t0 * t0).sqrt(); + let r1 = spec.radius * (1.0 - t1 * t1).sqrt(); + let z0 = spec.center.z + spec.height * t0; + let z1 = spec.center.z + spec.height * t1; + + for seg in 0..segments { + let a0 = ring_angle(seg, segments); + let a1 = ring_angle(seg + 1, segments); + let p0 = Vec3::new( + spec.center.x + a0.cos() * r0, + spec.center.y + a0.sin() * r0, + z0, + ); + let p1 = Vec3::new( + spec.center.x + a1.cos() * r0, + spec.center.y + a1.sin() * r0, + z0, + ); + + if ring + 1 == rings { + let normal = (p0 - spec.center) + .cross(top - spec.center) + .normalize_or_zero(); + mesh.append_triangle( + [p0, p1, top], + normal, + [Vec2::ZERO, Vec2::X, Vec2::Y], + spec.material, + ); + } else { + let p2 = Vec3::new( + spec.center.x + a1.cos() * r1, + spec.center.y + a1.sin() * r1, + z1, + ); + let p3 = Vec3::new( + spec.center.x + a0.cos() * r1, + spec.center.y + a0.sin() * r1, + z1, + ); + let normal = ((p0 + p1 + p2 + p3) * 0.25 - spec.center).normalize_or_zero(); + mesh.append_quad([p0, p1, p2, p3], normal, spec.material); + } + } + } +} + +pub fn append_floret_arm(mesh: &mut TriangleMesh, spec: FloretArmSpec) { + let forward = spec.direction.normalize_or_zero(); + let forward = if forward.length_squared() == 0.0 { + Vec3::X + } else { + forward + }; + let mut side = forward.cross(Vec3::Z).normalize_or_zero(); + if side.length_squared() == 0.0 { + side = Vec3::Y; + } + + let root_center = spec.anchor; + let tip_center = spec.anchor + forward * spec.length + Vec3::Z * spec.tip_lift; + let z = Vec3::Z * (spec.thickness * 0.5); + let r = side * (spec.root_width * 0.5); + let t = side * (spec.tip_width * 0.5); + + let bottom = [ + root_center - r - z, + root_center + r - z, + tip_center + t - z, + tip_center - t - z, + ]; + let top = [ + root_center - r + z, + root_center + r + z, + tip_center + t + z, + tip_center - t + z, + ]; + + mesh.append_quad( + [bottom[0], bottom[3], bottom[2], bottom[1]], + Vec3::NEG_Z, + spec.material, + ); + mesh.append_quad([top[0], top[1], top[2], top[3]], Vec3::Z, spec.material); + mesh.append_quad( + [bottom[0], bottom[1], top[1], top[0]], + -forward, + spec.material, + ); + mesh.append_quad( + [bottom[3], top[3], top[2], bottom[2]], + forward, + spec.material, + ); + mesh.append_quad([bottom[0], top[0], top[3], bottom[3]], -side, spec.material); + mesh.append_quad([bottom[1], bottom[2], top[2], top[1]], side, spec.material); +} + +fn ring_angle(index: usize, segments: usize) -> f32 { + index as f32 * std::f32::consts::TAU / segments as f32 +} diff --git a/packages/gamecult-geometry-rs/src/realtime_csg_parity.rs b/packages/gamecult-geometry-rs/src/realtime_csg_parity.rs new file mode 100644 index 00000000..fbb1ac70 --- /dev/null +++ b/packages/gamecult-geometry-rs/src/realtime_csg_parity.rs @@ -0,0 +1,166 @@ +use crate::{ + Aabb, Assembler, ConvexSolid, CsgBranchOp, CsgOperationType, CsgTreeArena, MaterialId, + PolygonCategory, Primitive, +}; +use bevy_math::{Quat, Vec3}; + +#[test] +fn parity_polygon_category_vocabulary_matches_public_demo() { + let categories = [ + PolygonCategory::Inside, + PolygonCategory::Outside, + PolygonCategory::Aligned, + PolygonCategory::ReverseAligned, + ]; + + assert_eq!(categories.len(), 4); +} + +#[test] +fn parity_operation_type_ordinals_match_public_api() { + assert_eq!(CsgOperationType::Additive as u8, 0); + assert_eq!(CsgOperationType::Subtractive as u8, 1); + assert_eq!(CsgOperationType::Intersecting as u8, 2); +} + +#[test] +fn parity_demo_branch_vocabulary_matches_public_demo() { + let branches = [ + CsgBranchOp::Addition, + CsgBranchOp::Subtraction, + CsgBranchOp::Common, + ]; + + assert_eq!(branches.len(), 3); +} + +#[test] +fn parity_tree_api_exposes_brush_branch_tree_handles() { + let mut arena = CsgTreeArena::new(); + let brush = arena.generate_brush( + "brush", + CsgOperationType::Additive, + Primitive::Box { + bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::ONE), + }, + MaterialId(1), + ); + let branch = arena.generate_branch("branch", CsgBranchOp::Addition, [brush.node]); + let tree = arena.generate_tree(branch.node); + + assert_eq!(arena.brush_count(), 1); + assert_eq!(arena.branch_count(), 1); + assert_eq!(arena.child_nodes(branch).expect("branch"), &[brush.node]); + assert_eq!(tree.root, branch.node); +} + +#[test] +fn parity_box_brush_observable_counts_match_realtime_csg_demo() { + let solid = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(2.0), + Quat::IDENTITY, + MaterialId(3), + ); + + assert_eq!(solid.clip_planes.len(), 6); + assert_eq!(solid.polygons.len(), 6); + assert_eq!( + solid + .polygons + .iter() + .map(|polygon| polygon.vertices.len()) + .sum::(), + 24 + ); +} + +#[test] +fn parity_center_box_subtraction_has_six_surviving_regions() { + let mut asm = Assembler::new(); + asm.solid_box( + "source", + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + MaterialId(1), + ); + asm.cut_box("void", Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0))); + + let output = asm.build(); + assert_eq!(output.report.emitted_convex_fragments, 6); + assert_eq!(output.report.warnings.len(), 0); +} + +#[test] +fn parity_identical_box_polygons_classify_as_aligned() { + let cutter = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(2.0), + Quat::IDENTITY, + MaterialId(0), + ); + let mut source = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(2.0), + Quat::IDENTITY, + MaterialId(1), + ); + + source.categorize_whole_polygons_against(&cutter); + + assert_eq!(source.polygons.len(), 6); + assert!( + source + .polygons + .iter() + .all(|polygon| polygon.category == PolygonCategory::Aligned) + ); +} + +#[test] +fn parity_outer_box_polygons_classify_as_outside_inner_box() { + let cutter = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(2.0), + Quat::IDENTITY, + MaterialId(0), + ); + let mut source = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(4.0), + Quat::IDENTITY, + MaterialId(1), + ); + + source.categorize_whole_polygons_against(&cutter); + + assert_eq!(source.polygons.len(), 6); + assert!( + source + .polygons + .iter() + .all(|polygon| polygon.category == PolygonCategory::Outside) + ); +} + +#[test] +fn parity_crossing_polygons_split_before_final_category() { + let source = ConvexSolid::box_from_center_size( + Vec3::ZERO, + Vec3::splat(4.0), + Quat::IDENTITY, + MaterialId(1), + ); + let cutter = ConvexSolid::box_from_center_size( + Vec3::new(0.0, 0.0, 1.0), + Vec3::new(2.0, 2.0, 2.0), + Quat::IDENTITY, + MaterialId(0), + ); + + let categorized = source.categorize_polygons_against(&cutter); + + assert_eq!(categorized.aligned.len(), 1); + assert!(categorized.outside.len() > source.polygons.len()); + assert_eq!(categorized.inside.len(), 0); + assert_eq!(categorized.reverse_aligned.len(), 0); +} diff --git a/packages/gamecult-geometry-rs/src/tree.rs b/packages/gamecult-geometry-rs/src/tree.rs new file mode 100644 index 00000000..14bdc226 --- /dev/null +++ b/packages/gamecult-geometry-rs/src/tree.rs @@ -0,0 +1,402 @@ +#![allow(dead_code)] +use crate::{Aabb, Assembler, Brush, BrushId, BrushOp, MaterialId, Primitive}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct CsgNodeId(pub u32); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct CsgTreeBrush { + pub node: CsgNodeId, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct CsgTreeBranch { + pub node: CsgNodeId, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct CsgTree { + pub root: CsgNodeId, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum CsgOperationType { + Additive = 0, + Subtractive = 1, + Intersecting = 2, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum CsgBranchOp { + Addition, + Subtraction, + Common, +} + +#[derive(Clone, Debug)] +pub enum CsgNode { + Brush(Brush), + Branch { + name: String, + op: CsgBranchOp, + children: Vec, + }, +} + +#[derive(Clone, Debug, Default)] +pub struct CsgTreeArena { + nodes: Vec, +} + +impl CsgTreeArena { + pub fn new() -> Self { + Self::default() + } + + pub fn nodes(&self) -> &[CsgNode] { + &self.nodes + } + + pub fn node(&self, id: CsgNodeId) -> Option<&CsgNode> { + self.nodes.get(id.0 as usize) + } + + pub fn node_mut(&mut self, id: CsgNodeId) -> Option<&mut CsgNode> { + self.nodes.get_mut(id.0 as usize) + } + + pub fn brush_count(&self) -> usize { + self.nodes + .iter() + .filter(|node| matches!(node, CsgNode::Brush(_))) + .count() + } + + pub fn branch_count(&self) -> usize { + self.nodes + .iter() + .filter(|node| matches!(node, CsgNode::Branch { .. })) + .count() + } + + pub fn generate_brush( + &mut self, + name: impl Into, + operation: CsgOperationType, + primitive: Primitive, + material: MaterialId, + ) -> CsgTreeBrush { + let id = CsgNodeId(self.nodes.len() as u32); + let brush = Brush::new( + BrushId(id.0), + name, + operation.into_brush_op(), + primitive, + material, + ); + self.nodes.push(CsgNode::Brush(brush)); + CsgTreeBrush { node: id } + } + + pub fn generate_branch( + &mut self, + name: impl Into, + op: CsgBranchOp, + children: impl IntoIterator, + ) -> CsgTreeBranch { + let id = CsgNodeId(self.nodes.len() as u32); + self.nodes.push(CsgNode::Branch { + name: name.into(), + op, + children: children.into_iter().collect(), + }); + CsgTreeBranch { node: id } + } + + pub fn generate_tree(&self, root: CsgNodeId) -> CsgTree { + CsgTree { root } + } + + pub fn set_brush_operation_type( + &mut self, + brush: CsgTreeBrush, + operation: CsgOperationType, + ) -> bool { + let Some(CsgNode::Brush(node)) = self.node_mut(brush.node) else { + return false; + }; + node.op = operation.into_brush_op(); + node.set_dirty(); + true + } + + pub fn set_branch_operation_type(&mut self, branch: CsgTreeBranch, op: CsgBranchOp) -> bool { + let Some(CsgNode::Branch { op: current_op, .. }) = self.node_mut(branch.node) else { + return false; + }; + *current_op = op; + true + } + + pub fn set_child_nodes( + &mut self, + branch: CsgTreeBranch, + children: impl IntoIterator, + ) -> bool { + let Some(CsgNode::Branch { + children: current_children, + .. + }) = self.node_mut(branch.node) + else { + return false; + }; + *current_children = children.into_iter().collect(); + true + } + + pub fn child_nodes(&self, branch: CsgTreeBranch) -> Option<&[CsgNodeId]> { + let Some(CsgNode::Branch { children, .. }) = self.node(branch.node) else { + return None; + }; + Some(children) + } + + pub fn node_bounds(&self, id: CsgNodeId) -> Option { + match self.node(id)? { + CsgNode::Brush(brush) => Some(brush.bounds()), + CsgNode::Branch { op, children, .. } => self.branch_bounds(*op, children), + } + } + + pub fn compile_tree_to_assembler(&self, tree: CsgTree) -> Assembler { + let mut assembler = Assembler::new(); + self.compile_node(tree.root, &mut assembler, None); + assembler + } + + fn compile_node( + &self, + id: CsgNodeId, + assembler: &mut Assembler, + operation_override: Option, + ) { + let Some(node) = self.node(id) else { + return; + }; + + match node { + CsgNode::Brush(brush) => { + let op = operation_override + .map(CsgOperationType::into_brush_op) + .unwrap_or(brush.op); + assembler.add_brush( + brush.name.clone(), + op, + brush.primitive.clone(), + brush.material, + ); + } + CsgNode::Branch { op, children, .. } => match op { + CsgBranchOp::Addition => { + for child in children { + self.compile_node(*child, assembler, operation_override); + } + } + CsgBranchOp::Subtraction => { + if let Some((first, rest)) = children.split_first() { + self.compile_node(*first, assembler, operation_override); + for child in rest { + self.compile_node( + *child, + assembler, + Some(CsgOperationType::Subtractive), + ); + } + } + } + CsgBranchOp::Common => { + if let Some((first, rest)) = children.split_first() { + self.compile_node(*first, assembler, operation_override); + for child in rest { + self.compile_node( + *child, + assembler, + Some(CsgOperationType::Intersecting), + ); + } + } + } + }, + } + } + + fn branch_bounds(&self, op: CsgBranchOp, children: &[CsgNodeId]) -> Option { + let mut child_bounds = children.iter().filter_map(|child| self.node_bounds(*child)); + match op { + CsgBranchOp::Addition => child_bounds.reduce(Aabb::union), + CsgBranchOp::Subtraction => child_bounds.next(), + CsgBranchOp::Common => { + let first = child_bounds.next()?; + child_bounds.try_fold(first, |bounds, child| bounds.intersection(child)) + } + } + } +} + +impl CsgOperationType { + pub fn into_brush_op(self) -> BrushOp { + match self { + Self::Additive => BrushOp::Add, + Self::Subtractive => BrushOp::Subtract, + Self::Intersecting => BrushOp::Intersect, + } + } +} + +impl From for CsgOperationType { + fn from(value: BrushOp) -> Self { + match value { + BrushOp::Add => Self::Additive, + BrushOp::Subtract => Self::Subtractive, + BrushOp::Intersect => Self::Intersecting, + } + } +} + +#[cfg(test)] +mod tests { + use bevy_math::Vec3; + + use super::*; + use crate::{Aabb, Primitive}; + + #[test] + fn tree_subtraction_compiles_to_ordered_brush_stream() { + let mut arena = CsgTreeArena::new(); + let solid = arena.generate_brush( + "solid", + CsgOperationType::Additive, + Primitive::Box { + bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + }, + MaterialId(1), + ); + let void = arena.generate_brush( + "void", + CsgOperationType::Additive, + Primitive::Box { + bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)), + }, + MaterialId(0), + ); + let branch = arena.generate_branch( + "solid minus void", + CsgBranchOp::Subtraction, + [solid.node, void.node], + ); + let assembler = arena.compile_tree_to_assembler(arena.generate_tree(branch.node)); + + assert_eq!(assembler.brushes().len(), 2); + assert_eq!(assembler.brushes()[0].op, BrushOp::Add); + assert_eq!(assembler.brushes()[1].op, BrushOp::Subtract); + assert_eq!(assembler.build().report.emitted_convex_fragments, 6); + } + + #[test] + fn tree_edit_api_replaces_children_and_operations() { + let mut arena = CsgTreeArena::new(); + let a = arena.generate_brush( + "a", + CsgOperationType::Additive, + Primitive::Box { + bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)), + }, + MaterialId(1), + ); + let b = arena.generate_brush( + "b", + CsgOperationType::Additive, + Primitive::Box { + bounds: Aabb::from_center_size(Vec3::X, Vec3::splat(1.0)), + }, + MaterialId(1), + ); + let branch = arena.generate_branch("branch", CsgBranchOp::Addition, [a.node]); + + assert!(arena.set_child_nodes(branch, [a.node, b.node])); + assert_eq!(arena.child_nodes(branch).expect("branch").len(), 2); + assert!(arena.set_branch_operation_type(branch, CsgBranchOp::Subtraction)); + assert!(arena.set_brush_operation_type(b, CsgOperationType::Subtractive)); + assert_eq!(arena.brush_count(), 2); + assert_eq!(arena.branch_count(), 1); + } + + #[test] + fn tree_bounds_follow_boolean_branch_shape() { + let mut arena = CsgTreeArena::new(); + let a = arena.generate_brush( + "a", + CsgOperationType::Additive, + Primitive::Box { + bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + }, + MaterialId(1), + ); + let b = arena.generate_brush( + "b", + CsgOperationType::Additive, + Primitive::Box { + bounds: Aabb::from_center_size(Vec3::X * 2.5, Vec3::splat(2.0)), + }, + MaterialId(1), + ); + let union = arena.generate_branch("union", CsgBranchOp::Addition, [a.node, b.node]); + let common = arena.generate_branch("common", CsgBranchOp::Common, [a.node, b.node]); + let subtract = + arena.generate_branch("subtract", CsgBranchOp::Subtraction, [a.node, b.node]); + + assert_eq!( + arena.node_bounds(union.node).expect("union"), + Aabb::new(Vec3::splat(-2.0), Vec3::new(3.5, 2.0, 2.0)) + ); + assert_eq!( + arena.node_bounds(common.node).expect("common"), + Aabb::new(Vec3::new(1.5, -1.0, -1.0), Vec3::new(2.0, 1.0, 1.0)) + ); + assert_eq!( + arena.node_bounds(subtract.node).expect("subtract"), + Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)) + ); + } + + #[test] + fn tree_common_compiles_to_additive_source_then_intersections() { + let mut arena = CsgTreeArena::new(); + let a = arena.generate_brush( + "a", + CsgOperationType::Additive, + Primitive::Box { + bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), + }, + MaterialId(1), + ); + let b = arena.generate_brush( + "b", + CsgOperationType::Additive, + Primitive::Box { + bounds: Aabb::from_center_size(Vec3::X, Vec3::splat(4.0)), + }, + MaterialId(1), + ); + let branch = arena.generate_branch("common", CsgBranchOp::Common, [a.node, b.node]); + + let assembler = arena.compile_tree_to_assembler(arena.generate_tree(branch.node)); + let output = assembler.build(); + + assert_eq!(assembler.brushes()[0].op, BrushOp::Add); + assert_eq!(assembler.brushes()[1].op, BrushOp::Intersect); + assert_eq!(output.report.emitted_convex_fragments, 1); + assert_eq!(output.report.warnings.len(), 0); + assert_eq!(output.mesh.triangle_count(), 12); + } +} From 9758940b35827b0ff0f1a93964390e22a71c26e7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 04:01:20 +0100 Subject: [PATCH 14/30] Integrate geometry worker through CultMesh --- ...th-gamecult-geometry-migration-progress.md | 39 +++- .../CultGeometryWorkerDocuments.cs | 87 +++++++++ .../CultGeometryWorkerProvider.cs | 124 ++++++++++++ .../GameCult.Geometry.csproj | 2 + .../CultGeometryWorkerProviderTests.cs | 182 ++++++++++++++++++ 5 files changed, 424 insertions(+), 10 deletions(-) create mode 100644 src/GameCult.Geometry/CultGeometryWorkerDocuments.cs create mode 100644 src/GameCult.Geometry/CultGeometryWorkerProvider.cs create mode 100644 tests/GameCult.Geometry.Tests/CultGeometryWorkerProviderTests.cs diff --git a/docs/cultmath-gamecult-geometry-migration-progress.md b/docs/cultmath-gamecult-geometry-migration-progress.md index ace7653e..79dce55e 100644 --- a/docs/cultmath-gamecult-geometry-migration-progress.md +++ b/docs/cultmath-gamecult-geometry-migration-progress.md @@ -4,7 +4,7 @@ Plan: `docs/cultmath-gamecult-geometry-migration-plan.md` (locked) Started: 2026-07-22 -Status: Stages 0-4 complete; Stage 5 in progress +Status: Stages 0-5 complete; Stage 6 in progress ## Objective @@ -59,8 +59,8 @@ Demotions: | 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 | In progress | Provider/dataflow mapped; typed commit-path smoke remains | -| 6. Consumer cutover/release | Pending | Requires local candidate feed and downstream passes | +| 5. CultCache/CultNet/CultMesh integration | Complete | One provider commit path persists, watches, replicates, and probes typed geometry state | +| 6. Consumer cutover/release | In progress | Fensalir/Aetheria exact cutover surfaces mapped; consumer edits and release receipts remain | | 7. Delete obsolete authorities/audit | Pending | Requires every prior stage gate | ## Verification Receipts @@ -244,13 +244,32 @@ a broader stage gate. 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 - recorded after this progress update is committed. + 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. ## Next Actions -1. Add the Geometry-owned worker provider with `CultNetDatabase.PutAsync` as - the single commit/publication primitive for direct and operation-handle use. -2. Prove domain/request to selected-cut/artifact persistence, raw envelope - replication, worker-state observation, and typed dev-probe agreement. -3. Add exact C#/Rust raw-envelope parity without growing a second networking - stack or a load-bearing JSON projection. +1. Cut Fensalir's C# and HLSL planetary surfaces atomically to + GameCult.Geometry, including shader resolver and parity harnesses. +2. Replace Aetheria Freeze's remaining `CultVec*` fixtures with CultMath values + under an exact serialization witness, then regenerate Unity project files. +3. Rebuild coordinated CultMath and Geometry candidates and run the named + downstream parity corpus before publication. diff --git a/src/GameCult.Geometry/CultGeometryWorkerDocuments.cs b/src/GameCult.Geometry/CultGeometryWorkerDocuments.cs new file mode 100644 index 00000000..280f7c4f --- /dev/null +++ b/src/GameCult.Geometry/CultGeometryWorkerDocuments.cs @@ -0,0 +1,87 @@ +using System; +using GameCult.Caching; +using MessagePack; + +namespace GameCult.Geometry +{ + public static class CultGeometryWorkerSchemaVersions + { + public const string State = "gamecult.geometry.worker_state.v1"; + } + + [MessagePackObject] + [CultDocument("gamecult.geometry.worker_state", CultGeometryWorkerSchemaVersions.State)] + public sealed class CultGeometryWorkerState + { + [Key(0), CultName] + public string WorkerId { get; set; } = string.Empty; + + [Key(1)] + public string Phase { get; set; } = string.Empty; + + [Key(2), CultReference(typeof(CultGeometryBuildRequest))] + public string ActiveRequestKey { get; set; } = string.Empty; + + [Key(3), CultReference(typeof(CultGeometrySelectedCutManifest))] + public string LastSelectedCutKey { get; set; } = string.Empty; + + [Key(4)] + public string[] LastArtifactKeys { get; set; } = Array.Empty(); + + [Key(5)] + public string LastError { get; set; } = string.Empty; + + [Key(6)] + public string UpdatedAt { get; set; } = string.Empty; + + [Key(7)] + public string ServedPackageVersion { get; set; } = string.Empty; + + public static CultRecordKey CreateRecordKey(string workerId) + { + if (string.IsNullOrWhiteSpace(workerId)) throw new ArgumentException("Value must be non-empty.", nameof(workerId)); + return new CultRecordKey("geometry:worker:" + workerId); + } + } + + [MessagePackObject] + public sealed class CultGeometryBuildCommand + { + [Key(0)] + public string RequestKey { get; set; } = string.Empty; + } + + [MessagePackObject] + public sealed class CultGeometryBuildReceipt + { + [Key(0)] + public string RequestKey { get; set; } = string.Empty; + + [Key(1)] + public string SelectedCutKey { get; set; } = string.Empty; + + [Key(2)] + public string[] ArtifactKeys { get; set; } = Array.Empty(); + + [Key(3)] + public string[] ContentHashes { get; set; } = Array.Empty(); + } + + public sealed class CultGeometryBuildOutput + { + public CultGeometrySelectedCutManifest SelectedCut { get; init; } = new(); + public CultGeometryChunkArtifact[] Artifacts { get; init; } = Array.Empty(); + } + + public sealed class CultGeometryDevelopmentProbe + { + public string Owner { get; init; } = string.Empty; + public string SchemaVersion { get; init; } = string.Empty; + public string SourceRecordKey { get; init; } = string.Empty; + public string SelectedCutKey { get; init; } = string.Empty; + public string[] SelectedNodes { get; init; } = Array.Empty(); + public string[] ArtifactKeys { get; init; } = Array.Empty(); + public string[] ContentHashes { get; init; } = Array.Empty(); + public string ServedPackageVersion { get; init; } = string.Empty; + } +} diff --git a/src/GameCult.Geometry/CultGeometryWorkerProvider.cs b/src/GameCult.Geometry/CultGeometryWorkerProvider.cs new file mode 100644 index 00000000..745da5a1 --- /dev/null +++ b/src/GameCult.Geometry/CultGeometryWorkerProvider.cs @@ -0,0 +1,124 @@ +using System; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using GameCult.Caching; +using GameCult.Mesh; +using GameCult.Networking; + +namespace GameCult.Geometry +{ + public interface ICultGeometryBuildPipeline + { + Task BuildAsync( + CultGeometryDomainDocument domain, + CultGeometryBuildRequest request); + } + + /// Owns geometry worker commands and the authoritative output commit path. + public sealed class CultGeometryWorkerProvider + { + public const string Owner = "GameCult.Geometry"; + public const string BuildOperationId = "gamecult.geometry.worker.build"; + + private readonly string _workerId; + private readonly CultNetDatabase _database; + private readonly ICultGeometryBuildPipeline _pipeline; + + public CultGeometryWorkerProvider( + string workerId, + CultNetDatabase database, + ICultGeometryBuildPipeline pipeline) + { + _workerId = string.IsNullOrWhiteSpace(workerId) + ? throw new ArgumentException("Value must be non-empty.", nameof(workerId)) + : workerId; + _database = database ?? throw new ArgumentNullException(nameof(database)); + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); + BuildOperation = new CultMeshOperationHandle( + BuildOperationId, + (command, _) => BuildAsync(command)); + } + + public CultMeshOperationHandle BuildOperation { get; } + + public CultRecordKey WorkerStateKey => CultGeometryWorkerState.CreateRecordKey(_workerId); + + public async Task BuildAsync(CultGeometryBuildCommand command) + { + if (command == null) throw new ArgumentNullException(nameof(command)); + if (string.IsNullOrWhiteSpace(command.RequestKey)) + throw new ArgumentException("Request key must be non-empty.", nameof(command)); + + var requestKey = new CultRecordKey(command.RequestKey); + var request = await RequireAsync(requestKey).ConfigureAwait(false); + var domain = await RequireAsync(new CultRecordKey(request.DomainKey)).ConfigureAwait(false); + var output = await _pipeline.BuildAsync(domain, request).ConfigureAwait(false) + ?? throw new InvalidOperationException("Geometry build pipeline returned no output."); + + output.SelectedCut.RequestKey = requestKey.Value; + var cutKey = CultGeometrySelectedCutManifest.CreateRecordKey(output.SelectedCut); + await _database.PutAsync(cutKey, output.SelectedCut).ConfigureAwait(false); + + var artifactKeys = new string[output.Artifacts.Length]; + for (var index = 0; index < output.Artifacts.Length; index++) + { + var artifact = output.Artifacts[index]; + artifact.CutKey = cutKey.Value; + artifact.SelectedCutId = output.SelectedCut.CutId; + var artifactKey = CultGeometryChunkArtifact.CreateRecordKey(artifact); + await _database.PutAsync(artifactKey, artifact).ConfigureAwait(false); + artifactKeys[index] = artifactKey.Value; + } + + var receipt = new CultGeometryBuildReceipt + { + RequestKey = requestKey.Value, + SelectedCutKey = cutKey.Value, + ArtifactKeys = artifactKeys, + ContentHashes = artifactKeys.Select(ContentHashFromKey).ToArray() + }; + await _database.PutAsync(WorkerStateKey, new CultGeometryWorkerState + { + WorkerId = _workerId, + Phase = "completed", + ActiveRequestKey = requestKey.Value, + LastSelectedCutKey = cutKey.Value, + LastArtifactKeys = artifactKeys, + UpdatedAt = DateTimeOffset.UtcNow.ToString("O"), + ServedPackageVersion = PackageVersion + }).ConfigureAwait(false); + return receipt; + } + + public async Task ProbeAsync() + { + var state = await RequireAsync(WorkerStateKey).ConfigureAwait(false); + var cut = await RequireAsync(new CultRecordKey(state.LastSelectedCutKey)).ConfigureAwait(false); + return new CultGeometryDevelopmentProbe + { + Owner = Owner, + SchemaVersion = CultGeometrySchemaVersions.SelectedCut, + SourceRecordKey = state.ActiveRequestKey, + SelectedCutKey = state.LastSelectedCutKey, + SelectedNodes = cut.SelectedNodes, + ArtifactKeys = state.LastArtifactKeys, + ContentHashes = state.LastArtifactKeys.Select(ContentHashFromKey).ToArray(), + ServedPackageVersion = PackageVersion + }; + } + + private async Task RequireAsync(CultRecordKey key) where T : class => + await _database.GetAsync(key).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Required geometry document '{key.Value}' was not found."); + + private static string ContentHashFromKey(string key) => + key[(key.LastIndexOf(':') + 1)..]; + + private static string PackageVersion => + typeof(CultGeometryWorkerProvider).Assembly + .GetCustomAttribute()?.InformationalVersion + ?? typeof(CultGeometryWorkerProvider).Assembly.GetName().Version?.ToString() + ?? "unknown"; + } +} diff --git a/src/GameCult.Geometry/GameCult.Geometry.csproj b/src/GameCult.Geometry/GameCult.Geometry.csproj index 32d72d0a..5f45e89b 100644 --- a/src/GameCult.Geometry/GameCult.Geometry.csproj +++ b/src/GameCult.Geometry/GameCult.Geometry.csproj @@ -13,6 +13,8 @@ + + diff --git a/tests/GameCult.Geometry.Tests/CultGeometryWorkerProviderTests.cs b/tests/GameCult.Geometry.Tests/CultGeometryWorkerProviderTests.cs new file mode 100644 index 00000000..0941f0f4 --- /dev/null +++ b/tests/GameCult.Geometry.Tests/CultGeometryWorkerProviderTests.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using CultMath; +using FluentAssertions; +using GameCult.Caching; +using GameCult.Mesh; +using GameCult.Networking; +using NUnit.Framework; +using R3; + +namespace GameCult.Geometry.Tests +{ + public sealed class CultGeometryWorkerProviderTests + { + [Test] + public async Task BuildOperation_UsesTheDirectCommitPath_AndPublishesTypedState() + { + using var database = CreateDatabase("geometry-worker"); + var (domain, request) = await SeedAsync(database); + var provider = new CultGeometryWorkerProvider("worker-1", database, new FakePipeline()); + var artifactChanges = new List>(); + using var subscription = database.Watch() + .Subscribe(change => artifactChanges.Add(change)); + + var receipt = await provider.BuildOperation.InvokeAsync( + new CultGeometryBuildCommand { RequestKey = CultGeometryBuildRequest.CreateRecordKey(request).Value }, + "test-runtime"); + + receipt.RequestKey.Should().Be(CultGeometryBuildRequest.CreateRecordKey(request).Value); + receipt.ArtifactKeys.Should().ContainSingle(); + receipt.ContentHashes.Should().ContainSingle(); + artifactChanges.Should().ContainSingle(change => change.Key.Value == receipt.ArtifactKeys[0]); + (await database.GetAsync(new CultRecordKey(receipt.SelectedCutKey))) + .Should().NotBeNull(); + (await database.GetAsync(new CultRecordKey(receipt.ArtifactKeys[0]))) + .Should().NotBeNull(); + + var state = await database.GetAsync(provider.WorkerStateKey); + state.Should().NotBeNull(); + state!.ActiveRequestKey.Should().Be(receipt.RequestKey); + state.LastSelectedCutKey.Should().Be(receipt.SelectedCutKey); + domain.DomainId.Should().Be("domain-1"); + } + + [Test] + public async Task DirectAndOperationBuilds_DeriveTheSameCanonicalOutputKeys() + { + using var database = CreateDatabase("geometry-direct-operation"); + var (_, request) = await SeedAsync(database); + var provider = new CultGeometryWorkerProvider("worker-1", database, new FakePipeline()); + var command = new CultGeometryBuildCommand + { + RequestKey = CultGeometryBuildRequest.CreateRecordKey(request).Value + }; + + var direct = await provider.BuildAsync(command); + var operation = await provider.BuildOperation.InvokeAsync(command, "test-runtime"); + + operation.SelectedCutKey.Should().Be(direct.SelectedCutKey); + operation.ArtifactKeys.Should().Equal(direct.ArtifactKeys); + operation.ContentHashes.Should().Equal(direct.ContentHashes); + } + + [Test] + public async Task Artifact_RawEnvelopeReplicates_AndWorkerStateCannotOverrideProbeTruth() + { + using var source = CreateDatabase("geometry-source"); + var (_, request) = await SeedAsync(source); + var provider = new CultGeometryWorkerProvider("worker-1", source, new FakePipeline()); + var receipt = await provider.BuildAsync(new CultGeometryBuildCommand + { + RequestKey = CultGeometryBuildRequest.CreateRecordKey(request).Value + }); + var artifactKey = new CultRecordKey(receipt.ArtifactKeys[0]); + var artifact = await source.GetAsync(artifactKey); + artifact.Should().NotBeNull(); + + var put = source.Documents.CreateRawDocumentPutMessage( + "geometry-artifact", + new CultRecordHandle(artifactKey), + artifact!, + new CultNetDocumentMessageOptions { SourceRuntimeId = "geometry-source", SourceRole = "geometry-worker" }); + put.Document.PayloadEncoding.Should().Be("messagepack"); + + var targetCache = new CultCache(); + var replicated = await source.Documents.ApplyRawDocumentPutMessageAsync(targetCache, put); + CultGeometryChunkArtifact.CreateRecordKey(replicated).Should().Be(artifactKey); + + await source.PutAsync(provider.WorkerStateKey, new CultGeometryWorkerState + { + WorkerId = "worker-1", + Phase = "lying-status", + ActiveRequestKey = receipt.RequestKey, + LastSelectedCutKey = receipt.SelectedCutKey, + LastArtifactKeys = receipt.ArtifactKeys, + ServedPackageVersion = "false-version" + }); + var probe = await provider.ProbeAsync(); + + probe.Owner.Should().Be(CultGeometryWorkerProvider.Owner); + probe.SchemaVersion.Should().Be(CultGeometrySchemaVersions.SelectedCut); + probe.SelectedCutKey.Should().Be(receipt.SelectedCutKey); + probe.ContentHashes.Should().Equal(receipt.ContentHashes); + probe.ServedPackageVersion.Should().NotBe("false-version"); + (await source.GetAsync(artifactKey))!.ChunkId.Should().Be("chunk-1"); + } + + [Test] + public void WorkerState_RegistersThroughTheGeneratedDocumentRegistry() + { + var descriptor = new CultCache().Registry.GetRequired(); + descriptor.SchemaVersion.Should().Be(CultGeometryWorkerSchemaVersions.State); + descriptor.SchemaName.Should().Be("gamecult.geometry.worker_state"); + } + + private static CultNetDatabase CreateDatabase(string runtimeId) + { + var cache = new CultCache(); + return new CultNetDatabase(cache, new CultNetDatabaseOptions + { + RuntimeId = runtimeId, + DocumentRegistry = new CultNetDocumentRegistry(cache.Registry) + }); + } + + private static async Task<(CultGeometryDomainDocument Domain, CultGeometryBuildRequest Request)> SeedAsync( + CultNetDatabase database) + { + var domain = new CultGeometryDomainDocument + { + DomainId = "domain-1", + RootKey = "root", + SourceRuntime = "test", + Root = new CultGeometryDomainNode { Name = "root", Kind = "root" } + }; + var domainKey = CultGeometryDomainDocument.CreateRecordKey(domain); + var request = new CultGeometryBuildRequest + { + RequestId = "request-1", + DomainKey = domainKey.Value, + WorkerGroup = "test", + ViewportHeightPixels = 1080, + VerticalFovRadians = 1, + TargetError = 0.01f, + TriangleBudget = 100, + ColliderBudget = 100 + }; + await database.PutAsync(domainKey, domain); + await database.PutAsync(CultGeometryBuildRequest.CreateRecordKey(request), request); + return (domain, request); + } + + private sealed class FakePipeline : ICultGeometryBuildPipeline + { + public Task BuildAsync( + CultGeometryDomainDocument domain, + CultGeometryBuildRequest request) + { + return Task.FromResult(new CultGeometryBuildOutput + { + SelectedCut = new CultGeometrySelectedCutManifest + { + CutId = "cut-1", + SelectedNodes = [domain.Root.Name] + }, + Artifacts = + [ + new CultGeometryChunkArtifact + { + ChunkId = "chunk-1", + BoundsMin = float3.zero, + BoundsMax = new float3(1, 1, 1), + SourceDomainKeys = [request.DomainKey], + RenderMesh = new CultGeometryTriangleMesh() + } + ] + }); + } + } + } +} From cdeea4745487c11334c4f9e964490de4e7cd2a30 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 04:07:20 +0100 Subject: [PATCH 15/30] Use compiler-portable geometry shader includes --- src/GameCult.Geometry/Shaders/GameCult.Geometry.hlsl | 8 ++++---- .../GeometryShaderOwnershipTests.cs | 6 +++--- .../Runtime/GameCultGeometryPlanetaryViewer.shader | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/GameCult.Geometry/Shaders/GameCult.Geometry.hlsl b/src/GameCult.Geometry/Shaders/GameCult.Geometry.hlsl index fb159077..00623b75 100644 --- a/src/GameCult.Geometry/Shaders/GameCult.Geometry.hlsl +++ b/src/GameCult.Geometry/Shaders/GameCult.Geometry.hlsl @@ -1,11 +1,11 @@ #ifndef GAMECULT_GEOMETRY_HLSL #define GAMECULT_GEOMETRY_HLSL -#ifndef GAMECULT_GEOMETRY_CULTMATH_INCLUDE -#define GAMECULT_GEOMETRY_CULTMATH_INCLUDE "CultMath.hlsl" +#if defined(GAMECULT_GEOMETRY_UNITY_PACKAGE) +#include "Packages/org.gamecult.cultmath/shaders/CultMath.hlsl" +#else +#include "CultMath/CultMath.hlsl" #endif - -#include GAMECULT_GEOMETRY_CULTMATH_INCLUDE #include "AdvancedErosionFilter.hlsl" #include "Planetary.hlsl" #include "SphericalErosion.hlsl" diff --git a/tests/GameCult.Geometry.Tests/GeometryShaderOwnershipTests.cs b/tests/GameCult.Geometry.Tests/GeometryShaderOwnershipTests.cs index ba5bcf64..e3650f85 100644 --- a/tests/GameCult.Geometry.Tests/GeometryShaderOwnershipTests.cs +++ b/tests/GameCult.Geometry.Tests/GeometryShaderOwnershipTests.cs @@ -16,8 +16,8 @@ public sealed class GeometryShaderOwnershipTests var spherical = File.ReadAllText(Path.Combine(shaders, "SphericalErosion.hlsl")); var refinement = File.ReadAllText(Path.Combine(shaders, "PlanetaryRadialRefinement.hlsl")); - Assert.That(umbrella, Does.Contain("#define GAMECULT_GEOMETRY_CULTMATH_INCLUDE \"CultMath.hlsl\"") - .And.Contain("#include GAMECULT_GEOMETRY_CULTMATH_INCLUDE") + 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\"") @@ -54,7 +54,7 @@ public sealed class GeometryShaderOwnershipTests "GameCultGeometryPlanetaryViewer.shader")); Assert.That(shader, Does.Contain( - "#define GAMECULT_GEOMETRY_CULTMATH_INCLUDE \"Packages/org.gamecult.cultmath/shaders/CultMath.hlsl\"") + "#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_") diff --git a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.shader b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.shader index 850e1dd5..9b4981c0 100644 --- a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.shader +++ b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.shader @@ -18,7 +18,7 @@ Shader "GameCult.Geometry/Planetary Field Viewer" #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" - #define GAMECULT_GEOMETRY_CULTMATH_INCLUDE "Packages/org.gamecult.cultmath/shaders/CultMath.hlsl" + #define GAMECULT_GEOMETRY_UNITY_PACKAGE 1 #include "Packages/org.gamecult.geometry/Shaders/GameCult.Geometry.hlsl" float _Radius; From 381d23aceec1c588e103ae170dfc26797046ce9a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 04:08:32 +0100 Subject: [PATCH 16/30] Add coordinated Geometry candidate build --- scripts/build-geometry-candidate.ps1 | 31 ++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 scripts/build-geometry-candidate.ps1 diff --git a/scripts/build-geometry-candidate.ps1 b/scripts/build-geometry-candidate.ps1 new file mode 100644 index 00000000..0edffcf7 --- /dev/null +++ b/scripts/build-geometry-candidate.ps1 @@ -0,0 +1,31 @@ +param( + [string] $Version = "0.1.0-geometry-migration.2", + [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 +$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=$Version -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: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:PackageVersion=$Version -p:CultMathCandidateFeed=$feed -p:UseSharedCompilation=false -m:1 +if ($LASTEXITCODE -ne 0) { throw "Geometry candidate pack failed." } +$package = Join-Path $output "GameCult.Geometry.$Version.nupkg" +if (-not (Test-Path -LiteralPath $package)) { throw "Geometry candidate package was not produced." } +Write-Host $package From 8a7eee717e5783190bf17337d3560defa4b28fa7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 04:13:43 +0100 Subject: [PATCH 17/30] Document generated geometry registry ownership --- src/GameCult.Geometry/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/GameCult.Geometry/README.md b/src/GameCult.Geometry/README.md index 3c1f75bd..42c681cb 100644 --- a/src/GameCult.Geometry/README.md +++ b/src/GameCult.Geometry/README.md @@ -44,11 +44,7 @@ using GameCult.Geometry; using GameCult.Mesh; using GameCult.Networking; -var geometryDocuments = new CultNetDocumentRegistry(CultDocumentRegistry.Shared) - .Register(CultNetDocumentBinding.ForDocument()) - .Register(CultNetDocumentBinding.ForDocument()) - .Register(CultNetDocumentBinding.ForDocument()) - .Register(CultNetDocumentBinding.ForDocument()); +var geometryDocuments = new CultNetDocumentRegistry(CultDocumentRegistry.Shared); using var node = await CultMesh.CreateNodeAsync("ragnarok-geometry.ccmp", new CultMeshNodeOptions { @@ -64,6 +60,10 @@ 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 exact IEEE-754 component bits. If this drifts, the same chunk becomes two From b9f20a640ea426f4aac282f8930a2efdb9e855a6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 04:14:10 +0100 Subject: [PATCH 18/30] Update Geometry repository scope --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d6e3778c..80dc90a5 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ The solution includes: - `GameCult.Caching.NewtonsoftJson`: Newtonsoft.Json-backed persistence for the cache - `GameCult.Caching.MessagePack.Generator`: source generator for MessagePack formatters for cache models - `GameCult.Caching.MessagePack.Analyzers`: packaging project that delivers the generator to consuming projects -- `GameCult.Geometry`: CultCache-native geometry domain, selected-cut, and chunk artifact documents for VibeGeometry/Fensalir-style pipelines +- `GameCult.Geometry`: geometry, planetary mapping, CSG, LOD selection, mesh assembly, and CultCache-native geometry documents built on CultMath primitives - `GameCult.Networking`: encrypted login/register/verify flows, schema-v0 contracts, transport adapters, and native RUDP sessions - `GameCult.Mesh`: CultMesh package home for distributed realtime database, shard replication, client prediction, Verse discovery, and mesh witness consensus - `GameCult.Caching.Tests`: NUnit tests for cache and backing-store behavior From 37e70a346c2c90fb0155027d4213a124f9df83de Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 04:16:16 +0100 Subject: [PATCH 19/30] Record consumer cutover and authority audit --- ...th-gamecult-geometry-migration-progress.md | 63 ++++++++++++++++--- 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/docs/cultmath-gamecult-geometry-migration-progress.md b/docs/cultmath-gamecult-geometry-migration-progress.md index 79dce55e..74a473c5 100644 --- a/docs/cultmath-gamecult-geometry-migration-progress.md +++ b/docs/cultmath-gamecult-geometry-migration-progress.md @@ -4,7 +4,8 @@ Plan: `docs/cultmath-gamecult-geometry-migration-plan.md` (locked) Started: 2026-07-22 -Status: Stages 0-5 complete; Stage 6 in progress +Status: Stages 0-5 complete; Stage 6 consumer cutover implemented with two +external verification/publication gates; Stage 7 source-authority cut complete ## Objective @@ -60,8 +61,8 @@ Demotions: | 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 | In progress | Fensalir/Aetheria exact cutover surfaces mapped; consumer edits and release receipts remain | -| 7. Delete obsolete authorities/audit | Pending | Requires every prior stage gate | +| 6. Consumer cutover/release | Gate pending | Fensalir green; Aetheria source cut pushed but full build/Unity regeneration await external dependency/editor state and immutable UPM tag | +| 7. Delete obsolete authorities/audit | Complete | VibeGeometry workspace authority archived; tracked negative audit is clean apart from retained provenance/witnesses | ## Verification Receipts @@ -265,11 +266,55 @@ a broader stage gate. 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 cutover commits `26948f81` and `557bc71f` replace `CultVec*` fixtures + with CultMath vectors under an exact legacy MessagePack byte witness, move + spatial rectangles to `GameCult.Geometry.CultRect`, add the real .NET/Unity + Geometry dependencies, and remove stale ownership guidance. +- Aetheria's geometry compiler errors are gone. Its full build is presently + blocked by concurrent CultMesh API drift (`CultMeshBody*` and persisted-record + types), outside this migration. Unity regeneration is also deferred while an + active Unity editor owns the project. The coordinated local file dependency + must become an immutable Geometry Unity tag before publication. +- 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. + +### 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. + ## Next Actions -1. Cut Fensalir's C# and HLSL planetary surfaces atomically to - GameCult.Geometry, including shader resolver and parity harnesses. -2. Replace Aetheria Freeze's remaining `CultVec*` fixtures with CultMath values - under an exact serialization witness, then regenerate Unity project files. -3. Rebuild coordinated CultMath and Geometry candidates and run the named - downstream parity corpus before publication. +1. Resolve Aetheria's concurrent CultMesh API/worktree drift, then rerun its + State/Freeze verifier and daemon smoke against this migration branch. +2. Close the active Unity editor/import workers, regenerate Aetheria Unity + project/lock files through Unity, and run the canonical EditMode suite. +3. Publish an immutable `org.gamecult.geometry` Unity package tag in the + coordinated release window, replace Aetheria's local candidate reference, + and repeat package/version inspection before declaring Stage 6 complete. From 77fe95c7b9cc9e295a1cc081d25594d9ff8cb46a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 04:25:04 +0100 Subject: [PATCH 20/30] Make Geometry Git UPM package self-contained --- scripts/build-geometry-unity-package.ps1 | 67 ++- unity/org.gamecult.geometry/LICENSES.md | 13 + unity/org.gamecult.geometry/Runtime.meta | 8 + .../GameCult.Geometry.Unity.asmdef.meta | 7 + .../Runtime/GameCult.Geometry.asmdef.meta | 7 + .../Runtime/Plugins.meta | 8 + .../Runtime/Plugins/GameCult.Geometry.dll | Bin 0 -> 144384 bytes .../Plugins/GameCult.Geometry.dll.meta | 21 + .../Runtime/Plugins/GameCult.Geometry.pdb | Bin 0 -> 62124 bytes .../Plugins/GameCult.Geometry.pdb.meta | 7 + unity/org.gamecult.geometry/Samples~.meta | 8 + .../Samples~/PlanetaryViewer.meta | 8 + .../Samples~/PlanetaryViewer/Editor.meta | 8 + ...eometry.PlanetaryViewer.Editor.asmdef.meta | 7 + ...ameCultGeometryPlanetaryViewerMenu.cs.meta | 11 + .../Samples~/PlanetaryViewer/Runtime.meta | 8 + ...eCult.Geometry.PlanetaryViewer.asmdef.meta | 7 + .../GameCultGeometryPlanetaryViewer.cs.meta | 11 + ...ameCultGeometryPlanetaryViewer.shader.meta | 9 + unity/org.gamecult.geometry/Shaders.meta | 8 + .../Shaders/AdvancedErosionFilter.hlsl | 147 +++++++ .../Shaders/AdvancedErosionFilter.hlsl.meta | 7 + .../Shaders/GameCult.Geometry.hlsl | 14 + .../Shaders/GameCult.Geometry.hlsl.meta | 7 + .../Shaders/Planetary.hlsl | 392 ++++++++++++++++++ .../Shaders/Planetary.hlsl.meta | 7 + .../Shaders/PlanetaryRadialRefinement.hlsl | 27 ++ .../PlanetaryRadialRefinement.hlsl.meta | 7 + .../Shaders/SphericalErosion.hlsl | 80 ++++ .../Shaders/SphericalErosion.hlsl.meta | 7 + .../THIRD-PARTY-NOTICES.md | 24 ++ 31 files changed, 922 insertions(+), 20 deletions(-) create mode 100644 unity/org.gamecult.geometry/LICENSES.md create mode 100644 unity/org.gamecult.geometry/Runtime.meta create mode 100644 unity/org.gamecult.geometry/Runtime/GameCult.Geometry.Unity.asmdef.meta create mode 100644 unity/org.gamecult.geometry/Runtime/GameCult.Geometry.asmdef.meta create mode 100644 unity/org.gamecult.geometry/Runtime/Plugins.meta create mode 100644 unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.dll create mode 100644 unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.dll.meta create mode 100644 unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.pdb create mode 100644 unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.pdb.meta create mode 100644 unity/org.gamecult.geometry/Samples~.meta create mode 100644 unity/org.gamecult.geometry/Samples~/PlanetaryViewer.meta create mode 100644 unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor.meta create mode 100644 unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCult.Geometry.PlanetaryViewer.Editor.asmdef.meta create mode 100644 unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCultGeometryPlanetaryViewerMenu.cs.meta create mode 100644 unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime.meta create mode 100644 unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCult.Geometry.PlanetaryViewer.asmdef.meta create mode 100644 unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.cs.meta create mode 100644 unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.shader.meta create mode 100644 unity/org.gamecult.geometry/Shaders.meta create mode 100644 unity/org.gamecult.geometry/Shaders/AdvancedErosionFilter.hlsl create mode 100644 unity/org.gamecult.geometry/Shaders/AdvancedErosionFilter.hlsl.meta create mode 100644 unity/org.gamecult.geometry/Shaders/GameCult.Geometry.hlsl create mode 100644 unity/org.gamecult.geometry/Shaders/GameCult.Geometry.hlsl.meta create mode 100644 unity/org.gamecult.geometry/Shaders/Planetary.hlsl create mode 100644 unity/org.gamecult.geometry/Shaders/Planetary.hlsl.meta create mode 100644 unity/org.gamecult.geometry/Shaders/PlanetaryRadialRefinement.hlsl create mode 100644 unity/org.gamecult.geometry/Shaders/PlanetaryRadialRefinement.hlsl.meta create mode 100644 unity/org.gamecult.geometry/Shaders/SphericalErosion.hlsl create mode 100644 unity/org.gamecult.geometry/Shaders/SphericalErosion.hlsl.meta create mode 100644 unity/org.gamecult.geometry/THIRD-PARTY-NOTICES.md diff --git a/scripts/build-geometry-unity-package.ps1 b/scripts/build-geometry-unity-package.ps1 index 8c1b8b1f..a16370eb 100644 --- a/scripts/build-geometry-unity-package.ps1 +++ b/scripts/build-geometry-unity-package.ps1 @@ -21,6 +21,7 @@ $candidateFeed = if ([System.IO.Path]::IsPathRooted($CultMathCandidateFeed)) { 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.0.1.0-geometry-migration.2.nupkg"))) { throw "CultMath migration candidate is missing from $candidateFeed. Pack CultMath before staging Geometry." @@ -43,33 +44,59 @@ if ($LASTEXITCODE -ne 0) { throw "GameCult.Geometry 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 -Copy-Item -LiteralPath (Join-Path $templateRoot "Runtime\GameCult.Geometry.asmdef") -Destination (Join-Path $outputRoot "Runtime") -Copy-Item -LiteralPath (Join-Path $templateRoot "Runtime\GameCult.Geometry.Unity.asmdef") -Destination (Join-Path $outputRoot "Runtime") -Copy-Item -LiteralPath (Join-Path $templateRoot "Runtime\PlanetaryPatchMeshAdapter.cs") -Destination (Join-Path $outputRoot "Runtime") -Copy-Item -LiteralPath (Join-Path $templateRoot "Runtime\PlanetaryPatchMeshAdapter.cs.meta") -Destination (Join-Path $outputRoot "Runtime") -Copy-Item -LiteralPath (Join-Path $templateRoot "Runtime\PlanetaryPageUpload.cs") -Destination (Join-Path $outputRoot "Runtime") -Copy-Item -LiteralPath (Join-Path $templateRoot "Runtime\PlanetaryPageUpload.cs.meta") -Destination (Join-Path $outputRoot "Runtime") -Copy-Item -LiteralPath (Join-Path $templateRoot "Samples~") -Destination $outputRoot -Recurse -$shaderOutputRoot = Join-Path $outputRoot "Shaders" -New-Item -ItemType Directory -Force -Path $shaderOutputRoot | Out-Null -Copy-Item -Path (Join-Path $repoRoot "src\GameCult.Geometry\Shaders\*.hlsl") -Destination $shaderOutputRoot -Copy-Item -Path (Join-Path $repoRoot "src\GameCult.Geometry\Shaders\*.hlsl.meta") -Destination $shaderOutputRoot -ErrorAction SilentlyContinue -Copy-Item -LiteralPath (Join-Path $repoRoot "src\GameCult.Geometry\LICENSES.md") -Destination $outputRoot -Copy-Item -LiteralPath (Join-Path $repoRoot "src\GameCult.Geometry\THIRD-PARTY-NOTICES.md") -Destination $outputRoot - $geometryAssembly = Join-Path $publishRoot "GameCult.Geometry.dll" if (-not (Test-Path -LiteralPath $geometryAssembly)) { throw "Geometry Unity package is missing GameCult.Geometry.dll" } -Copy-Item -LiteralPath $geometryAssembly -Destination $pluginRoot $geometrySymbols = [System.IO.Path]::ChangeExtension($geometryAssembly, ".pdb") -if (Test-Path -LiteralPath $geometrySymbols) { - Copy-Item -LiteralPath $geometrySymbols -Destination $pluginRoot + +$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", + "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 ', ')" +} + +$trackedAssembly = Join-Path $templatePluginRoot "GameCult.Geometry.dll" +if ((Get-FileHash -Algorithm SHA256 -LiteralPath $trackedAssembly).Hash -ne + (Get-FileHash -Algorithm SHA256 -LiteralPath $geometryAssembly).Hash) { + throw "Tracked GameCult.Geometry.dll does not match the current Release publish output. Sync the tracked Git-UPM package before tagging." +} +if (Test-Path -LiteralPath $geometrySymbols) { + $trackedSymbols = Join-Path $templatePluginRoot "GameCult.Geometry.pdb" + if ((Get-FileHash -Algorithm SHA256 -LiteralPath $trackedSymbols).Hash -ne + (Get-FileHash -Algorithm SHA256 -LiteralPath $geometrySymbols).Hash) { + throw "Tracked GameCult.Geometry.pdb does not match the current Release publish output. Sync the tracked Git-UPM package before tagging." + } +} + +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") { diff --git a/unity/org.gamecult.geometry/LICENSES.md b/unity/org.gamecult.geometry/LICENSES.md new file mode 100644 index 00000000..67455b87 --- /dev/null +++ b/unity/org.gamecult.geometry/LICENSES.md @@ -0,0 +1,13 @@ +# GameCult.Geometry licenses + +GameCult.Geometry is a mixed-license package. License follows file ownership; +the package as a whole is not relicensed under one blanket expression. + +- CultLib-authored integration, persistence, schema, and packaging files are + licensed under the MIT License published by the CultLib repository. +- Files under `Planetary/` and `Shaders/` transferred from CultMath remain + licensed under the Mozilla Public License 2.0. Their source notices and + provenance are preserved in `THIRD-PARTY-NOTICES.md`. + +Downstream redistributors must preserve the applicable notices for the files +they redistribute. diff --git a/unity/org.gamecult.geometry/Runtime.meta b/unity/org.gamecult.geometry/Runtime.meta new file mode 100644 index 00000000..0f218627 --- /dev/null +++ b/unity/org.gamecult.geometry/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d888f08710df48a780e393bc7b6ce1f5 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Runtime/GameCult.Geometry.Unity.asmdef.meta b/unity/org.gamecult.geometry/Runtime/GameCult.Geometry.Unity.asmdef.meta new file mode 100644 index 00000000..deaa0678 --- /dev/null +++ b/unity/org.gamecult.geometry/Runtime/GameCult.Geometry.Unity.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 5a5dc5d9ba8d4aea93b53061d6376f7f +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Runtime/GameCult.Geometry.asmdef.meta b/unity/org.gamecult.geometry/Runtime/GameCult.Geometry.asmdef.meta new file mode 100644 index 00000000..ab0835ee --- /dev/null +++ b/unity/org.gamecult.geometry/Runtime/GameCult.Geometry.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a4dee1a0c3f04709994b8cc1f974da7a +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Runtime/Plugins.meta b/unity/org.gamecult.geometry/Runtime/Plugins.meta new file mode 100644 index 00000000..98acd60d --- /dev/null +++ b/unity/org.gamecult.geometry/Runtime/Plugins.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7f8484deafdf444abe3b1dedb9d09d16 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.dll b/unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.dll new file mode 100644 index 0000000000000000000000000000000000000000..5664e0edb164066c3f8b315e4a7c32f42c1380d4 GIT binary patch literal 144384 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

?*xxt>HT$a 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

Ry5r?Z8Mf&oM(n^c31E7C!7ztGU0Go zR%)mBY@57~s}svYg%WS!nFdMlV1lV$Cox`s#~*~POFzFzWulnh>bH`7rv8_p-}0j{hyss;|s<=zJ?CqZ*I`XB1sf>c4%%j5oe( zuDHi4r`)sgFNee;xfY&f)g=|hkc+A$x7xzJET_20h8~KaTKpE>fiFGm%2#w!*zCLI zPIL2Hwu$kd06hxR3bJg{X~?oQAv-YruOf|NzBkDQjhiy}y2+@|Nq>Tj%mU%aaxW`O ztEQpPY&Cm|p*MZ?AEWH7umN>o5$J|l<9asY?=B`w%gpS4_APjV z#ra4LMu_aMvszRPn|qK;MmVpvZFkpTk^8bYzh-heFK|=*Ocl#j+Zg?!sA)XjGW9r4 zin~{>(rxj`CmwSy&9Hy{WB>QyJe>HfIJ?5aBcl)!e^eJ`_~SfmIJ2T~l)ax558*l0 z^f>syaP}y7{$kM;BifGK`4u(_tW|F!CTh^r&y=TQ>Rb0e8S%MLJY>&-Eh{Xfb~p3^ z#^x2-+F102-ybGl37eSKt z9<#n1?h6AYf0Sl0E}N4;17x zVm*c(D;sg{eew+Nmd$gIhR?XG@&{OockgoY-(UdAs!A3n4f3&T%2d~2LS(J z!-iL2n@^8b^O)6+ldI2t*fwvBueJNQc2`?9V;|{1fQA$okr_GiGH6)KggYDYu*`)@ zGn#LH@m*A(*A<@zo^*!beylI(&%!+4JX z^gST|P$sEk^tg?WmTkp-oLuC)XQ^nh*OYN%>)Ylw-}`ePzp06FMEr6L9`&(0x~Mxj zcxypo<~*5IhayPP zDijaV(H^92pV9NJdiQc^ula70n?`>4Ik2~JnXsjL(E9hd+np|YzN22O^4Qrkf3xSF zkOaD=S6W4y1x{uFS+OWQMg?Y^!~iWNe(i2cFNADft+(27ve(M~094Dh0Vca~%e_C3 znQTjG4xZ+yFV-OIDV!>=Jjl^nTn;%P%V7k_N(osRg?7wz_JI2sRBZ7BCDoDdeRdqx za&gN1#1szF57a9weYnu+#EiL23PtQactBy%TG zC^s%D_=3kpGqYqyN$Q183MduL|7&UNA36X73U*hBaAwSxix`l7NWwC(^@)4iV!!ce zA@Z7k0EDML83j>5t#Aq^#r@reVdG5OmTtJB>uc>sC(A#;MuAz2#pzzl$PR)a zdn!)0Lr={NJ*N)bQLQ+Sd$(RAVBK`?qKu=#K7Z<{xVTV=`{0)jq|M84?;g)9of*_R zzw6EjyV}8j0>fxi$%5!Hznng>4UhTx#q|bhmpXre%D(J-QzoVS3vge65LEnulp`Da z!6gl!jRNQ28(r0X#Y=<+q~;@Ob>?my4BSojk{+O%?KbIlQqM#$o;o=)19JM&G zh@irJn(m3GTuFt_?$WG}kF*XJ40Pb$54-xA2J$T|PRDkWi~%4{grv5R#!SfZPsfVh z;Ss;7?$Qsrm>1CKuxM?q>%ifE1g1G5*mnejEZg+b);e~T@A_BPdp@4gH~Aw7PI6d; z7gfRmt>&~n$I#s!E9#f|G<#3=*N}L<%6B&n?H`JD9P*`>V<4Ks#&>T>H{mRib7-mL zJnv;~Pol3>844}^KVYyv(Y;X!Z&+IM2@g5=`<3x>kE#O4s!v>8I9|Hr&qF5UmEa!e zp)X%NcND|6n;I;vxOLOJRn=w2(eOg2K3HmunrN9VxZSBSi-u@e4fR`*`gGJOqfxni z?S4Z`WRLSz402HPUaOtEzu$zJabvt*^6&f+#0xScXZ14=W3Pu!!N6I1Pvftg^mgkg zesHJt!_J030{pb+H3vNS6*64H0UvX^G0toU(qHR0N{y(`Y>N9ejUR^It% zQ{VN$1!oN_E~|$8jgZA0GfOBj^`qELc*HJN#<@{@xq&+$n64_y;kfrfhUJxH72>3? z^jPaktX3nw2pTSuSn6@B*n59@oZ5Dm2=iYdgsr1eQqSQ2+2&U`{1w-HW~3jvyz@up zMUX)UNP#qB7{y4)2c0&I9!sXAk@RohyoWFC{5ipUGtrdO(eYRqIl&^nly3Rm z&48rC&#&v!2c3PPtFF(#w*7v!6+I5w(z{A1`a2vFcjf*|BKqje#0o8{P48)(xbb+y9LB11vU96bX!=W&bxMM4;FPe;*$Xe7x!$j#A8hK-t!J-sBT+c9#Gd*2nM>eahdWE0)EnOS zYaIevYRPn~WYTCFPGQF`GOB+4ZA|2Zz=u99C0b!S*B+VNAC6iRV*SC1qWdh1{I{s5 z7{d+OHD5f676vRzDr}o}X?9aT*swmc?tk_3OWeBMxO%iO1JL_Y15hgTleL; zr6xmR)82;|rcJ3UMDpZRpIvGuNv_hJL;Jx*;H1Oly%ERFr}s!dHuSq;(47Zd_l~uk z3zLdg%J+jH*Nl*fnC1XlePInA<67b^_3$%(fvY>T#Gm>W5}vk_t1W#MCQN{il7DP`oy6 z@7&*^snMMFzvbihsTuX>WF_JUHIu;msJq2)=5C*(!JU^0=8|!45;@C@4!4s`C4zCN8^#2W< zikay_@2@(%2M-!_ZJE+RlUTpCuUK8%mO0i4q3AddM?~Nd%Oc5rTyS&IML6R>-O10u z@-*BDOUZdn+S_R!Eo;2L4la-uhOfe4@wZN5^2*`M1DFGOOCZHDPUh! z@RzX~F0%hXg%Zf>)ek1%EhLG%vhP<3!b3J5+WGcg@*9bNH2y94>zbZQhK`eL!&g0b zy&bSxredI~xRy|tg{b;LzPdjD&&qf(8Pn%&+_vtgwX)AFH{;X%CjpuN7>wM3DlX~e z5rwH+_KDjVc+gb!6Hd~>0dw+`&goa3o&G-n)0q73Fp`Fakq*N_T+X>-{G8CXs1X(y zyEY3gn2>^AJ40cSk@w{V#zJ!9a`f%ZZU4=kOAJF=Hwx{o5DIW7w>bKHs7H%UNdk5< zKRmy@dsV>9M}B*B88VE25b!H#zgH3{1gxw5$q@M5DOE;`zgEBo3zfTa;c{L7*-Joq zZv?(|zB0L-ZFX8NeNEKZchi)ikuD2Ig#9P{Z<;yC>mF%PT$da=)$nMLS0f{_=lRCx z5&zjs+%REWGpc&`56s0+d4DhXzzqqUw{t`3O9w;Y>ZsQ#l0QTeIt2G}oog8AZ_CX? zT-&n)C%CIfJ(;rf6+LDj&@bD-g*nBrw#^+T>_z9uZPMP?-kVlG!Tz$xw#0)ALmwNk z{<9a)a$#t*clDx1j0DT=}Z+FsMwVqv}wD*4qWnMqsf}#TMPf$7a&oTkynx?oJYaq59crrHVM44xadtD z9Vf>gTl!2{?w@ef&NxKdeG%BgZ_%9?w0vpg+!abULnm~G7OB}wTK9tD2VGf|r-n#^ zwrwu&RLI?kuhHq3B(|^u*Cn>H_o|i%_i*r5hQ&X)t#e9^j0ma~Ga}TYv_f0o=EwM0 zXRf0M`W56iJGY>67wJrtuX`RATchCKlJvIn(djJdKS7sU^dai~;r2MZaH)JdYq7?z z69Ma*E?O7tRS>!^a@2lWIZEl%ix>Svv+H>`RcdEYRhu*X

IbF>T9q0`p8GdkvI9bfui6GJVHg?T;IMH1ARL>_^Q~&^FfVe zXa4GWvR`Oo`i7%`ew(@Ltuo{y#LLN}W6{2x(0Z}7sGHM{od1_W|FQB~%h!I4M_;(| zzzx3m9gl{tTlwEjUbX!WO>&7QbO%vK8rN%zISI-8bi610P#b);QAN1x)(>YADhxKv zXvmcCD-ZB^cRt_QRx-yectwXy=-snB^M4-?p0fx;m79fUZ(^X$Xyt}4FBbdptH&QM z&vw=o0>Ou8%3%Yv-=0nB?$>0zYM;8mx^hQ=Cx44wO|9`p1sa0<)>G7Q_*M>$*3n64 z$UTC<1>73NsTMwKXQTw(X*e5Bx9^kpt`(O*V^k84tpAyG@v8d@Z=b*J$*dJ_CcuN9 z%;HMn)ZTqRmE6R&-Ep23GtVG!VRcmJ*NAV;bgzAWVuQ-5Z8$oSMD0=>@#B6IPsGo7 zSZ3hZuVKGsxrNhMlKan9)8s$x_~bd2wJ7%Nl2)N_#@$kZ;xCb#U;4Kv7<0epwq*(D zM|#dp-FbMum7H*k0Ym-jk|}N2@|Z(Dm^rfPnQ<*gFL?GvEB4m6=_UP8ixcL-pqaVP zU>&(ug4;MGsx6{M#4Z2T2S zh+(g2tQLPvMmMo-kHc@inC-u0UwVy_eXehRu&}RL^zhx+A!xKeT$cQ5vM8wk$Lb&q&WhYg*j>3|pP80mcU_#gs8*k~}r_}WGdY14x zzlq2Jlw*kt2V4Z=A{G}FxZvYr78Kd7qF(q@id=0g4&t(EpR?nsj*9`u`8&tE2yz6srjj-=4T|; zawOGqG}X&!U2%*vpRYyN`cz$KMAzox1Y*1J>n$MZ9pV-NS|mnX*-}7|D^ImrNcXak zhFU_OFQLzu)4eV?q1vycudg(x&e!PCwJ}w%r_a~Z=bPwun{252TTSWOimF@b_N{dL zUG#Xn=<)W_=X>e%c42Kv!5^@oYg?-Br2Frr`#(z4bJR!%l7PwcyFi?+kqL(vL&WN1 zzVH`X13OqSUtAW0gA^PjpkT6aQG+M4l-SO8#rTL!$FLR{B5sM*Gv`D-jm}8VkY{&wHNr5?<1K+PzB|!xe6lnqp zYDxsW0n~LKeO;Pjoj{smnLv)33V|Fo6#_*HWq~4vvOvX*f>)tX7pPN$B~YjP(WKjH zQmhu}XjAQV45(UPgRb#cco9QjM8g}=@TL^Y1g7-37Bt=#=J5MQ#1%;3iC|Vzzys;d z2hyDnraK!GsD7iI z>3T3#$I|`9()}gScqGuwO{R%Urin|X@k^!0&7k|waKZB}E69QQwnPRT@liyg7D@g< zOFZA$g0aAODaewO4yOZ{iWCYX=^;nbL(&F7Fq%e*Pl>32Poq*tv#^e4;dq+l@g&Kz zf=T-DTD%y4tYC_T8epIx7->t3m;$pJj0F?aL5?Y}%|J*?FrUXn<^+5o89tRQgFFai zhQDcyM34wkjnNw(peJg`S1nSxE^xxS#4hy9DY49^<5 z6QGmO(E!?S^u_f;k4)|W$cup0$39`$416>TitJYGBJGr<6u?E2UuI`$P-a`3tk?#c z*z(GnYBR-j}d=2Jx&c#vYZ+EXDRuFeFN(SsO;%#jwj`56 zEC5>qyaJa1{%{E}6$%7_3*ix$kbqB0i-xJQnZy7ImNo*JRzv{=QY0b>q>eFJXY*? zY|*y|NZJu>JxUKjk==@qZYw}|V4%hpM@sPddQ{6`X@Es3#Yk~@Vz`o&Br6#yvf@gU zm6Qy1CQDXwa@0k6>P&&GIErK?twdH5%D57XRdo`BVRL5Tp1C@t1(MPsMNCpB3SX4g z$;4H@A(!6{9}?FyWlJM#0?8sx0zL?@h6^y8TF8Y!a>yCqP{3ws04@p`$O~Hm8w{a} z8;jyoPn66+hDef-09wUR5u?@|OM^DjC17nBO(0h^i~)u{4V&XQ0=`RbgGbPZFcNpp zMr8~%2u0z32jNi@8AuKJ6Y$-8IRZXyuZj!kN&&H=eV9lZC9_e9O>8ZE0zEl_ZHfC~ z!Gr>SWTA|YNSq}Y6haahPe4qEQOiR*xV0(BDzd;HC!7GxPc63?DA_Nydg2qcB46$FrpWbvSqBx4AI-^!>}2VV?Rz{b)E zh>K!oh$0ny$|?qlQ9_|&NL@Kr91Ie)qb3<9gHbDn1|S@T5YReB9N8l~DWpz#auKUq z2$vxli>XkMAXk7I5&=wQ;1Q22fn^kX+?-kXL-m7X*s5p<8p%O^gt#j5Mf}-V2|J20 zuq-Lrh^s=<&Qc{SZ8b4*_}o2zf(};?JgK4qn*|~SmdligR7DObjsvo5Yy#e~0{6tr zgJD#y*`mk|IdG6HGf*0g6>tXxQ4%a3>8O!I?r4-8Xn>d`8jPIekP{k%n|B1VIr_Rf972+&5K30kVPqvAPMt-Nm25P1F@~(Tv1FwjM^=jQ z)V&0<;v`a45>+LW)qtU7rItcfX{0aJNGDyfLxw$nFPL@3A`nd(S|OMy3=2{yF8C9$ zsthE+k6ADvTJglf2Bw0C?SMEF)XrPfOBxvG&O*9a6{V30)@HcNBKYSB(0u+mCS7Kt zWEKh%%_k6lA*x{^XH-KV7pj1cWl#nI0tJO5RTL2@2S+>72o%j4Ibv%-7JvYUObbS>97aP5hn7l6wAu%x97ReX5pKMZHVDvI{zUZ(O^}}pGQQ)Rw!l#go6Bw{&+c|#=m4C3O$i7M^S<@uoA&|{v^5NMIP4= zpYgb)=ySQ*_)L|u?gJ?6E=O5+6|QXA$CLJY7(Ts&_uvD69yYC5R!5ABWNt8=kgsUU3&e~%$JUq_O%5Wt;t22^;0ozNgdkTp9@S)Q>Prc&3T3^eB|=Ck zmq^8x0ap{7GX-gIfTUnq6VXbb{F&I?lAXv=fr%VtzGW%%twxz|MK#g_aWzQ4ps3-F zyX0uZQ&qO63B)P7pcOFJ@(|5~at0CAicnB)Th$cqo5Ou)N^8Kz>k&L@(M%u;){ibu zY0l*CafLQNHs~NM zWRZyN5fsi?D*Y^>i}+u<&?Y!c%xET74ki*OEMUEc1D7mmq{pa~#2O%pEf~aHOhi;` zaZnN$)5{Re$-!i+7LzQ|C?PBLQZYW?8f-%ij3R?zr5#BmN)iGtf&5V@HVc3Th!7oe zvFQj0+XigzWDrm%Gl%SJ_ z?Vb{UKDo%_@aKo<*g$yAU&OYf42Tk;ri>Zt4(5{IL^|xO8asq6`K#DupuxnppNX9b zT|n?5iV+t@Vgz1Ni2*8UU~w3gER;eZ1FTFI7=KAh#K|br4&MmJaWlkf8px7BdWgrU z2DJh{X6Z>HbFFA8#KVJtJafnmRC!Jic}9iS(v%!)QpT4jBgWt62S{(P za6yO@uoM|Gz~c2&T#B>6b?it+5D=S))(j$pP+0KS!kP@iQ8DDkP2)gl1QRY)cp;oV zqn0!UOlDQWcJfpMpNf`4xK8%k*T0Q?THk^eFN1Psv7LZi z9;&tmO9Bt8DP>cnludzBHm<6esxv&p9RO|Zu@X5D)kYAm6ydp&3wHr_*?@QfN12pm z0CnOaO9o4vbm`QJ1;m*_lo4nIULZq|%dA$w(Z2=?#Eqk|mH|;3*tq!)f*u2*M{m3e zRA#`4Ff9bqWK_#zRh0k)SS%FFsOI&%8_1}Z!a^!83bJSnyHqH~6(OlC=m(i152my> zU<#BU>{NfA3&~RQPoe zBS>Mip?6KDv=Vd&1M3p*asc3`0gfR8VmwAijs@Hqh3|;r@DA>9ai^}#4qy6?8IJxy zmAJjLu7;yxKtci&B!MdMk_G}JSV#8QuD_fTu(hQ>`#lU_< z2l1HFm}OvVNS1L3Qx5P)Sqv#N^VmSXjUJ#8CkfX8=Yh1)PF-3YD$JLHIt0&zG5>upUaK%~yd;esrguz@PD*GdHJ1uG%gXfzU7vp5*4skU9HhBCY1j+_1_mH@Q{q~)=n!p72w|3wVfZ2niW z7UO@T>+fVO&Hq8xlKh`!Z6FZQv<6SFC=i2iO$B2Jt0)eT8O+7uge(MkV4u7R1ud{o z{Hdgk9d70Y-bWw{`T@g&dqn(J9F27Z^C$-i>{dj{*i?avVsvB$8su<_f&paB6=;&R zhCqv~2MDyq)VvY066k|&3_|I^cR~~5FJvGEJW9lUC<)BSwps#nvX&B9h^70%0#)Dy z5r!1f!y#<46iUF+_aMSpliZf1>>b!dG8h-yL{mnsB3@X?;n>~+n;EdBl@RQzB7Zrs zs!j}iq=0m#)39w*p`vUR2?$OR8Y9UzqT+QfwNolZZHK5)+aUueLnx&}Miv89yvQEZ zNvEo!K~}N@C{L>?CeRE73M}w~2NNKR3vGt+2KZfK7GK zU!s%&;teOPO4z{QAc!!`Bx8Z2Sf)VgA@X6sHnSG)5YS`b7>9v4kz>G3AyZi-X%i0E z5-1=D-d)ZEZPG?T9MB_sdDzPt!2wJ-EbPXZ*wjl=7Ed_Kwv1Y3tX>M>DXkble;xx# zNu(tPL$t`}gQAh)q8$;<1ml>`guqlu94tkcIP`C=j&TylOFN0);0B0<1>^++C88Mc zpm@!$4qg&MR2h`WLXxs+aUnQRSpcDEEZA2tr{NHbA((m@Tqq)N(-H*4BtSP0rAQ)E z20RUl!`cf*R+1_pK93Z8Ds_C4(Umt;e@$9 zilBF~>_QRJVY-Kj0K&8VW9Sg zyPCls}KOGzF^k z@~ZFE^m6Jm$$1&Q>#gy}L?9fYVv@K>P$xaW7g#P1aa6K2@{q$Y_J7cnF$LGFgC_>)U_jz5?kSi1Ws<4Qh= zvJ=6Sfd~=9OB~q*kbell;h`1MgY_c3vjCGL&c6@?6PN;%?t;8H5JI?0LnI7KM2JK> zAm(G?EdnLNA68xrz)zeoAr}NJlqZc;7?t$4Aq2xx*hs?88FG_^_U@GY4Tq=ua_Mp`2WoXuV9601pq|WcLJkm{ z^eU1fqgDbhK@=IV)d2yWxdbeLM2p?9P{u>-u|zRV4*yUb!S5B}CB&i}5BwW8WE=}j z;LnA%D3hS}``9Q)91MktBq&q?JRTw!t?EP~_Kbm%VjObGg}oDA3X@`~DDQ|F%#w$- z3$Ehz;$Wem4BLLzC>3w}X=g%v*lw^Vs@18eoJ%P9Ccv~C2*Y$uLO~Y%qZ{^*?qrC{ z0B9%7aDOrCh>Q6RrVv61x?tgzFdma(txr}q%4B6LPgZthC1J14G)Dx((Hyp8kb)~1 zDF?h+qCiB`M~XzW1PVnKO0bU+g`?00Y1Yd4cq{~5fmGQ4KPB3u&*(JP?b>AhXTyE2X{t~J97A&BDH{6)BhmWmLygY_+`%D z2Wkikz=I`t2-ZMT{J=Y!e-K+t0+uWy1o>1&irO)kqIS%ssU33($|}lHR*_3tMG49( za;VKX2RUavFS3dbxZ)_nh7+#HO*|>ZO#a#oV739UAOKTC^41@3l7ShP!=X1DZz?HZ zYpR7EHNA%_gO7&bJxAfwkrYNPsk8JW3z&QInFJ{;(pXqz5X3){I6l(F@u~{-=miX) z4)Or^j{{pd{9uHE?<(T0DjCFzhrJnj6wO9{7O~-(@Pr{0FeSYPG%~|xQwJBixX_~t zSc8yH3}kVzEs5t^2N!z%igXMM8wMN+c-oi2u0pB!eHMfw3)!yN4PzD=l#mZcy-Ph5 zvJeW@Vn|~J6a|Atz$`IFm%&2a(UTzyKZHmAEKoOK6BFg&0=j3SOk9-xT9o6{;kc+E z2qr4&y<6RT1fdZP+t&*j*l<~}s|JrJOhhquDk)ZEl}f&wVkVeDzAj-VnCXW7uDoC& z>3>xPtI5|gbOr02`Kz+w4p`v8;B}r;ucc!*w6;!MxaSPuJb^9QA@B4QMoD=@{zp2}yOH~-)IMnw6#yAHG zV3-6Nn#v>j!WU4CkpO$fUjovIL=-X3@bCwLlSe2W{_5I~Ly0@6YsZ0o@4p`CTQL_- zp-)i}{^1gyHvGB@=90VuZ$7{+u)wIU1N$2_LomAFMqvjN7r-1Wq1F~W2-dNTAmpS6 zZU!1U{nGH4)^5-mrj2ZE&xWTUNG;&^*1v#60!TIu;!A-f|2K$zHh@6DhDRBcfWuKV zz&Z&W50Zvg@1T+zqk$`Kt%f+R_Y zCHL9H5g2%oED@qMBoA1v#b6;WgMf)(!k(ZzwnGNE0P_X=h71XqJoqmPvL!KE4!cV* z0yt0+EwvE_?W$?AIA(>tvi}}b4wk0aDADCK8ja$7KtuO`g@$r}H28BE&>4wQ|HHYx zS1CMGdhs-)@T~Y3Ji$A%$fs9;;RF)TNo2X0EEn{ZU=g`^pPY*=C%H*V7yRjDquFFR zgDjyVlGps60-)03f2I-?%op)z(99NtLz*5qoYi-@vhT3@PlpZ+zzldJhWEqhBXjx) z9{2p=h(C|+2Xt{^-{JheLm?(Y3DBT83(NW+t$3RyPxD`%3&6w|;= z!W`_xtkdr0uukt0_(FKVL)7xG6ar7p|AnVuS%m@&JcX_2X?;AM+Q+hnzQbjIJZ$W{ z+K(4)^i^Qi{@%ksG1#J))v(AVCjxfDdygm))$L_uF2zV}_x_~6{Y7+aFMCLN)W=LQ z!3FFpaIw3mk5usytTnk5FUfY8dB=tU^UVH*dBMLhZ^$nKIk}f1LfXVopSS@jG?l_@ z5Eu4dgOG*3Yu0;Y)O%#wd(wKXIfh@H4UHzxF#P=d@Lm`eG>z^i>LL8AC1qP?6ax_k|3ZY{FGLt3{^UL)gi{HaFYm*AMem_OFZ20( zdoT7oq^2aem$JbWWg-6y%KFg+&q^_^)D@CqQe3f{py*pz0`wXF3w`|@73jHXAAMjj z|40sh-k=;Y(5Hp?Q~Ks_W*;SeZjZmX@9Lty!$ZA?6e*Ftq&WR=6p5N0%3{wU(WjIO zEH*hwBg_6nk$#;cP^9I3GzhIO0S^azQS7f?eO8x3{5M5{ZMFYUB+3F%GeP-h%GFRM z<>;vr{5BjCy+OxQ2=?HLK6Uo{k*Brg8+s74*TpwuZxEUeV+GU1P;HN z>n_I-#&V=QfyKZPZEZS)Hp;LC4DZ>&qUs+3oed6eAYdV#BE=active) omitted+=amplitude; else if(index==active-1) omitted+=amplitude*(1.0-final_weight); amplitude*=abs(gain); + } + GameCultGeometryErosionBandSelection result; result.active_octaves=active; result.final_octave_weight=active>0?final_weight:0.0; result.finest_included_wavelength=finest; result.unresolved_height_bound=omitted; return result; +} + +float gamecult_geometry_erosion_hash_unit(int2 cell, uint salt) +{ + uint value = (uint)cell.x * 0x8da6b343u ^ (uint)cell.y * 0xd8163841u ^ salt; + value ^= value >> 16; value *= 0x7feb352du; value ^= value >> 15; value *= 0x846ca68bu; value ^= value >> 16; + return (value & 0x00ffffffu) * (1.0 / 16777216.0); +} +float2 gamecult_geometry_erosion_hash2(float2 input) +{ + int2 cell = (int2)input; + return float2(gamecult_geometry_erosion_hash_unit(cell, 0x68bc21ebu), gamecult_geometry_erosion_hash_unit(cell, 0x02e5be93u)) * 2.0 - 1.0; +} + +float4 gamecult_geometry_phacelle_noise(float2 position, float2 normal_direction, float frequency, float offset_cycles, float normalization) +{ + float2 side_direction = float2(-normal_direction.y, normal_direction.x) * frequency * 6.28318530718; + float offset = offset_cycles * 6.28318530718; + float2 cell = floor(position); + float2 local = frac(position); + float2 phase = 0.0; + float weight_sum = 0.0; + [unroll] for (int x = -1; x <= 2; x++) + [unroll] for (int y = -1; y <= 2; y++) + { + float2 grid_offset = float2(x, y); + float2 grid_point = cell + grid_offset; + float2 random_offset = gamecult_geometry_erosion_hash2(grid_point) * 0.5; + float2 delta = local - grid_offset - random_offset; + float weight = max(0.0, exp(-dot(delta, delta) * 2.0) - 0.01111); + weight_sum += weight; + float wave = dot(delta, side_direction) + offset; + phase += float2(cos(wave), sin(wave)) * weight; + } + float2 interpolated = phase / max(weight_sum, 1.0e-20); + float magnitude = max(1.0 - saturate(normalization), length(interpolated)); + return float4(interpolated / max(magnitude, 1.0e-20), side_direction); +} + +float gamecult_geometry_erosion_pow_inverse(float value, float power) { return 1.0 - pow(1.0 - saturate(value), power); } +float gamecult_geometry_erosion_ease_out(float value) { float inverse = 1.0 - saturate(value); return 1.0 - inverse * inverse; } +float gamecult_geometry_erosion_smooth_start(float value, float smoothing) +{ + return smoothing <= 1.0e-10 || value >= smoothing ? value - 0.5 * smoothing : 0.5 * value * value / smoothing; +} +float2 gamecult_geometry_erosion_safe_normalize(float2 value) +{ + float value_length = length(value); + return abs(value_length) > 1.0e-10 ? value / value_length : value; +} + +GameCultGeometryAdvancedErosionResult gamecult_geometry_advanced_erosion_filter_banded(float2 position, float3 base_height_and_slope, float fade_target, GameCultGeometryAdvancedErosionParameters p, GameCultGeometryErosionBandSelection band) +{ + float strength = p.strength * p.scale; + fade_target = clamp(fade_target, -1.0, 1.0); + float3 input_value = base_height_and_slope; + float3 height_and_slope = input_value; + float frequency = 1.0 / max(p.scale * p.cell_scale, 1.0e-10); + float2 slope = height_and_slope.yz; + float slope_length = max(length(slope), 1.0e-10); + float magnitude = 0.0; + float rounding_multiplier = 1.0; + float input_rounding = lerp(p.rounding.y, p.rounding.x, saturate(fade_target + 0.5)) * p.rounding.z; + float combined_mask = gamecult_geometry_erosion_ease_out(gamecult_geometry_erosion_smooth_start(slope_length * p.onset.x, input_rounding * p.onset.x)); + float ridge_mask = gamecult_geometry_erosion_ease_out(slope_length * p.onset.z); + float ridge_fade = fade_target; + float2 gully_slope = lerp(slope, slope / slope_length * p.assumed_slope.x, p.assumed_slope.y); + + int octave_count=min(min(max(p.octaves,0),8),max(band.active_octaves,0)); + [loop] for (int octave = 0; octave < octave_count; octave++) + { + float4 phacelle = gamecult_geometry_phacelle_noise(position * frequency, gamecult_geometry_erosion_safe_normalize(gully_slope), p.cell_scale, 0.25, p.normalization); + float2 derivative_direction = phacelle.zw * -frequency; + float sloping = abs(phacelle.y); + gully_slope += sign(phacelle.y) * derivative_direction * strength * p.gully_weight; + float3 gullies = float3(phacelle.x, phacelle.y * derivative_direction.x, phacelle.y * derivative_direction.y); + float3 faded = lerp(float3(fade_target, 0.0, 0.0), gullies * p.gully_weight, combined_mask); + float octave_weight=octave==octave_count-1?saturate(band.final_octave_weight):1.0; + height_and_slope += faded * strength * octave_weight; + magnitude += strength * octave_weight; + fade_target = faded.x; + float octave_rounding = lerp(p.rounding.y, p.rounding.x, saturate(phacelle.x + 0.5)) * rounding_multiplier; + float new_mask = gamecult_geometry_erosion_ease_out(gamecult_geometry_erosion_smooth_start(sloping * p.onset.y, octave_rounding * p.onset.y)); + combined_mask = gamecult_geometry_erosion_pow_inverse(combined_mask, p.detail) * new_mask; + ridge_fade = lerp(ridge_fade, lerp(ridge_fade, gullies.x, ridge_mask), octave_weight); + ridge_mask *= lerp(1.0, gamecult_geometry_erosion_ease_out(sloping * p.onset.w), octave_weight); + strength *= p.gain; + frequency *= p.lacunarity; + rounding_multiplier *= p.rounding.w; + } + + GameCultGeometryAdvancedErosionResult result; + result.delta = height_and_slope - input_value; + result.magnitude = magnitude; + result.ridge_map = ridge_fade * (1.0 - ridge_mask); + result.fade_target = fade_target; + return result; +} + +GameCultGeometryAdvancedErosionResult gamecult_geometry_advanced_erosion_filter(float2 position, float3 base_height_and_slope, float fade_target, GameCultGeometryAdvancedErosionParameters p) +{ + GameCultGeometryErosionBandSelection band; band.active_octaves=p.octaves; band.final_octave_weight=1.0; band.finest_included_wavelength=0.0; band.unresolved_height_bound=0.0; + return gamecult_geometry_advanced_erosion_filter_banded(position, base_height_and_slope, fade_target, p, band); +} + +#endif diff --git a/unity/org.gamecult.geometry/Shaders/AdvancedErosionFilter.hlsl.meta b/unity/org.gamecult.geometry/Shaders/AdvancedErosionFilter.hlsl.meta new file mode 100644 index 00000000..015c51c2 --- /dev/null +++ b/unity/org.gamecult.geometry/Shaders/AdvancedErosionFilter.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3252ac02e8853ef4b9d1fbc5204b5c6b +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Shaders/GameCult.Geometry.hlsl b/unity/org.gamecult.geometry/Shaders/GameCult.Geometry.hlsl new file mode 100644 index 00000000..00623b75 --- /dev/null +++ b/unity/org.gamecult.geometry/Shaders/GameCult.Geometry.hlsl @@ -0,0 +1,14 @@ +#ifndef GAMECULT_GEOMETRY_HLSL +#define GAMECULT_GEOMETRY_HLSL + +#if defined(GAMECULT_GEOMETRY_UNITY_PACKAGE) +#include "Packages/org.gamecult.cultmath/shaders/CultMath.hlsl" +#else +#include "CultMath/CultMath.hlsl" +#endif +#include "AdvancedErosionFilter.hlsl" +#include "Planetary.hlsl" +#include "SphericalErosion.hlsl" +#include "PlanetaryRadialRefinement.hlsl" + +#endif diff --git a/unity/org.gamecult.geometry/Shaders/GameCult.Geometry.hlsl.meta b/unity/org.gamecult.geometry/Shaders/GameCult.Geometry.hlsl.meta new file mode 100644 index 00000000..d685ccce --- /dev/null +++ b/unity/org.gamecult.geometry/Shaders/GameCult.Geometry.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b253fa50ddd34bf3b69fb323fe6e00e8 +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Shaders/Planetary.hlsl b/unity/org.gamecult.geometry/Shaders/Planetary.hlsl new file mode 100644 index 00000000..8e16bfff --- /dev/null +++ b/unity/org.gamecult.geometry/Shaders/Planetary.hlsl @@ -0,0 +1,392 @@ +#ifndef GAMECULT_GEOMETRY_PLANETARY_HLSL +#define GAMECULT_GEOMETRY_PLANETARY_HLSL + +static const float GAMECULT_GEOMETRY_PLANETARY_QUARTER_PI = 0.78539816339744830962; + +float3 gamecult_geometry_planetary_face_direction(int face, float2 coordinate) +{ + float2 tangent = tan(coordinate * GAMECULT_GEOMETRY_PLANETARY_QUARTER_PI); + float3 cube = face == 0 ? float3(1.0, tangent.y, -tangent.x) + : face == 1 ? float3(-1.0, tangent.y, tangent.x) + : face == 2 ? float3(tangent.x, 1.0, -tangent.y) + : face == 3 ? float3(tangent.x, -1.0, tangent.y) + : face == 4 ? float3(tangent.x, tangent.y, 1.0) + : float3(-tangent.x, tangent.y, -1.0); + return cultmath_normalize(cube); +} + +float2 gamecult_geometry_planetary_face_coordinate(float3 direction, out int face) +{ + float3 d = cultmath_normalize(direction); + float3 a = abs(d); + float2 cube_uv; + if (a.x >= a.y && a.x >= a.z) + { + if (d.x >= 0.0) { face = 0; cube_uv = float2(-d.z, d.y) / a.x; } + else { face = 1; cube_uv = float2(d.z, d.y) / a.x; } + } + else if (a.y >= a.z) + { + if (d.y >= 0.0) { face = 2; cube_uv = float2(d.x, -d.z) / a.y; } + else { face = 3; cube_uv = float2(d.x, d.z) / a.y; } + } + else + { + if (d.z >= 0.0) { face = 4; cube_uv = d.xy / a.z; } + else { face = 5; cube_uv = float2(-d.x, d.y) / a.z; } + } + return atan(cube_uv) / GAMECULT_GEOMETRY_PLANETARY_QUARTER_PI; +} + +bool gamecult_geometry_planetary_page_local(float3 direction, float4 tile_address, float active, out float2 local) +{ + int face; + float2 uv = gamecult_geometry_planetary_face_coordinate(direction, face); + local = 0.0; + if (active < 0.5 || abs((float)face - tile_address.x) > 0.25) return false; + float axis_tiles = exp2(tile_address.y); + float2 scaled = (uv * 0.5 + 0.5) * axis_tiles; + float2 canonical = floor(min(scaled, axis_tiles - 1.0e-5)); + if (any(abs(canonical - tile_address.zw) > 0.25)) return false; + local = scaled - tile_address.zw; + return all(local >= 0.0) && all(local <= 1.0); +} + +float3 gamecult_geometry_planetary_surface_normal(float3 direction, float3 world_distance_tangent_gradient) +{ + direction = cultmath_normalize(direction); + float3 tangent_gradient = world_distance_tangent_gradient - direction * dot(world_distance_tangent_gradient, direction); + return cultmath_normalize(direction - tangent_gradient); +} + +float2 gamecult_geometry_planetary_equirectangular_forward(float3 direction) +{ + direction = cultmath_normalize(direction); + return float2(atan2(direction.y, direction.x) / CULTMATH_PI, asin(clamp(direction.z, -1.0, 1.0)) / CULTMATH_HALF_PI); +} + +float3 gamecult_geometry_planetary_equirectangular_inverse(float2 coordinate) +{ + float longitude = coordinate.x * CULTMATH_PI; + float latitude = coordinate.y * CULTMATH_HALF_PI; + float cos_latitude = cos(latitude); + return cultmath_normalize(float3(cos_latitude * cos(longitude), cos_latitude * sin(longitude), sin(latitude))); +} + +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_A1 = 1.340264; +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_A2 = -0.081106; +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_A3 = 0.000893; +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_A4 = 0.003796; +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_M = 0.8660254037844386; +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_X_MAX = 2.706629; +static const float GAMECULT_GEOMETRY_EQUAL_EARTH_Y_MAX = 1.3173628; + +float gamecult_geometry_equal_earth_y(float theta) +{ + float theta2 = theta * theta; + float theta6 = theta2 * theta2 * theta2; + return theta * (GAMECULT_GEOMETRY_EQUAL_EARTH_A1 + GAMECULT_GEOMETRY_EQUAL_EARTH_A2 * theta2 + theta6 * (GAMECULT_GEOMETRY_EQUAL_EARTH_A3 + GAMECULT_GEOMETRY_EQUAL_EARTH_A4 * theta2)); +} + +float2 gamecult_geometry_planetary_equal_earth_forward(float3 direction) +{ + direction = cultmath_normalize(direction); + float longitude = atan2(direction.y, direction.x); + float latitude = asin(clamp(direction.z, -1.0, 1.0)); + float theta = asin(GAMECULT_GEOMETRY_EQUAL_EARTH_M * sin(latitude)); + float theta2 = theta * theta; + float theta6 = theta2 * theta2 * theta2; + float denominator = GAMECULT_GEOMETRY_EQUAL_EARTH_M * (GAMECULT_GEOMETRY_EQUAL_EARTH_A1 + 3.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A2 * theta2 + theta6 * (7.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A3 + 9.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A4 * theta2)); + return float2(longitude * cos(theta) / denominator / GAMECULT_GEOMETRY_EQUAL_EARTH_X_MAX, gamecult_geometry_equal_earth_y(theta) / GAMECULT_GEOMETRY_EQUAL_EARTH_Y_MAX); +} + +float3 gamecult_geometry_planetary_equal_earth_inverse(float2 coordinate) +{ + float target_y = coordinate.y * GAMECULT_GEOMETRY_EQUAL_EARTH_Y_MAX; + float theta = target_y / GAMECULT_GEOMETRY_EQUAL_EARTH_A1; + [unroll] for (int iteration = 0; iteration < 8; iteration++) + { + float theta2 = theta * theta; + float theta6 = theta2 * theta2 * theta2; + float derivative = GAMECULT_GEOMETRY_EQUAL_EARTH_A1 + 3.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A2 * theta2 + theta6 * (7.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A3 + 9.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A4 * theta2); + theta -= (gamecult_geometry_equal_earth_y(theta) - target_y) / derivative; + } + float latitude = asin(clamp(sin(theta) / GAMECULT_GEOMETRY_EQUAL_EARTH_M, -1.0, 1.0)); + float theta2 = theta * theta; + float theta6 = theta2 * theta2 * theta2; + float denominator = GAMECULT_GEOMETRY_EQUAL_EARTH_M * (GAMECULT_GEOMETRY_EQUAL_EARTH_A1 + 3.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A2 * theta2 + theta6 * (7.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A3 + 9.0 * GAMECULT_GEOMETRY_EQUAL_EARTH_A4 * theta2)); + float longitude = coordinate.x * GAMECULT_GEOMETRY_EQUAL_EARTH_X_MAX * denominator / cos(theta); + float cos_latitude = cos(latitude); + return cultmath_normalize(float3(cos_latitude * cos(longitude), cos_latitude * sin(longitude), sin(latitude))); +} + +float gamecult_geometry_planetary_wrap_longitude(float longitude) +{ + longitude = fmod(longitude, CULTMATH_TAU); + if (longitude > CULTMATH_PI) longitude -= CULTMATH_TAU; + if (longitude < -CULTMATH_PI) longitude += CULTMATH_TAU; + return longitude; +} + +float3 gamecult_geometry_planetary_lon_lat_direction(float longitude, float latitude) +{ + float cos_latitude = cos(latitude); + return cultmath_normalize(float3(cos_latitude * cos(longitude), cos_latitude * sin(longitude), sin(latitude))); +} + +float gamecult_geometry_planetary_asinh(float value) { return log(value + sqrt(value * value + 1.0)); } +float gamecult_geometry_planetary_sinh(float value) { return 0.5 * (exp(value) - exp(-value)); } + +bool gamecult_geometry_planetary_azimuthal_forward(float3 direction, float2 center_lon_lat, int kind, out float2 coordinate) +{ + direction = cultmath_normalize(direction); + float longitude = gamecult_geometry_planetary_wrap_longitude(atan2(direction.y, direction.x) - center_lon_lat.x); + float latitude = asin(clamp(direction.z, -1.0, 1.0)); + float sin0 = sin(center_lon_lat.y), cos0 = cos(center_lon_lat.y); + float sin_latitude = sin(latitude), cos_latitude = cos(latitude), cos_longitude = cos(longitude); + float cos_c = clamp(sin0 * sin_latitude + cos0 * cos_latitude * cos_longitude, -1.0, 1.0); + float2 raw = float2(cos_latitude * sin(longitude), cos0 * sin_latitude - sin0 * cos_latitude * cos_longitude); + float factor = 1.0; + if (kind == 3) + { + if (cos_c < 0.0) { coordinate = 0.0; return false; } + } + else if (kind == 4) + { + float c = acos(cos_c); factor = (c < 1.0e-6 ? 1.0 : c / sin(c)) / CULTMATH_PI; + } + else if (kind == 5) factor = sqrt(2.0 / max(1.0 + cos_c, 1.0e-20)) * 0.5; + else + { + if (cos_c <= 0.0) { coordinate = 0.0; return false; } + factor = 1.0 / cos_c; + } + coordinate = raw * factor; + return true; +} + +bool gamecult_geometry_planetary_azimuthal_inverse(float2 coordinate, float2 center_lon_lat, int kind, out float3 direction) +{ + float rho = length(coordinate); + float c; + if (kind == 3) { if (rho > 1.0) { direction = 0.0; return false; } c = asin(clamp(rho, 0.0, 1.0)); } + else if (kind == 4) { if (rho > 1.0) { direction = 0.0; return false; } c = rho * CULTMATH_PI; } + else if (kind == 5) { if (rho > 1.0) { direction = 0.0; return false; } c = 2.0 * asin(clamp(rho, 0.0, 1.0)); } + else c = atan(rho); + if (rho < 1.0e-6) { direction = gamecult_geometry_planetary_lon_lat_direction(center_lon_lat.x, center_lon_lat.y); return true; } + float sin_c = sin(c), cos_c = cos(c), sin0 = sin(center_lon_lat.y), cos0 = cos(center_lon_lat.y); + float latitude = asin(clamp(cos_c * sin0 + coordinate.y * sin_c * cos0 / rho, -1.0, 1.0)); + float delta_longitude = atan2(coordinate.x * sin_c, rho * cos0 * cos_c - coordinate.y * sin0 * sin_c); + direction = gamecult_geometry_planetary_lon_lat_direction(gamecult_geometry_planetary_wrap_longitude(delta_longitude + center_lon_lat.x), latitude); + return true; +} + +bool gamecult_geometry_planetary_cube_atlas_forward(float3 direction, out float2 coordinate) +{ + int face; + float2 face_coordinate = gamecult_geometry_planetary_face_coordinate(direction, face); + int column = face % 3; + int row = face / 3; + coordinate = float2(-1.0 + (column + (face_coordinate.x + 1.0) * 0.5) * (2.0 / 3.0), 1.0 - (row + (face_coordinate.y + 1.0) * 0.5)); + return true; +} + +bool gamecult_geometry_planetary_cube_atlas_inverse(float2 coordinate, out float3 direction) +{ + if (any(coordinate < -1.0) || any(coordinate > 1.0)) { direction = 0.0; return false; } + int column = min((int)((coordinate.x + 1.0) * 1.5), 2); + int row = min((int)(1.0 - coordinate.y), 1); + float u = ((coordinate.x + 1.0) * 1.5 - column) * 2.0 - 1.0; + float v = ((1.0 - coordinate.y) - row) * 2.0 - 1.0; + direction = gamecult_geometry_planetary_face_direction(row * 3 + column, float2(u, v)); + return true; +} + +bool gamecult_geometry_planetary_projection_forward(float3 direction, int kind, float3 center_lon_lat_scale, out float2 coordinate) +{ + float scale = max(center_lon_lat_scale.z, 1.0e-20); + if (kind == 6) + { + bool valid = gamecult_geometry_planetary_cube_atlas_forward(direction, coordinate); + coordinate /= scale; return valid; + } + if (kind >= 3) + { + bool valid = gamecult_geometry_planetary_azimuthal_forward(direction, center_lon_lat_scale.xy, kind, coordinate); + coordinate /= scale; return valid; + } + direction = cultmath_normalize(direction); + float longitude = gamecult_geometry_planetary_wrap_longitude(atan2(direction.y, direction.x) - center_lon_lat_scale.x); + float latitude = asin(clamp(direction.z, -1.0, 1.0)); + if (kind == 0) coordinate = float2(longitude / CULTMATH_PI, latitude / CULTMATH_HALF_PI); + else if (kind == 1) + { + const float maximum_latitude = 1.4844222297453324; + if (abs(latitude) > maximum_latitude) { coordinate = 0.0; return false; } + coordinate = float2(longitude / CULTMATH_PI, gamecult_geometry_planetary_asinh(tan(latitude)) / CULTMATH_PI); + } + else + { + float3 centered = gamecult_geometry_planetary_lon_lat_direction(longitude, latitude); + coordinate = gamecult_geometry_planetary_equal_earth_forward(centered); + } + coordinate /= scale; + return true; +} + +bool gamecult_geometry_planetary_projection_inverse(float2 coordinate, int kind, float3 center_lon_lat_scale, out float3 direction) +{ + coordinate *= max(center_lon_lat_scale.z, 1.0e-20); + if (kind == 6) return gamecult_geometry_planetary_cube_atlas_inverse(coordinate, direction); + if (kind >= 3) return gamecult_geometry_planetary_azimuthal_inverse(coordinate, center_lon_lat_scale.xy, kind, direction); + if (any(abs(coordinate) > 1.0)) { direction = 0.0; return false; } + float longitude, latitude; + if (kind == 0) { longitude = coordinate.x * CULTMATH_PI; latitude = coordinate.y * CULTMATH_HALF_PI; } + else if (kind == 1) { longitude = coordinate.x * CULTMATH_PI; latitude = atan(gamecult_geometry_planetary_sinh(coordinate.y * CULTMATH_PI)); } + else + { + float3 centered = gamecult_geometry_planetary_equal_earth_inverse(coordinate); + longitude = atan2(centered.y, centered.x); latitude = asin(clamp(centered.z, -1.0, 1.0)); + } + direction = gamecult_geometry_planetary_lon_lat_direction(gamecult_geometry_planetary_wrap_longitude(longitude + center_lon_lat_scale.x), latitude); + return true; +} + +struct GameCultGeometryPlanetaryFieldDefinition +{ + float radius; + int seed; + GameCultGeometryAdvancedErosionParameters erosion; +}; + +struct GameCultGeometryPlanetaryBaseFieldSample +{ + float radial_displacement; + float3 radial_gradient; + float field_value; + float3 field_gradient; + float fade_target; +}; + +struct GameCultGeometryPlanetarySurfaceSample +{ + float radial_displacement; + float3 tangent_gradient; + float3 surface_normal; + float slope; + float ridge; + float gully; + float finest_resolved_wavelength; + float unresolved_height_bound; +}; + +struct GameCultGeometryPlanetaryPageSample +{ + float4 height_gradient; + float2 masks; +}; + +GameCultGeometryPlanetaryPageSample gamecult_geometry_planetary_page_lerp( + GameCultGeometryPlanetaryPageSample a, + GameCultGeometryPlanetaryPageSample b, + GameCultGeometryPlanetaryPageSample c, + GameCultGeometryPlanetaryPageSample d, + float2 fraction) +{ + GameCultGeometryPlanetaryPageSample result; + result.height_gradient = lerp(lerp(a.height_gradient, b.height_gradient, fraction.x), lerp(c.height_gradient, d.height_gradient, fraction.x), fraction.y); + result.masks = lerp(lerp(a.masks, b.masks, fraction.x), lerp(c.masks, d.masks, fraction.x), fraction.y); + return result; +} + +GameCultGeometryPlanetaryPageSample gamecult_geometry_planetary_residual_sample( + GameCultGeometryPlanetarySurfaceSample child, + GameCultGeometryPlanetarySurfaceSample parent, + bool has_parent) +{ + GameCultGeometryPlanetaryPageSample result; + result.height_gradient = float4(child.radial_displacement, child.tangent_gradient); + if (has_parent) result.height_gradient -= float4(parent.radial_displacement, parent.tangent_gradient); + result.masks = float2(child.ridge, child.gully); + return result; +} + +float4 gamecult_geometry_planetary_summary_empty() +{ + return float4(3.402823466e+38, -3.402823466e+38, 0.0, 0.0); +} + +float4 gamecult_geometry_planetary_summary_accumulate(float4 summary, float4 height_gradient, float unresolved_height) +{ + summary.x = min(summary.x, height_gradient.x); + summary.y = max(summary.y, height_gradient.x); + summary.z = max(summary.z, length(height_gradient.yzw)); + summary.w = max(summary.w, max(unresolved_height, 0.0)); + return summary; +} + +float gamecult_geometry_planetary_pow4(float value) { float square = value * value; return square * square; } + +GameCultGeometryPlanetarySurfaceSample gamecult_geometry_planetary_field_sample( + GameCultGeometryPlanetaryFieldDefinition definition, + float3 unit_direction, + GameCultGeometryPlanetaryBaseFieldSample base_sample, + float footprint_meters) +{ + float3 direction = cultmath_normalize(unit_direction); + float3 radial_gradient = base_sample.radial_gradient - direction * dot(base_sample.radial_gradient, direction); + float3 field_gradient = base_sample.field_gradient - direction * dot(base_sample.field_gradient, direction); + GameCultGeometryAdvancedErosionParameters p = definition.erosion; + GameCultGeometryErosionBandSelection band = gamecult_geometry_select_erosion_bands( + p.scale * p.cell_scale, footprint_meters, p.octaves, p.lacunarity, + p.strength * p.scale, p.gain, 2.0); + float3 powers = float3( + gamecult_geometry_planetary_pow4(abs(direction.x)), + gamecult_geometry_planetary_pow4(abs(direction.y)), + gamecult_geometry_planetary_pow4(abs(direction.z))); + float power_sum = max(powers.x + powers.y + powers.z, 1.0e-6); + float3 weights = powers / power_sum; + float3 world = direction * definition.radius; + float seed = (float)definition.seed; + GameCultGeometryAdvancedErosionResult xy = gamecult_geometry_advanced_erosion_filter_banded( + world.xy + float2(713.0 + seed * 0.754877666, -291.0 + seed * 0.569840296), + float3(base_sample.field_value, field_gradient.x, field_gradient.y), base_sample.fade_target, p, band); + GameCultGeometryAdvancedErosionResult yz = gamecult_geometry_advanced_erosion_filter_banded( + world.yz + float2(-431.0 + seed * 0.438289027, 887.0 + seed * 0.328438163), + float3(base_sample.field_value, field_gradient.y, field_gradient.z), base_sample.fade_target, p, band); + GameCultGeometryAdvancedErosionResult zx = gamecult_geometry_advanced_erosion_filter_banded( + world.zx + float2(197.0 + seed * 0.219783071, 557.0 + seed * 0.145898034), + float3(base_sample.field_value, field_gradient.z, field_gradient.x), base_sample.fade_target, p, band); + float erosion_height = xy.delta.x * weights.z + yz.delta.x * weights.x + zx.delta.x * weights.y; + float3 erosion_gradient = float3( + xy.delta.y * weights.z + zx.delta.z * weights.y, + xy.delta.z * weights.z + yz.delta.y * weights.x, + yz.delta.z * weights.x + zx.delta.y * weights.y); + float3 power_gradient_x = float3(4.0 * direction.x * direction.x * direction.x / definition.radius, 0.0, 0.0); + float3 power_gradient_y = float3(0.0, 4.0 * direction.y * direction.y * direction.y / definition.radius, 0.0); + float3 power_gradient_z = float3(0.0, 0.0, 4.0 * direction.z * direction.z * direction.z / definition.radius); + power_gradient_x -= direction * dot(power_gradient_x, direction); + power_gradient_y -= direction * dot(power_gradient_y, direction); + power_gradient_z -= direction * dot(power_gradient_z, direction); + float3 power_sum_gradient = power_gradient_x + power_gradient_y + power_gradient_z; + float inverse_power_sum_squared = 1.0 / (power_sum * power_sum); + float3 weight_gradient_x = (power_gradient_x * power_sum - power_sum_gradient * powers.x) * inverse_power_sum_squared; + float3 weight_gradient_y = (power_gradient_y * power_sum - power_sum_gradient * powers.y) * inverse_power_sum_squared; + float3 weight_gradient_z = (power_gradient_z * power_sum - power_sum_gradient * powers.z) * inverse_power_sum_squared; + erosion_gradient += yz.delta.x * weight_gradient_x + zx.delta.x * weight_gradient_y + xy.delta.x * weight_gradient_z; + float3 gradient = radial_gradient + erosion_gradient; + gradient -= direction * dot(gradient, direction); + float ridge_evidence = xy.ridge_map * weights.z + yz.ridge_map * weights.x + zx.ridge_map * weights.y; + GameCultGeometryPlanetarySurfaceSample result; + result.radial_displacement = base_sample.radial_displacement + erosion_height; + result.tangent_gradient = gradient; + result.surface_normal = gamecult_geometry_planetary_surface_normal(direction, gradient); + result.slope = length(gradient); + result.ridge = saturate(ridge_evidence * 0.5 + 0.5); + result.gully = saturate(0.5 - ridge_evidence * 0.5); + result.finest_resolved_wavelength = band.finest_included_wavelength; + result.unresolved_height_bound = band.unresolved_height_bound; + return result; +} + +#endif diff --git a/unity/org.gamecult.geometry/Shaders/Planetary.hlsl.meta b/unity/org.gamecult.geometry/Shaders/Planetary.hlsl.meta new file mode 100644 index 00000000..009c2989 --- /dev/null +++ b/unity/org.gamecult.geometry/Shaders/Planetary.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: bb055ad5328888e479e90ae3fe62c3fe +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Shaders/PlanetaryRadialRefinement.hlsl b/unity/org.gamecult.geometry/Shaders/PlanetaryRadialRefinement.hlsl new file mode 100644 index 00000000..20f3e2e5 --- /dev/null +++ b/unity/org.gamecult.geometry/Shaders/PlanetaryRadialRefinement.hlsl @@ -0,0 +1,27 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +#ifndef GAMECULT_GEOMETRY_PLANETARY_RADIAL_REFINEMENT_HLSL +#define GAMECULT_GEOMETRY_PLANETARY_RADIAL_REFINEMENT_HLSL + +float3 gamecult_geometry_planetary_radial_refinement_step( + float3 position, + float3 ray_direction, + float3 center, + float target_radius, + float minimum_derivative, + float maximum_correction) +{ + float3 local=position-center; + float radius=length(local); + float3 radial=local/max(radius,0.0001); + float error=radius-target_radius; + float derivative=dot(ray_direction,radial); + float safe_minimum=max(minimum_derivative,0.0001); + float safe_derivative=abs(derivative)>safe_minimum?derivative:(derivative<0?-safe_minimum:safe_minimum); + float correction=clamp(-error/safe_derivative,-abs(maximum_correction),abs(maximum_correction)); + return position+ray_direction*correction; +} + +#endif diff --git a/unity/org.gamecult.geometry/Shaders/PlanetaryRadialRefinement.hlsl.meta b/unity/org.gamecult.geometry/Shaders/PlanetaryRadialRefinement.hlsl.meta new file mode 100644 index 00000000..28d629b1 --- /dev/null +++ b/unity/org.gamecult.geometry/Shaders/PlanetaryRadialRefinement.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 356389371b854e12b9d419f7ee310d81 +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Shaders/SphericalErosion.hlsl b/unity/org.gamecult.geometry/Shaders/SphericalErosion.hlsl new file mode 100644 index 00000000..107ba24b --- /dev/null +++ b/unity/org.gamecult.geometry/Shaders/SphericalErosion.hlsl @@ -0,0 +1,80 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +#ifndef GAMECULT_GEOMETRY_SPHERICAL_EROSION_HLSL +#define GAMECULT_GEOMETRY_SPHERICAL_EROSION_HLSL + +struct GameCultGeometrySphericalErosionParameters +{ + float frequency; float amplitude; float lacunarity; float gain; + float slope_strength; float detail; float seed; int octaves; +}; + +struct GameCultGeometrySphericalErosionSample +{ + float height_offset; float ridge; float gully; float3 gradient; +}; + +float gamecult_geometry_spherical_cell_wave(float3 position, float3 flow, float3 cell, float seed) +{ + float3 pivot = cell + float3(cultmath_hash(cell + seed + 11.7), cultmath_hash(cell.yzx + seed + 37.1), cultmath_hash(cell.zyx + seed + 73.9)); + return dot(position - pivot, flow) * 6.28318530718 + cultmath_hash(cell + seed + 101.3) * 6.28318530718; +} + +float gamecult_geometry_spherical_directional_wave(float3 position, float3 flow, float seed) +{ + float3 cell = floor(position); + float3 local = cultmath_frac(position); + float3 weights = local * local * (3.0 - 2.0 * local); + float z0 = cultmath_lerp( + cultmath_lerp(gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(0,0,0), seed), gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(1,0,0), seed), weights.x), + cultmath_lerp(gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(0,1,0), seed), gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(1,1,0), seed), weights.x), weights.y); + float z1 = cultmath_lerp( + cultmath_lerp(gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(0,0,1), seed), gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(1,0,1), seed), weights.x), + cultmath_lerp(gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(0,1,1), seed), gamecult_geometry_spherical_cell_wave(position, flow, cell + float3(1,1,1), seed), weights.x), weights.y); + return cultmath_lerp(z0, z1, weights.z); +} + +float3 gamecult_geometry_spherical_stable_tangent(float3 position) +{ + float3 axis = abs(position.z) < 0.8 ? float3(0,0,1) : float3(0,1,0); + return cultmath_normalize(cross(axis, position)); +} + +GameCultGeometrySphericalErosionSample gamecult_geometry_spherical_erosion(float3 unit_position, float base_height, float3 base_gradient, GameCultGeometrySphericalErosionParameters p) +{ + float3 position = cultmath_normalize(unit_position); + float3 gradient = base_gradient - position * dot(base_gradient, position); + float slope = length(gradient); + float3 flow = slope > 1.0e-6 ? -gradient / slope : gamecult_geometry_spherical_stable_tangent(position); + float frequency = max(p.frequency, 0.001); + float amplitude = p.amplitude; + float combi_mask = 1.0 - pow(1.0 - cultmath_saturate(slope * p.slope_strength), 2.0); + float fade_target = cultmath_saturate(base_height * 0.5 + 0.5) * 2.0 - 1.0; + GameCultGeometrySphericalErosionSample result = (GameCultGeometrySphericalErosionSample)0; + [loop] for (int octave = 0; octave < min(max(p.octaves, 0), 8); octave++) + { + float phase = gamecult_geometry_spherical_directional_wave(position * frequency, flow, p.seed + octave * 19.19); + float wave = cos(phase); + float wave_slope = sin(phase); + float faded = cultmath_lerp(fade_target, wave, combi_mask); + result.height_offset += faded * amplitude; + result.ridge = max(result.ridge, cultmath_saturate(faded)); + result.gully = max(result.gully, cultmath_saturate(-faded)); + float3 across = cultmath_normalize(cross(position, flow)); + gradient += across * (sign(wave_slope) * amplitude * frequency * combi_mask); + gradient -= position * dot(gradient, position); + slope = length(gradient); + flow = slope > 1.0e-6 ? -gradient / slope : gamecult_geometry_spherical_stable_tangent(position); + float new_mask = 1.0 - pow(1.0 - cultmath_saturate(abs(wave_slope) * 1.35), 2.0); + combi_mask = (1.0 - pow(1.0 - cultmath_saturate(combi_mask), max(p.detail, 0.001))) * new_mask; + fade_target = faded; + frequency *= max(p.lacunarity, 1.01); + amplitude *= cultmath_saturate(p.gain); + } + result.gradient = gradient; + return result; +} + +#endif diff --git a/unity/org.gamecult.geometry/Shaders/SphericalErosion.hlsl.meta b/unity/org.gamecult.geometry/Shaders/SphericalErosion.hlsl.meta new file mode 100644 index 00000000..7b7ddc79 --- /dev/null +++ b/unity/org.gamecult.geometry/Shaders/SphericalErosion.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 43d458fc5b6b441a941e37001df919e3 +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/THIRD-PARTY-NOTICES.md b/unity/org.gamecult.geometry/THIRD-PARTY-NOTICES.md new file mode 100644 index 00000000..3388b601 --- /dev/null +++ b/unity/org.gamecult.geometry/THIRD-PARTY-NOTICES.md @@ -0,0 +1,24 @@ +# GameCult.Geometry source provenance + +## CultMath planetary geometry + +The C# files under `Planetary/` and the HLSL files under `Shaders/` are licensed +under the Mozilla Public License 2.0 and retain MPL-2.0 notices. CultMath owns +the numeric primitives used by these geometry systems; GameCult.Geometry owns +their spatial meaning. + +## Advanced Terrain Erosion Filter + +`Planetary/AdvancedErosionFilter.cs` and `Shaders/AdvancedErosionFilter.hlsl` +adapt the mathematical procedure from +Rune Skovbo Johansen's **Advanced Terrain Erosion Filter**. + +- Copyright 2025 Rune Skovbo Johansen. +- Upstream: +- Explanation: +- License: Mozilla Public License 2.0, + +The implementation includes C#/HLSL type lowering, bounded octave counts, +finite zero-rounding behavior, and a shared integer avalanche hash for CPU/GPU +cell-selection parity. The technique builds on earlier work credited upstream +to Fewes and Clay John; the hash form follows public shader math by Inigo Quilez. From 0a3aebd12e47c23e836d8168ac85bc2175f88e03 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 04:37:09 +0100 Subject: [PATCH 21/30] Normalize Geometry release package versions --- scripts/build-geometry-candidate.ps1 | 16 +++++++++++----- scripts/build-geometry-unity-package.ps1 | 12 ++++++++---- .../GameCult.Geometry.csproj | 13 ++++++++----- .../Runtime/Plugins/GameCult.Geometry.dll | Bin 144384 -> 144384 bytes .../Runtime/Plugins/GameCult.Geometry.pdb | Bin 62124 -> 62084 bytes .../org.gamecult.geometry/Runtime/Unity.meta | 8 ++++++++ .../GameCult.Geometry.Unity.asmdef | 0 .../GameCult.Geometry.Unity.asmdef.meta | 0 .../{ => Unity}/PlanetaryPageUpload.cs | 0 .../{ => Unity}/PlanetaryPageUpload.cs.meta | 0 .../{ => Unity}/PlanetaryPatchMeshAdapter.cs | 0 .../PlanetaryPatchMeshAdapter.cs.meta | 0 12 files changed, 35 insertions(+), 14 deletions(-) create mode 100644 unity/org.gamecult.geometry/Runtime/Unity.meta rename unity/org.gamecult.geometry/Runtime/{ => Unity}/GameCult.Geometry.Unity.asmdef (100%) rename unity/org.gamecult.geometry/Runtime/{ => Unity}/GameCult.Geometry.Unity.asmdef.meta (100%) rename unity/org.gamecult.geometry/Runtime/{ => Unity}/PlanetaryPageUpload.cs (100%) rename unity/org.gamecult.geometry/Runtime/{ => Unity}/PlanetaryPageUpload.cs.meta (100%) rename unity/org.gamecult.geometry/Runtime/{ => Unity}/PlanetaryPatchMeshAdapter.cs (100%) rename unity/org.gamecult.geometry/Runtime/{ => Unity}/PlanetaryPatchMeshAdapter.cs.meta (100%) diff --git a/scripts/build-geometry-candidate.ps1 b/scripts/build-geometry-candidate.ps1 index 0edffcf7..0643dd8b 100644 --- a/scripts/build-geometry-candidate.ps1 +++ b/scripts/build-geometry-candidate.ps1 @@ -1,5 +1,7 @@ param( - [string] $Version = "0.1.0-geometry-migration.2", + [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" ) @@ -9,6 +11,10 @@ $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", @@ -19,13 +25,13 @@ $supportProjects = @( ) foreach ($relativeProject in $supportProjects) { $supportProject = Join-Path $root $relativeProject - dotnet pack $supportProject -c Release -o $output --disable-build-servers -p:PackageVersion=$Version -p:UseSharedCompilation=false -m:1 + 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:UseSharedCompilation=false -m:1 +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:PackageVersion=$Version -p:CultMathCandidateFeed=$feed -p:UseSharedCompilation=false -m:1 +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.$Version.nupkg" +$package = Join-Path $output "GameCult.Geometry.$GeometryVersion.nupkg" if (-not (Test-Path -LiteralPath $package)) { throw "Geometry candidate package was not produced." } Write-Host $package diff --git a/scripts/build-geometry-unity-package.ps1 b/scripts/build-geometry-unity-package.ps1 index a16370eb..1bd9af53 100644 --- a/scripts/build-geometry-unity-package.ps1 +++ b/scripts/build-geometry-unity-package.ps1 @@ -1,7 +1,8 @@ param( [string] $Configuration = "Release", [string] $OutputDirectory = "artifacts\unity\org.gamecult.geometry", - [string] $CultMathCandidateFeed = "..\CultMath\.tools\local-feed" + [string] $CultMathCandidateFeed = "..\CultMath\.tools\local-feed", + [string] $CultMathVersion = "0.1.0" ) $ErrorActionPreference = "Stop" @@ -23,8 +24,8 @@ $candidateFeed = if ([System.IO.Path]::IsPathRooted($CultMathCandidateFeed)) { $pluginRoot = Join-Path $outputRoot "Runtime\Plugins" $templatePluginRoot = Join-Path $templateRoot "Runtime\Plugins" -if (-not (Test-Path -LiteralPath (Join-Path $candidateFeed "CultMath.0.1.0-geometry-migration.2.nupkg"))) { - throw "CultMath migration candidate is missing from $candidateFeed. Pack CultMath before staging Geometry." +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) { @@ -34,7 +35,7 @@ if (Test-Path -LiteralPath $outputRoot) { Remove-Item -LiteralPath $outputRoot -Recurse -Force } -dotnet restore $projectPath --disable-build-servers -p:CultMathCandidateFeed=$candidateFeed -p:UseSharedCompilation=false -m:1 +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" } @@ -57,6 +58,9 @@ $requiredTemplateFiles = @( "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", "Shaders\AdvancedErosionFilter.hlsl", "Shaders\GameCult.Geometry.hlsl", "Shaders\Planetary.hlsl", diff --git a/src/GameCult.Geometry/GameCult.Geometry.csproj b/src/GameCult.Geometry/GameCult.Geometry.csproj index 5f45e89b..705fa2f4 100644 --- a/src/GameCult.Geometry/GameCult.Geometry.csproj +++ b/src/GameCult.Geometry/GameCult.Geometry.csproj @@ -2,19 +2,22 @@ Spatial values, planetary geometry, queries, and typed geometry state built on CultMath primitives GameCult.Geometry + 0.1.0 + false game;geometry;planetary;csg;lod;cultcache;cultmesh README.md LICENSES.md $(NoWarn);1591 - 0.1.0-geometry-migration.2 + 0.1.0 + 1.0.3 - - - - + + + + diff --git a/unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.dll b/unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.dll index 5664e0edb164066c3f8b315e4a7c32f42c1380d4..11453d370809c6f32c900c307900632ce2a8bfb5 100644 GIT binary patch delta 2029 zcmYL~ZD>cC>zZ>aY4$^iQAXag!m!d7~%$dli{B8sCeQ2?{j|t z=RWs2=bq%I>ef`r+q_;vkf>t_abM9 z=bgN3eh@tTCvE;+aKq{O0z}}_JaD_I{sJ(N*Zq9&<%9goDjdITyIq^I^b3vUnJ)j( z;lRvwcQ-r1l%8aD)Hhj&XAf&;>6Q?iWjF01)_@wu8)72NF0(O_B%UOmWbd<6-U)Ow zJ7$T`5}!rB;LRYP6nWx#;(6rXcnipxjnugmfPX!d*B6)IRp6cc5>#6Fg$ zL2O}6_6&ln16wdq3#2XswP2tY4A_F4!lbqt5hpuAnxv!&wCCi-LF^$buVF>ss;H1( zC9N4$zw4NFOE9B)1d!T{dDbsGjzas9H4z{bBn_ic7YT}!Ms@Q9lDi^JWR^$pz-B1J}; zG=s)1k)t?IsRbncqC}*O@tUYmTqUhh&N^~k29M#+iV2%#iTUKzF=!nd69J2ADM%W& zsLdRwI6<1U(2^pb7AYcW(hQOru}E=_G*6iYb*SCQy)H3}{}Zg3m*NUGV?rZ&XjIh@137;fH}jK<(qyDnIRN&`|` zgZi1T#Gn_AIH3gT1R0IPSXdW@2DJ}MWR%GW4nsS~Ef^n$7R#itHLBP1_As=z;g7jd z)en+}NW+clXM9oFb0S43O*%`OK|3pdM%#zeCR8F_AuXe=iweb6(sd*;*%8P8naN@( z4o%#ydss6~i`3EFiOcfZ2uwk+_~Qt)0j|Q`Bn0vD^l|)C(2LRI@C8_W-crXlwqLf5 z!igt_JSC^9QE}GT1rO`2v6@GoAB9d#UmArzNF4IU-6|vDRxct&aoW9&9r7+xoTGS| z;u6K*y4B985UC=GdYMO62#^Lnio_{Ss8|k;L+{2zUWZpDIlWj~E{sDDOcbw;!&#?^ zFAuaO z?0;8(e|>f8BkAz*(cO>emWNaozxC(VJvT-_9eL$G`{ffX@aR}pu_NnjflKmxAKxd} z{M;)yb@AsnZZ|$kVr;t{?BYF5stt8Kyh_={6Y!S&r;EE_pxC~XFM!R@y4fx~{l!u@ ue+dkJW|mEU?tE;>vwrS-JS>*{JOHxd=Z9^92cPVl&lI=p=64|LxBL%lTuu@I delta 2143 zcmYk8ZA?>F7{||ZT4)O>LLCGnLh08#ftCJ10UDifc&f*oVY?S?AY-L5L0lXn00_-Xq6Jg?E;$ijy`^Ywc zZgTS|@loQV$Zyz^$R|aLc#3!m`9oV8`IN|^MhnFXo)Yrr0cg{e+wFjNNMk$LVM^{$ zr&)OfPrBFbNwnsqR2S#%X}pNauiL>43Heav-IFhMK*NqnQOnh^O{8XChZ8aLa+avD zbJeMjxSzC@f3GIUC(sR`J1gTIPz`f(ssk#G^J1E^W=PZMM`f-9?8cYHO)8i}ln^<- zz!qgeCk|muHgtly28W=bA&{CiGz1L|L4!k(gP2q`D}rQ)NW+vgfcBic(urf3m$$H@ zXGl|&nMOV*t`o_U=8(+GN7xxe zrLGI?BTq!Fo>rICu2=K%QS2ve)zj)C9}+<#A<{7M0pxl|j7Xexlz0+(nPZyB3~7pZ z8hKP)Cz4gV$+)Oj`*l&T_G=F9gvgVH z+0_mEj8VagR2q;Piqvbq*bPl+1PO&m2goSy!NO6IE>in2OGb_ie-Bi1+=9U#sL)Lc zL$P{1@Ag1t6~4^Hs=lAJl{8SSUgImuo)a-bane!JB--=xKH3hPHlZx(B54losK`^i zM7n|`A!~y8J2RPS4MHh5My;%zrbSv?UWd!l-wT6qEb~(@Q~|ESZ(;D`$=ioN3eikg zAAAA2{kCk)26jSLMd0}Q_pDiys*yJ>vu~`dZkd(X-VfsP@c0g#@7PP30kVQR+ z7{zhR1~zP)rg(

l9}xzF|>2BTr-r$@?~DRTX@seybuuibE=vokyW*&9JT3rjkrH zEG;h{g$6j6xpfpiG!+1TVKlPq3*#i){1u z^=JOt%r9u&ZL6g}GT6PBW3MN%mEcFO#G$U99cna2Y*uU|=#9vmf}7WU_3QZg&t{JM z&b_(j^q%0c9{Gph5zliP`|dea#UI8d2Nw4{z9iS*+j18Ryf_}HxOUuBfnSiz4!%>a zt>>?wGkj*9^}L~!Fm>an>Ukn^4CCf ziZ#em7dLhNXD|ozvS!wg&Ba0(H?plP%skjykVIH7s+%?8p9^U->%$BW5-(=EF>1t| o7Nilj4Ot&nVzR}>9sle5Z5Q`}!T0Q+guh!dWgh+r#$CF90l@%l;s5{u diff --git a/unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.pdb b/unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.pdb index c1f682e96d1bcca2058aef3873b27ab1a17829ff..2d182e8ecb6c0436024291bef3924a7533d8e93c 100644 GIT binary patch delta 4653 zcmY+{2Ut{B8V2D1&I}y}1_42-La>4a6~zjO5nV-%idZlry+|EY5F8Lh1f&gCR6rCG zjYKjFSdoy38pRcREQm3LRk3WW0TcH-cW&G)&-0$||Ia<=+2hZg*Lbbci?Au1TWw%{0SQ29O*+p7yyI8%OArGmVJ-lI<25J>9)7&8MZd?ZQGymRS^_DOF%?)M z8GFGnB=ad)vtgWI3X=IuFgrn6scmmZTC@Z+OrRhf$;9%o%666(twxgu6BD)QB(%Xx zAh{N4gQ=Xol{Q())f|w_aDgk5ahJ0LZMDgR-=ISe5y%b+F&H( zEEt1iCW1FxZ97hfBG7chvn2K`I;68&%?Qc#7g!^iPk8KBAy_>E9WzQW9?5tLe2~l> zfgh3y5CkKcC_w^}NrrUTw3f3505$MgtZBAH$uySa}pacgyH;aWY~2wx=Y(SQ^^3V<|F!mbqdAo^69 zqV^?{`9{!yJe9(3ITWjIp=0g~enm1*1+S2=_|PmEMxiZo4Sn=SRpQdqPXlT5XsmS145Pwo5y1Dc(twg9;xjlGT? z8PHO6%t}E7l8NK7d$<_T8Z;Buvxkw=wDoGUkj#9+5+oD2o^1-2QYxBDV5X4LDj1qB zrBg63LrNQ<6zVh7hh9L(qrC;*r5YVmD`-GA@W)z8sfj<gYKNDI+1 z%Xn;$9cF{jF_D6JB(s*s-pJpO6lf~44Cx6>--sGop%28_hNPEmL^j#%t!3n!t+o)! zEE5DFnMgr=HapxwM*qUZkWK2%t2fDrNfTrtvo@*UPeys@m=eKG65YD==*YDr7Bvf7HSZ?z)pZLGGYZ?;*}pL}gYySCd<$96VvOCdXKscHwS z?Z|qk9mVWq^&ryQHHZf9VzoWx?y{#EzIM>W1qaeoEj6wmueh4DTXZjJiRS+&+r!b0 zQ1%BmM+spPqy}GSufk_KKF8Vol2~1&9$!1e8z7~8eJS1uDdXz}3H^{JeEm*thV0MR zGm|Zl1C04*_!cNpKs&&35h6>C5d%C+H2c3xBeKz~DF(?6c})H`K5R|Otv4nN)#*yB0*LPKY2_a6Ds zKYjWJ^aVe>>+BFMF6^?FUMg9ySZq8j_mFDu?_Uq;G^^@8lx?Z8;9y6-&DG@S#m$O$ zxt%&1Qt4c2dPT>biN~z(E^<1aExDFieQuYN=O(dvzE|_5cSh%ilwSLJZyL!>C)U-^ zHTEB=yUZs1_7# zssmOU?uWY-u8KV)Uv|AJU;jMP=6Ib`_=lD<-#+tH)3*578@JkrEor+s^7Rhsy5M@b z^|fao^tL*F?R7@^;OVI&y)LP`Zc9CG9DI`yU}6w8@WS19wePmdi)#&+6>NAo+27`Q z^*`O+?lcy!(kK@XKb$)4Emu~sdqdrjlA5FC180XMCI*Iu%vGwsNY{wT$;~;T8n@!5 zgq2(5DwmMOVpbMiQ5A$&@nva>$}PH@U$E0b^&qZ-FZY~PWhc$y%YBxr+sTLcwFfS% za?`Z=*{|-Xwr9xr^6+ET;f-DVf;!A@Zsg1Q9jf_Te&New1*(a~+xha;2-U69e6bdu zp_x2RsG@f46b~6YHZ;V2Owibs?m}tsLzZ zs2Z`mQZ)UFQA#G+a#Yt++H^GhkXNNvwomJUt#hXbt_qo&6fJk28Wekbo4L`xoiu;KT zPuK=_Jzm5HBW2dPJR^2&{4NZk`K)SG+MHj_MX0u&uM%nU`y*6SF3ythw@miH67?fR gsLC$4OVqdIpEg3Z=6aSkzbw0)EfZ$-Ix0>63u0z3JOBUy delta 4696 zcmY+|30#d?9|!RNbMI}F?(J4mT2-<%Oo}3J6jwyD6SB02(xSyu-HJA8x!pv|P-GY~ z=o;Htnz6-D46=<3S!T@87(0Xacb@yqyz}IdS62jbFr`wi7Z zjw55@LW$_uAFA@N>lXWzO)<(3T(D^Vm`}2{y`>6HDL3HA$v{K~QjV;pB8rl8v_dW- zSvQUxx{2tpB}W%5MKs@*qeNQ~X&g8@?I5BsXO2>wMHJtUqgDMxlskZ<$^q!z(7TD~ zGY^hNc!;Q*7e{@(M6}nNqm$ku8tKc?OkWYM#DA$R^^BC}R2ykd=ipK#N7vyFJcK9k z0^WltiX#IshVEbsr{HIJ1W(~5`~~7@j=F#xEWi$&p+5|QVK5RV!XgER1Xu=2$b}*( zhicdiU%?(Y0LS1ov_KoQ!$Y;Fc(M8Ug&bXhpWr_H4j&h)4~iYSg)ibL29h8RRzrol zQ#?l=wV0zgNChS2spm+-l+$833Wjiqfh5R@cT!QOx2Oh!`cmeN06fY(Numfl44}+8#hQSyJgy|3rVXz1iU>UG=xljb< zPz{^mE7$`E;@NL=3EhpCP<(=fQWLbl@3o_ZGBB3|YZG)x4Tg!@Cy_*Y)H6}550d#@ z;DKbk1qviHUN8m8%n~d>GEsshNG3T^U8nEZ6K~2!#}o)kk<5A?R@n#W(MB{|A$+MG z-G`T8m4vH-D|jcdyX({FB&`4>GgUAf$%G`a1MT%Gn%`hRhRIqcNX8OelG)w*k^zlH z$4nH=Kr(X$;m8=a+J1-usnEz$49Gl%-Nk?gqGpB&Mj#nK!35-l6!xkJte%FBnIi~A zG7AOqNG4T~fn;(7YmtmvP=#dbU_0z)Z?gZ)fcB$FOf{gb&;o6#?7Hl$3w5MwJw`rG zWmoHo*=OjOH-b+{Mt2$W_bMZFjG4d+$vE)X%g1)1zG%{y8PYDuTW&}rR~S+bY=v*( z`xWdWUFqx!t&2$JnxGx|U3@WB(s6X-r;IQo6v-1u**m(HbW~3$*dG)BbkB>wkcdn^=MwfaHW*i z!Q@OSJ%Hs|QrZDW;C7bw(C4U`_X2UYlo&&SDUz`gI3iuL*^35AX>PXmkTsZO$^=zN zX0u=il4)cc9WC)$TF^0Vf_7v(f2_5X9`eV^$bXgABqTF)m3F&~qR}yNf)r#5|5Q#g zN=L`6=CM6JF8! znhv;-k0bNhaVBI`pk;<+tOO1PY}02ZGzJqTPyt^+J?w-=I0WtR5p36}<$4xM6Y90b zgd!mgzJgYG2j+z)WCwu|QOMqIN~wif8Av8auolUv1yx9s5X9-{GJ^E%n zaqxaTWR40>BAFj}?4v17=|?o_YfY&YZowmXy4IXtf?<(4Wfc8=EK81Zia1*P&m2c; z^wt0Bx1iq%nj#5(%g;;bNio|iA>R^~Iuuo+L+PwmqA5UA3Oj{|2T&h_cAdel9Z46rU$i_ z^`PNu_P~lP%B^T+IjgNnvCf*Z*Rk4$KCH7LzY12{(zOa(a$e7BJNkaT9qDdhwLKMW zu%{pRx+nQn_N0nRHt#_0RSq<)B>PdEqpBzgQird$F5uq#0k^Cz~U? z^L65KOJoldzL}a~jkMwGd+NFgmP(bPH|dhi=-&AGBWX+rzKyFCVopLOiVBgKw&`pU zNvKG%A9b~E8^+NFuP}N`&Bd6Jdte;ZZ3f1tmSJKE6)I9tt8FVVK62QPalLaJ#`=D* zFdiOYEh2}As9j5bMjfPC8E7U`mORfL;o$kn=hOCrfGLMp#@d7}EmHW8*F4?Z zIa2y(uq^9s3VvO8O}n+|mXC3|=Nmg+?_MWWY2$AP_o*K+FssM7n!~cKvMpD~_6T~i z<6DjNUHwPD;|*i|izdv8-~K2hB(2@BOcW z8kKqx?%BOx?9uVLN!dX=JIoK~w2mL7v)tJ5N56*+2K{1uLjI7%lqVOL`;6s<&C;BB z=~H#_^5Xo*rxPZhlb&t0+vDULq<%J6Sw6Tm@VCG2Ua@O-IrB0jxA&zTej!)(_}Vnf zYTKg6UK~8Bbl2Em_sq^L>jNz_=2lfT?lL`6Yh0i8P3B-%nY`xZ$|naEvn%@ax>D?7 z-dx9o{Jg=8{ry{>Gze8iZ;JJjAMKv1zh*@G* zmcG+OMsMNE^357)%$NLv$^gxw_*%aFqEj<3c_LqKR%jZR@8s8ReWM9Y*XL(ClYYP$}-? z?iu0f8sa|4Bg`#qsB2`X>%hT-UEMsx2e}P&4;kn-C~|0cFpb` z$G=`@pPUr2;Kb-Mw}r2_%}Jc7KDx2HMSIaEYCjFs|wN@TaMPWz*~aauBOJ z|5-jEVrjInc67?9v(zM^^+mUuQWAl7G*hJjogI``KD7%Q$_Uv*Rb&SqU5)OxXtZJfR;OhNy#^3ys;a z3EyLg+{dar>etOFoJzCg>=uzOzh9;4em+3LzjAprmT0e}(k#7rNuqrve_NGi;?>pq Q{Ic9LY?(0Ial6j!zn~XMIRF3v diff --git a/unity/org.gamecult.geometry/Runtime/Unity.meta b/unity/org.gamecult.geometry/Runtime/Unity.meta new file mode 100644 index 00000000..0b6207ab --- /dev/null +++ b/unity/org.gamecult.geometry/Runtime/Unity.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3436e51255844bf896e76ecfce1a8a5c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Runtime/GameCult.Geometry.Unity.asmdef b/unity/org.gamecult.geometry/Runtime/Unity/GameCult.Geometry.Unity.asmdef similarity index 100% rename from unity/org.gamecult.geometry/Runtime/GameCult.Geometry.Unity.asmdef rename to unity/org.gamecult.geometry/Runtime/Unity/GameCult.Geometry.Unity.asmdef diff --git a/unity/org.gamecult.geometry/Runtime/GameCult.Geometry.Unity.asmdef.meta b/unity/org.gamecult.geometry/Runtime/Unity/GameCult.Geometry.Unity.asmdef.meta similarity index 100% rename from unity/org.gamecult.geometry/Runtime/GameCult.Geometry.Unity.asmdef.meta rename to unity/org.gamecult.geometry/Runtime/Unity/GameCult.Geometry.Unity.asmdef.meta diff --git a/unity/org.gamecult.geometry/Runtime/PlanetaryPageUpload.cs b/unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPageUpload.cs similarity index 100% rename from unity/org.gamecult.geometry/Runtime/PlanetaryPageUpload.cs rename to unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPageUpload.cs diff --git a/unity/org.gamecult.geometry/Runtime/PlanetaryPageUpload.cs.meta b/unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPageUpload.cs.meta similarity index 100% rename from unity/org.gamecult.geometry/Runtime/PlanetaryPageUpload.cs.meta rename to unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPageUpload.cs.meta diff --git a/unity/org.gamecult.geometry/Runtime/PlanetaryPatchMeshAdapter.cs b/unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPatchMeshAdapter.cs similarity index 100% rename from unity/org.gamecult.geometry/Runtime/PlanetaryPatchMeshAdapter.cs rename to unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPatchMeshAdapter.cs diff --git a/unity/org.gamecult.geometry/Runtime/PlanetaryPatchMeshAdapter.cs.meta b/unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPatchMeshAdapter.cs.meta similarity index 100% rename from unity/org.gamecult.geometry/Runtime/PlanetaryPatchMeshAdapter.cs.meta rename to unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPatchMeshAdapter.cs.meta From 383a13b5e294824abcb50fff37bb52e31f67b28c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 04:38:57 +0100 Subject: [PATCH 22/30] Refresh Geometry Unity release assembly --- .../Runtime/Plugins/GameCult.Geometry.dll | Bin 144384 -> 144384 bytes .../Runtime/Plugins/GameCult.Geometry.pdb | Bin 62084 -> 62084 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.dll b/unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.dll index 11453d370809c6f32c900c307900632ce2a8bfb5..55d82a70537c2b8be23b74858b3bd5300d65307a 100644 GIT binary patch delta 114 zcmZp8!O`%7V?qauQC`E6#-6P`j1&F|C})eNTGvWAtrZe52oBjMzFp@(V}>@%o}BMX zr_a!03J{p8&Xc8D_}FJ&f5nZ%Opzz1|IlJeQ-F$|iGzxQ)L++Y{&V%!RI#T1cb(ho Iw3(VX0JbVJj{pDw delta 114 zcmZp8!O`%7V?qbZ3DZk28hf_(Fi!X{&t=Jj2YT23)#58 zPoJU16d=I%dvQ{ut$_2yzsElnoh_U){f8D)ngUEz5Go2%f9;#7$)&XANrC?vUTv?_ IW@_R906QZx`2YX_ diff --git a/unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.pdb b/unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.pdb index 2d182e8ecb6c0436024291bef3924a7533d8e93c..f333f5d4b7244c1d220064be51a42f672d4f430e 100644 GIT binary patch delta 313 zcmZp9%G~mlc|wiIRCS&#)xyU<^ZF}p9A=6F87#WooqgUV`OG=@ z)WXy;@9bp5`LdHa=L<+E|68`4p@D&+-)&nOTl^cBmiqFJ(DnN+0u?CN--w;|gy8@K zLpAHHT0NBq&fPq7GDWm+E_6@KO?58KDbe%EPfyRxOV>-u$zfn%EN(yA2Qq7MslWlj zgq6TEm14a nFUm$3znF37QHBF-4D3stIehAV`%QfJ@v^3SklE&+^SgrpJ_C4p delta 313 zcmZp9%G~mlc|wf{+wa9mjkW^L6aOCnRCKm*%0f2o?;9uh%+@eBPfN8lO*T)mOis2; zHL^5KG)zu5urN$9GBvd{NwG{zvq(-dF-zRcGUo!5#B8o(iy0O$GMtUM?r#IL6N+u_Q6Q;Rd8YEvo?%x5^j!r Date: Wed, 22 Jul 2026 04:40:44 +0100 Subject: [PATCH 23/30] Stabilize Geometry Unity release binaries --- .../GameCult.Geometry.csproj | 2 ++ .../Runtime/Plugins/GameCult.Geometry.dll | Bin 144384 -> 144384 bytes .../Runtime/Plugins/GameCult.Geometry.pdb | Bin 62084 -> 61276 bytes 3 files changed, 2 insertions(+) diff --git a/src/GameCult.Geometry/GameCult.Geometry.csproj b/src/GameCult.Geometry/GameCult.Geometry.csproj index 705fa2f4..c8c9800a 100644 --- a/src/GameCult.Geometry/GameCult.Geometry.csproj +++ b/src/GameCult.Geometry/GameCult.Geometry.csproj @@ -4,6 +4,8 @@ GameCult.Geometry 0.1.0 false + false + false game;geometry;planetary;csg;lod;cultcache;cultmesh README.md diff --git a/unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.dll b/unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.dll index 55d82a70537c2b8be23b74858b3bd5300d65307a..6e6138a9c216fa04ee3436e5de47a52b69e64645 100644 GIT binary patch delta 114 zcmZp8!O`%7V?qZD`;ntl8hf_(Fi!X3&r71u~%VDBG^~Q5ovNz;~)!Ex`G4|eG Jr_I#F0RUb9Froke delta 114 zcmZp8!O`%7V?qauQC`E6#-6P`j1&F|C})eNTGvWAtrZe52oBjMzFp@(V}>@%o}BMX zr_a!03J{p8&Xc8D_}FJ&f5nZ%Opzz1|IlJeQ-F$|iGzxQ)L++Y{&V%!RI#T1cb(ho Iw3(VX0JbVJj{pDw diff --git a/unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.pdb b/unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.pdb index f333f5d4b7244c1d220064be51a42f672d4f430e..8617282e829ba861b44c66558a082c6e54b3c8b1 100644 GIT binary patch delta 4472 zcmY+{30PF+8VB(AoS9*mfPop1ML=g76iHG^uS+gHBx<-&NN$KLt1=8bh$bTeDuO1C z0j36uOh zbM9Ns>H$d$CJ@<5iR^Ji9eUIW1$pxv>G_0L(qtkn(l#l9W}{xs^_WSqF~|<|_6GFN z+Oy}PA3oyf;bX%#jd}alojxWp(Gk!bBh{{M5W2Y{gl6Y4oj_mdL@IC-XqlTxb`OD^ z9wHq#3UuBmQl43$O0!6z0Rr_45b0pBK&OL6^0x>SVG-$wO`x+jk-i8Q=yJG7S9=Te zdvEl8(f1YUmHq;4?l01UK>}3|63K6EtNL12Xlh=L&y4Y3dp(;zcla8eD5 zwXgxUz;mgB&P^QYVH=sL8^o5|i0 zHe2C=WM)+ml37&Y$euj(QT+qShN(s(+1PB?CU={a^fUz>o2g1gGP?|`EaUZ*k7gkZ z$M zh2|;3kgT`rDI^;L(Y)F+*g$D$F2VIYeij3{|NCQ=)8B<`Oh0qM!3ixsP^%#9npxBSxU%-!M z#q1Gu>|@m_B>P78=c@0~v8$>MB)co)!=HPM{y;Oa(1Tuvq#_RrDE6R4SO}}3zL+1x zlU^@Yyn$rfRT6Sr+7eG<9x5}^T*5bpcv4V_vS>XfS+i=Jydcn%K9LuA(b$EGcx3!S zemhq3qN(WEEL9qkWy$!AeqL07X7D0D$4JjEQp6(J3#utdHggf*lwzb3G$+9KB_qv; zo{NpN7vf5dyP&#)WF4w|NOJN)1B~>PQ`uye(?~2`m5pS@ zDkqXH;~Q;3IJOxb+oqC`lDut*ky_<#y{X$0MGqv4T%z3WO~cW#7}YrBIQgoHylEmj zHciI&STQ>r9ZOd^kgQO~?`Wzwy@Y0CnKxa5n3qw*A-D%r?oICHJ`_^U@7hG;$`uol zY?^8|lBKI0<-B>AiB4joM}=~8euatHLRA^EtU`HzCR&b;y`ri^*6~$KKND?4^8swG z#Fmx3oteI`R9r=}4%OXCzA4g7Axq7a0-2BpC9njR!77m83;1OzzB~px&GZ|Zq07t^ z52dgLK85RW8*IzX^z?E*-IvBMS0o@=qG}G3WvH@|tVp#O$tvW1crW$k+Uf3R#m3dR z$kwRVAz71*-bdTszUsM3V|j)QdH!T_CCbe$+?cvfAI0(~COg zAv)gJ|rkAad?s#f!SqmRGuxFaKhp zVES=&FuhRAbqEEm385)#xDKVy*Mw5Ai|cN*-PMim%XN3EU)!C&S+<>d35Ng3`T7+5V@MCVwmQ6!_@u%735O37e?q9^vwe|%a{X(rKQchB z#}ox2yYlCTK)n`)AVcMPcX@ZD1zI&m;UkT=uwUaNgyZ;I(c$Dqu`w6%w|XgBBWS53 zx>?lHPtj*Y4gJ&Y4#p-Ae~pIzFb>4{rg<{P_<&@MmTp8Bp}rGbhjFZB7slUgXD}w| zd)+~VM*3^<6|&zzjEDM9#<+b@5yt0+)?qw5!lu*Gwdj$0EnSIjHfZT`^wgeuT2(o_ z*qsJf&n`AlNp%zI8yF3wtGR&s$r`CNZlJq#c=BDHYsQSeV&zuXsu@-x*wr{I*!B7> zyIW=F(k@F!OFpx2Y2|vwL&-gLsz$C?&5+VEHY@cSon*AXp{%LfA??X&ROY3^dZGQTxV$}aO!>TQKmW#uJh%?`|0?NVyVCiPxS{ZTvj zx}q9GU4}-VzypDK_h|`Hb=}mBhnh0tUhB8vLBq9o0c~QSL7(y2>FM~D-zUXx$G?!4U+*^XUKQ2nNB$-}8`D6pjk(vm z7e87)AvHfkT|4%{sd{SDQlIY6_js+fPuz~rqoT%zCuKZZ8<$$pvp731^RMlzpGa(@ zHaGh8TKK{>XuyAM#)vKT_2t5k)jpaD8+*Z3lEVz#PTqS zhcpbyRnm@qKkH2L()lTWpFVs@@Kko-)mT`1WS?MBW_Y`-Gdx`3TX;d%LktctSJKfH zCV6W|JtmT?c(%oL_h_M*k|sHhZ5H*)7HLwz$#Gic;G*GJr94@hl;7U2RS%Y*I!${1 Tbh*2-uzUkARHrYVZ}R&eb3$z| delta 5325 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 From ace29fe2b5fed69970b2eae2731a816bbee0e083 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 04:42:56 +0100 Subject: [PATCH 24/30] Verify Geometry Unity package release version --- scripts/build-geometry-unity-package.ps1 | 16 ++++++---------- .../GameCult.Geometry.csproj | 2 -- .../Runtime/Plugins/GameCult.Geometry.dll | Bin 144384 -> 144384 bytes .../Runtime/Plugins/GameCult.Geometry.pdb | Bin 61276 -> 62084 bytes 4 files changed, 6 insertions(+), 12 deletions(-) diff --git a/scripts/build-geometry-unity-package.ps1 b/scripts/build-geometry-unity-package.ps1 index 1bd9af53..31002f5e 100644 --- a/scripts/build-geometry-unity-package.ps1 +++ b/scripts/build-geometry-unity-package.ps1 @@ -85,17 +85,13 @@ if ($missingAssetMetas.Count -ne 0 -or $missingDirectoryMetas.Count -ne 0) { throw "Tracked Geometry Unity package has assets without stable .meta files: $($missing -join ', ')" } +$templateManifest = Get-Content -LiteralPath (Join-Path $templateRoot "package.json") -Raw | ConvertFrom-Json +$expectedFileVersion = "$($templateManifest.version).0" $trackedAssembly = Join-Path $templatePluginRoot "GameCult.Geometry.dll" -if ((Get-FileHash -Algorithm SHA256 -LiteralPath $trackedAssembly).Hash -ne - (Get-FileHash -Algorithm SHA256 -LiteralPath $geometryAssembly).Hash) { - throw "Tracked GameCult.Geometry.dll does not match the current Release publish output. Sync the tracked Git-UPM package before tagging." -} -if (Test-Path -LiteralPath $geometrySymbols) { - $trackedSymbols = Join-Path $templatePluginRoot "GameCult.Geometry.pdb" - if ((Get-FileHash -Algorithm SHA256 -LiteralPath $trackedSymbols).Hash -ne - (Get-FileHash -Algorithm SHA256 -LiteralPath $geometrySymbols).Hash) { - throw "Tracked GameCult.Geometry.pdb does not match the current Release publish output. Sync the tracked Git-UPM package before tagging." - } +$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 diff --git a/src/GameCult.Geometry/GameCult.Geometry.csproj b/src/GameCult.Geometry/GameCult.Geometry.csproj index c8c9800a..705fa2f4 100644 --- a/src/GameCult.Geometry/GameCult.Geometry.csproj +++ b/src/GameCult.Geometry/GameCult.Geometry.csproj @@ -4,8 +4,6 @@ GameCult.Geometry 0.1.0 false - false - false game;geometry;planetary;csg;lod;cultcache;cultmesh README.md diff --git a/unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.dll b/unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.dll index 6e6138a9c216fa04ee3436e5de47a52b69e64645..82c42828de209e3eef78ff26fdc8e1719854f1f4 100644 GIT binary patch delta 114 zcmZp8!O`%7V?qaupxf)OjXhg?7$^J_FrL#GyJB;qa39Wo)U_e`qnKDL_SePC!LL>dz>Aty%Eo%9q2-y{2rh J(`IVo003NBGE@Kn delta 114 zcmZp8!O`%7V?qZD`;ntl8hf_(Fi!X3&r71u~%VDBG^~Q5ovNz;~)!Ex`G4|eG Jr_I#F0RUb9Froke diff --git a/unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.pdb b/unity/org.gamecult.geometry/Runtime/Plugins/GameCult.Geometry.pdb index 8617282e829ba861b44c66558a082c6e54b3c8b1..33bf653acc27889f11bdc115dcf6ba67a5eb8dc5 100644 GIT binary patch delta 5325 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 Tbh*2-uzUkARHrYVZ}R&eb3$z| From 4fcb181d8dc58229ac1810be1ff8fff98d64c82b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 04:59:59 +0100 Subject: [PATCH 25/30] Make Geometry Unity sources C# 9 compatible --- .../Runtime/Unity/PlanetaryPageUpload.cs | 4 +++- .../Runtime/Unity/PlanetaryPatchMeshAdapter.cs | 4 +++- .../Editor/GameCultGeometryPlanetaryViewerMenu.cs | 4 +++- .../Runtime/GameCultGeometryPlanetaryViewer.cs | 4 +++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPageUpload.cs b/unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPageUpload.cs index 8e0b431e..195903b4 100644 --- a/unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPageUpload.cs +++ b/unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPageUpload.cs @@ -5,7 +5,8 @@ using System.Runtime.InteropServices; using CultMath; using UnityEngine; -namespace GameCult.Geometry.Unity; +namespace GameCult.Geometry.Unity +{ [StructLayout(LayoutKind.Sequential)] public readonly struct PlanetaryPageInputGpu { public PlanetaryPageInputGpu(Vector4 directionRadius, Vector4 sampling) { DirectionRadius = directionRadius; Sampling = sampling; } public readonly Vector4 DirectionRadius; public readonly Vector4 Sampling; } [StructLayout(LayoutKind.Sequential)] public readonly struct PlanetaryPageMetadataGpu { public PlanetaryPageMetadataGpu(Vector4 address, Vector4 layout, Vector4 bounds, Vector4 state) { Address = address; Layout = layout; Bounds = bounds; State = state; } public readonly Vector4 Address; public readonly Vector4 Layout; public readonly Vector4 Bounds; public readonly Vector4 State; } @@ -27,3 +28,4 @@ public static class PlanetaryPageUploadBuilder } private static Vector4 ToUnity(float4 value) => new(value.x, value.y, value.z, value.w); } +} diff --git a/unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPatchMeshAdapter.cs b/unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPatchMeshAdapter.cs index 8e758426..111b9931 100644 --- a/unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPatchMeshAdapter.cs +++ b/unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPatchMeshAdapter.cs @@ -3,7 +3,8 @@ using CultMath; using UnityEngine; using UnityEngine.Rendering; -namespace GameCult.Geometry.Unity; +namespace GameCult.Geometry.Unity +{ public static class PlanetaryPatchMeshAdapter { @@ -25,3 +26,4 @@ public static class PlanetaryPatchMeshAdapter mesh.RecalculateBounds(); return mesh; } } +} diff --git a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCultGeometryPlanetaryViewerMenu.cs b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCultGeometryPlanetaryViewerMenu.cs index e2a32d17..59ee6c22 100644 --- a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCultGeometryPlanetaryViewerMenu.cs +++ b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Editor/GameCultGeometryPlanetaryViewerMenu.cs @@ -2,7 +2,8 @@ using UnityEditor; using UnityEngine; using GameCult.Geometry.Unity.Samples; -namespace GameCult.Geometry.Unity.Samples.Editor; +namespace GameCult.Geometry.Unity.Samples.Editor +{ public static class GameCultGeometryPlanetaryViewerMenu { @@ -25,3 +26,4 @@ public static class GameCultGeometryPlanetaryViewerMenu camera.farClipPlane = 1000; } } +} diff --git a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.cs b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.cs index 2cfb0ce5..2f874747 100644 --- a/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.cs +++ b/unity/org.gamecult.geometry/Samples~/PlanetaryViewer/Runtime/GameCultGeometryPlanetaryViewer.cs @@ -4,7 +4,8 @@ using CultMath; using GameCult.Geometry; using UnityEngine; -namespace GameCult.Geometry.Unity.Samples; +namespace GameCult.Geometry.Unity.Samples +{ [ExecuteAlways] public sealed class GameCultGeometryPlanetaryViewer : MonoBehaviour @@ -139,3 +140,4 @@ public sealed class GameCultGeometryPlanetaryViewer : MonoBehaviour } } } +} From d91b59eb4a8c5a7db15cd30adcd6b33a8a3de206 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 05:05:56 +0100 Subject: [PATCH 26/30] Disambiguate Unity mesh adapter type --- .../Runtime/Unity/PlanetaryPatchMeshAdapter.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPatchMeshAdapter.cs b/unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPatchMeshAdapter.cs index 111b9931..e880842d 100644 --- a/unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPatchMeshAdapter.cs +++ b/unity/org.gamecult.geometry/Runtime/Unity/PlanetaryPatchMeshAdapter.cs @@ -8,7 +8,7 @@ namespace GameCult.Geometry.Unity public static class PlanetaryPatchMeshAdapter { - public static Mesh CreateFaceMesh(PlanetaryCubeFace face, int cellsPerAxis, float radius = 1) + public static UnityEngine.Mesh CreateFaceMesh(PlanetaryCubeFace face, int cellsPerAxis, float radius = 1) { if (!float.IsFinite(radius) || radius <= 0) throw new ArgumentOutOfRangeException(nameof(radius)); var source = PlanetaryPatch.CreateFace(face, cellsPerAxis); @@ -22,7 +22,7 @@ public static class PlanetaryPatchMeshAdapter vertices[i] = normal * radius; normals[i] = normal; uv[i] = new Vector2(source.Vertices[i].LocalCoordinate.x, source.Vertices[i].LocalCoordinate.y); } - var mesh = new Mesh { name = $"GameCult.Geometry Planetary {face} {cellsPerAxis}x{cellsPerAxis}", indexFormat = vertices.Length > ushort.MaxValue ? IndexFormat.UInt32 : IndexFormat.UInt16, vertices = vertices, normals = normals, uv = uv, triangles = source.Indices }; + var mesh = new UnityEngine.Mesh { name = $"GameCult.Geometry Planetary {face} {cellsPerAxis}x{cellsPerAxis}", indexFormat = vertices.Length > ushort.MaxValue ? IndexFormat.UInt32 : IndexFormat.UInt16, vertices = vertices, normals = normals, uv = uv, triangles = source.Indices }; mesh.RecalculateBounds(); return mesh; } } From 4df2c2a42bb4a11fa9d49ffdcf1af6293ab09819 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 05:17:07 +0100 Subject: [PATCH 27/30] Add Geometry Unity EditMode package tests --- scripts/build-geometry-unity-package.ps1 | 8 +++ unity/org.gamecult.geometry/Tests.meta | 8 +++ unity/org.gamecult.geometry/Tests/Editor.meta | 8 +++ .../GameCult.Geometry.Tests.Editor.asmdef | 19 +++++++ ...GameCult.Geometry.Tests.Editor.asmdef.meta | 7 +++ .../Tests/Editor/GeometryPackageTests.cs | 54 +++++++++++++++++++ .../Tests/Editor/GeometryPackageTests.cs.meta | 11 ++++ 7 files changed, 115 insertions(+) create mode 100644 unity/org.gamecult.geometry/Tests.meta create mode 100644 unity/org.gamecult.geometry/Tests/Editor.meta create mode 100644 unity/org.gamecult.geometry/Tests/Editor/GameCult.Geometry.Tests.Editor.asmdef create mode 100644 unity/org.gamecult.geometry/Tests/Editor/GameCult.Geometry.Tests.Editor.asmdef.meta create mode 100644 unity/org.gamecult.geometry/Tests/Editor/GeometryPackageTests.cs create mode 100644 unity/org.gamecult.geometry/Tests/Editor/GeometryPackageTests.cs.meta diff --git a/scripts/build-geometry-unity-package.ps1 b/scripts/build-geometry-unity-package.ps1 index 31002f5e..27f3c567 100644 --- a/scripts/build-geometry-unity-package.ps1 +++ b/scripts/build-geometry-unity-package.ps1 @@ -61,6 +61,8 @@ $requiredTemplateFiles = @( "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", @@ -85,6 +87,12 @@ if ($missingAssetMetas.Count -ne 0 -or $missingDirectoryMetas.Count -ne 0) { 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" diff --git a/unity/org.gamecult.geometry/Tests.meta b/unity/org.gamecult.geometry/Tests.meta new file mode 100644 index 00000000..68cfab58 --- /dev/null +++ b/unity/org.gamecult.geometry/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 97ae3726627d441b8d85e69d4ea804b9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Tests/Editor.meta b/unity/org.gamecult.geometry/Tests/Editor.meta new file mode 100644 index 00000000..89790e17 --- /dev/null +++ b/unity/org.gamecult.geometry/Tests/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3aa2de1207ba4593a6927bed95bdfea0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Tests/Editor/GameCult.Geometry.Tests.Editor.asmdef b/unity/org.gamecult.geometry/Tests/Editor/GameCult.Geometry.Tests.Editor.asmdef new file mode 100644 index 00000000..817ba8f6 --- /dev/null +++ b/unity/org.gamecult.geometry/Tests/Editor/GameCult.Geometry.Tests.Editor.asmdef @@ -0,0 +1,19 @@ +{ + "name": "GameCult.Geometry.Tests.Editor", + "rootNamespace": "GameCult.Geometry.Tests.Editor", + "references": [ + "CultMath", + "GameCult.Geometry", + "GameCult.Geometry.Unity" + ], + "includePlatforms": ["Editor"], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": false, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false, + "optionalUnityReferences": ["TestAssemblies"] +} diff --git a/unity/org.gamecult.geometry/Tests/Editor/GameCult.Geometry.Tests.Editor.asmdef.meta b/unity/org.gamecult.geometry/Tests/Editor/GameCult.Geometry.Tests.Editor.asmdef.meta new file mode 100644 index 00000000..7d75ac20 --- /dev/null +++ b/unity/org.gamecult.geometry/Tests/Editor/GameCult.Geometry.Tests.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 73e8e3a82a3441ec9f2a0f668c90f5b5 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/org.gamecult.geometry/Tests/Editor/GeometryPackageTests.cs b/unity/org.gamecult.geometry/Tests/Editor/GeometryPackageTests.cs new file mode 100644 index 00000000..dec669a7 --- /dev/null +++ b/unity/org.gamecult.geometry/Tests/Editor/GeometryPackageTests.cs @@ -0,0 +1,54 @@ +using CultMath; +using GameCult.Geometry.Unity; +using NUnit.Framework; +using UnityEngine; + +namespace GameCult.Geometry.Tests.Editor +{ + public sealed class GeometryPackageTests + { + [Test] + public void CultMathPrimitivesAreVisible() + { + var value = new float3(2f, 3f, 4f); + + Assert.That(value.x, Is.EqualTo(2f)); + Assert.That(value.y, Is.EqualTo(3f)); + Assert.That(value.z, Is.EqualTo(4f)); + } + + [Test] + public void CoreGeometryTypesUseCultMathPrimitives() + { + var sphere = new CultSphere(new float3(1f, 2f, 3f), 4f); + + Assert.That(sphere.Center, Is.EqualTo(new float3(1f, 2f, 3f))); + Assert.That(sphere.Radius, Is.EqualTo(4f)); + Assert.That(sphere.Contains(new float3(1f, 2f, 7f)), Is.True); + } + + [Test] + public void PlanetaryAdapterCreatesUnityMesh() + { + var mesh = PlanetaryPatchMeshAdapter.CreateFaceMesh( + PlanetaryCubeFace.PositiveZ, + 2, + 3f); + + try + { + Assert.That(mesh, Is.Not.Null); + Assert.That(mesh.name, Does.Contain("PositiveZ")); + Assert.That(mesh.vertexCount, Is.EqualTo(9)); + Assert.That(mesh.normals.Length, Is.EqualTo(9)); + Assert.That(mesh.uv.Length, Is.EqualTo(9)); + Assert.That(mesh.triangles.Length, Is.EqualTo(24)); + Assert.That(mesh.bounds.size.sqrMagnitude, Is.GreaterThan(0f)); + } + finally + { + Object.DestroyImmediate(mesh); + } + } + } +} diff --git a/unity/org.gamecult.geometry/Tests/Editor/GeometryPackageTests.cs.meta b/unity/org.gamecult.geometry/Tests/Editor/GeometryPackageTests.cs.meta new file mode 100644 index 00000000..89858d8d --- /dev/null +++ b/unity/org.gamecult.geometry/Tests/Editor/GeometryPackageTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cc0a7b5b20004c72b9c9395d3c73360c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 98d15eaf65a2f4ce141f57c98c215c771cb197d5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 05:18:17 +0100 Subject: [PATCH 28/30] Harden CultLib Unity package build --- scripts/build-unity-package.ps1 | 127 +++++++++++++++++++----- unity/org.gamecult.cultlib/package.json | 2 +- 2 files changed, 104 insertions(+), 25 deletions(-) diff --git a/scripts/build-unity-package.ps1 b/scripts/build-unity-package.ps1 index e5cc82aa..6ba396e8 100644 --- a/scripts/build-unity-package.ps1 +++ b/scripts/build-unity-package.ps1 @@ -15,22 +15,7 @@ $outputRoot = if ([System.IO.Path]::IsPathRooted($OutputDirectory)) { Join-Path $repoRoot $OutputDirectory } $pluginRoot = Join-Path $outputRoot "Runtime\Plugins" - -if (Test-Path -LiteralPath $publishRoot) { - Remove-Item -LiteralPath $publishRoot -Recurse -Force -} -if (Test-Path -LiteralPath $outputRoot) { - Remove-Item -LiteralPath $outputRoot -Recurse -Force -} - -dotnet publish $projectPath -c $Configuration -o $publishRoot -if ($LASTEXITCODE -ne 0) { - throw "CultLib publish failed with exit code $LASTEXITCODE" -} - -New-Item -ItemType Directory -Force -Path $pluginRoot | Out-Null -Copy-Item -LiteralPath (Join-Path $templateRoot "package.json") -Destination $outputRoot -Copy-Item -LiteralPath (Join-Path $templateRoot "README.md") -Destination $outputRoot +$manifest = Get-Content -LiteralPath (Join-Path $templateRoot "package.json") -Raw | ConvertFrom-Json $expectedAssemblies = @( "ConcurrentCollections.dll", @@ -57,6 +42,68 @@ $expectedAssemblies = @( "System.Text.Json.dll", "System.Threading.Channels.dll" ) +$ownedAssemblies = @( + "GameCult.Caching.dll", + "GameCult.Caching.MessagePack.dll", + "GameCult.Logging.dll", + "GameCult.Mesh.dll", + "GameCult.Networking.dll" +) +$expectedPdbs = $ownedAssemblies | ForEach-Object { [System.IO.Path]::ChangeExtension($_, ".pdb") } + +function Assert-SameNames([string[]] $Actual, [string[]] $Expected, [string] $Description) { + $difference = Compare-Object ($Expected | Sort-Object) ($Actual | Sort-Object) + if ($difference) { + throw "$Description differs from the expected package contract:`n$($difference | Out-String)" + } +} + +function Assert-Meta([string] $Path) { + if (-not (Test-Path -LiteralPath "$Path.meta")) { + throw "Unity metadata is missing for: $Path" + } +} + +if ($manifest.name -ne "org.gamecult.cultlib" -or $manifest.version -notmatch '^\d+\.\d+\.\d+$') { + throw "CultLib package manifest has an invalid name or semantic version." +} + +$templatePluginRoot = Join-Path $templateRoot "Runtime\Plugins" +Assert-SameNames ` + (Get-ChildItem -LiteralPath $templatePluginRoot -Filter "*.dll" | Select-Object -ExpandProperty Name) ` + $expectedAssemblies ` + "Tracked Unity DLL set" +Assert-SameNames ` + (Get-ChildItem -LiteralPath $templatePluginRoot -Filter "*.pdb" | Select-Object -ExpandProperty Name) ` + $expectedPdbs ` + "Tracked Unity PDB set" + +@( + (Join-Path $templateRoot "package.json"), + (Join-Path $templateRoot "README.md"), + (Join-Path $templateRoot "Runtime"), + (Join-Path $templateRoot "Runtime\GameCult.CultLib.asmdef"), + $templatePluginRoot +) + ($expectedAssemblies | ForEach-Object { Join-Path $templatePluginRoot $_ }) + + ($expectedPdbs | ForEach-Object { Join-Path $templatePluginRoot $_ }) | + ForEach-Object { Assert-Meta $_ } + +$asmdef = Get-Content -LiteralPath (Join-Path $templateRoot "Runtime\GameCult.CultLib.asmdef") -Raw | ConvertFrom-Json +Assert-SameNames $asmdef.precompiledReferences $expectedAssemblies "asmdef precompiled reference set" + +if (Test-Path -LiteralPath $publishRoot) { + Remove-Item -LiteralPath $publishRoot -Recurse -Force +} +if (Test-Path -LiteralPath $outputRoot) { + Remove-Item -LiteralPath $outputRoot -Recurse -Force +} + +dotnet publish $projectPath -c $Configuration -o $publishRoot --disable-build-servers ` + -p:UseSharedCompilation=false -m:1 -p:Version=$($manifest.version) +if ($LASTEXITCODE -ne 0) { + throw "CultLib publish failed with exit code $LASTEXITCODE" +} + $publishedByName = @{} foreach ($assembly in Get-ChildItem -LiteralPath $publishRoot -Filter "*.dll") { $publishedByName[$assembly.Name] = $assembly @@ -67,18 +114,50 @@ foreach ($assemblyName in $expectedAssemblies) { } } -Copy-Item -LiteralPath (Join-Path $templateRoot "Runtime\GameCult.CultLib.asmdef") ` - -Destination (Join-Path $outputRoot "Runtime") +Copy-Item -LiteralPath $templateRoot -Destination $outputRoot -Recurse foreach ($assemblyName in $expectedAssemblies) { - $assembly = $publishedByName[$assemblyName] - Copy-Item -LiteralPath $assembly.FullName -Destination $pluginRoot - $pdb = [System.IO.Path]::ChangeExtension($assembly.FullName, ".pdb") - if (Test-Path -LiteralPath $pdb) { - Copy-Item -LiteralPath $pdb -Destination $pluginRoot + Copy-Item -LiteralPath $publishedByName[$assemblyName].FullName -Destination $pluginRoot -Force +} +foreach ($pdbName in $expectedPdbs) { + $pdbPath = Join-Path $publishRoot $pdbName + if (-not (Test-Path -LiteralPath $pdbPath)) { + throw "CultLib Unity package is missing expected symbols: $pdbName" + } + Copy-Item -LiteralPath $pdbPath -Destination $pluginRoot -Force +} + +Assert-SameNames ` + (Get-ChildItem -LiteralPath $pluginRoot -Filter "*.dll" | Select-Object -ExpandProperty Name) ` + $expectedAssemblies ` + "Staged Unity DLL set" +Assert-SameNames ` + (Get-ChildItem -LiteralPath $pluginRoot -Filter "*.pdb" | Select-Object -ExpandProperty Name) ` + $expectedPdbs ` + "Staged Unity PDB set" + +$expectedFileVersion = "$($manifest.version).0" +foreach ($assemblyName in $ownedAssemblies) { + $assemblyPath = Join-Path $pluginRoot $assemblyName + $fileVersion = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($assemblyPath).FileVersion + if ($fileVersion -ne $expectedFileVersion) { + throw "$assemblyName has file version $fileVersion; expected $expectedFileVersion." + } + $freshHash = (Get-FileHash -LiteralPath (Join-Path $publishRoot $assemblyName) -Algorithm SHA256).Hash + $stagedHash = (Get-FileHash -LiteralPath $assemblyPath -Algorithm SHA256).Hash + if ($freshHash -ne $stagedHash) { + throw "$assemblyName differs between the fresh publish and staged package." } } -$manifest = Get-Content -LiteralPath (Join-Path $outputRoot "package.json") -Raw | ConvertFrom-Json +$resolverAssemblyPath = Join-Path $pluginRoot "GameCult.Caching.MessagePack.dll" +$resolverAssemblyMetadata = [System.Text.Encoding]::UTF8.GetString( + [System.IO.File]::ReadAllBytes($resolverAssemblyPath)) +if (-not $resolverAssemblyMetadata.Contains("CultDocumentMessagePackResolversAttribute")) { + throw "GameCult.Caching.MessagePack.dll lacks CultDocumentMessagePackResolversAttribute." +} + Write-Host "CultLib Unity package: $outputRoot" Write-Host "Package: $($manifest.name)@$($manifest.version)" Write-Host "Managed assemblies: $($expectedAssemblies.Count)" +Write-Host "Verified owned assembly version: $expectedFileVersion" +Write-Host "Verified Stage 1 MessagePack resolver attribute." diff --git a/unity/org.gamecult.cultlib/package.json b/unity/org.gamecult.cultlib/package.json index cd0c11ac..9fc5e04e 100644 --- a/unity/org.gamecult.cultlib/package.json +++ b/unity/org.gamecult.cultlib/package.json @@ -1,7 +1,7 @@ { "name": "org.gamecult.cultlib", "displayName": "CultLib", - "version": "1.0.7", + "version": "1.0.44", "unity": "2021.3", "description": "CultCache, CultNet, and CultMesh managed runtime assemblies for Unity.", "license": "MIT", From 3f454f2a8bf9fcb2a0457766b1e4c611a890abba Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 05:21:25 +0100 Subject: [PATCH 29/30] Refresh CultLib Unity binaries --- .../Plugins/GameCult.Caching.MessagePack.dll | Bin 25088 -> 27648 bytes .../Plugins/GameCult.Caching.MessagePack.pdb | Bin 16112 -> 16644 bytes .../Runtime/Plugins/GameCult.Caching.dll | Bin 78848 -> 78848 bytes .../Runtime/Plugins/GameCult.Caching.pdb | Bin 28352 -> 28628 bytes .../Runtime/Plugins/GameCult.Logging.dll | Bin 6656 -> 6656 bytes .../Runtime/Plugins/GameCult.Logging.pdb | Bin 9480 -> 9640 bytes .../Runtime/Plugins/GameCult.Mesh.dll | Bin 281600 -> 280576 bytes .../Runtime/Plugins/GameCult.Mesh.pdb | Bin 95544 -> 95548 bytes .../Runtime/Plugins/GameCult.Networking.dll | Bin 419328 -> 420864 bytes .../Runtime/Plugins/GameCult.Networking.pdb | Bin 211696 -> 212240 bytes unity/org.gamecult.geometry/package.json | 2 +- 11 files changed, 1 insertion(+), 1 deletion(-) diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll index 4b83a9e6985daa99013b273772dc7098634388ac..70b09dcbcb1c58e930d09699e50e83632d263817 100644 GIT binary patch literal 27648 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 U{r$XFH5C7jg8!@f|3d`+57FA1+5i9m literal 25088 zcmeHvdwg5PmH)Z-N>?viwj^7ABn}F}L5X8yJ1>#~i4*5x$cs3Em;#An%ZZ4r>qv5f z2}$h&1(x@gS17cEcXok7p)9mb8(LtOw%LU(rDdThq4be$SxR4&b~pUK=ghsb>=?S+ z&;IlKd{&9hoY$N=Gjrz5+>zv!=iNvK5xMaG@I#`fQSxh{z>fz*5C>2AN|2uP9h&^K zw&Kv_bz4)pSbx^ql1=o*x)Ygq&vwD(ErKSFpY}0F&5UtP* zdNX_M(@tw|(uuKv)=abk9IxU&uoGnr?;*U2$_3Yz-^^hD<#iAce0~|U=?Yfm|0AVM zG7GPDu)CU(A)@aVj4;D~-Az;ibj=RvI=uf*87sIh+FsDhi|RGylNaYfKYT6#WYSmL zZ&31z5p8P9=Ca+O#J3?JCf?`aUG!Rr>1xU*(-sujSGpT-wzUcGqSr#AMMb2bSMj*w z#lGp}ribSc-M4^96Yl>UOw!|(ME>a)s#34IZZVA2M~ypo!=!8ae4B5KCE;(e)L;_E zqHL^oPZbwUIdt9}!YsL_N19!9BHV+~klU(;I1aSIDAVJ$ApBv9PeAFmCUVU?TtzA_ zs}8*SMsw<(NkDQZFo?05)7~U~mzx_oQ8#uA0tT8^3_-x~I1E9+K%^=SLBQ}i3_-x~ zI}Aa<2sjKuz`*n=8-jpQ>M#VEuUGBn_U&;&|0B%?9YTrW3+eI6=*5ikEJmU~`w4&? zhRA=3(dfycr)IfkSg>p6rhr>-SXdUObrNvDVO0RHZ#1Shn5I<^mcavNHdYxHh7%Ic zR6P!hW_&7yGiK?EpPSD@!wRFYPKG34@PONvJXRbm(_>9T(J({L%^ss&Vpy18Tia_* zXKk-&o50aGoe+wgpF)_=28egV8&{b0-Dm}+u^W?&vT}Egl$R=T9HY??CZXbus8*O+ z*ffck9D*qH66R$>ya{r@X`O;%#v;=~+VQ`HzH1_gw=lcmCCsrdjH>I(B%V#H6_V&3 z_gFJg#%BTKF{=}%S0GF=4dze~jZByM!Ggfb!u;%sh?mDI(L2qIqhAfGp9aBkr4z>j z^p8=5v#iK5j{)Zt5YlH%j-Lum!Mz{h-c`(brvkeR{$`9uJ|3Vh4>4u z>(;8`8camo@Fx;-qq`W_kjdeyx*Kw%zdsw@Ur*RGs=v>|U*v}4##{QETMhdqo@h(d zi{7pQ!PztWWlXqrHYiV3%Pa_3x76#C!LinYh(=ruQLpqTgjK4>Ddei!5OIZEzSf_5 zs$1sT^&)O>$bB86MYB4krpYzAUm+7JJni>{Ow=?(X2{+8c1^_7Pzu31*6@Ju33+7j z-_mQ<JU8b@$G3#!KvT)={wmKK=05GdlkKE4r1qibqKLuq^y(9*CGN2+Mx@r`4hH}IbB zuf;f)=&G;w=$WnWdf>9Gv3pPk+2=Z^=RK(8^gLOF_ZHz>itw#PcnY{3iiX96KN@H# zGd*{d=w7jj(GGY_gw!uFt``++Gf@idkk5lPr(?+>Nt&YXgZ@z&V;5CwL}f)|ybaaH zALBG6s+10OOoHfbS^6ahUVRFIN1)BK(pfon1xzOC3IPmO)RT#qy$g z*$pYrO9=vY4{+;K0OL-_5DUhej$szmh=otHh0B<(9icC0dVlP~tUPf8&|{YWylls;|Rw0*B2#SPiaw>Z|r#4dy-ZzX7za0oZdb zW7h!;MR<3{`7VAvsOgi8I8u+vWh0k!48yts0zU1S(@!*9@skVrte~PMMU(t2YDlY8zg!(emhsy;|t-wKerb| zz?7_t{pHlV(GS@ju~JRR(!70}5jW=h4ycA)k~VR^{v0UlP5@|z+59k965 zaOq(b^AqUv;gXPdh6kKSz&pOvhB}Y+6cFCu+A;m5zW6_YoMHOnPown6d^Em(*zcKvXBjmW;<3dx_MA96 z?pAZ$Jp)1g5KC0t`8e)WW9wC85m)XU4mE5IvQ)6FE{*dXeubNE)~I>vVVwz09?@wi2Lth`o;ld5 zz+vB1yE^e9m~$JSfY*1E5A&(<+*H;(@^YoLt@Xlnt=8nhMv7cdc@FQL$_ zmjSJR1&DtSAp1C~st1QxSnz#-z+t~R1NSYD0Dl7=Q90VA#+fQ!C*ZE)YS^yje#Bpe zR=vt;*)PMG9{&M2@gD+Q+lyYuUt^N}!WpeP8sno}47G=H{{~*bV?>SXQ`YOiJyq5l zD2j81u2P;4JNGhl^l&whAF)oEX(vNdlHnWNXSJ@{sA9ddt{Q2@ARp<~cx*+s(Q&FW*W=rQA1XzB@Ycf)z{AG6L$1Bju&phHl{nX>hM z-KdJciCWXy9}TxB{(1_s6M}b8a3=6bvqmSN#C{WcDTY#A>vW)#82o^l$KhbY&=EG{ zuR!N`K7R#$m_%N{th%6fiM|W0QvFmZIpb~@l%wIr|Hppzg%AF-rbY9zPiHF<(jnN@D_a+b4&C~xj0sdT&d}m zb`==;IgF@Q0*7OGR)L zJ4>5#NPp%K#&6_Oc9LQJl4X8ldaGe!%>33nOoQK}O1`{pU^*$ZeQCK_$#s>%I33l- z-z`?S(9mV?Q#OAMkvfLcqRulHX?)|0R|(!bW;Hc8&1|08GLJcKLVVG+XmiRQqCcWM z6$@udXFi+CY{{`ka|_Wcs6J(VC%F(DoHnK`Ti?-+vI6vtFgj&X+S<&@cQ%YSLiI`Q;}4O@QGN!S@TkOkj_|8Nz?t z&pI0ge$m@g=BF0|{xXxU@w5F0N_v3*MkbX-|rGDBK zeyY+>>xI9m@@Ozfk5x1Or4Zw9*EGSa8m*@+NOziy|5@xC+M8%4jdo3{^>T~PnM*5f zBis_9FNe#Szf@!XDS=-`OuD74qBKZPMVbJA?PfbSXb)DJJ3#U{Yv|T9;5~E*`#K{4YBhIaFf2IAE`EJO&Ql3 z)Y=t>nDQmr#)V_Rvlt%S%HrRS+lqr_W^RPbwKc<7K!&@O3f{ zSC%tpwNZf)dqn#3LGVoucY1+?7*UN_MYxp#Bx}&J?eia@vIW?EsEQ2lcxE zQ--XOa(W%Hv>pGZKcM~wvg<&dDYAco z^Xt*t3Nj{_({F@|F$GOtpX=#ldK> zXqkH}V1qji_)QnXw*<~{S-{V6T@3gI;dtD)!2S-`kkq}(bM{bYRRZu{fqeq60h~hv zmDhSEQ-$Y7z?Pa@0M|zE09*n%lWq<#$EYszCp;z{4c!a4yYdmh<{;ZYS$`ZY-a$`! zqV%%yRapC`{*S<`=sSST^eSLb|1sbu{a1iP`ul);ftxhZ^m|Pt|1$5I7`jfMsm3KU zY;Gt}Q@uEn6sSjajXqVNSayAZ;`q5ssA1d6{X+dryD8iN&4-1$srv57Y*0_zvUf`6 zf%=(Ey!Q-hgqQNgLzRm1e!J#%!$Wm8^@Ey6kZY&f)PrTUnwOev>Z!6Pkgw<3 z)Qi4a%|{Dt>J8r$h{@$P^)6(7T4Pfl{}VX)blKDn^Bix05;k?Uxyf5X7unReyxY8` zl(nf>y?JktcG=Wjuvtcz+0+wguZ*s>sd>R2i0YedYE7^l)Lk}}^zHG6=zg0T_FaiP z`9~Ec;~AlYLfuLW{Wp0dbmxgYO1DzTxWijXKNm`kd=>p#C^hm`)QLwwA~o{Wv`MI2 z>79~?yw!BMO}$_8n74)=wyA^0)81P8olU)J9Q014^G{Ok{hj-typ!l=oBEdfCGQFJ zv`u}k^oQOURn#lZFO?qgPNsD>^?KF6dru@6PCD%MJ5|5&PNA2Dx`}Ra|H*q2mEeE^ zKWW(Q@=c|KHkGO=^_`64xst7|sq#&uo2M!2hEU8moxFT_MZKM&>Ao5CkBX8wYoz}Y ziessq8p&*6&FAR#s+qnf3fj~=RiE&kLQ$K_7>j+)bdpVd%INU5P_s~Mr4rAbcL{YX zT^`(K&ZdWjdVx+x2A)HIY~&VRpt9iEzBzP$lcLu8&-Trw=WOZ(#OJB>$7YrdQ&;r{ z-#oesXBehj=7pe^w=#7T9WC2neu7@Hsa2kB=4mv6kH65Y4d#8P(?X$cqOVtPGtZzq zZR%L{pl<=KnWe0d=QgvA-c*!~%VG-5X4z-yrtmy%F_jB-L`!2wX{YKrEIXn#dgp_h zDwK+zCDbaETANE~zEGD@jD~zmXpv3Lqr7h^EwQO8{Yu|5T5D4&{YKw%I?txA)^GE5 z&}N&u2eLEiBB4|ZXHnLcT}Ti3&Z29IWGm>iw(L?`?BlbVMq6+W(dmac?-|IE+zWmo zLt}T)g`SYGbf9KlEH{H?*QvrV0z^fJy+C&izL)T`O}>!l;O zhKDSNuX?03xff0!pTi@J>X*ZLNTf=q87EuhW3lrN_B)NDRSX|2YJI2p{{_+9DNxyb zuBN^ak$p#1+lE)6R7L-s9x|bV(l}cbGr2UL>!Bqk#t&N&UxZ< zF(-<%T^BODiFcB-F*4*$$k=y8G~AE#?oMEo(1l% z-cCp8%OyK-UU{cv2=M)qYiOE2we}Y9*~5u|w}aDQK13}#x30nRBY-Dhx0yi?mGPZIJ#0p9BxmdEQ#UO!*|h4{D|W zepuN6c&4{S>(I|I=V@o_@06_2&Zh^XN#O67WVQF`sBtae?~I$YE-e<>tM#CjM?~{U zaMnkj)zUWS{gUs)+M}Kw@PB9YXIcXOms4Iw<7Gg%jwc6$M z)#%Ul?fUK2ztRUWFCU`TX5&$PP;3rr&FI~r_Iw$`cj3vP_6vABs2z(`7=zjkl{J9u z=b+dh)E0PfLe_pz#_+>Zh7@8Lt(XDL&sDY>do_P`mw2erUhS)}zgN3i;7YSrW6z%e zCt6dh?bR+6{%an_4@Fp0jfqR|()VhSYPRrgw76H>2Y8eQan9MRU0||>$u$gfB7NJ# z_#2p=*J!JIkuj{do6C%=^@aWw#%uJ?;dRDM`j7Qb0(aMLGWMaBazyp@-bvbC{aA3T zaV=(Thp}H;e_W)`2;_Mm#(1|xYqkAaB>Dn$X4F1GH%koc*Z55K9-UbIrU8y|knRNk zPtf^IlwA9@fr<&PBbdSMbRT`Pti^S$!2R0ijd`wzX`uQbJ);d*taLpowVu^)tnPMw zjXoW@#q}JWQ1x?tzs|Auntn&cL#`uwy1G_7s-NmN=~_x7Hm;?PsxJLn`bo)?uKNT& zuDuKWdHSN7e?wcRMUS|?E7o2SYp=;Fdre>K|G@Qzw0IwV7j=7VpAWkO?)&Jmiiz&G zZGWzfwz_|1<5yL0a{tc8Z?Asbyz9(~=Mrkxpn%WTmmxoWj`^0uY_`)6 zq6dNV31Gi|GDh`?_FzQ!bZNKOcs4TD92TJ;15Ti0fK$lhZ=wcT4LB3`98I|K zO#?2b8vxIue+2BLR{%dr-ar#|3*1K2f#)a@n2B4Nqk+Y=i|zvS1GeLy_0p0hfEC_u z;dcwan=T_y$z{U1OgNW;(^PV~=e9Cc9)wI%~Q=D^D*;z(~Hv&ZWe1E@ezHc>`Q>o*O^ZN z|2OYJz<=;De_ilJz$<)*d^&DX4Ad?K)NzaA23`iJUJgZVY;H|hJnMQ4N5nY0BlD|N& z(3@1IEzmC1?$f@j9n)&`*Y%9?ug1H^$*y)+m+M;B7hJz|{l<07HPwBN`$qQ<-A|hT zV1C_n4e_Z@zWE8ruW!kTk$LKMl4pRgT&0ING|H>da}-)*Yc}Cj$~+vg?bjU71vu;S zCq^dvWWsMB`csOrC`DgNF%D7%@*jy_nO#n_%O!T%VV7s2oJqaNUVo--)TLQ?e~bRC zKQHft#%k>f*J{mle@a{FzD&Csjdw`{pKIsHvm7R{nq>u zN^$wLGrBi!Y)|F-(}}^w=|nEq(z3CcPCH}X#*K3rSu>DMx24l(Y_@3@%u|BgR$CzS zSUbX8o6K419m&Osd?IaaS!!k1CcCX{4_g!41rsfF+UovfrY$#^={}=p<3`YD3~;eF zndn)aNe^}=w+|#U-N{WY1T%e!d_I|7oao-lj8*V4u{q6X$CAuIUoz`ZtFt}HY_ew* zbMZhruiRLh>_wGziQG06g@*bQ8IWz=`IN;-$6_m;P8KLfZ4_7BW;-jB`K?yZqJdNz z?W)Rs{fVr?muw$Mq*M99#nuRK(Lir+GJ9?|h3;^x*=%BP3~!Xbg}$@#mC0N#u_d`C z(Ygne4N&gEpfhlb_kDrhVQEp3IG9_1mJh9%W|Lj z?(SrgSp|#e>0mz?r!}8}^*mzM;q!QPZbc~e(6S`rfBQg+@f>%nhuU@~Qu+0nT%tF* zI>YgqPGZocA04?7dR21gvVjyD!G!mwwhUykvNawl=v1t1CNroTRNE z`f4OQ^NCDPBHKfmWIpHMyc*XevpLjG_NcT(k`Bf)3o|`dP&C&PLiBIh#ApP_bILiI9>d$uakrx3N;g&bM@LUv@(^&&A> zTbIb9C}MJ*J&mS#=wMPTWYFSFP7+w7IGvdPbdv3U1iQ%F zQ5uEu`zQsTi-Ly*?QvWkBjb9I&e=wWcvb9L$ z%m#YJze*ji4i3Ya!4ZL-wb>asusOvi<`L%Ps3LM2Lc&qjfN3qED(E?}J zaumC<-tf?c?Xj;PD-Of7)yb84*<^%h%Wjdg z!w82vg<-WasqREt_5REp8kPkx4qvTUNkZ8aD{^M%1g>US9x;V1#HLW4>148>I=7|z zX$wCdJ7cgSmDx^9v+v$~kTJm6uMx7bnYa#e%vH($6+Se{H{7N~eIKsq~Aa^$=XF6znA z8)%VLb+bV`<${A2rShn}BbmkW8vpu&%@mrKh2iX9@IpO46&O4_Rt^s%1|!U(XEjrgVmC@awA&}-w-&=qNOkrAdb+oYjUSJEvS)ED_#w- zgWFBpJ{2#pra~ZN3`Xt)o17k@lb8yXEvayhQU_caS34m)DHB7M%=3{*MZ?Mz{@x0w zXgnxe*}Oe>{Ismlvyy{3+Sns!{vMl1W%GlAa=+w(0Y`bYCCP0Q2xI{qPwerh)pA3L zhIyaBX`@#Z)HA*06tem^cHmMf)t$=Y9-=+91tnraB4KsbK0q)B-Nx3$eXy~0)(UH9 zGRvC^?%pzaIgRn1m$QAzyeTgWEa!1kEf96H>4Z|z^dr=qdrx~Q;l_c+T(8KD*kX$% z<0STCDGyF(CefeUYT+^f_h{qPlH0%{A!mX@gVGDf!pLd2z$wZ>1zyf>P&&|=w(N;w z1PM2r$BLAFVWbhs5-J?j*a>4ypvXdAY2TwD1hcukoW@t-A%IqAq_Bf}9kb*zdC?%r zg+0o3mh5Wc6=F%EhfD6S$~sK(k&4T0cw_PlOM&+R99~krgU+mVCUHTHCyA!T*}?ui z{}G}7t%GX&l=YFf`uJp!9;CL+ptRbNgZrvs8|B9v+BoJ&YCi!Njseo~Q4=jwHqOqj z_}rm!)Yn<|F@CI;$_S%`NxmT#pPgGs8Lk3Ryjv8QSW(XB5yZpg3{Y_rDe#u`_2-?T zKu>|wyrq=FuOgE8-d#U_wVbD|!1`zapMd9qZ5Bxezh}tcJC8|7`oSNhCelK^cFi?* zJ?dTw4J16!TYxdY8=rQ?C<`hNn^{;%QV%#8P-*;&;WIG{{1`r!XAZa715P)nKImqI z!!7oMis8e&CZc+lvTnDuuoV*i#6B;U7~erHL~Sb}&A`WA$W?Fthdr$68)Ko+(@>N2 zsasma?K^%%G#%~YVF5koR=D3Z@4w?`6Z)I7t*IVhR`ByA?ggQpF*c1x((0PN`Gbe0uZWadpXrw%picq-E7t`@caA+Xhr<-sA-fZ`IjPTHKcvcv{Zq#&P zh6mY2-BTIfjpm10FG6S>)GX!F_Z8L5t}OIchKBaBB0kj?G(w(c4ZjDNgkNSs?u$`n zc<5^7dH8f4Jt1^1G&~tT>fDjj@iDg^K0SQ8J4SV&Vi2Rm$Kew(2&#u}G&Qup1b-l@ z&W--|fq?Z%@GCUjsJTlBBL^fxb?70qCczgLh^E)0hld{FD)_0-Aln~$Bz(G`9UFG* z6YJbw2SNWLFj7LUI(PW=iBj9qt8-6OdO*s_1>cIAFv$qwRqip+*)MDEPzq!5jOK>> zO!_d0Xk#{GJW9`?5xCA8+-Wtd1EM8vSMzKuJ9Xiq175t9w%_9l4S0oz;q#v0flH(H zAbTAD?dEwzhYpxhJYMSYqH=iXSuejZ4jK#X@^MT>9lQb4UF~s0jcq)SaUFV5b1UdA z74IEpphy9=BNRkL)=i>v(Vj#IN;m$d6@gXian}XCL9}-W|GBB4a_UgPiux22D-4l6{PpqzuVTHX{WOGCzAWH-8Lcs?E3iF^^gd&=Xy9?0b zdmMLyzzv}6#cM&-K=d*;xm-us4JWZ4L@I?FD{T^Mr?g3$=@&(|BbC*n{5{&?&PcDZ z-0Ivx{AG#IcHJA(Ia5S5FQ*3#jUMrM)bRZtc`FzUuR(`Q%*N1@w-C9 z`$EI_dqczfdCx(@^I}y%8EQPAJXQGl5aT+0nuy7lyeq}2nAtK(2*aVFy`iC7!b5k# z{qQi-3X<|II`?I0ufJK>IqBm|%pUv|5@;@j90MlGLyvHkp##X^BQxYB1Ky+W-tX>; ze0ZqwM`wGVynD_qOMboY>)s!)s1H>9aO%k}7xq*ae}Ty5Wx!t&Vitd2$gFegCjNU3 z6FZ<89@I_U3&&;O;tUF;*)C^69><##$fyf9n66OSz-8q~KUJm^)XVvV`>eJb#vInH0&A9+5Lm;(a17rZ!vI{3DPqdQ zVUN9)%SqS?uqh*l$>t2mG{ootN-b2Vz^XSK%7sL=z}!be=O&FVWku^c}_$9k=7jGuYbcL$`M7XCGN6MpS9Q==v! z8oGOX<|cbL&urg;=iTFVfB}++0JlJ5r_c^LkWBJA|#djcnIyCCFwzIu+$20T4az;({ z@^3`{FyZK(KmQ!tSTg^7e9XyToXeeW&)50Z=8Mk9U(7xK<3{{^JEG4o`i>7`|6}Vk z_4jOczSd^@a+Tu?_xJk^e|&4{d0#vhe^UG1rThl~?ANV~#~f;O>HO_o#l>fQ+4gjL zWg?ZKz8t>QlT6B|RV@9m9yZ1f%V=Z&D-{2hTK;y&&}efK&3G!M_mP4$BXXk0Trcq+2%qxMN`b#+97_+2 zPBZ$g{>{RT3C4i-&`i`_410a}r|vP3`wA8sp_f3r>Nez~?oC*SZxU4NG3YyY9LL|A zEEZk93*pxs`JT3FpYJ0wxItNkl6~P9mwBndBfvfH7XD^@S6eH1*$%B7B;0?#_ZbpXJ(7&Gqq7W66)o&8u~+$$cp7-EZOH3y7Gm+!Rr zHi&)XI-7ArrRIufo$zgU?!#)^9?vKD!HG(4NBA>BFP0_0vJCO@V962@LpF$LwSRmhBv2_Knb0Vu2g!$;mDGzN4x5r8e}$fd z)W@K84{MS2WaB=}=i zlVNV04vux>NU+k4BfxPF*+zyULiI3$&;t6|1NIOo9_eDCLNho4ED?AOI1wxpcpX^n z#tGmguv|zF04IaJTx=txP>4_s4+Hf%!EvCyhq?fq`wT?4@yJ2 zTJRqMPIqG|IKz$Uq|SVVhr8I;S0Vkv5pE8&fGkwgUx5WY(v7i)lWaG}s&;WQ8Z-uJ zFA3=yFdfnt1lCbTmCL|6$e(HxzMu>dqiBS{lfk)e?1>5}8<0-BlWyLWjo=K}v2%qS z17{071f1{2bchPTIYRm~eQd*5Gg!ZL$vrD^R+Ua zWB;U2pFdqcI9d?zK{&iQ zS1u9x7V%=2jU&`m4)tVQdHZ}=LR-X85{aXRBex~7$p5}H799{Se+%tM zWY6NA>~RP0BO>A{A?vh6L~1r)N}A`n z!1SR)F=kidmG*D^*_V11dvm_qH~DGRQ){;HN4%A4uZKD8ha>l_INTd}ZS1I)E$3&K zEeW`izi!Q~+gHEZe(%rnV?j+y!%D9Z^p${D;?)1V2HP`x-k2EmT%h)kCr9x zmL_hG*|sLkAZzm%HD6UO z9C7U6*4&6n+bxNo|C6`76-oa-y(?>z-=LwbDHk@j*x8SA3Q9hew4RWxonP|J%=Oiw z!*;(gu<|oa{^+qAVqfVseP-M7n2t3Q&l-SbN}UpnbO zt;KSC(}RIMU!-^ipIQCVYqLH&*OXiS^ozd@&k7z9aiA<@$HDi`%vLqS<2!=wO55_LcoYHZr7h^3o_>U}W}z3-kZ6KI-`{ zen?Pa-UnlQ9K=pYTWe>@e$#=G`}Tc#Zf*F)8+n#mYYsL7bwPb~HU8z)l{Gckru<)C z)Xz83lB#p2?Bl9A98f`>0W4|PB zQAr3FZeJ}cA`xEL<9#@(m}HAv3@A;Tfzfv~Tm{8`HNj z{(MA6Ff))(!#jX4&Ul&`#GlTHVFLMk8CoWYkI&2)G#HmE`%RfQ>qq;Be)C;*XfVGp zGn5J8*JqY6gZZ18#o?j2YejOtM8V=j&EQ!4OmPcobzW*t9mZDHK2No?a N(Eh?Oe%J7<{{nuFS||Vj delta 3908 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 z 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 ML4#DPlo7Q3KjBkO-2eap delta 5861 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 Vbd;{{6hIHPUXHo3gTIIb{{q@tl+geH diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb index 238279b2f0a5d47d75b9e90e30ef3026394e496f..c083a175f27311e80e67dc6e079c6fd17493f2cb 100644 GIT binary patch delta 1231 zcmeD1TH!sRK%qv7fkD|RCqIdSfnmN1-vR@XjZO{qzjv1{^gd@{Yr0|LgbejM21bT1 z1}27`K>8Sv{sg4|1L87XV=cbkvRqB=&8ygs>rka=}S{RudBqmOFWLB%^j$vfs;un`yiipWoD4Zo( zyU&2BW1~m_N77oYYd%HK`c&<7nU*|UB&pnbeFm>vW=^V4etLRpkzR5!4?=6hYJ~?& zVqY2ED=sT^&`RWTTYhQcmWrD<8$35GTyo90dxk&vIG&0$KwQJ_Pnntae zlN~*q-OGVId#W^Z93m}QL$5iyM_jXI6ilfzh^)MJP@IV!N% zc?nf~l={|o;dcAHpSAHDqbJ_1NeudJ8l|x}e$P4P#tY`a*jPK|e)eqxf#dhN|H}8- zC*B0-+Pm7n9$9M7Z|b~T}k zuZlRxc$`^tL%`%l^C3Nf4WZ|%gP2&a{+K$KeT%BiM#BIB32~Mkw=OSPHrI^(!xr)T zUWY<|O`p}Dd-kQL_>HWH?~W^W+T2m&50MNybT01Lh4T_jrmgia_5bmBtgFAbHsq(`N?D@)k?dSEg*RId_$67NndD_&^H-GYcUNc8`)#uc0OKL3Fe|q`) z=c0<%?x%ZZC7Q*3Z!S+=-j=)LXxk#*lXuT8{J6$l%-LNrx9>s5oTWmn{KvMGHXi-? zZ?XC0=>{uZZxu{jHf!m-E|;BZe?v>GWLn>z-Y@&l>0|$n`2BBhO!;tf^MzAWOCAT- zo2Rf&IVdn`#&>2$=7x*aN-6otrManjCB;fs)k@A*F)`qb6B7fLh&O0$N zpv)5!LsssIiP2KBQktC3ZmeOFXlR&XYHV(tXkwURY?fwjVQ7(RVquzQVqj@%mSmJ@ f24ov28g4$szCecY+~j1HXqIz~i_|9{QgHzQ*;Uf& delta 1066 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

v*K&@s4LZE?BX9#YHRLwc`CNZe8*D z75A;!va)++@5+IdBP-vs^6HfzTKTD!|FLrRs`gczSLIgSusi;o?(%hJef9eG^=sE}T0gOVWWyC3-nQX}4IkaG_q

cl&t{oLAjBuX9D` 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

|IST`WjLT8if-v| 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 lwzsoZ)6}Zr!?d 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

(437+}7S)Djb?*)>gYs{nT=}l77lBFqyPJ*HSn~ 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

J&xxr&Ly11OkE#;rE?_*CoUGz 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 diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb index b212292531091089b87d9c32e8947a81885455b7..87fe95e4256f70e0e4b9ab4d121f99fef3aa6656 100644 GIT binary patch delta 38407 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 delta 38331 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­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 delta 24190 zcmZwP2|QHY{|E4M@7N`>S7bsdLPaV{qJFQ z19>fSDe^O9Bax43M2c(>au{+p@&@FG$mKF@*h}~hE~LmNAWuWyk9-VSP~l^=NRf3z z_D5cfoP~S``7yGMDj(mH6j?a(IOOff`;nij%J`r+*sxaP*`5?x7;+SHHu47K7swxx z&D8n$CZxy$kOv~CA+JK#(BNZCNRjIrUPp>7(w*lxQe;|P z7?TOif*wSc*MoNyks>=6!1F^=WIOute4G?n&|sb;Ns*Zj=h>bVS=*63_ay0=Me)3r zq;q8=&%va~Ql|2}h7?)cOrDpLqKrAs;v3#1J^Oh)UnNDBznJHFV&jh7{QkeV*$Y@VwE4=fkAP{LOeC zLDHA8l;=gH$h@q19zcq$#D?d$q{!yk@x0oO-hYv`cH|q~Ns&Ej&GQ#hWHv55cOXSJ zqdm_{NRbtH;Q2NwvKAgZcP2$v=Ed`OQe@-lgF;|)NLrjP&m%}WupiH>Ns&zo5;SOI zQ4npU6{~usT~$>Jl(M050+X_Na4{LeGT<8c1t!eo^?C3jbcyE`ci4?ov=HVOFVNie z>52BEs=%JYSMUR@fPbJWjT2ZMXaF0)#;_T5gf6fn>;ikjI9Lc-0-sHvD`Pg;aE3mx z9~=v(!=-QyJP##_djXPPAD9XA;Snf1frkq)bT;2@8Qco@kRp2n zKS75%ywV1CASEmimce&WGl|z7pbs1Y$G~Vf9nO~FVIjN#Z@^Mm2H(KXuo6m=`2n<{ z0c;4ZpdECFy%+0Z+k8@D^+~j~}unYzIA{H=F@i!F+g=R5S@|k;3=& zf{~<5!qTvj19!l~@C>|3DmMvxR#TC%`tz%MB`lP(gpG%Ba4t-T+u(Wl2HGy5ed!x5 zH3{>j4GD{diEt6z1s}nWq;iumn}vL@;V>4)!8Nb|-hm(C4``Ii_mV=H9Ui*FA#fjj z2!BFx5q$??hHyRH2KT^&@B^%XUW@towQxH;L@GBGb`NzO*%Cgq32X^tVFug^|A87y z`A9uzN-8&1CjFnvRH~>l89WPL!Y0dj-I?T#UVl~Qg;E$yflJ{EmhHlUk`lQqOug(In(H{;a6-}Lmqw*M*!*}p2`~~YQ=Lc&F zBjF4<8!mupa2wo9Dz~)Ds%kQI)|Gx{Ri~dPxH7m4K7-$&G?VvECaV{?43%T>47>|9 zvjnEW>OoUz0h>a5=nUPsGUka59~c1p!=W%7Cc`|q7aoE|@C>{R@4+XeqG_;SsMKA- zmnDU*NloSoWu5Wh4gFz1I0TM>V_-C#1{2_1xCkzXtKfRL4eo&lq0vfyU_00vwhzEV zR~QeI;Ubs;*T94DI{XHkXIDQO2w`YtGkh42i#0`EZCBRrJD_pk!~h3aehdUc@*w176y5w?Y$V0Y*b`@={$5l(}N zFa<7^;b8^LgZc0XJOMAj8?Y3X!8hj~0I0?>#NiY>IhpXW( zD2Ijc47>vGz(=rr6V8wK*rE8t(Kz6DE!CeQ-fKu6dXc7olZKkN^Oab;{IHpas#PzLA1 z#c($~2#erZcomkw$M7Zm0DnW(t$d+6&R61)Y=;d@vC|3dXVoCnYZT0k4<2;0I=usigJ{quyh=El8c zXRvh_{)FOoKC2eghYg@5w1usqE9?&aVShLbj)ddk6qo{+!WA%AhKH?i4?F~m;W>B> z-i1%#EBF!qfWi*EbI=yHhOW>P_JDohAQ-v>@4;wnM8oMY1FnIa;7)h|9)qXhWq2Dt zgfHMb_zi081v!)Q1i&VuvdGPn}1gWKRePzey|@L3M1e+I2oqG<#09J2zS8!umGNd zm*A~kcsm|o<2C#Qe?oCLPE4o|8<2Ha3)m6*L9IOk(_#8B9bSiJ@H^Dp%g5+J6Bx2r zh@#R8Y-r~5ZeKVEhQiS>8cv6^;C#3Yu7vC0Hn=r#JH$A3xfKO2mnnDZM z6xu^)=mtHZ4-A0);ZQgl&W9ICMbl#y$Y~qQ^kq(Re!#9U7|w&&;A{8^{)FN|K3)sz z!v@e2+QQb*6?P}94?D0XS64!17&HdPz!`8hTmaKxHe3($U_LwoPrwWC1}udy$?Ajs zL`8ClKLKNsH^oIQ3VO_;s)RaGkF}*rJ?0L(!k#r_>ajpn2EpNQ6dVsH!1HVI=P#`b^W&oX_EA)q>Nkf(cH^ZH9KRgOg!1M4L zEFl%mkl7#OGdM#x=m~vbUl|^PVHo5uvLPFdJP}TXijm34sW2U8!*#F#o`mJ_J*oUl*G2Uvd~S3vAdxN3weDZBg-oz2QJO97e*4a2ia6E8seK1D3)v_y&H4m4)>0 z07E7z;)~FR2CyNtf_Bgex?mt_oG(ZYJDlLT3+xGlU@#m3qhKtIgDG$+Tmf_8R=5Wqg2nJ0yaw;q!NU{y zDlO8|P3C=)FCqXAfFW=coB*f71hV@4n}^C0m<4m-7PuQ8ghlWyyb4R;WB5{rhY#>O zWT*IoG@u?dg;LlIwuJ4V2lR%+;68X1o`e_SO?V%Eg*K;U{9ul!xtCzr8J;J?>2Mz0 z3QJ)btb`h8`FLyC3yy$k(B>TPO@wmz4Awc%>&?#NtIQP}o-h|~g?r#3SPaj>=kOQQ zyuc?ihK*n!I08 z-~;#-{)VcT`A8jT1kJfJ)&v{&unp`8yTV?uFB}5H;aE5c&V)%Y6)uOX;YPRv?uQp) z8I--j!)I6tC0F=LwV?rQ2(6$Ubb_nlMz{m+hu7dk_y&qs`JCc4t|J@(V`X?)2-D$a zxC_c*349D+!oSe?I-kx8wt>CjShxo6hR5MU_!a(!sy90FDa>zh1L0_x2-m>F@HVV~ z1~>WmhBx`o>IO0+W<}dZ%oetS?O-R^4fcXTa4-yok#GW>0^?y4TnN+PC3q8-!lzsr zdxeb;umb*ps<-&E>Occ%0h>a5=nUPUC-i~6;XoJ;$HGZ)Cgk6{jhKvo^EP5hD5b*X za5dZrcfkGd47>vQF9(g-9pp!_9KMGY@Gn%q%}46O#;`eTCBs8|*ctl3-f$os4kO`2 zI1MI}#w-o4Bo)n=<)U&Bo`kpHeOLxx!;iP=6V#Y#+@Zh3Vn)ysHYZJ4E9gbmXZ~aZ z77nAxhAalIfg9lgSO71>NALw~TEh2=fMa2D34MY#WNWdp89st0cll5YXahZBhA=&s9MT%9cTa>kTNr7iH+uP2Al;`;99s37Q;L6CHw<*@AIk6 zU~}jKz2GP~3C@D+;bE?foxz6w13rZ{sc2?wDk=#|m0VQbz)lbOcyAa02fz@R3fID7 zC_Lih>%*3?JCr5kAr&r%yI?WANh&upR*w1~sP>poS{E8aE3*0tw4`j#+QCk+8=MXg zz=9TdI0-Mn>+lsRWhzg&4dA|~iVrM-9eG-P5XGSccA};tunUw0c0;N11eI4c6$z_Q z%72uyYT5svS;Ff4w<2Q(RFbfU&AuGc1cAU&3@1 z8_1?gS*nzqDdm<*xt&t(Oj*KwlqxTkveWbGS3<%bRAu@DQwiJgB5i_$v1C&@KP8&f zld!V#e@BMCRGcU(?8!_1_ad)IMN?rPmGXC`%wF-48f5iIBc*JQ-Zn~=i%NOy>wov! zTa#JZIaO_KRaQY2RrcpKUz3GQm8rhr+d8D8sj(o+YHSRQfivK2xPYu4nO2jlE7>&_ zHMU+UAEvCvifbzB>=I>l8M~)cdcsTWOHD;FsXF_Oy6Qi?R|gtFbFz9&6Qyjgl-por zC)i!7?vIMBzfx%!N+Xpj&y{k$xBnh&Y)#f+D{8W0DVj|69sipL9kRO8P$@Sab0)6qdm^@H4E0lJ|V1HZ*_@p%t`)PS6c{L0=fi?M8p{ z%BKi}V_*!a+;mt{O+|;LVoW-z%d+A6_li?om*ru5FFXW`NJZ0SXO;35rF^?4D@N+E z2Q?Kv_FO5yrL4!kDpmS^sGeU>mmTQ!u4%Iq_rF0$8WKBzBQJ!l9jL9?RSU^g4=X2Xt9-G&{95}|rbGmL45G0oU4syAaPa0$F$ zGqO2GHpj^3Oco`ItT~H;`$$EzWlj>2*u&x$3WDE94mHt|vy?}4w7gz~JHQqa(R5W|+Yma^Hv9CRAqfWP9 zuny1*_JDb$qB+oBGJ!c@HwWzIz(O@ds;nm!%@I>OVvHlR)D)Q`YYtn&w$L5skc!rl z4bT!%rDoz;VggIqSEDNQza1-1K55Qya6ubz->sC+dj7go@ zI9-uBvq>-x&W2Y=MQekRZ7{M88>UBptIs01GPao>6wL*LT=2YISkrnUb7A~Nb7A(# z&d?2dLLV3aZ<31E7W1^lJZ&*gTg=mzUDl`jf7mT}AIi$`@EU%E-{D+?>gn3CrG_GF zhw0kk?P$lcP~m4lJH}70c8s6f?buq>H^Ci>G5r2AwDwq5duD7*x9u=BZVe>E0#Q z0eZn6us0k4canU4t_IS=Nn}V8hb7dT4jHn{sz1hsWTT znqF@##T!fU#!|eo6mJ&RL}cD~L;V=Elew0aRf_$K7Tw%CZw zhw&HIhwVb69(p+Rc*ofwI{`X)TX+zO` zF~}E#d~u@r;zaXh{A=Bp@lShSW^Kz&CKatG_Uehfda`bIBI}6{*Pi&m?#X(g5(E#B ziq?ybYeDCKFE*)#NXEh0a6ViLSHMbA(frs}dy)CE-B1pXLH>E*hx5P>A4h)d6zUh@ z4cOnIdLDl)TIP>$3I2?KkMd^@j@R>dRJ9?#u4gRQj=g&LZo_(nv+? z&+4`jS%2nCD%t=xhw=cnv!*hT)o~HoK-P*>v_UM1@*uXarZSkBwWT{9nJkbV6fKyY zpd8HJ*HnhE9_>UngsmhMZ78N5%G$NBt_))fDGy`op`EM98nf&0F8o6(nkDP+CNfJl z6sC~W(t3ONn~U@VqCWl0UX`wr@n%`Itx4OOX$2lu=5neU(TKWLBQ8{J>(ln${NEny zw~O&{Y3trv%8teJ+bMXXdv~ePt<7`z-!t)62MVl^Ds_={t18nM@jw6IO~0xVw3Pp| zgEyn9ijc-HN%KZG{8AH`Je^;F=B*(VSd=V_Ut#2}QB~>N3Vy+mx5iLl$=Un|Zr-e` zDy!D;pMiO^sj5Wm5$MNK-fXKXYWoEGnU6R7s>+l70{t+?Tg$4-vP1OqTx)DNRh9A% z3-q%FZ!T4puLS~qr{;}rGNwj%Gv5^GTP1HDAl*27TcGb`ymhLonBAcpN0B|NDjlgp zcOz5lT2)E8Bh;f0P2Rk#N+<56^?6?;yE#a}Q&VFhgB3}g$%oL(K<~5)yH35ck*-p@ zE#cSG+R>Z%4atGtNMjXVJ4SD#3(`Y+YrH6_u*eU zCYlf_hi<~YjpRnRV1GkWq5rvnF})cQ)}8LYc0pQRMDrnyKSlE)#nOG(8A#{ouImFx zyXl_m+mz^jM%9NjA&MGhG$FmG5;morCPZ5Lo+d!;l;e=xx&C^Oc&=|CazM$&Ahlq+L>hO{NQM)IH5wU>Qhto6&?w$LwfAq*0D^ zvpFRdcAlRLs2MrYwO~5w6g6W?5;nwzPFvK@(l{5?_Oz$NAysq`SQvUwQ}1NdLOtlK za&}EI1I7AXXbDIgyVGwAYI<*@mgGxIKsx3}&yGI0By3j@jiaQ(PSVs~x;!1HWTjV~ zX46-N3M-z@m?Q4_R4N)MURXu}{3{^F@&qyLh0skZK7? z_b3%1HKNOT>@(jZvMOn^sgyiWE2v6}Sswr7W$xy9_Ir>>VQQ^tr*8RlC%iAJyl_@C%RaGCX#8=W|BrQ9runrDvb-@V=G(*{PCMS-bJ$v~z!OY1kDl&Xr?7zoOw3N<0S>n23 z%#PJ=g>&047~Mp2#VIas%~Z9seTHtI5ioaLC)%%xtm-g{6Q!*N{v17Sm0r@L$m6q& zk6v2pan$F?j*DX|*KSPSJYv%qiw4z89UnGnEFG&)_{hkZ*yw3W$1IYrk9w4RXn(sG z!!=WdAe1T^c){HJc{^aNvy*g@ z_pNd9M|Zq1i1)v8+;gbV?R9SYqfaAEMmLV@7_&}aZd^4}VA!bevBUrS^k>bHTK1J$ zk6Ed0QPQHJSKSYvtp^O;=CW?C&$;A~4H4@eMp&63cAd6j#L}CC`i=;{x@Y^+YXw##D%>&?#D8D3*&0&A)wBGAeq1zkiW}MDOP776U1wT*FbyrNmPms(O5<#>21ev{3`cNL+;<9I6rmB#YQ@Y3VwOVOdvUwx_ zj6-8Se>41aI4d(uB3M?x(z73BE|Ulys&}7bC(TitB>pwe{ahwVvDg(pM_lJwsHelG zgf5$La^=gt-TG(#k_Z+x<0nW9ZA>SxjR-BQzcIaixy8=B8G&D~Rtz5BSF7~y`Mi`n zD>Hpmghthen{y~LRz>JOs_KnR5~NFh@7YuE)Fb=T%!WF>j{6!vuPpK_*w(z@wa=VU zCDylp7*xHnLD9p<#Y`9ceI^OK-BPQ;nDmP z=HIh5SxC(cQWHEoX(>K!l20XHk`6fCOLIfqRISk1UvszHIxg$?_fLzZ7aZ%W-cPN+ z<*crrW?*<&bhy$>I#)mQftp}f{aogDla@T+ex>A4WAnwyx|MRjoJzxC`#fIePHc7j ze*Qbx`K>EV6`xTw(WL0e*lGR~V*mg1nLAzD-k#|-I6Zf(iE~)vZ#|E;?HixlDQ{m= zu!d{h`t@|4otKOc9j)FcJSv<%F6)j9kBy1VT&ym%*R9K+$=m{?`g4oto|{{eS*k9S z%l=<+-aV=R<#8P@*^PJZ_~-VqYZ`?PuT=+%4M)d*U)bfY?~Drn(8NbRs*LSOFCE>m zm0t3D$+Wq}{q0ky+?nV%$+Ph!9}VZ+lUrx>p57{N^#k*5%NIttL|AOyb`CEv#Mk-wFd0nzrv!F9Zfx5hflGpA ze~xDVj7Z#hBuHnKyoaNjPVE#BWAP%%O?X^mu1Y+~M^JJLsm z#rDmQuUhrxeb~_%hre__(7n*ARia5@-*#hnJY4D*(ruGYENj#M>x9m^UvfK-)M%hz zY# z89s6P0JAk_*V?&V$`}22$E{ww=<=cq`PaT3u$bjIDF08>J0$^8L!!GM{_@1`$mkjO zC3zc%6uzmNA-9$Uwlwa#EUQA2c~?uAVW6*kzAVsPV7DMx zK1N&6(fr?@bba|^L*b9)e|xeV+8`*eyoR}S!-WFp!{)waHZBc`?Zmu?I$c#scp_dPvkL! zg`5{b0$9s)hXMRN-N* z`_0sr=g$<@)!LVMOg?Uw5L4?q=XI6;oF|mk+PAoi{M%w-WvzY7zsk2}2nT9?e%VFx zsjGx`wf5zz%FpErA+@e^V}jgslMqpB-`0=v;%!25t#j^LAP?Cs?5lOZeIauD{enZS z&*AV2dF&CPeXVmAzL1|T5>C}R=gCv@4X1<;wa$6rqI}0i!K2nWuUW{|t_#;|?YpBY z|9D66sCCW^@>=^EK9YYk6OC%^lWNJW8;koTwY)BZ)ouAFYcZkLKAR}{f@WfNt$lW8 z@=h(p<+b)XoRYtF6timWYaJ(F<0LxO+Slf!{I!eNsn)*s_2izeVyjyFIt-RC>?roD zwXf4*d8LQAuGT)U3GxG7#3{A*brAK-R zgG$HU^EN%!{i%7ke^PrN@mtw+?5$PlO`TKwy;`?ytm8Tjoo~7;E=^q+aeBi)w*Ci9 zD!nVU>}H%UNf=wlG}KZSCvpF^d3^j8*(cfGUq|w1FWPPMVAzbJgfPu-mwons(*E4+ zl)08h*5AM#_d^>Oy}J6O`_1*^)Nd@daNHv)Z?ybH`vdPZ->mp^F8u6^OHQ~S9AdTn!Mwg-t`x34ebBu1D4FVym!H&+PyhN;He~jRxmk+ftx!Po*_tU+lZr1k>9BXlPqIL78H#?bjaA9W_Na%+oys&^xZZ- zSiZVTY{bB0bAqz(HJ(^$H+k7Xd4fUDbHkQA8&y9pd#d-4D4PjKS?_jNeiS^LfA>RR zZrP{OBas;f@h>g>le~|qZI*QSWBW{h=)&D|`demNB%K*|x?s?lX4dT@e4~#~9~gW+ zxc&Q?odQP4?q}T3?z;Mm!>&fd-`*~)m!bJ7yBBM(`{Q#?+@i|sWg%X zwmNrC>5=d=(&UEw@&lT69CA%~(b;AE`mlYEvuq8c+g-Z*R^21F>`UhJ26Ku!jtYI# zcuBni$MtU-o0g1>__K3rQ|~uZA2>{i?eJ~#=ttiT?;E_RUp{q<*`}52tcpjzPO03QwLVSF$ZOW9#)QKm z`BHrKmG`jK5u5A1(fRhrrt`GfpW7OZ^Rm-FIB@3~YuDSsz1B(_ZAtT1%bMP@v25qb z$oY1!EWMgX&zc-Iz~=Fe^qfWu*G;bTZFy4Rg0pMIivFWEx>g1ro;Lo|N|}jvWp?Vv*qd4c4rryZnk`&TZ8t| zyPtlTnrX5)C2G{$Ef;$}pMT=n%%MHkEgb!J!idd@7lvP()V}pEvsvGrY+@I>o4qdV zZfkID^RA(n)Ow6;u|xfO+lYD3Z42wQxPvpH{Dy;YyWJc%*A^qmfu%h*Qj$we&VeU-KRVcvUk60 zw>kMqK-8^!y{E)KIDN*a;q`3qk_qxl^EPJJyHs!6saT8S<~zQBi8}34S!c(*5LsMq zuX*3qDm_c}WKZfA41Do?oWsKLpH9{H2^9T`jjk3a2YEz3N-+-pWE~`aU8?RFP}a;Y zB0la=UAvWoLKC~4bblN?S2gkAcgvg`fjd^+o$xa{Ys8hn{hxw%{GzM+fB#CDU?vy} z^#naZSEws!3tECi5EACkupCi!jW5R1b%teE*I@nN>MN?j!Tj>a7o7yj}mojrW>2 z8WBamqY{#AKm26$TOph9J?7r3AzsJ+NqYTuR8B@_(J;}(H1|{EG(Ll2!=%h+TW$Ro zWgm0icGcHA^IECcK&}}gt~R%g8(k#CMbq!Fn3aiyKMdCu=@(GUAYr{uU;0I0&x9>H z>MTTLYMIw&iawh2RTv9NIHIGLX&o=luj@tE7CI)ptsYVkFS=+((lj9nziG(tc=4?? zeuz*c#E;<2;;iNDCgP%bIwIFf5It1uD2DM7bM!O&C5g)wJI)!(E7LehoJ`J6f_BtS z7PB>*{5yZG!}yVC?1jbReo<*Hwa+WhSth0mwn|g}|HChP^4~S|PZt*oiZx)$bgUBV ztyyaNO4=(fTCR~HE|C~xa>Y`WM#y)r5Mzag|2guC654Y`84fD%m@Q7KeQip^^l5DN zB*hN*SBd|K>X?#_er2tALSOOJu+lxgd{oUVi#KNu-+fPCB0WidbgwvBRIKPfJ76qL z*=xU8RQ2TX2LESR)jDW48rxbfE|e(V<*FwyKYv)%5&ru&|GSRr^P}R3Xe)8X(|Zh7BAG2OW8p!}qvb$q=gJ`$?VHpN+(`S^}FN{oxn>`@|Gi1E8K z$CQXY>fv~JH>*}8KV2d=m$?7u9lAG}uZX{j_fmMOc}sDg-`hg9|97(6-xte5{truV B6+8d{ diff --git a/unity/org.gamecult.geometry/package.json b/unity/org.gamecult.geometry/package.json index 7ac223b5..27877abd 100644 --- a/unity/org.gamecult.geometry/package.json +++ b/unity/org.gamecult.geometry/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "org.gamecult.cultmath": "0.1.0", - "org.gamecult.cultlib": "1.0.7" + "org.gamecult.cultlib": "1.0.44" }, "samples": [{"displayName":"Planetary Field Viewer","description":"Planetary geometry CPU/HLSL parity viewer.","path":"Samples~/PlanetaryViewer"}] } From b65619ff95f21633217a9ccae4d09cf9665a1719 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Jul 2026 05:44:34 +0100 Subject: [PATCH 30/30] Reference Geometry test plugins explicitly --- .../Tests/Editor/GameCult.Geometry.Tests.Editor.asmdef | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/unity/org.gamecult.geometry/Tests/Editor/GameCult.Geometry.Tests.Editor.asmdef b/unity/org.gamecult.geometry/Tests/Editor/GameCult.Geometry.Tests.Editor.asmdef index 817ba8f6..1b93ed0d 100644 --- a/unity/org.gamecult.geometry/Tests/Editor/GameCult.Geometry.Tests.Editor.asmdef +++ b/unity/org.gamecult.geometry/Tests/Editor/GameCult.Geometry.Tests.Editor.asmdef @@ -2,15 +2,16 @@ "name": "GameCult.Geometry.Tests.Editor", "rootNamespace": "GameCult.Geometry.Tests.Editor", "references": [ - "CultMath", - "GameCult.Geometry", "GameCult.Geometry.Unity" ], "includePlatforms": ["Editor"], "excludePlatforms": [], "allowUnsafeCode": false, - "overrideReferences": false, - "precompiledReferences": [], + "overrideReferences": true, + "precompiledReferences": [ + "CultMath.dll", + "GameCult.Geometry.dll" + ], "autoReferenced": false, "defineConstraints": [], "versionDefines": [],