diff --git a/.gitignore b/.gitignore index be58b0e..fd76cf8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,12 @@ __pycache__/ *.pyd *.egg-info/ build/ +.build/ dist/ +swift/**/.build/ +swift/**/DerivedData/ +swift/**/.derivedData/ +swift/**/*.xcuserstate # The interactive paper's Sites adapter is source, not a generated build. !site/build/ !site/build/sites-vite-plugin.ts @@ -16,6 +21,9 @@ extern/ records/ # Paper figures are intentional artifacts (the user's global ignore excludes *.png). !paper/figures/*.png +!paper/baylearn/*.png +# The compiled abstract is a build artifact; rebuild with tectonic (see paper/baylearn/README.md). +paper/baylearn/*.pdf # Agent scaffolding (not package content) .claude/ diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..78641bf --- /dev/null +++ b/Package.swift @@ -0,0 +1,39 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +// The root manifest makes the Swift library consumable directly from this Git +// repository. The nested manifest remains convenient for focused development. +let package = Package( + name: "LCTSpatial", + platforms: [ + .macOS(.v15), + .visionOS(.v2), + ], + products: [ + .library(name: "LCTSpatial", targets: ["LCTSpatial"]), + .library(name: "LCTSpatialMetal", targets: ["LCTSpatialMetal"]), + ], + targets: [ + .target( + name: "LCTSpatial", + path: "swift/LCTSpatial/Sources/LCTSpatial" + ), + .target( + name: "LCTSpatialMetal", + dependencies: ["LCTSpatial"], + path: "swift/LCTSpatial/Sources/LCTSpatialMetal", + resources: [.process("Shaders")] + ), + .testTarget( + name: "LCTSpatialTests", + dependencies: ["LCTSpatial"], + path: "swift/LCTSpatial/Tests/LCTSpatialTests" + ), + .testTarget( + name: "LCTSpatialMetalTests", + dependencies: ["LCTSpatial", "LCTSpatialMetal"], + path: "swift/LCTSpatial/Tests/LCTSpatialMetalTests" + ), + ] +) diff --git a/README.md b/README.md index e4ff70b..2ad4c04 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,22 @@ The finite-dimensional tradeoff is explicit: - `normalization="compositional"` favors behavior that tracks matrix composition more closely +## Vision Pro spatial atlas + +The repository is also a Swift package and includes a simulator-ready visionOS +instrument under [`swift/LCTVision`](swift/LCTVision). It is designed for fast +visual intuition-building: scrub `a`, `b`, `c`, or `d` on four complex planes, +while an automatically selected dependent coefficient keeps `ad - bc = 1`. + +Two views update together without conflating their meaning: + +- a Metal-updated canonical mesh preview for immediate geometric feedback; +- a phase-colored sampled complex field produced by the actual separable LCT. + +Add the repository as a Swift Package dependency to use the dimension-agnostic +`LCTSpatial` core or the `LCTSpatialMetal` GPU implementation. The standalone +package and API guide also live at [`swift/LCTSpatial`](swift/LCTSpatial). + ## Install The current public release is diff --git a/docs/guides/visionos-spatial-visualizer-dev-guide.md b/docs/guides/visionos-spatial-visualizer-dev-guide.md new file mode 100644 index 0000000..ef2f236 --- /dev/null +++ b/docs/guides/visionos-spatial-visualizer-dev-guide.md @@ -0,0 +1,1203 @@ +# Building the visionOS Spatial LCT Visualizer by Hand: A Developer's Guide + +This is the long-form companion to the `codex/alok-781-lct-spatial` branch: a +reusable Swift/Metal package (`swift/LCTSpatial`) plus an Apple Vision Pro app +(`swift/LCTVision`) for interactively scrubbing an `SL(2,C)` linear canonical +transform and watching a 3D complex field respond in real time. Like the MLX +guide, it explains the surrounding system, every design decision, and enough +background that you could reimplement the whole change yourself without reading +the diff. + +The branch is seven commits, and the commit boundaries are a good build order — +this guide walks them in roughly that order: + +``` +bcbfceb feat(swift): add dimension-agnostic SL2C transform core +21a4759 feat(metal): add separable complex field transform +3f92445 feat(vision): add Metal-backed canonical mesh preview +5682714 feat(vision): scaffold volumetric LCT field explorer +b4000a7 feat(vision): add scrub-first SL2C control deck +b7caea5 docs(swift): expose spatial package from repository root +75569fd fix(metal): support the b-zero LCT branch +``` + +## 1. Mathematical background: from SL(2,R) to SL(2,C), and onto a grid + +### 1.1 The LCT as a unitary representation + +The continuum linear canonical transform is a family of integral operators +indexed by a matrix + +``` +M = [ a b ] + [ c d ] with ad − bc = 1, +``` + +acting on `L²(R)` as + +``` +(L_M f)(y) = 1/√(ib) ∫ exp( iπ (a x² − 2xy + d y²) / b ) f(x) dx (b ≠ 0) +``` + +For real parameters this is (up to a sign ambiguity) a **unitary representation +of SL(2,R)** — more precisely of its double cover, the metaplectic group: +`L_{M₁} L_{M₂} = ± L_{M₁M₂}`. The subgroups you know by name: + +- `(0, 1, −1, 0)` — the Fourier transform (up to a constant phase); +- `(cos θ, sin θ, −sin θ, cos θ)` — the fractional Fourier transform, the + rotation orbit through the Fourier point; +- `(1, b, 0, 1)` — Fresnel free-space propagation (chirp convolution); +- `(1, 0, c, 1)` — a thin lens (chirp multiplication); +- `(a, 0, 0, 1/a)` — coordinate scaling. + +On phase space the picture is simpler still: `L_M` conjugates position and +momentum operators linearly, `(q, p) ↦ (aq + bp, cq + dp)`. That phase-space +picture is exactly what the app's "canonical preview" mesh shows (section 8). + +### 1.2 Why extend to SL(2,C) — and what you give up + +Allowing complex `a, b, c, d` (still with `ad − bc = 1`) is genuinely +interesting: complex-ABCD matrices are the standard bookkeeping for Gaussian +beams through lossy or gain media in laser optics, `b = i`-type parameters give +Gauss–Weierstrass/Bargmann-flavored smoothing operators, and the analytic +continuation of the FrFT angle connects the Fourier orbit to heat flow. + +What you give up is unitarity. With complex parameters the quadratic form in +the exponent acquires a nonzero *real* part, so the kernel magnitude +`|exp(·)| ≠ 1` pointwise: the operator can amplify, decay, or be outright +unbounded depending on where you sit in `SL(2,C)`. Determinant one alone does +not guarantee a bounded operator. The package says this out loud in +`swift/LCTSpatial/README.md:40-46` ("Mathematical boundary") and responds in +two ways rather than pretending the problem away: + +1. every kernel evaluation clamps the real part of the complex exponent + (section 10), so an interactive scrub can visit wild parameters without + producing NaN/Inf geometry; +2. the singular `b = 0` branch **refuses** genuinely complex `d` + (section 1.5), because there is no honest way to evaluate it on a real grid. + +### 1.3 Separable multidimensional transforms + +The general n-dimensional LCT is parameterized by `Sp(2n)` — a `2n × 2n` +block-symplectic matrix mixing all axes. This package deliberately restricts to +the **diagonal embedding `SL(2) ↪ Sp(2n)`**: one scalar 2×2 block, applied +independently along every axis of the field. That is the "isotropic" or +separable LCT: + +``` +L_M^(3D) = L_M ⊗ L_M ⊗ L_M +``` + +Two reasons for the restriction. First, UX: four complex numbers (really three +free ones, given the determinant lock) is a scrubbable control surface; a full +`Sp(6)` element is 21 real parameters and is not. Second, cost: a separable +transform of an `n³` field is three passes of `n`-point 1D transforms — +`O(rank · n^(rank+1))` — instead of an `n³ × n³` dense kernel. The rank-agnostic +implementation means the same code transforms a 1D test vector, a 2D image, or +the app's 3D voxel field. + +### 1.4 The discretization tradeoff + +Exactly as in the Python package (see the top of +`docs/guides/mlx-backend-dev-guide.md`): a finite-grid LCT cannot +simultaneously be unitary, compose like the underlying matrices, and sample the +continuum kernel. The Python side exposes the choice +(`normalization="unitary" | "compositional"`); the Swift side, whose job is +*visual intuition* rather than numerics-grade computation, picks one convention +and keeps it simple. The discrete kernel used everywhere +(`ComplexField.swift:127-137`, `LCTKernels.metal:62-73`) is the **centered, +sampled kernel with `1/√n` normalization**: + +``` +K(y, x) = (1/√n) · exp( iπ (a x² − 2xy + d y²) / (b·n) ) +x, y ∈ {−(n−1)/2, …, +(n−1)/2} +``` + +Points worth internalizing: + +- The grid is **centered**: index `i` maps to coordinate `i − (n−1)/2` + (half-integers for even `n`). Centering makes the Fourier point the ordinary + centered DFT and makes the FrFT orbit rotate about the field's middle rather + than its corner. +- At the Fourier preset `(a,b,c,d) = (0,1,−1,0)` the phase collapses to + `−2πi·xy/n` and the prefactor is `1/√n`: a unitary centered DFT, which is why + the impulse test in section 11 expects flat magnitude `1/√n`. +- There is **no `1/√(ib)` prefactor**. The continuum amplitude factor is a + global complex constant per matrix; for a display tool it only rescales and + rotates every voxel's color identically, so the implementation uses the real + `1/√n` and stays exactly unitary at the Fourier point instead. If you extend + this library toward numerics, this is the first convention to revisit. +- Complexity is honest brute force: each output sample sums over `n` inputs, no + FFT factorization, no Bluestein. At the app's default `14³` field that is + `3 × 14⁴ ≈ 115k` kernel evaluations per transform — trivial for a GPU and + fine even for the CPU reference. This is a *deliberate* difference from the + Python/MLX backends, which are `O(N log N)` because they run inside training + loops. Here, clarity and rank-agnosticism win. + +### 1.5 The `b = 0` singular branch, and the refusal of complex `d` + +At `b = 0` the integral kernel degenerates (the `1/b` blows up) and the +continuum operator becomes a chirp-multiplied coordinate scaling: +`(L f)(y) = √d · exp(iπ c d y²) · f(d y)` with `d = 1/a`. The finite-grid +version used here (doc comments at `ComplexField.swift:144-145` and +`LCTKernels.metal:76-77`) is + +``` +(L f)(y) = √d · exp( iπ c d y² / n ) · f(d·y) +``` + +on the same centered grid, with the `/n` phase scaling chosen to match the +`b ≠ 0` kernel's `/(b·n)` convention, and `f(d·y)` evaluated by **linear +interpolation** between the two nearest grid samples, zero outside the grid +(`ComplexField.swift:165-178`, `LCTKernels.metal:94-109`). This mirrors the +Python implementation's `grid_sample(padding_mode="zeros", align_corners=True)` +branch — the closed form `p = Re(d)·(k − (n−1)/2) + (n−1)/2` is literally +`ComplexField.swift:166-167` (`sourceCoordinate = matrix.d.real * y + center`). + +Now the refusal. Both the CPU path (`ComplexField.swift:76-79`) and the Metal +path (`MetalLCTTransformer.swift:57-60`) do: + +```swift +if matrix.b.magnitude <= 1e-6 { + guard abs(matrix.d.imaginary) <= 1e-5 else { + throw DiscreteLCTError.complexScalingUnsupported + } +``` + +Why refuse rather than "do something"? Because `f(d·y)` with complex `d` asks +for the field's value at a **complex coordinate**. On the continuum that is an +analytic continuation — you would need to pick a contour and assume the signal +extends holomorphically off the real axis. On a finite grid of samples there is +no canonical continuation at all: any formula you write (interpolate the real +part of the coordinate and multiply by some fudge, resum against a Gaussian, +etc.) is inventing data the samples do not contain. The README states the +policy (`swift/LCTSpatial/README.md:36-38`): unsupported is reported "rather +than silently inventing a contour deformation." Note the asymmetry: complex `c` +is *fine* in this branch — it only appears inside the chirp `exp(iπ c d y²/n)`, +where a complex value just means gain/decay, handled by the exponent clamp. The +Metal parity test in section 11 deliberately uses `c = −0.28 + 0.05i` to pin +that down. + +Also note the branch ordering: the identity matrix has `b = 0`, but both +implementations check `matrix == .identity` *first* +(`ComplexField.swift:74`, `MetalLCTTransformer.swift:56`) and return the field +untouched — the same fast-path lesson the NanoGPT benchmarking taught on the +Python side. + +## 2. Architecture map + +The change is one package with two products plus one app target: + +``` +Package.swift # root manifest — repo is itself a Swift package +swift/LCTSpatial/ + Package.swift # nested manifest for focused development + Sources/LCTSpatial/ # product 1: pure Swift core, no Metal, no UI + Complex32.swift + ComplexField.swift # ComplexField + DiscreteLCT (CPU reference) + SL2CMatrix.swift # SL2CMatrix + SL2CConstraint + CanonicalPairTransform + MeshGeometry.swift # MeshGeometry + OBJMeshLoader + MeshVoxelizer + PhaseColor.swift + Sources/LCTSpatialMetal/ # product 2: GPU implementations + MetalLCTTransformer.swift # true sampled-field transform + MetalCanonicalPreview.swift # LowLevelMesh canonical preview + Shaders/LCTKernels.metal # all three kernels + Tests/LCTSpatialTests/ # 8 CPU tests + Tests/LCTSpatialMetalTests/ # 2 CPU-vs-GPU parity tests +swift/LCTVision/ # visionOS app (XcodeGen project) + project.yml + LCTVision/ + LCTVisionApp.swift StudioView.swift SpatialScene.swift + LCTStudioModel.swift ControlDeck.swift ArgandPad.swift + SpectralCloud.swift LCTPreset.swift DemoGeometry.swift Info.plist +``` + +Wiring diagram — boxes are state-carrying objects, labeled edges are the maps +between them: + +```mermaid +graph LR + subgraph Input + OBJ[OBJ file] -->|"OBJMeshLoader.parse"| Geo[MeshGeometry] + Torus[DemoGeometry.torus] -->|"analytic mesh"| Geo + end + + Geo -->|"MeshVoxelizer.surfaceField(resolution)"| SrcField["ComplexField n³ (source)"] + + subgraph Controls + Pads[ArgandPad ×4] -->|"drag → Complex32"| Model[LCTStudioModel] + Presets[LCTPreset buttons] -->|"apply(preset)"| Model + Model -->|"constraint.set(coeff, value)"| Lock[SL2CConstraint] + Lock -->|"det-locked SL2CMatrix"| Model + end + + Model -->|"scheduleTransform: 42ms debounce + generation guard"| Xform{MetalLCTTransformer or DiscreteLCT} + SrcField -->|"input field"| Xform + Xform -->|"separable axis passes"| OutField["ComplexField n³ (output)"] + + subgraph Volumetric scene + Geo -->|"positions + normals → MTLBuffer"| Preview[MetalCanonicalPreview] + Model -->|"matrix a,b"| Preview + Preview -->|"canonical_preview_mesh kernel → LowLevelMesh"| Mesh[RealityKit ModelEntity] + OutField -->|"top-K magnitude + phase bins"| Cloud[SpectralCloud] + Mesh --> Scene[SpatialScene.root] + Cloud --> Scene + end + + Scene -->|"RealityView content"| Studio[StudioView / volumetric window] +``` + +The load-bearing architectural fact, stated in +`swift/LCTSpatial/README.md:6-12` and enforced by the module split: there are +**two different operations that must never be conflated** — + +- `DiscreteLCT` / `MetalLCTTransformer` transform a *sampled complex field*. + This is the actual Atlas-style signal transform. +- `CanonicalPairTransform` / `MetalCanonicalPreview` apply the raw 2×2 matrix + to *paired vectors* (`q′ = aq + bp`). It is a zero-latency phase-space + preview, not the integral transform of anything. + +Section 9 returns to why both exist and why they are kept apart. + +## 3. `Complex32` — the interop-stable scalar + +`swift/LCTSpatial/Sources/LCTSpatial/Complex32.swift` + +The obvious question first: why not `swift-numerics`' `Complex`? Three +reasons, all about the Metal boundary: + +1. **Layout guarantee.** `Complex32` is `@frozen` with exactly two `Float` + stored properties (`Complex32.swift:4-7`), so its stride is 8 bytes and + bit-identical to Metal's `float2`. `MetalLCTTransformer.transform` asserts + this at runtime — `precondition(MemoryLayout.stride == + MemoryLayout>.stride)` (`MetalLCTTransformer.swift:61`) — and + then blits `[Complex32]` arrays straight into `MTLBuffer`s with no + conversion pass. +2. **Zero dependencies.** The package has no external requirements, which keeps + the root-manifest trick in section 12 friction-free. +3. **Control over the numerics that matter here** — two members deserve + attention: + +```swift +// Complex32.swift:41-47 — principal square root via half-angle identities +public var squareRoot: Self { + let radius = magnitude + let realPart = sqrt(max((radius + real) / 2, 0)) + let imaginaryMagnitude = sqrt(max((radius - real) / 2, 0)) + let imaginaryPart = imaginary < 0 ? -imaginaryMagnitude : imaginaryMagnitude + return .init(real: realPart, imaginary: imaginaryPart) +} +``` + +This is `√((|z|+Re z)/2) + i·sign(Im z)·√((|z|−Re z)/2)` — the principal branch +(cut along the negative real axis), computed without any `atan2`/half-angle +trig, and the `max(·, 0)` guards absorb float cancellation when `|z| ≈ |Re z|`. +It is used for the `√d` amplitude in the singular branch, and it is transcribed +*verbatim* into Metal as `complex_sqrt` (`LCTKernels.metal:28-34`) so CPU and +GPU pick the same branch for every `d`. + +```swift +// Complex32.swift:49-57 — the safety valve for SL(2,C) +public func exponential(maximumReal: Float = 20) -> Self { + let boundedReal = min(max(real, -maximumReal), maximumReal) + let scale = exp(boundedReal) + return .init(real: scale * cos(imaginary), imaginary: scale * sin(imaginary)) +} +``` + +`exponential` clamps only the **real** part of the exponent — the phase is +never touched, so the hue channel of the visualization stays exact; only the +magnitude saturates. This single function is what makes scrubbing arbitrary +complex parameters safe (section 10). + +The rest of the file is the operator zoo (`+ − * /`, scalar variants, `+=`, +unary minus, `Complex32.swift:67-117`), all `@inlinable` so the hot CPU loops +don't pay call overhead, plus `zero/one/i`, `magnitude(Squared)`, `phase` +(`atan2`), `conjugate`, and a `description` used directly by the app's +determinant readout. + +## 4. `ComplexField` and `DiscreteLCT` — the rank-agnostic CPU reference + +`swift/LCTSpatial/Sources/LCTSpatial/ComplexField.swift` + +### 4.1 The container + +`ComplexField` (`ComplexField.swift:12-55`) is a validated row-major +`shape: [Int]` + `values: [Complex32]` pair. The initializer rejects empty +shapes, non-positive dimensions, and count mismatches with typed errors +(`ComplexFieldError`, lines 3-9) — errors as data, not preconditions, because +field shapes come from user actions (resolution menu, imported meshes). +Convenience members: `rank`, `count`, `maxMagnitude`, a bounds-checked +`flatIndex(_:)` in the usual row-major recurrence +(`result = result * shape[axis] + index`, lines 37-49), and +`normalizedMagnitudes(gamma:)` for display scaling. + +### 4.2 The separable driver + +`DiscreteLCT.transform` (`ComplexField.swift:68-104`) is the CPU reference — +the doc comment (lines 63-67) is explicit that it is "intentionally +O(rank × n^(rank+1))" and exists for "tests, tiny fields, and the fallback +path; `LCTSpatialMetal` owns live grids." Its structure is the dispatch you +must reproduce on the GPU: + +1. `matrix == .identity` → return the field untouched (line 74); +2. `matrix.b.magnitude <= 1e-6` → the singular branch, after refusing complex + `d` (lines 76-91); +3. otherwise → the generic kernel (lines 93-103). + +Both branches loop `for axis in field.shape.indices` and re-feed the output of +one axis pass as the input of the next — this loop *is* the tensor-product +factorization `L ⊗ L ⊗ L`. + +### 4.3 Strided axis addressing — the one idiom to burn in + +Both `transformAxis` and the Metal kernels address "the 1D line through element +`e` along `axis`" the same way. For row-major `shape`: + +```swift +// ComplexField.swift:113-115 +let length = shape[axis] +let stride = shape.dropFirst(axis + 1).reduce(1, *) // product of trailing dims +let block = length * stride +``` + +Elements of one line are `blockStart + k * stride + inner` for +`k in 0..= 0, sourceCoordinate <= Float(length - 1) else { continue } +let lower = Int(floor(sourceCoordinate)) +let upper = min(lower + 1, length - 1) +let fraction = sourceCoordinate - Float(lower) +let sample = lowerValue * (1 - fraction) + upperValue * fraction +let phase = piI * (matrix.c * matrix.d) * (y * y / Float(length)) +let chirp = phase.exponential(maximumReal: maximumKernelExponent) +output[...] = amplitude * chirp * sample +``` + +where `amplitude = matrix.d.squareRoot` (line 159; complex, but `d` is +near-real by the guard) and out-of-grid samples stay zero via the `continue` +against a zero-initialized output — the "padding_mode=zeros" analog. Only +`d.real` steers the resampling; `c` may be complex and only feeds the chirp. + +## 5. `SL2CMatrix` and `SL2CConstraint` — the determinant lock + +`swift/LCTSpatial/Sources/LCTSpatial/SL2CMatrix.swift` + +### 5.1 The matrix type + +`SL2CMatrix` (lines 8-64) is four `Complex32` fields with named constructors +(`identity`, `fourier = (0, 1, −1, 0)`, `fractionalFourier(angle:)`, +`fresnel(distance:)` — note `distance` is *complex*, which is how the app's +"Complex lens" preset works), a `determinant`, an +`isUnimodular(tolerance: 1e-4)` predicate, and — the piece that makes generic +UI possible — a subscript over the `SL2CCoefficient` enum (`case a, b, c, d`, +lines 3-5) so the four Argand pads can be one view driven by a `ForEach`. + +### 5.2 The design problem the constraint solves + +`ad − bc = 1` is one complex equation on four complex unknowns. During a scrub +the user freely drags *one* coefficient; something else must move to stay on +the group. The obvious answer — "always solve for `d = (1+bc)/a`" — breaks the +moment `a` gets small: the solved `d` shoots off to infinity and the UI +explodes. And the Fourier matrix itself has `a = 0`, so the obvious answer +breaks *at the most important preset*. + +`SL2CConstraint` (lines 74-150) answers with a **preferred-pivot-with-fallback** +scheme. State: the current `matrix`, the current `dependent` coefficient +(exposed to the UI as the orange `AUTO` chip), a `lockDeterminant` flag, and +`pivotEpsilon = 1e-4`. The core method: + +```swift +// SL2CMatrix.swift:92-109 +public mutating func set(_ coefficient: SL2CCoefficient, to value: Complex32) throws -> SL2CMatrix { + matrix[coefficient] = value + guard lockDeterminant else { return matrix } + let preferred = coefficient == dependent ? nil : dependent + let selected = + try preferred.flatMap { candidate in + pivotMagnitude(for: candidate, in: matrix) >= pivotEpsilon ? candidate : nil + } ?? bestPivot(excluding: coefficient) + matrix[selected] = solvedValue(for: selected, in: matrix) + dependent = selected + return matrix +} +``` + +Read it carefully — every clause is a UX decision: + +- **Write first, solve second.** The user's edit always lands verbatim; the + constraint never fights the finger. +- **Sticky dependent.** If the current dependent's solve is well-conditioned, + keep it — the AUTO chip should not jump around while you scrub. +- **Scrubbing the AUTO pad is allowed.** If you edit the dependent coefficient + itself, `preferred` becomes `nil` (line 100) and a new dependent is chosen + from the other three — every pad stays live. +- **Conditioning is denominator magnitude.** `pivotMagnitude` (lines 129-136) + maps each candidate to the magnitude of the denominator its solve would + divide by: solving `d = (1+bc)/a` divides by `a`, `a = (1+bc)/d` by `d`, + `c = (ad−1)/b` by `b`, `b = (ad−1)/c` by `c` (`solvedValue`, lines 138-149). +- **Auto-pivot.** `bestPivot` (lines 116-127) filters candidates ≠ the edited + coefficient with denominator ≥ `pivotEpsilon` and takes the + largest-magnitude one; if *no* entry is stable — the matrix has been dragged + somewhere every solve is singular — it throws `noStablePivot`, which the app + surfaces as a status message ("No stable unimodular pivot", + `LCTStudioModel.swift:54`) instead of corrupting the matrix. + +`replace(with:dependent:)` (lines 111-114) is the preset path: it swaps the +whole matrix in without solving, since presets are exact group elements by +construction. + +### 5.3 `CanonicalPairTransform` + +The last twenty lines of the file (152-168) are the "dimension-agnostic" phase +space map from the first commit's title: given equal-length `q` and `p` arrays +of any dimensionality, return `(aq + bp, cq + dp)` elementwise. The unit test +uses it at the Fourier point to check `(q, p) ↦ (p, −q)`. Its real purpose is +to be the *specification* the Metal preview kernel implements per vertex +(section 8). + +## 6. Mesh ingestion: `MeshGeometry`, OBJ, voxelization + +`swift/LCTSpatial/Sources/LCTSpatial/MeshGeometry.swift` + +### 6.1 `MeshGeometry` + +A validated triangle soup (lines 10-71): `positions`, `normals`, `indices`, +with the initializer rejecting empty vertex sets, non-multiple-of-3 index +counts, and out-of-range indices. Two behaviors to know: + +- **Normals are optional in, guaranteed out.** If the caller's `normals` count + doesn't match `positions`, area-weighted smooth normals are generated + (lines 52-70): accumulate the *unnormalized* face cross product onto each + corner — the cross product's length is twice the triangle area, so big + triangles dominate for free — then normalize, with a `(0,1,0)` fallback for + degenerate vertices. This matters because the preview uses the normal field + as the momentum `p`; imported OBJs (whose `vn` lines are ignored — see below) + still get a smooth `p`. +- **`normalized(extent: 1.7)`** (lines 35-50) recenters to the AABB midpoint + and scales the longest axis to a target extent, so any import lands at a + sensible size in the study volume, with a guard against zero-extent + degenerate meshes. + +### 6.2 `OBJMeshLoader` — deliberately minimal + +Lines 80-120: a dependency-free Wavefront parser that handles exactly two +commands. `v` lines become positions (malformed → error with a 1-based line +number). `f` lines: each corner token is split on `/` and only the leading +position index is kept — **`vt`/`vn` references are dropped**, normals being +regenerated as above; **negative (relative) indices** are resolved OBJ-style as +`positions.count + rawIndex` (line 102); and polygons with more than three +corners are **fan-triangulated** from the first vertex +(lines 108-110). No faces at all → `noFaces`. Everything else (`o`, `g`, +`usemtl`, `s`, comments) falls through the `guard let command` and is ignored. +The README is explicit that OBJ is "only the sample app's current +document-picker format" (`swift/LCTVision/README.md:31-32`) — the library API +boundary is `MeshGeometry`, so a USDZ importer is an app-side extension, not a +library change (section 13). + +### 6.3 `MeshVoxelizer` — from surface to field + +The bridge between "a mesh" and "a thing an LCT can transform" +(lines 122-162). `surfaceField(from:resolution:)`: + +1. normalize the mesh into `[-0.85, 0.85]³` (via `normalized()`); +2. for each triangle, choose a sampling density from its longest edge — + `steps = max(2, min(64, ceil(longest · resolution)))` (lines 147-148) — so + sample spacing tracks voxel size regardless of tessellation; +3. walk barycentric coordinates `(u, v) = (i/steps, j/steps)` with + `j ≤ steps − i` (the triangle half of the square, lines 149-154) and + **splat** each surface point into the nearest voxel (`round`, not + trilinear — lines 132-141) of a `resolution³` density accumulator; +4. normalize by the max count and emit a **purely real** `ComplexField` + (`Complex32(real: min(density/max, 1))`, line 159) of shape + `[resolution, resolution, resolution]`. + +The result: voxels containing surface get density ≈ their local surface area, +empty space is exactly zero, and the initial phase is zero everywhere — so all +phase structure you subsequently see in the cloud is *created by the +transform*, which is exactly the pedagogical point. + +## 7. Phase coloring — the Atlas convention + +`swift/LCTSpatial/Sources/LCTSpatial/PhaseColor.swift` + +The package standardizes on the *Atlas of Fourier Transforms* / +domain-coloring convention: **hue encodes phase, luminance encodes +magnitude**. The CPU-side implementation: + +```swift +// PhaseColor.swift:12-16 +let magnitude = pow(min(value.magnitude / max(maximumMagnitude, .leastNonzeroMagnitude), 1), gamma) +let hue = (value.phase + .pi) / (2 * .pi) +let rgb = hsvToRGB(hue: hue, saturation: 0.82, value: 0.18 + 0.82 * magnitude) +return SIMD4(rgb.x, rgb.y, rgb.z, alpha * magnitude) +``` + +Decisions encoded here: phase `−π…π` maps affinely onto one trip around the +hue circle (so phase is periodic with no seam); magnitude is normalized against +the field max and gamma-compressed with `gamma = 0.55` to lift faint structure +(a straight linear map makes everything but the peak invisible); value has a +`0.18` floor so even near-zero samples read as dark color rather than black; +alpha *also* scales with magnitude so weak samples fade out. `hsvToRGB` (lines +19-34) is the standard six-sector conversion, written out to avoid importing +UIKit into the core target. + +The same convention appears twice more, adapted to context: the Metal preview +kernel's `phase_rgb` (`LCTKernels.metal:130-135`) computes the hue wheel with +the branchless `fract`/`abs` idiom and mixes toward a `0.18`-luminance floor +with weight `0.86`; and `SpectralCloud` bakes the wheel into 18 quantized +materials (section 9.2). One convention, three renderers — when you see cyan +mean "phase ≈ +π/2" in one view, it means the same in the others. + +## 8. The Metal layer + +### 8.1 `MetalLCTTransformer` — the true transform on GPU + +`swift/LCTSpatial/Sources/LCTSpatialMetal/MetalLCTTransformer.swift` and the +first two kernels of `Shaders/LCTKernels.metal`. + +The class holds a device, a queue, and **two pipelines** — `lct_axis_pass` and +`lct_singular_axis_pass` — compiled once at init (lines 33-49). The header +`@_exported import LCTSpatial` (line 2) means app code importing +`LCTSpatialMetal` sees the core types without a second import. + +`transform(_:matrix:maximumKernelExponent:)` (lines 51-136), step by step: + +1. **Dispatch decision on the CPU, once per call** (lines 56-60): identity → + early return; `b.magnitude <= 1e-6` → singular pipeline, with complex `d` + refused as `MetalLCTError.complexScalingUnsupported`. The GPU kernels + contain no branch selection — same philosophy as the MLX "plan" pattern: + decide on concrete values host-side, keep device code straight-line. +2. **Two shared-storage buffers** (lines 63-76): the input array is copied into + buffer A, buffer B starts empty. `.storageModeShared` is the right choice on + Apple-silicon unified memory — no blit encoders, and the final readback is + a pointer bind. This is also where the `Complex32`/`float2` stride + precondition lives (line 61). +3. **One encoder per axis, ping-ponging** (lines 86-124): for each axis, + compute `length`, `elementStride`, `block` exactly as the CPU does, pack + them with the total count into a `SIMD4 dimensions`, bind the + matrix coefficients as `float2` via `setBytes` (small constants — no buffer + management), dispatch, then `swap(&input, &output)`. All axis passes are + encoded into a **single command buffer**, so the GPU runs the three axes + back to back with no CPU round-trips; ordering between encoders within one + command buffer is guaranteed by Metal. +4. **Threadgroup layout** (lines 117-121): a 1D grid of `field.count` threads — + one thread per *output element*, each summing its whole line — with + threadgroup width `min(pipeline.maxTotalThreadsPerThreadgroup, 256)`, using + `dispatchThreads` (non-uniform threadgroup sizes, fine on every + Apple-family GPU) plus a `gid >= totalCount` guard in the kernel + (`LCTKernels.metal:50-52`) as belt and braces. +5. **Synchronous completion** (lines 126-131): `commit()` + + `waitUntilCompleted()`, with the command-buffer status checked and any Metal + error surfaced as `commandFailed(String)`. Synchronous is correct here + because the caller (`LCTStudioModel`) already runs the whole call inside + `Task.detached` — the main thread never blocks (section 9.3). +6. **Readback from `input`** (lines 133-135) — after the final `swap`, the + most recent output is in the variable named `input`. A classic ping-pong + gotcha; the tests would catch you within minutes if you read the wrong one, + which is exactly why the tests exist. + +The kernel bodies (`LCTKernels.metal:37-74` and `78-117`) are line-for-line +transcriptions of sections 4.4 and 4.5 built on a tiny complex library +(`complex_mul/div/add`, `complex_exp_bounded`, `complex_sqrt`, lines 4-34). +The only new arithmetic is the inversion of the strided addressing — a thread +knows its flat `gid` and must recover its line: + +```metal +// LCTKernels.metal:54-55 +const uint outputCoordinate = (gid / elementStride) % length; +const uint lineStart = gid - outputCoordinate * elementStride; +``` + +which inverts `gid = lineStart + outputCoordinate * elementStride`. Everything +else — centered coordinates, `/(b·n)` phase scaling, `rsqrt(n)` normalization, +the interpolated singular branch that writes explicit zeros for out-of-range +sources (`LCTKernels.metal:99-102`; the CPU's `continue` relied on +zero-initialized output, a GPU kernel must store the zero itself) — mirrors +the CPU reference. + +Note the argument-table asymmetry: the generic kernel binds `a, b, d` (buffers +2-4; `c` never appears in the `b ≠ 0` kernel formula) while the singular kernel +binds `c, d` (buffers 2-3), so `dimensions` and `maximumExponent` sit at +different indices per pipeline — mirrored on the host at +`MetalLCTTransformer.swift:104-115`. If you touch either signature, touch both +sides. + +**The library-loading fallback** (`makeLibrary`, lines 138-160) is a subtle +piece of SwiftPM reality: when the package is built *by Xcode* (the visionOS +app), `.metal` resources are compiled into the target's default metallib, so +`device.makeDefaultLibrary(bundle: Bundle.module)` succeeds. When built by +*command-line SwiftPM* (`swift test`), no metallib exists — but the `.metal` +source ships as a processed resource, so the code finds `LCTKernels.metal` in +the bundle (checking both the flattened and `Shaders/` subdirectory layouts, +lines 147-154) and compiles it at runtime with `device.makeLibrary(source:)`. +Order matters: metallib first (fast, pre-optimized), source as the portable +fallback. This is why `resources: [.process("Shaders")]` appears in both +manifests (`Package.swift:26`, `swift/LCTSpatial/Package.swift:20`). + +Concurrency posture: the class is `final … @unchecked Sendable` with +`@preconcurrency import Metal` (lines 3, 19). All stored state (device, queue, +pipelines) is immutable after init and Metal queues are thread-safe for +command-buffer creation; each `transform` call builds its own command buffer, +so calls from detached tasks don't share mutable state. `@unchecked` is a +promise the compiler can't verify — the init-only-immutable pattern is what +backs it. + +### 8.2 `MetalCanonicalPreview` — LowLevelMesh and the third kernel + +`swift/LCTSpatial/Sources/LCTSpatialMetal/MetalCanonicalPreview.swift` and +`canonical_preview_mesh` (`LCTKernels.metal:139-171`). + +**What it shows.** The doc comment (lines 17-22) is the contract: "treats mesh +position as `q` and its scaled normal as the paired field `p`, then displays +`q′ = aq + bp`. It is a responsive canonical-map aid; use `MetalLCTTransformer` +for the actual sampled-field transform." Per vertex the kernel computes +(`LCTKernels.metal:152-155`): + +```metal +const float3 companion = sourceVertex.normal * parameters.x; // p = normal × companionScale (0.18) +const float3 realPosition = a.x * position + b.x * companion; +const float3 imaginaryPosition = a.y * position + b.y * companion; +``` + +i.e. the complex vector `q′ = aq + bp` split into its real part (rendered as +the solid mesh) and imaginary part (rendered as a translucent "ghost" whose +alpha is `0.28 × showsImaginary` — `LCTKernels.metal:170`). At the identity, +`q′ = q` and the ghost coincides at zero alpha contribution; scrub `b` and the +mesh inflates along its normals; make `a` complex and the ghost detaches. The +per-vertex color (lines 156-162) probes the phase of `q′` along a fixed +incommensurate direction `normalize(float3(1, 1.6180339, 2.6180339))` — the +golden ratio and its square, chosen so the probe never aligns with a mesh +symmetry axis — and feeds `atan2(imaginaryProbe, realProbe)` and a saturating +luminance `0.25 + 0.75(1 − e^{−magnitude})` through `phase_rgb`. + +**Why `LowLevelMesh`.** The naive way to animate a RealityKit mesh is to +rebuild a `MeshResource` from arrays every frame — a CPU-side re-upload plus +resource churn, at 90 Hz, on a headset. `LowLevelMesh` (RealityKit's +GPU-resident mesh API, visionOS 2+) exists precisely for this: you declare +vertex attributes/layouts once, and thereafter obtain the vertex buffer as an +`MTLBuffer` *inside a command buffer* via +`lowLevelMesh.replace(bufferIndex: 0, using: commandBuffer)` +(`MetalCanonicalPreview.swift:149`) and let a compute kernel write it. The +mesh the renderer draws next frame *is* the kernel's output buffer — zero +CPU-side geometry work per update, which is what makes the preview genuinely +zero-latency while the true transform debounces behind it. + +Construction (`init`, lines 34-135) in order: + +- **Source buffer**: interleaved `PreviewSourceVertex { position; normal }` + (Swift struct at lines 6-9, Metal struct at `LCTKernels.metal:119-122`) + uploaded once. Both `SIMD3` and Metal `float3` have 16-byte + stride/alignment, so the layouts agree (32 bytes); the output + `PreviewVertex` adds a `float4` color (48 bytes). The vertex attribute + offsets are derived with `MemoryLayout.offset(of:)` + (lines 67-83) rather than hand-counted — do the same in any extension. +- **Doubled capacity**: the descriptor asks for `vertexCapacity = V × 2` and + `indexCapacity = I × 2` (lines 87-93). Vertices `[0, V)` are the real copy, + `[V, 2V)` the imaginary ghost; the kernel writes both from one dispatch + (`output[gid]` and `output[gid + vertexCount]`, + `LCTKernels.metal:164-170`). +- **Index buffer written once** (lines 95-102): the original indices, then the + same indices shifted by `+V` for the ghost copy. Topology never changes; + only vertex data animates. +- **Two parts, two materials** (lines 107-124): part 0 covers the real index + range with `materialIndex: 0`, part 1 the ghost range with + `materialIndex: 1`. This is how one entity renders solid-real + + translucent-imaginary: `SpatialScene.makePreviewMaterials()` supplies + `[SimpleMaterial (mint, slightly metallic), UnlitMaterial (indigo, + transparent)]` in that order (`SpatialScene.swift:54-64`). +- **Generous static bounds** (`boundsExtent: 8`, lines 103-106): the bounding + box must be declared up front and the kernel can move vertices anywhere the + matrix sends them; a huge fixed box avoids per-frame bounds updates at the + cost of culling precision — the right trade for a single hero object. +- Finally `meshResource = try MeshResource(from: lowLevelMesh)` (line 132) — + the bridge RealityKit entities consume — and one `update(matrix: .identity)` + so the mesh is valid before its first frame. + +`update(matrix:companionScale:showsImaginary:)` (lines 137-170) encodes a +single dispatch of `canonical_preview_mesh` with `a`, `b` as `float2`, a +packed `parameters = (companionScale, showsImaginary ? 1 : 0, 0, 0)`, and the +vertex count — then `commit()` **without** `waitUntilCompleted`: the renderer, +not the CPU, is the consumer, so there is nothing to wait for. The class is +`@MainActor` because `LowLevelMesh`/`MeshResource` mutation belongs to +RealityKit's update world; contrast with the transformer, which is +actor-agnostic and runs detached. + +## 9. The app + +### 9.1 Scene scaffolding: app entry, `StudioView`, `SpatialScene` + +`LCTVisionApp.swift` is eleven lines that matter: a `WindowGroup` with +`.windowStyle(.volumetric)` and `.defaultSize(width: 1.32, height: 0.86, +depth: 0.72, in: .meters)` (lines 12-13) — a desk-scale volume, not an +immersive space, so the instrument floats in the user's real room and can sit +next to other windows. The matching Info.plist scene-manifest key is +`UIApplicationPreferredDefaultSceneSessionRole: +UIWindowSceneSessionRoleVolumetricApplication` (`Info.plist:23-31`, generated +from `project.yml:20-24`). A single `LCTStudioModel` is created at app scope +and injected as an `@EnvironmentObject`. + +`StudioView.swift` composes the volume: a `RealityView` whose `make` closure +installs the scene once and whose `update` closure re-syncs it whenever any +`@Published` model property changes (lines 11-25); a 2D overlay with a header +(source name + live status line, tinted yellow while a transform is in flight, +lines 68-91), two labels naming the two views — "CANONICAL MAP / instant +preview" in mint and "SAMPLED FIELD / amplitude × phase" in indigo (lines +29-35), the color-coding that keeps the section-2 distinction visible to the +*user*, not just the architect — and the `ControlDeck` pinned at the bottom. +The `.fileImporter` (lines 42-49) is gated on +`UTType(filenameExtension: "obj")` and hands the picked URL to the model. + +`SpatialScene.swift` owns the RealityKit entity graph under a single `root` +entity. `install` (lines 15-39) tears down and rebuilds: a +`MetalCanonicalPreview` wrapped in a `ModelEntity` at `(−0.31, 0.03, 0)`, +scaled ×0.36; a `SpectralCloud` at `(+0.31, 0.03, 0)`; and thin unlit box +"axis frames" (teal/indigo, lines 66-79) marking each station's corner axes. +`update` (lines 41-52) is the per-frame cheap path: it re-installs **only** +when the geometry actually changed, detected by a deliberately cheap signature +— vertex count, index count, and the first position's components hashed +together (lines 81-91). That's not collision-proof; it's a heuristic that is +right for every real trigger (new import, different demo mesh) and costs +nanoseconds. Otherwise it just forwards the matrix to the preview and the +field to the cloud. + +### 9.2 `SpectralCloud` — rendering the true transform + +`SpectralCloud.swift` renders the output `ComplexField` as a point cloud, and +every design choice is a RealityKit-economics choice: + +- **Entity pool, allocated once** (lines 14-43): `maximumPoints = 520` sphere + `ModelEntity`s (radius 0.008) created at init and toggled with `isEnabled` — + never created or destroyed per frame, because entity churn is the classic + RealityKit performance cliff. +- **18 pre-baked phase materials** (lines 17-31): RealityKit materials are + value-copied per assignment and rebuilding an `UnlitMaterial` per point per + frame would thrash; instead the hue wheel is quantized into `phaseBins = 18` + `UnlitMaterial`s built once via `PhaseColor.rgba` on unit-magnitude phasors. + A point's phase selects a bin (lines 73-77); 20° hue steps are well under + what the eye needs from a 0.008 m sphere. +- **Top-K by magnitude** (lines 52-56): keep samples above 6.5% of the field + max, sort descending, take the first 520. A 14³ field has 2,744 voxels, most + near zero after a transform of a sparse surface field; the top-K crop is what + keeps the display legible *and* the entity budget fixed. +- **Change detection by content hash** (lines 46-50): a cheap FNV-style hash + over the values' bit patterns (`partial = partial &* 31 &+ + Int(real.bitPattern ^ imaginary.bitPattern)`) skips the whole update when + the field is unchanged — which happens constantly, because SwiftUI calls the + `RealityView` update closure for *any* model change, including ones (status + text, toggle state) that don't touch the field. +- **Placement and scaling** (lines 60-82): the flat index is decoded to + `(x, y, z)` by the row-major inverse (`yz = shape[1] * shape[2]` etc.), + mapped to `[−0.5, 0.5]³` and scaled ×0.58 into station space; point scale is + `0.55 + 1.55 · (m/m_max)^0.55` — the same 0.55 gamma as the color pipeline, + so size and brightness compress consistently. + +### 9.3 `LCTStudioModel` — the interaction core + +`LCTVision/LCTStudioModel.swift`, `@MainActor final class … ObservableObject`. +State worth naming: the current `matrix` and `dependent` (mirrors of the +private `SL2CConstraint`), `sourceGeometry`/`sourceField` (the voxelized +input), `outputField` (what the cloud renders), `status` (the header's live +line), `selectedPreset`, `isPlaying`, `showsImaginary`, `resolution = 14`, and +the machinery: an optional `MetalLCTTransformer` (constructed with `try?` at +init — no Metal, e.g. some CI contexts, degrades to the CPU reference rather +than crashing, surfaced in the deck as "CPU REFERENCE" vs "METAL FIELD +ONLINE"), a `transformTask`, a `playbackTask`, and a `generation` counter. + +**Scrub path** (`set(_:to:)`, lines 45-56): stop any orbit, push the value +through the constraint, mirror `matrix`/`dependent` out to the UI, clear the +preset highlight, update status, `scheduleTransform()`. A `noStablePivot` +throw becomes a status message; the matrix is left as the constraint left it. + +**`scheduleTransform` — the 42 ms debounce + generation guard** (lines +147-178). This is the concurrency heart of the app, so here it is nearly +whole: + +```swift +private func scheduleTransform(immediate: Bool = false) { + generation += 1 + let requestedGeneration = generation + transformTask?.cancel() + let field = sourceField + let matrix = matrix + let transformer = transformer + isTransforming = true + + transformTask = Task { + if !immediate { + try? await Task.sleep(for: .milliseconds(42)) + } + guard !Task.isCancelled else { return } + do { + let transformed = try await Task.detached { + if let transformer { + return try transformer.transform(field, matrix: matrix) + } + return try DiscreteLCT.transform(field, matrix: matrix) + }.value + guard !Task.isCancelled, requestedGeneration == generation else { return } + outputField = transformed + ... +``` + +The layers, and why each exists: + +- **Debounce (42 ms).** A drag emits changes far faster than a volumetric + transform is worth recomputing. Sleeping ~2-3 frames before starting, and + cancelling the sleep when the next change lands, coalesces a burst of scrub + events into one transform at the trailing edge. The canonical preview + meanwhile updates *every* frame — this is exactly the division of labor the + two-operation architecture buys. Presets, orbit frames, imports, and + resolution rebuilds pass `immediate: true` because they are discrete events, + not streams. +- **`Task.cancel()`** kills work still in the sleep; but a transform already + running on the GPU cannot be interrupted — +- **— which is what the generation counter is for.** Every schedule bumps + `generation`; a completing task compares its captured `requestedGeneration` + against the current value and **discards its own result if stale** (line + 168). Without this, a slow transform finishing after a newer one would + clobber `outputField` with obsolete data — the classic out-of-order-writes + bug in debounced pipelines. +- **`Task.detached`** hops off the main actor for the compute (the transformer + blocks in `waitUntilCompleted`); captured values (`field`, `matrix`, + `transformer`) are taken as locals *before* the task so the detached closure + touches no main-actor state. + +**FrFT orbit** (`togglePlayback`, lines 86-110): a repeat-while task stepping +`angle = 2π · (frame mod 96)/96` every 90 ms — a full Fourier orbit in +≈ 8.6 s — installing each `SL2CMatrix.fractionalFourier(angle:)` via +`constraint.replace` and scheduling an immediate transform. At 14³ on Metal +each frame's transform comfortably beats the 90 ms cadence, and the generation +guard silently drops frames if it ever doesn't. Any manual scrub or preset +calls `stopPlayback()` first — the orbit never fights the finger. + +**Preset path** (`apply`, lines 58-67) has one subtlety flagged in section 5: + +```swift +let preferredDependent: SL2CCoefficient = preset == .fourier ? .c : .d +``` + +The Fourier matrix has `a = d = 0`, so solving for `d` (denominator `a`) is +singular from the first scrub; seeding the dependent as `c` (denominator +`b = 1`) puts the AUTO chip somewhere well-conditioned before the user ever +touches a pad. The presets themselves (`LCTPreset.swift:22-35`) are Identity, +Fourier, FrFT (π/4), Fresnel (real distance 0.72), and **Complex lens** — +`fresnel(distance: 0.72 + 0.16i)`, the one preset that leaves the real +subgroup and makes the imaginary ghost visibly detach. + +**Import and resolution** (`importOBJ` lines 112-139, `rebuildResolution` +lines 69-84) share a shape: set a progress status, run parse + voxelize inside +`Task.detached` (voxelizing at 22³ takes real milliseconds), then swap the new +`sourceField` in and schedule an immediate transform. The import path also +handles the security-scoped-resource dance required for files obtained from +the visionOS document picker (lines 118-121) — forget +`startAccessingSecurityScopedResource` and reads fail with permission errors +only outside the simulator, a classic device-vs-simulator trap. + +The demo source is `DemoGeometry.torus()` (`DemoGeometry.swift:5-44`): a +54×22-segment torus with *analytic* normals (the minor-circle radial +direction, line 24-26 — exact, not accumulated), oriented facing the viewer +(comment at lines 19-20; imported meshes keep their authored orientation). +A torus is the right demo: curved, with a hole (so shear/scaling reads +instantly), and its normal field makes `q′ = aq + bp` visibly "breathe". + +### 9.4 `ArgandPad` and `ControlDeck` — the scrub-first control surface + +The design premise (commit `b4000a7`, "scrub-first"): a complex coefficient +should be dragged on a **2D complex plane**, not two 1D sliders — the user is +supposed to develop geometric intuition for complex parameters, and phase/ +magnitude are what the eye sees in the visualization, so Re/Im should be a +single 2D gesture. + +`ArgandPad.swift` — one pad per coefficient, fully value-driven: + +- The **coordinate map**: `value(at:size:)` (lines 66-73) sends pad pixels to + `[-1, 1]²` (y flipped so up = +Im, as on paper), clamps, scales by `range`; + `point(for:size:)` (lines 75-79) is its inverse for drawing the handle. The + deck instantiates every pad with `range: 2` (`ControlDeck.swift:40`) — + coefficients scrub over `[-2, 2]²`, comfortably covering every preset. +- **`DragGesture(minimumDistance: 0)`** (lines 42-48) so a bare tap already + sets a value — no dead zone — with each change routed straight to + `model.set(coefficient, to:)`, i.e. through the determinant lock. There is + no local value state; the pad renders whatever the model publishes, so when + the *constraint* moves this coefficient (because it became the dependent), + the handle moves by itself. +- **Rendering** (lines 34-39, 81-138): a `Canvas` inside + `TimelineView(.animation(minimumInterval: 1/24))` — the timeline drives a + slow scanline sweep (2.2 s period, lines 112-117) that gives the deck a live + instrument feel at capped cost; grid lines, center axes, and a + radial-glow + white-dot + accent-ring handle (lines 119-137). +- **The AUTO chip** (lines 20-27): when `isDependent`, a small capsule badge + in the pad's accent color, plus a brighter border (line 57). This is the + entire UI of the auto-pivot mechanism — the user learns "the lock is + currently spending this coefficient" purely by watching the chip hop. +- Accessibility labels/values (lines 50-51) and `.hoverEffect(.highlight)` — + on visionOS hover effects are how eye-gaze targeting gives feedback, so they + are function, not polish. + +`ControlDeck.swift` assembles the instrument: header row with the deck title, +a Metal/CPU status dot, and the **determinant readout** — `det =` rendered +from `model.determinant.description` under a mint "UNIMODULAR LOCK" caption +with a lock icon (lines 95-112); watching it hold `1.000 + 0.000i` while all +four pads move is the visible proof the constraint works. Then the four pads +via `ForEach(SL2CCoefficient.allCases)` with a fixed accent map (a: mint, +b: cyan, c: purple, d: orange — lines 7-12), and the action row: Orbit/Pause, +the five preset buttons (selected one tinted cyan), Import OBJ (disabled while +sampling), the resolution menu offering **10³/14³/18³/22³** (lines 72-76), and +the "Imaginary ghost" button-style toggle bound directly to +`$model.showsImaginary`. The whole deck sits on `.ultraThinMaterial` in a +rounded rect — standard visionOS glass so it reads as UI, not scene content. + +## 10. Numerical-stability decisions, collected + +- **Exponent clamping.** Every kernel evaluation goes through + `complex_exp_bounded` / `Complex32.exponential`, clamping the exponent's + real part to `±maximumKernelExponent`. The transform-level default is **12** + (`ComplexField.swift:72`, `MetalLCTTransformer.swift:54`): `e¹² ≈ 1.6×10⁵`, + so a length-`n` sum of clamped terms stays far from float32 overflow + (`≈ 3.4×10³⁸`) and, more to the point, the *display* stays bounded — + magnitudes saturate rather than exploding, so scrubbing through a + contractive/expansive region of `SL(2,C)` shows a saturating glow instead of + NaN geometry. The scalar-level default of 20 (`Complex32.swift:50`) is the + looser general-purpose bound; the kernels always pass 12 explicitly. Only + the real part is clamped — phase, i.e. hue, is never distorted. +- **Pivot epsilon.** `SL2CConstraint.pivotEpsilon = 1e-4` + (`SL2CMatrix.swift:84`): denominators below 1e-4 would amplify a scrub + quantum (~1e-2 in coefficient space) into O(100) jumps in the solved + coefficient; the fallback pivot keeps the solve conditioned instead. +- **Branch thresholds.** `b.magnitude ≤ 1e-6` selects the singular branch and + `|Im d| ≤ 1e-5` gates it — chosen so float32 noise on a real-intended + parameter (e.g. a preset built from `cos/sin`) never flips a branch, while + anything a user deliberately scrubs is unambiguous. +- **Unimodularity tolerance.** `isUnimodular(tolerance: 1e-4)` + (`SL2CMatrix.swift:42-44`): a product of float32 values inherits ~1e-7 + relative error per op; 1e-4 is loose enough for chained scrubs, tight enough + to catch a real constraint bug. +- **CPU/Metal parity strategy.** Unlike the torch/MLX effort — where parity + had to be *engineered* via shared tables (see the mlx guide's Trap 2/3) — + here both sides are fresh float32 transcriptions of the same formula, and + the strategy is: (a) share the exact same indexing scheme and branch + thresholds; (b) transcribe the special functions (`complex_sqrt`, + `complex_exp_bounded`) rather than reaching for library equivalents with + different branch cuts; (c) accept small tolerances — `2e-4` generic, `3e-4` + singular — for what remains: fast-math on the GPU, different summation + order, `rsqrt` vs `1/sqrt`. The mlx guide's deeper lesson still applies: a + second implementation of the same math is one of the best bug detectors + there is, and the two parity tests are that detector. + +## 11. Testing — what each of the ten tests pins down + +`swift/LCTSpatial/Tests/LCTSpatialTests/LCTSpatialTests.swift` (Swift Testing, +`@Test` + `#expect`) — eight CPU tests: + +1. **`complexArithmetic`** (lines 6-13): `(2+3i)(−1+4i) = −14+5i` exactly, and + `z·w/w ≈ z` to 1e-5 — pins the multiplication sign convention and the + division-by-`|w|²` formula. If you ever flip a sign in `Complex32.*`, this + is the test that fires, and everything downstream depends on it. +2. **`determinantLockUpdatesD`** (lines 15-21): from identity, set `b` then + `c` to arbitrary complex values; the dependent must remain `d` and the + matrix must stay unimodular to 1e-5 — the happy path of the sticky + preferred pivot. +3. **`determinantLockPivotsNearZeroA`** (lines 23-28): start at the *Fourier* + matrix (where `a = 0`), set `a` to zero — the `d`-solve's denominator is + dead, so the constraint must pivot (`dependent != .d`) and still land on + the group. This is the auto-pivot mechanism exercised at exactly the preset + where the naive design breaks. +4. **`canonicalPairIsDimensionAgnostic`** (lines 30-36): the Fourier matrix on + 4-vectors gives `(q, p) ↦ (p, −q)` — the phase-space rotation, and the + "dimension-agnostic" claim (nothing in the map cares that these are + 4-vectors rather than 3-vectors or scalars). +5. **`fourierOfCenteredImpulseHasFlatMagnitude`** (lines 38-47): δ at the + center of a 5-point grid → all output magnitudes `= 1/√5` to 1e-5. This + simultaneously pins the *centering* (an off-center impulse would show phase + ramps but the centered one has flat magnitude only if `x = i − (n−1)/2`) + and the `1/√n` unitary normalization at the Fourier point. +6. **`multidimensionalIdentityPreservesField`** (lines 49-53): a 2×3×4 field + through the identity is `==` — bitwise — the input. Pins the identity + early-out *and* incidentally exercises non-cubic rank-3 shape handling. +7. **`singularBranchAppliesChirpWithoutChangingMagnitude`** (lines 55-70): the + lens matrix `(1, 0, 0.35, 1)` (b = 0, d = 1 → no resampling, pure chirp) + must preserve every sample's magnitude to 1e-5 while changing relative + phases (`values[0].phase != values[3].phase`). Pins that the chirp is a + pure phase for real `c·d` and that `√d = 1` introduces no amplitude. +8. **`objImportAndVoxelization`** (lines 72-85): a 3-vertex inline OBJ parses + to the expected positions/indices, and voxelizing it at resolution 8 yields + an `[8,8,8]` field with nonzero max magnitude — an end-to-end smoke of the + mesh ingestion pipeline. + +`Tests/LCTSpatialMetalTests/MetalLCTTransformerTests.swift` — two parity +tests, both guarded by `guard let device = MTLCreateSystemDefaultDevice() +else { return }` so they silently pass on GPU-less runners: + +9. **`metalMatchesCPUReferenceAcrossThreeAxes`** (lines 7-21): a 3×3×3 complex + field through an FrFT at the deliberately non-special angle 0.63, CPU vs + GPU, elementwise magnitude difference `< 2e-4`. Three axes means the + ping-pong swap runs an odd number of times — this test fails instantly if + the final readback uses the wrong buffer. +10. **`metalMatchesCPUSingularBranch`** (lines 23-41): a 5×5 field through + `(1.25, 0, −0.28+0.05i, 0.8)` — `b = 0`, real `d ≠ 1` (so interpolation + *and* the zero-padding edge actually execute), **complex `c`** (so the + chirp has a real exponent part and the clamp path runs) — to `3e-4`. This + is the regression test added by the final commit (`75569fd fix(metal): + support the b-zero LCT branch`): before it, the Metal path had no singular + kernel at all and diverged from the CPU on every `b = 0` matrix. + +What is *not* tested, knowingly: `MetalCanonicalPreview` (requires RealityKit, +i.e. an app context — its math is three multiplies checked by eye against the +`CanonicalPairTransform` spec), the SwiftUI layer, and the app model's +debounce/generation logic (candidates for extension; see section 13). + +## 12. Build, run, consume + +**Package tests** — from the repository root (the root manifest maps targets +into `swift/LCTSpatial` via explicit `path:`s, `Package.swift:17-37`): + +```sh +swift test # all 10 tests; Metal pair no-ops without a GPU +``` + +or `cd swift/LCTSpatial && swift test` against the nested manifest — same +sources, both manifests declare macOS 15 / visionOS 2 platforms. Remember from +section 8.1 that under `swift test` the Metal shader is compiled *from source +at runtime* via the `makeLibrary` fallback — a shader syntax error therefore +shows up as a test-time `makeLibrary` throw, not a build-time failure. + +**The app** — the Xcode project is generated by XcodeGen from +`swift/LCTVision/project.yml` (the `.pbxproj` is checked in for convenience, +but `project.yml` is the source of truth — edit it, not the project): + +```sh +cd swift/LCTVision +xcodegen generate +xcodebuild \ + -project LCTVision.xcodeproj \ + -scheme LCTVision \ + -sdk xrsimulator \ + -destination 'platform=visionOS Simulator,name=Apple Vision Pro,OS=26.5' \ + CODE_SIGNING_ALLOWED=NO \ + build +``` + +(`swift/LCTVision/README.md:13-21`; adjust `OS=` to your installed simulator +runtime, and `CODE_SIGNING_ALLOWED=NO` is what makes an unprovisioned +simulator build work.) The interesting `project.yml` lines: the local package +reference `packages: LCTSpatial: {path: ../LCTSpatial}` (lines 9-11) with both +products as dependencies (lines 28-32); `TARGETED_DEVICE_FAMILY: 7` (Vision +Pro); `GENERATE_INFOPLIST_FILE: NO` with the checked-in plist; +`SWIFT_VERSION: 6.0`. + +**Consuming from another project** — this is what commit `b7caea5` bought: the +*repository root* is a Swift package, so + +```swift +.package(url: "https://github.com//LCT.git", branch: "codex/alok-781-lct-spatial") +// products: "LCTSpatial" (pure Swift), "LCTSpatialMetal" (GPU) +``` + +works directly, no subpath gymnastics — SwiftPM only resolves root manifests +from Git URLs, which is exactly why the root `Package.swift` exists alongside +the nested one (its header comment, `Package.swift:5-6`, says so). The same +commit added the Swift build artifacts to `.gitignore` (`.build/`, +`swift/**/DerivedData/`, `swift/**/*.xcuserstate`) and the "Vision Pro spatial +atlas" section to the root `README.md`. + +## 13. Pitfalls and extension points + +**Pitfalls you will hit if you rebuild this:** + +- *Reading the wrong ping-pong buffer* after an odd number of axis swaps + (section 8.1, test 9). +- *Divergent argument tables* between the two field kernels — `dimensions` + and `maximumExponent` sit at different buffer indices per pipeline; host + and shader must move together. +- *Metal struct padding*: `float3` is 16-byte aligned; derive Swift-side + offsets with `MemoryLayout.offset(of:)` and never hand-pack a struct whose + Metal twin you haven't checked. +- *`Bundle.module` metallib vs source*: works differently under Xcode and + `swift test`; keep both paths of `makeLibrary` alive when you add kernels. +- *Security-scoped URLs*: file-importer URLs need + `startAccessingSecurityScopedResource`, and the failure only appears + off-simulator. +- *SwiftUI update-storm amplification*: the `RealityView` update closure runs + on every model publish; without `SpectralCloud`'s content hash and + `SpatialScene`'s geometry signature, unrelated UI state changes would + re-render 520 entities per keystroke. +- *Out-of-order transform completion*: without the generation guard, a stale + GPU result overwrites a fresh one — the bug is invisible until a slow frame + makes it visible, so keep the guard even though you can't easily provoke it. +- *Two places know the default resolution*: `resolution = 14` + (`LCTStudioModel.swift:20`) and the literal 14 in `init` (line 31). Change + one, change both — or refactor to a single constant when you touch this. + +**Extension points, in rough order of value:** + +- **USDZ import.** The library boundary is `MeshGeometry`; add an app-side + loader with ModelIO (`MDLAsset` → positions/indices) and nothing in + `LCTSpatial` changes. USDZ is the native visionOS interchange format and + what people actually have on-device. +- **Surface the per-vertex phase colors.** The preview kernel already writes a + `.color` vertex attribute, but the current `SimpleMaterial`/`UnlitMaterial` + tints don't read it — a ShaderGraphMaterial (or LowLevelTexture-based + material) sampling vertex color would show phase *on the preview mesh*, not + just the cloud, unifying the two views' color language. +- **Higher resolutions.** Cost is `O(n⁴)` per axis pass: 22³ ≈ 703k kernel + evals is instant, but 64³ is 50M with n-length serial loops per thread — + at that point add a per-axis dense-matrix formulation (n×n kernel matrix, + then a batched matmul via MPS), or a Bluestein FFT path like the Python + package. The voxelizer's `splat` also wants trilinear weights instead of + `round` before resolution grows. +- **Instanced cloud rendering.** 520 `ModelEntity`s is fine; 5,000 is not. + A `LowLevelMesh`-based point/impostor cloud (the preview already shows the + pattern) removes the entity ceiling and the 18-bin material quantization at + once. +- **Device vs simulator interaction.** The simulator renders the volume and + drag gestures fine (Metal runs on the host GPU), but eye-gaze hover effects, + hand-tracked pinch-drag ergonomics of the 120×94 pt pads, and real HMD + performance only exist on hardware. Expect to retune pad sizes and the + 42 ms/90 ms constants after the first on-device session; building for device + additionally requires a signing team in `project.yml`. +- **Test the app model.** `LCTStudioModel`'s debounce/generation logic is pure + enough to extract behind a clock/transformer protocol and pin with async + tests — the one part of the app currently protected only by manual scrubbing. +- **The compositional/unitary knob.** If the Swift side ever needs to *agree + numerically* with the Python package rather than merely rhyme with it, port + the normalization modes and the `1/√(ib)` prefactor, then write cross- + language parity fixtures (JSON in, JSON out) the way + `tests/test_mlx_backend.py` does across backends. diff --git a/paper/baylearn/README.md b/paper/baylearn/README.md new file mode 100644 index 0000000..9e0da0f --- /dev/null +++ b/paper/baylearn/README.md @@ -0,0 +1,26 @@ +# BayLearn 2026 extended abstract + +Anonymized 2-page abstract (NeurIPS 2023 format) summarizing the pre-registered +LCT-layer evaluation in `paper/report.md`. + +- **Deadline:** Thu July 30, 2026, 11:59pm PDT +- **Submit via CMT:** https://cmt3.research.microsoft.com/BAYLEARN2026 +- **Rules:** 2 pages max in NeurIPS 2023 style (`neurips_2023.sty`, vendored + here), one extra page for references/acknowledgements only, fully anonymized. +- **Tracking:** Linear ALOK-808. + +## Build + +```bash +cd paper/baylearn +tectonic baylearn2026.tex +``` + +(or `latexmk -pdf baylearn2026.tex` with TeXLive). + +`std_study.png` is copied from `paper/figures/` — regenerate upstream with +`MPLBACKEND=Agg uv run --with matplotlib python scripts/plot_mps_study.py` +(which writes both `mps_study.png` and `std_study.png`) if the study data +changes, then re-copy. + +The PDF must stay anonymous: no author names, no repository URL. diff --git a/paper/baylearn/baylearn2026.tex b/paper/baylearn/baylearn2026.tex new file mode 100644 index 0000000..2149093 --- /dev/null +++ b/paper/baylearn/baylearn2026.tex @@ -0,0 +1,207 @@ +\documentclass{article} + +% BayLearn 2026 submission: 2-page extended abstract, NeurIPS 2023 format, +% anonymized for double-blind review. Body is inserted between the markers +% below; see paper/baylearn/README.md for build instructions. +% Default (non-final) mode renders the anonymous "Anonymous Author(s)" block +% and reviewer line numbers, as required for double-blind review. +\PassOptionsToPackage{numbers,compress}{natbib} +\usepackage{neurips_2023} +\makeatletter +\renewcommand{\@noticestring}{Submitted to the Bay Area Machine Learning Symposium (BayLearn 2026).} +\makeatother + +\usepackage[utf8]{inputenc} +\usepackage[T1]{fontenc} +\usepackage{hyperref} +\usepackage{url} +\usepackage{booktabs} +\usepackage{amsfonts} +\usepackage{amsmath} +\usepackage{nicefrac} +\usepackage{microtype} +\usepackage{graphicx} +\usepackage{xcolor} + +\title{Structure Beats Rank-1, Width Beats Structure: An Honest Accounting of Linear Canonical Transform Layers in Transformers} + +\author{Anonymous} + +\begin{document} + +% BODY-START +\maketitle + +\begin{abstract} +Structured efficient linear layers promise dense-layer quality at sub-quadratic cost. We evaluate +the maximally spectral member of the family in a transformer: a Linear Canonical Transform (LCT) +layer---the symplectic family unifying the Fourier, fractional-Fourier, Fresnel, and scaling +transforms---replacing the MLP up-projection with FFT $\to$ learned complex diagonal $\to$ +inverse FFT. A pre-registered, bug-audited protocol yields a three-way accounting: (i)~\emph{structure beats naive factorization} ($0.083 \pm 0.007$ nats +over a rank-1 control at matched budget, 4/4 paired seeds; catastrophically at 34M); +(ii)~\emph{structure buys wall-clock at width} ($1.24\times$ end-to-end at trunk width 1024; +kernel wins to $18\times$ at width 8192); (iii)~\emph{structure never beats width}: a matched-budget narrower +dense trunk beats every structured arm in every paired comparison, and learning the geometry does +not rescue it. The audit overturned our own earlier positives: five harness bugs collectively +produced spurious 0.03--0.13-nat wins---evaluation noise alone ($\sim$0.03 nats) was that size. +\end{abstract} + +\section{Problem: what does a spectral structural prior actually buy?} + +Structured efficient linear layers replace $O(N^2)$ dense maps with $O(N \log N)$ FFT-like +factorizations~\cite{moczulski2016acdc,dao2019butterfly,dao2022monarch}, and +FNet/FNO~\cite{leethorp2021fnet,li2020fno} show spectral mixing carries real work. The LCT completes this spectral line: the family of unit-determinant +$2\times 2$ actions on time--frequency space~\cite{ozaktas2001}. \texttt{LCTLinear} packs +channels into complex pairs, transforms, applies a learnable diagonal, inverse-transforms, and +crops ($\mathrm{padded}/2 + \mathrm{out}$ real parameters, not +$\mathrm{in}\times\mathrm{out}$); a companion activation applies +modReLU~\cite{arjovsky2016unitary} in-domain. Every main arm keeps the geometry +\emph{fixed}, learning only the spectral diagonal and bias. \textbf{At matched parameters and +measured wall-clock, what does structure buy vs.\ (a) same-budget rank-1 factorization, +(b) same-width dense, and (c) a same-budget narrower dense trunk?} + +\paragraph{Protocol, and five ways the evidence was wrong.} NanoGPT-style char-level models on tinyshakespeare (4 layers, 4 heads, width 256, sequence 256, +batch 64, dropout 0.2, AdamW, $1/\sqrt{d_{\mathrm{head}}}$ attention scaling, warmup+cosine, peak +lr $5\mathrm{e}{-3}$ from a fresh pilot sweep). Variants replace each MLP up-projection with +\texttt{LCTLinear} ($-32\%$ total parameters) or the ReLU with the LCT activation. Controls: +the width-256 baseline; a parameter-matched width-212 baseline; a rank-1 factorized up-projection +at 2{,}214{,}977 parameters (0.05\% off the LCT variant), pre-registered before these confirmatory +replications. Four common seeds, paired batch streams, 2{,}000 steps, deterministic full-split +validation; decision rule fixed in advance: real improvement iff paired best-val delta +$< -0.01$ nats, consistent sign 4/4 seeds, no net wall-clock loss. + +Earlier 20--40-step runs at width 64 had the LCT arms \emph{winning} by 0.03--0.13 nats; none +survived five harness bugs that collectively produced those positives: (1)~\textbf{wrong input +gradients}---the backward used the adjoint of \texttt{repeat\_interleave} for a \texttt{repeat} +forward, corrupting input gradients exactly when $\mathrm{out} > \mathrm{in}$ (the +up-projection)---\emph{anti}-LCT: post-fix seed-identical reruns improved LCT variants in 8/9 +rows ($\sim$0.03 nats masked); (2)~\textbf{the activation never trained}---its lazy +parameters materialized after the optimizer; (3)~\textbf{every seed was the same +seed}---the model re-seeded the global RNG at import; (4)~\textbf{unpaired +comparisons}---variants saw different data orders per seed; (5)~\textbf{noisy +evaluation}---8 random batches from $\sim$435 windows; measured MPS noise $\sim$0.03 nats, the +size of the reported effects. Bugs 1--2 were caught only by cross-checking a +second backend (MLX) against the PyTorch reference (finite differences as referee; rank-deficient +kernels amplify ulps into $O(1)$ divergence); the regression test uses an exact oracle for any +linear layer, $\partial_x \sum(\mathrm{layer}(x)\odot G) = GW$. Bugs 3--5 came from adversarial +red-team review; forensics like these belong in the record~\cite{lucic2018gans,melis2018}. + +\section{Results: the three-way accounting} +\label{sec:results} + +\begin{table}[t] +\centering +\caption{Best validation loss (nats). Top: repaired substrate, char-Shakespeare, MPS, 2{,}000 +steps, 4 paired seeds. Bottom: 10\,MB text8, A100, width 1024, 3{,}000 steps, 2 paired seeds. +Bold: winning dense controls (top two tie).} +\label{tab:main} +\footnotesize +\setlength{\tabcolsep}{5pt} +\begin{tabular}{lrrl} +\toprule +Configuration & Params & tok/s & Best val loss \\ +\midrule +Dense baseline (width 256) & 3.26M & 171k & $\mathbf{1.4656 \pm 0.0103}$ \\ +Dense matched (width 212) & 2.25M & 194k & $\mathbf{1.4702 \pm 0.0050}$ \\ +LCT up-projection (Fourier) & 2.21M & 145k & $1.5645 \pm 0.0060$ \\ +LCT activation (modReLU) & 3.26M & 112k & $1.5954 \pm 0.0080$ \\ +Rank-1 factorized up-projection & 2.21M & 186k & $1.6470 \pm 0.0065$ \\ +\midrule +Dense matched (width 840) & 34.2M & 64.5k & $\mathbf{1.2956\;/\;1.2938}$ \\ +Dense baseline (width 1024) & 50.7M & 48.4k & $1.3430\;/\;1.3271$ \\ +LCT up-projection @1024 & 33.9M & 60.1k & $1.4087\;/\;1.4107$ \\ +Rank-1 @1024 & 33.9M & 64.8k & $2.0406\;/\;1.8816$ \\ +\bottomrule +\end{tabular} +\end{table} + +\textbf{Structure beats factorization.} At matched budget the LCT layer beats rank-1 by +$-0.083 \pm 0.007$ nats in 4/4 paired seeds (Table~\ref{tab:main}, top), the activation by +$-0.052 \pm 0.009$; at 34M parameters rank-1 collapses entirely (bottom); the ordering +replicates on text8 at width 256 (LCT $+0.14$/$+0.17$ over baseline, rank-1 and activation +$+0.29$--$0.33$; 2 paired seeds). \textbf{Structure buys wall-clock at width.} A100 kernels beat +dense +$2.5\times$/$8.7\times$/$17.9\times$ at square widths 1024/4096/8192 ($18.3\times$ with +backward at 8192), converting end-to-end ($1.24\times$, width 1024); rectangular shapes dilute it +($0.85\times$ on $256\to1024$), and the $18\times$ square win at 8192 marks attention projections +at width $\geq 2048$ as the strongest \emph{untested} placement, alongside chirp-like +audio/radar domains. + +\textbf{Width beats structure.} In 4/4 paired seeds the LCT layer loses to both dense controls +($+0.094$/$+0.099$ nats; activation $+0.130$). At 34M parameters a width-840 dense model matched +within 0.8\% beats every arm \emph{and} out-runs the LCT model (Table~\ref{tab:main}); the +verdict holds across two substrates, datasets, hardware targets, a $15\times$ parameter range, and +every swept axis (lr, angle, normalization, inverse, schedule). + +\textbf{Late takeoff, conditioning, learned geometry.} The identity-initialized layer improves +latest: on the original sick substrate (no attention scaling; its two dense controls +\emph{disagreed} by 0.22 nats), a single-seed 5{,}000-step run beat the plateaued same-width +baseline by 0.32 nats (1.910 vs.\ 2.228)---a conditioning rescue of a pathological control, +landing at $\sim$1.91 at \emph{both} trunk widths: the structured projection is the capacity +bottleneck. On the healthy substrate the gap narrows ($+0.094$ at 2{,}000 to $+0.019$/$+0.033$ at +5{,}000 steps, 2 paired seeds) but no longer closes. Learning the geometry did not help: an +exploratory H100 run (single seed, 500 steps, width 1024; determinant-preserving +$C(k)\,S(s)\,R(\theta)$) reached 1.979 vs.\ 1.940 fixed Fourier, 1.944 canonical-fixed, +1.743 dense. + +\textbf{Conclusions.} Spectral structure is a real prior---it dominates naive factorization and +delivers real speed at width~\cite{moczulski2016acdc,dao2019butterfly,dao2022monarch}---but +never, here, beats plain width; we propose the three-way report as the minimum honest +comparison, with a checklist: a second implementation as gradient oracle; paired batches; +deterministic evaluation; a measured noise floor (refuse effects of its size); seeds verified +distinct, dense controls agreeing; distrust of short-horizon rankings (a 300-step pilot +inverted by 2{,}000 steps). Code and all artifacts will be released. +% BODY-END + +% BIB-START +\begin{thebibliography}{9} + +\bibitem{ozaktas2001} +H.~M. Ozaktas, Z.~Zalevsky, and M.~A. Kutay. +\newblock \emph{The Fractional Fourier Transform: with Applications in Optics and Signal Processing}. +\newblock Wiley, 2001. + +\bibitem{leethorp2021fnet} +J.~Lee-Thorp, J.~Ainslie, I.~Eckstein, and S.~Onta\~{n}\'{o}n. +\newblock {FNet}: Mixing tokens with {Fourier} transforms. +\newblock arXiv:2105.03824, 2021. + +\bibitem{li2020fno} +Z.~Li, N.~Kovachki, K.~Azizzadenesheli, B.~Liu, K.~Bhattacharya, A.~Stuart, and A.~Anandkumar. +\newblock Fourier neural operator for parametric partial differential equations. +\newblock arXiv:2010.08895, 2020. + +\bibitem{moczulski2016acdc} +M.~Moczulski, M.~Denil, J.~Appleyard, and N.~de~Freitas. +\newblock {ACDC}: A structured efficient linear layer. +\newblock In \emph{International Conference on Learning Representations}, 2016. + +\bibitem{dao2019butterfly} +T.~Dao, A.~Gu, M.~Eichhorn, A.~Rudra, and C.~R\'{e}. +\newblock Learning fast algorithms for linear transforms using butterfly factorizations. +\newblock In \emph{International Conference on Machine Learning}, 2019. + +\bibitem{dao2022monarch} +T.~Dao, B.~Chen, N.~S. Sohoni, A.~Desai, M.~Poli, J.~Grogan, A.~Liu, A.~Rao, A.~Rudra, and C.~R\'{e}. +\newblock Monarch: Expressive structured matrices for efficient and accurate training. +\newblock In \emph{International Conference on Machine Learning}, 2022. + +\bibitem{arjovsky2016unitary} +M.~Arjovsky, A.~Shah, and Y.~Bengio. +\newblock Unitary evolution recurrent neural networks. +\newblock In \emph{International Conference on Machine Learning}, 2016. + +\bibitem{lucic2018gans} +M.~Lucic, K.~Kurach, M.~Michalski, S.~Gelly, and O.~Bousquet. +\newblock Are {GANs} created equal? {A} large-scale study. +\newblock In \emph{Advances in Neural Information Processing Systems}, 2018. + +\bibitem{melis2018} +G.~Melis, C.~Dyer, and P.~Blunsom. +\newblock On the state of the art of evaluation in neural language models. +\newblock In \emph{International Conference on Learning Representations}, 2018. + +\end{thebibliography} +% BIB-END + +\end{document} diff --git a/paper/baylearn/neurips_2023.sty b/paper/baylearn/neurips_2023.sty new file mode 100644 index 0000000..c2c73af --- /dev/null +++ b/paper/baylearn/neurips_2023.sty @@ -0,0 +1,375 @@ +% partial rewrite of the LaTeX2e package for submissions to the +% Conference on Neural Information Processing Systems (NeurIPS): +% +% - uses more LaTeX conventions +% - line numbers at submission time replaced with aligned numbers from +% lineno package +% - \nipsfinalcopy replaced with [final] package option +% - automatically loads times package for authors +% - loads natbib automatically; this can be suppressed with the +% [nonatbib] package option +% - adds foot line to first page identifying the conference +% - adds preprint option for submission to e.g. arXiv +% - conference acronym modified +% +% Roman Garnett (garnett@wustl.edu) and the many authors of +% nips15submit_e.sty, including MK and drstrip@sandia +% +% last revision: March 2023 + +\NeedsTeXFormat{LaTeX2e} +\ProvidesPackage{neurips_2023}[2023/03/31 NeurIPS 2023 submission/camera-ready style file] + +% declare final option, which creates camera-ready copy +\newif\if@neuripsfinal\@neuripsfinalfalse +\DeclareOption{final}{ + \@neuripsfinaltrue +} + +% declare nonatbib option, which does not load natbib in case of +% package clash (users can pass options to natbib via +% \PassOptionsToPackage) +\newif\if@natbib\@natbibtrue +\DeclareOption{nonatbib}{ + \@natbibfalse +} + +% declare preprint option, which creates a preprint version ready for +% upload to, e.g., arXiv +\newif\if@preprint\@preprintfalse +\DeclareOption{preprint}{ + \@preprinttrue +} + +\ProcessOptions\relax + +% determine whether this is an anonymized submission +\newif\if@submission\@submissiontrue +\if@neuripsfinal\@submissionfalse\fi +\if@preprint\@submissionfalse\fi + +% fonts +\renewcommand{\rmdefault}{ptm} +\renewcommand{\sfdefault}{phv} + +% change this every year for notice string at bottom +\newcommand{\@neuripsordinal}{37th} +\newcommand{\@neuripsyear}{2023} +\newcommand{\@neuripslocation}{New Orleans} + +% acknowledgments +\usepackage{environ} +\newcommand{\acksection}{\section*{Acknowledgments and Disclosure of Funding}} +\NewEnviron{ack}{% + \acksection + \BODY +} + + +% load natbib unless told otherwise +\if@natbib + \RequirePackage{natbib} +\fi + +% set page geometry +\usepackage[verbose=true,letterpaper]{geometry} +\AtBeginDocument{ + \newgeometry{ + textheight=9in, + textwidth=5.5in, + top=1in, + headheight=12pt, + headsep=25pt, + footskip=30pt + } + \@ifpackageloaded{fullpage} + {\PackageWarning{neurips_2023}{fullpage package not allowed! Overwriting formatting.}} + {} +} + +\widowpenalty=10000 +\clubpenalty=10000 +\flushbottom +\sloppy + + +% font sizes with reduced leading +\renewcommand{\normalsize}{% + \@setfontsize\normalsize\@xpt\@xipt + \abovedisplayskip 7\p@ \@plus 2\p@ \@minus 5\p@ + \abovedisplayshortskip \z@ \@plus 3\p@ + \belowdisplayskip \abovedisplayskip + \belowdisplayshortskip 4\p@ \@plus 3\p@ \@minus 3\p@ +} +\normalsize +\renewcommand{\small}{% + \@setfontsize\small\@ixpt\@xpt + \abovedisplayskip 6\p@ \@plus 1.5\p@ \@minus 4\p@ + \abovedisplayshortskip \z@ \@plus 2\p@ + \belowdisplayskip \abovedisplayskip + \belowdisplayshortskip 3\p@ \@plus 2\p@ \@minus 2\p@ +} +\renewcommand{\footnotesize}{\@setfontsize\footnotesize\@ixpt\@xpt} +\renewcommand{\scriptsize}{\@setfontsize\scriptsize\@viipt\@viiipt} +\renewcommand{\tiny}{\@setfontsize\tiny\@vipt\@viipt} +\renewcommand{\large}{\@setfontsize\large\@xiipt{14}} +\renewcommand{\Large}{\@setfontsize\Large\@xivpt{16}} +\renewcommand{\LARGE}{\@setfontsize\LARGE\@xviipt{20}} +\renewcommand{\huge}{\@setfontsize\huge\@xxpt{23}} +\renewcommand{\Huge}{\@setfontsize\Huge\@xxvpt{28}} + +% sections with less space +\providecommand{\section}{} +\renewcommand{\section}{% + \@startsection{section}{1}{\z@}% + {-2.0ex \@plus -0.5ex \@minus -0.2ex}% + { 1.5ex \@plus 0.3ex \@minus 0.2ex}% + {\large\bf\raggedright}% +} +\providecommand{\subsection}{} +\renewcommand{\subsection}{% + \@startsection{subsection}{2}{\z@}% + {-1.8ex \@plus -0.5ex \@minus -0.2ex}% + { 0.8ex \@plus 0.2ex}% + {\normalsize\bf\raggedright}% +} +\providecommand{\subsubsection}{} +\renewcommand{\subsubsection}{% + \@startsection{subsubsection}{3}{\z@}% + {-1.5ex \@plus -0.5ex \@minus -0.2ex}% + { 0.5ex \@plus 0.2ex}% + {\normalsize\bf\raggedright}% +} +\providecommand{\paragraph}{} +\renewcommand{\paragraph}{% + \@startsection{paragraph}{4}{\z@}% + {1.5ex \@plus 0.5ex \@minus 0.2ex}% + {-1em}% + {\normalsize\bf}% +} +\providecommand{\subparagraph}{} +\renewcommand{\subparagraph}{% + \@startsection{subparagraph}{5}{\z@}% + {1.5ex \@plus 0.5ex \@minus 0.2ex}% + {-1em}% + {\normalsize\bf}% +} +\providecommand{\subsubsubsection}{} +\renewcommand{\subsubsubsection}{% + \vskip5pt{\noindent\normalsize\rm\raggedright}% +} + +% float placement +\renewcommand{\topfraction }{0.85} +\renewcommand{\bottomfraction }{0.4} +\renewcommand{\textfraction }{0.1} +\renewcommand{\floatpagefraction}{0.7} + +\newlength{\@neuripsabovecaptionskip}\setlength{\@neuripsabovecaptionskip}{7\p@} +\newlength{\@neuripsbelowcaptionskip}\setlength{\@neuripsbelowcaptionskip}{\z@} + +\setlength{\abovecaptionskip}{\@neuripsabovecaptionskip} +\setlength{\belowcaptionskip}{\@neuripsbelowcaptionskip} + +% swap above/belowcaptionskip lengths for tables +\renewenvironment{table} + {\setlength{\abovecaptionskip}{\@neuripsbelowcaptionskip}% + \setlength{\belowcaptionskip}{\@neuripsabovecaptionskip}% + \@float{table}} + {\end@float} + +% footnote formatting +\setlength{\footnotesep }{6.65\p@} +\setlength{\skip\footins}{9\p@ \@plus 4\p@ \@minus 2\p@} +\renewcommand{\footnoterule}{\kern-3\p@ \hrule width 12pc \kern 2.6\p@} +\setcounter{footnote}{0} + +% paragraph formatting +\setlength{\parindent}{\z@} +\setlength{\parskip }{5.5\p@} + +% list formatting +\setlength{\topsep }{4\p@ \@plus 1\p@ \@minus 2\p@} +\setlength{\partopsep }{1\p@ \@plus 0.5\p@ \@minus 0.5\p@} +\setlength{\itemsep }{2\p@ \@plus 1\p@ \@minus 0.5\p@} +\setlength{\parsep }{2\p@ \@plus 1\p@ \@minus 0.5\p@} +\setlength{\leftmargin }{3pc} +\setlength{\leftmargini }{\leftmargin} +\setlength{\leftmarginii }{2em} +\setlength{\leftmarginiii}{1.5em} +\setlength{\leftmarginiv }{1.0em} +\setlength{\leftmarginv }{0.5em} +\def\@listi {\leftmargin\leftmargini} +\def\@listii {\leftmargin\leftmarginii + \labelwidth\leftmarginii + \advance\labelwidth-\labelsep + \topsep 2\p@ \@plus 1\p@ \@minus 0.5\p@ + \parsep 1\p@ \@plus 0.5\p@ \@minus 0.5\p@ + \itemsep \parsep} +\def\@listiii{\leftmargin\leftmarginiii + \labelwidth\leftmarginiii + \advance\labelwidth-\labelsep + \topsep 1\p@ \@plus 0.5\p@ \@minus 0.5\p@ + \parsep \z@ + \partopsep 0.5\p@ \@plus 0\p@ \@minus 0.5\p@ + \itemsep \topsep} +\def\@listiv {\leftmargin\leftmarginiv + \labelwidth\leftmarginiv + \advance\labelwidth-\labelsep} +\def\@listv {\leftmargin\leftmarginv + \labelwidth\leftmarginv + \advance\labelwidth-\labelsep} +\def\@listvi {\leftmargin\leftmarginvi + \labelwidth\leftmarginvi + \advance\labelwidth-\labelsep} + +% create title +\providecommand{\maketitle}{} +\renewcommand{\maketitle}{% + \par + \begingroup + \renewcommand{\thefootnote}{\fnsymbol{footnote}} + % for perfect author name centering + \renewcommand{\@makefnmark}{\hbox to \z@{$^{\@thefnmark}$\hss}} + % The footnote-mark was overlapping the footnote-text, + % added the following to fix this problem (MK) + \long\def\@makefntext##1{% + \parindent 1em\noindent + \hbox to 1.8em{\hss $\m@th ^{\@thefnmark}$}##1 + } + \thispagestyle{empty} + \@maketitle + \@thanks + \@notice + \endgroup + \let\maketitle\relax + \let\thanks\relax +} + +% rules for title box at top of first page +\newcommand{\@toptitlebar}{ + \hrule height 4\p@ + \vskip 0.25in + \vskip -\parskip% +} +\newcommand{\@bottomtitlebar}{ + \vskip 0.29in + \vskip -\parskip + \hrule height 1\p@ + \vskip 0.09in% +} + +% create title (includes both anonymized and non-anonymized versions) +\providecommand{\@maketitle}{} +\renewcommand{\@maketitle}{% + \vbox{% + \hsize\textwidth + \linewidth\hsize + \vskip 0.1in + \@toptitlebar + \centering + {\LARGE\bf \@title\par} + \@bottomtitlebar + \if@submission + \begin{tabular}[t]{c}\bf\rule{\z@}{24\p@} + Anonymous Author(s) \\ + Affiliation \\ + Address \\ + \texttt{email} \\ + \end{tabular}% + \else + \def\And{% + \end{tabular}\hfil\linebreak[0]\hfil% + \begin{tabular}[t]{c}\bf\rule{\z@}{24\p@}\ignorespaces% + } + \def\AND{% + \end{tabular}\hfil\linebreak[4]\hfil% + \begin{tabular}[t]{c}\bf\rule{\z@}{24\p@}\ignorespaces% + } + \begin{tabular}[t]{c}\bf\rule{\z@}{24\p@}\@author\end{tabular}% + \fi + \vskip 0.3in \@minus 0.1in + } +} + +% add conference notice to bottom of first page +\newcommand{\ftype@noticebox}{8} +\newcommand{\@notice}{% + % give a bit of extra room back to authors on first page + \enlargethispage{2\baselineskip}% + \@float{noticebox}[b]% + \footnotesize\@noticestring% + \end@float% +} + +% abstract styling +\renewenvironment{abstract}% +{% + \vskip 0.075in% + \centerline% + {\large\bf Abstract}% + \vspace{0.5ex}% + \begin{quote}% +} +{ + \par% + \end{quote}% + \vskip 1ex% +} + +% handle tweaks for camera-ready copy vs. submission copy +\if@preprint + \newcommand{\@noticestring}{% + Preprint. Under review.% + } +\else + \if@neuripsfinal + \newcommand{\@noticestring}{% + \@neuripsordinal\/ Conference on Neural Information Processing Systems + (NeurIPS \@neuripsyear).%, \@neuripslocation.% + } + \else + \newcommand{\@noticestring}{% + Submitted to \@neuripsordinal\/ Conference on Neural Information + Processing Systems (NeurIPS \@neuripsyear). Do not distribute.% + } + + % hide the acknowledgements + \NewEnviron{hide}{} + \let\ack\hide + \let\endack\endhide + + % line numbers for submission + \RequirePackage{lineno} + \linenumbers + + % fix incompatibilities between lineno and amsmath, if required, by + % transparently wrapping linenomath environments around amsmath + % environments + \AtBeginDocument{% + \@ifpackageloaded{amsmath}{% + \newcommand*\patchAmsMathEnvironmentForLineno[1]{% + \expandafter\let\csname old#1\expandafter\endcsname\csname #1\endcsname + \expandafter\let\csname oldend#1\expandafter\endcsname\csname end#1\endcsname + \renewenvironment{#1}% + {\linenomath\csname old#1\endcsname}% + {\csname oldend#1\endcsname\endlinenomath}% + }% + \newcommand*\patchBothAmsMathEnvironmentsForLineno[1]{% + \patchAmsMathEnvironmentForLineno{#1}% + \patchAmsMathEnvironmentForLineno{#1*}% + }% + \patchBothAmsMathEnvironmentsForLineno{equation}% + \patchBothAmsMathEnvironmentsForLineno{align}% + \patchBothAmsMathEnvironmentsForLineno{flalign}% + \patchBothAmsMathEnvironmentsForLineno{alignat}% + \patchBothAmsMathEnvironmentsForLineno{gather}% + \patchBothAmsMathEnvironmentsForLineno{multline}% + } + {} + } + \fi +\fi + + +\endinput diff --git a/paper/baylearn/std_study.png b/paper/baylearn/std_study.png new file mode 100644 index 0000000..7a15062 Binary files /dev/null and b/paper/baylearn/std_study.png differ diff --git a/swift/LCTSpatial/Package.swift b/swift/LCTSpatial/Package.swift new file mode 100644 index 0000000..9d5cab5 --- /dev/null +++ b/swift/LCTSpatial/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "LCTSpatial", + platforms: [ + .macOS(.v15), + .visionOS(.v2), + ], + products: [ + .library(name: "LCTSpatial", targets: ["LCTSpatial"]), + .library(name: "LCTSpatialMetal", targets: ["LCTSpatialMetal"]), + ], + targets: [ + .target(name: "LCTSpatial"), + .target( + name: "LCTSpatialMetal", + dependencies: ["LCTSpatial"], + resources: [.process("Shaders")] + ), + .testTarget( + name: "LCTSpatialTests", + dependencies: ["LCTSpatial"] + ), + .testTarget( + name: "LCTSpatialMetalTests", + dependencies: ["LCTSpatial", "LCTSpatialMetal"] + ), + ] +) diff --git a/swift/LCTSpatial/README.md b/swift/LCTSpatial/README.md new file mode 100644 index 0000000..f6b2767 --- /dev/null +++ b/swift/LCTSpatial/README.md @@ -0,0 +1,46 @@ +# LCTSpatial + +`LCTSpatial` is a Swift/Metal toolkit for spatial intuition about finite-grid +linear canonical transforms. + +The package deliberately exposes two different operations: + +- `DiscreteLCT` / `MetalLCTTransformer` transform a sampled complex field. This + is the Atlas-style signal transform. +- `CanonicalPairTransform` applies the `2×2` canonical map to paired vectors or + mesh attributes. It is useful as a zero-latency preview, but is not presented + as the integral transform of a mesh. + +The scalar canonical block is modeled over `SL(2,C)`. A `ComplexField` may have +any rank; the same block is applied separably along every axis. The visionOS +sample uses rank three. + +```swift +import LCTSpatial + +var lock = SL2CConstraint(matrix: .identity, dependent: .d) +let matrix = try lock.set(.b, to: Complex32(real: 0.8, imaginary: 0.15)) + +let field = try ComplexField( + shape: [16, 16, 16], + values: Array(repeating: .zero, count: 16 * 16 * 16) +) +let transformed = try DiscreteLCT.transform(field, matrix: matrix) +``` + +`SL2CConstraint` keeps `ad - bc = 1` during interactive scrubbing. It prefers +to solve for `d`, then automatically changes the dependent coefficient if the +current denominator approaches a singular pivot. + +The finite-grid implementation includes the `b = 0` chirp/scaling branch when +`d` is real, using centered linear interpolation. A genuinely complex `d` in +that branch would move sampling off the real grid and is reported as unsupported +rather than silently inventing a contour deformation. + +## Mathematical boundary + +Classical unitary LCTs use real symplectic parameters. Complex matrices are a +useful extension, but determinant one alone does not guarantee a bounded or +unitary transform. The CPU and Metal kernels clamp the real part of the complex +exponent for interactive safety. Apps should expose that stability limit rather +than treating every point of `SL(2,C)` as physically interchangeable. diff --git a/swift/LCTSpatial/Sources/LCTSpatial/Complex32.swift b/swift/LCTSpatial/Sources/LCTSpatial/Complex32.swift new file mode 100644 index 0000000..8077339 --- /dev/null +++ b/swift/LCTSpatial/Sources/LCTSpatial/Complex32.swift @@ -0,0 +1,117 @@ +import Foundation + +/// A compact complex scalar with a stable two-float layout for Swift/Metal interop. +@frozen +public struct Complex32: Codable, Hashable, Sendable { + public var real: Float + public var imaginary: Float + + @inlinable + public init(real: Float = 0, imaginary: Float = 0) { + self.real = real + self.imaginary = imaginary + } + + @inlinable public static var zero: Self { .init() } + @inlinable public static var one: Self { .init(real: 1) } + @inlinable public static var i: Self { .init(imaginary: 1) } + + @inlinable + public var magnitudeSquared: Float { + real * real + imaginary * imaginary + } + + @inlinable + public var magnitude: Float { + sqrt(magnitudeSquared) + } + + @inlinable + public var phase: Float { + atan2(imaginary, real) + } + + @inlinable + public var conjugate: Self { + .init(real: real, imaginary: -imaginary) + } + + /// The principal square root. + @inlinable + public var squareRoot: Self { + let radius = magnitude + let realPart = sqrt(max((radius + real) / 2, 0)) + let imaginaryMagnitude = sqrt(max((radius - real) / 2, 0)) + let imaginaryPart = imaginary < 0 ? -imaginaryMagnitude : imaginaryMagnitude + return .init(real: realPart, imaginary: imaginaryPart) + } + + @inlinable + public func exponential(maximumReal: Float = 20) -> Self { + let boundedReal = min(max(real, -maximumReal), maximumReal) + let scale = exp(boundedReal) + return .init( + real: scale * cos(imaginary), + imaginary: scale * sin(imaginary) + ) + } +} + +extension Complex32: CustomStringConvertible { + public var description: String { + let sign = imaginary < 0 ? "−" : "+" + return String(format: "%.3f %@ %.3fi", real, sign, abs(imaginary)) + } +} + +@inlinable +public prefix func - (value: Complex32) -> Complex32 { + .init(real: -value.real, imaginary: -value.imaginary) +} + +@inlinable +public func + (lhs: Complex32, rhs: Complex32) -> Complex32 { + .init(real: lhs.real + rhs.real, imaginary: lhs.imaginary + rhs.imaginary) +} + +@inlinable +public func - (lhs: Complex32, rhs: Complex32) -> Complex32 { + .init(real: lhs.real - rhs.real, imaginary: lhs.imaginary - rhs.imaginary) +} + +@inlinable +public func * (lhs: Complex32, rhs: Complex32) -> Complex32 { + .init( + real: lhs.real * rhs.real - lhs.imaginary * rhs.imaginary, + imaginary: lhs.real * rhs.imaginary + lhs.imaginary * rhs.real + ) +} + +@inlinable +public func * (lhs: Complex32, rhs: Float) -> Complex32 { + .init(real: lhs.real * rhs, imaginary: lhs.imaginary * rhs) +} + +@inlinable +public func * (lhs: Float, rhs: Complex32) -> Complex32 { + rhs * lhs +} + +@inlinable +public func / (lhs: Complex32, rhs: Float) -> Complex32 { + .init(real: lhs.real / rhs, imaginary: lhs.imaginary / rhs) +} + +@inlinable +public func / (lhs: Complex32, rhs: Complex32) -> Complex32 { + let denominator = rhs.magnitudeSquared + return .init( + real: (lhs.real * rhs.real + lhs.imaginary * rhs.imaginary) / denominator, + imaginary: (lhs.imaginary * rhs.real - lhs.real * rhs.imaginary) / denominator + ) +} + +@inlinable +public func += (lhs: inout Complex32, rhs: Complex32) { + lhs = lhs + rhs +} diff --git a/swift/LCTSpatial/Sources/LCTSpatial/ComplexField.swift b/swift/LCTSpatial/Sources/LCTSpatial/ComplexField.swift new file mode 100644 index 0000000..349779b --- /dev/null +++ b/swift/LCTSpatial/Sources/LCTSpatial/ComplexField.swift @@ -0,0 +1,184 @@ +import Foundation + +public enum ComplexFieldError: Error, Equatable, Sendable { + case emptyShape + case nonPositiveDimension(Int) + case valueCount(expected: Int, actual: Int) + case rankMismatch(expected: Int, actual: Int) + case indexOutOfBounds(axis: Int, value: Int) +} + +/// A row-major complex field with arbitrary rank. +public struct ComplexField: Sendable, Equatable { + public let shape: [Int] + public var values: [Complex32] + + public init(shape: [Int], values: [Complex32]) throws { + guard !shape.isEmpty else { throw ComplexFieldError.emptyShape } + for dimension in shape where dimension <= 0 { + throw ComplexFieldError.nonPositiveDimension(dimension) + } + let expected = shape.reduce(1, *) + guard values.count == expected else { + throw ComplexFieldError.valueCount(expected: expected, actual: values.count) + } + self.shape = shape + self.values = values + } + + public init(shape: [Int], repeating value: Complex32 = .zero) throws { + try self.init(shape: shape, values: .init(repeating: value, count: shape.reduce(1, *))) + } + + public var rank: Int { shape.count } + public var count: Int { values.count } + public var maxMagnitude: Float { values.lazy.map(\.magnitude).max() ?? 0 } + + public func flatIndex(_ indices: [Int]) throws -> Int { + guard indices.count == rank else { + throw ComplexFieldError.rankMismatch(expected: rank, actual: indices.count) + } + var result = 0 + for (axis, index) in indices.enumerated() { + guard (0.. [Float] { + let maximum = max(maxMagnitude, .leastNonzeroMagnitude) + return values.map { pow($0.magnitude / maximum, gamma) } + } +} + +public enum DiscreteLCTError: Error, Equatable, Sendable { + /// Complex coordinate scaling would require sampling the input away from + /// the real grid. The current finite-grid model supports real `d` here. + case complexScalingUnsupported +} + +/// A small, deterministic CPU reference for the separable finite-grid LCT. +/// +/// The scalar `SL(2,C)` block is applied independently along each field axis. +/// This implementation is intentionally O(rank × n^(rank+1)) and is meant for +/// tests, tiny fields, and the fallback path; `LCTSpatialMetal` owns live grids. +public enum DiscreteLCT { + public static func transform( + _ field: ComplexField, + matrix: SL2CMatrix, + maximumKernelExponent: Float = 12 + ) throws -> ComplexField { + if matrix == .identity { return field } + + if matrix.b.magnitude <= 1e-6 { + guard abs(matrix.d.imaginary) <= 1e-5 else { + throw DiscreteLCTError.complexScalingUnsupported + } + var values = field.values + for axis in field.shape.indices { + values = transformSingularAxis( + values, + shape: field.shape, + axis: axis, + matrix: matrix, + maximumKernelExponent: maximumKernelExponent + ) + } + return try ComplexField(shape: field.shape, values: values) + } + + var values = field.values + for axis in field.shape.indices { + values = transformAxis( + values, + shape: field.shape, + axis: axis, + matrix: matrix, + maximumKernelExponent: maximumKernelExponent + ) + } + return try ComplexField(shape: field.shape, values: values) + } + + private static func transformAxis( + _ input: [Complex32], + shape: [Int], + axis: Int, + matrix: SL2CMatrix, + maximumKernelExponent: Float + ) -> [Complex32] { + let length = shape[axis] + let stride = shape.dropFirst(axis + 1).reduce(1, *) + let block = length * stride + let outerCount = input.count / block + let normalization = 1 / sqrt(Float(length)) + let piI = Complex32(real: 0, imaginary: .pi) + var output = [Complex32](repeating: .zero, count: input.count) + + for outer in 0.. [Complex32] { + let length = shape[axis] + let stride = shape.dropFirst(axis + 1).reduce(1, *) + let block = length * stride + let outerCount = input.count / block + let center = Float(length - 1) / 2 + let piI = Complex32(real: 0, imaginary: .pi) + let amplitude = matrix.d.squareRoot + var output = [Complex32](repeating: .zero, count: input.count) + + for outer in 0..= 0, sourceCoordinate <= Float(length - 1) else { continue } + + let lower = Int(floor(sourceCoordinate)) + let upper = min(lower + 1, length - 1) + let fraction = sourceCoordinate - Float(lower) + let lowerValue = input[blockStart + lower * stride + inner] + let upperValue = input[blockStart + upper * stride + inner] + let sample = lowerValue * (1 - fraction) + upperValue * fraction + let phase = piI * (matrix.c * matrix.d) * (y * y / Float(length)) + let chirp = phase.exponential(maximumReal: maximumKernelExponent) + output[blockStart + outputIndex * stride + inner] = amplitude * chirp * sample + } + } + } + return output + } +} diff --git a/swift/LCTSpatial/Sources/LCTSpatial/MeshGeometry.swift b/swift/LCTSpatial/Sources/LCTSpatial/MeshGeometry.swift new file mode 100644 index 0000000..d6050b9 --- /dev/null +++ b/swift/LCTSpatial/Sources/LCTSpatial/MeshGeometry.swift @@ -0,0 +1,162 @@ +import Foundation +import simd + +public enum MeshGeometryError: Error, Equatable, Sendable { + case emptyVertices + case indexOutOfBounds(UInt32) + case indexCountNotTriangular +} + +public struct MeshGeometry: Sendable, Equatable { + public var positions: [SIMD3] + public var normals: [SIMD3] + public var indices: [UInt32] + + public init( + positions: [SIMD3], + normals: [SIMD3] = [], + indices: [UInt32] + ) throws { + guard !positions.isEmpty else { throw MeshGeometryError.emptyVertices } + guard indices.count.isMultiple(of: 3) else { + throw MeshGeometryError.indexCountNotTriangular + } + if let invalid = indices.first(where: { Int($0) >= positions.count }) { + throw MeshGeometryError.indexOutOfBounds(invalid) + } + self.positions = positions + self.indices = indices + self.normals = + normals.count == positions.count + ? normals + : Self.generatedNormals(positions: positions, indices: indices) + } + + public func normalized(extent targetExtent: Float = 1.7) throws -> Self { + var minimum = SIMD3(repeating: .greatestFiniteMagnitude) + var maximum = SIMD3(repeating: -.greatestFiniteMagnitude) + for position in positions { + minimum = simd.min(minimum, position) + maximum = simd.max(maximum, position) + } + let center = (minimum + maximum) / 2 + let sourceExtent = max(maximum.x - minimum.x, maximum.y - minimum.y, maximum.z - minimum.z) + let scale = sourceExtent > .leastNonzeroMagnitude ? targetExtent / sourceExtent : 1 + return try Self( + positions: positions.map { ($0 - center) * scale }, + normals: normals, + indices: indices + ) + } + + private static func generatedNormals( + positions: [SIMD3], + indices: [UInt32] + ) -> [SIMD3] { + var normals = [SIMD3](repeating: .zero, count: positions.count) + for triangle in stride(from: 0, to: indices.count, by: 3) { + let i0 = Int(indices[triangle]) + let i1 = Int(indices[triangle + 1]) + let i2 = Int(indices[triangle + 2]) + let face = simd_cross(positions[i1] - positions[i0], positions[i2] - positions[i0]) + normals[i0] += face + normals[i1] += face + normals[i2] += face + } + return normals.map { normal in + let length = simd_length(normal) + return length > .leastNonzeroMagnitude ? normal / length : SIMD3(0, 1, 0) + } + } +} + +public enum OBJMeshError: Error, Equatable, Sendable { + case malformedVertex(line: Int) + case malformedFace(line: Int) + case noFaces +} + +/// A dependency-free Wavefront OBJ loader suitable for document-picker imports. +public enum OBJMeshLoader { + public static func parse(_ source: String) throws -> MeshGeometry { + var positions: [SIMD3] = [] + var indices: [UInt32] = [] + + for (offset, rawLine) in source.split(whereSeparator: \.isNewline).enumerated() { + let lineNumber = offset + 1 + let parts = rawLine.split(whereSeparator: \.isWhitespace) + guard let command = parts.first else { continue } + if command == "v" { + guard parts.count >= 4, + let x = Float(parts[1]), + let y = Float(parts[2]), + let z = Float(parts[3]) + else { throw OBJMeshError.malformedVertex(line: lineNumber) } + positions.append(.init(x, y, z)) + } else if command == "f" { + guard parts.count >= 4 else { throw OBJMeshError.malformedFace(line: lineNumber) } + let face = try parts.dropFirst().map { token -> UInt32 in + guard let head = token.split(separator: "/", omittingEmptySubsequences: false).first, + let rawIndex = Int(head), rawIndex != 0 + else { throw OBJMeshError.malformedFace(line: lineNumber) } + let resolved = rawIndex > 0 ? rawIndex - 1 : positions.count + rawIndex + guard positions.indices.contains(resolved) else { + throw OBJMeshError.malformedFace(line: lineNumber) + } + return UInt32(resolved) + } + for corner in 1..<(face.count - 1) { + indices.append(contentsOf: [face[0], face[corner], face[corner + 1]]) + } + } + } + guard !indices.isEmpty else { throw OBJMeshError.noFaces } + return try MeshGeometry(positions: positions, indices: indices) + } + + public static func load(contentsOf url: URL) throws -> MeshGeometry { + try parse(String(contentsOf: url, encoding: .utf8)) + } +} + +public enum MeshVoxelizer { + /// Samples triangle surfaces into a cubic complex field for the true LCT path. + public static func surfaceField( + from geometry: MeshGeometry, + resolution: Int + ) throws -> ComplexField { + precondition(resolution >= 4) + let mesh = try geometry.normalized() + var density = [Float](repeating: 0, count: resolution * resolution * resolution) + + func splat(_ point: SIMD3) { + let unit = simd_clamp((point + 1) / 2, .zero, SIMD3(repeating: 1)) + let scaled = unit * Float(resolution - 1) + let coordinate = SIMD3(round(scaled.x), round(scaled.y), round(scaled.z)) + let x = Int(coordinate.x) + let y = Int(coordinate.y) + let z = Int(coordinate.z) + let index = (x * resolution + y) * resolution + z + density[index] += 1 + } + + for triangle in stride(from: 0, to: mesh.indices.count, by: 3) { + let p0 = mesh.positions[Int(mesh.indices[triangle])] + let p1 = mesh.positions[Int(mesh.indices[triangle + 1])] + let p2 = mesh.positions[Int(mesh.indices[triangle + 2])] + let longest = max(simd_length(p1 - p0), simd_length(p2 - p1), simd_length(p0 - p2)) + let steps = max(2, min(64, Int(ceil(longest * Float(resolution))))) + for i in 0...steps { + for j in 0...(steps - i) { + let u = Float(i) / Float(steps) + let v = Float(j) / Float(steps) + splat(p0 + u * (p1 - p0) + v * (p2 - p0)) + } + } + } + + let maximum = max(density.max() ?? 0, 1) + let values = density.map { Complex32(real: min($0 / maximum, 1)) } + return try ComplexField(shape: [resolution, resolution, resolution], values: values) + } +} diff --git a/swift/LCTSpatial/Sources/LCTSpatial/PhaseColor.swift b/swift/LCTSpatial/Sources/LCTSpatial/PhaseColor.swift new file mode 100644 index 0000000..ee33228 --- /dev/null +++ b/swift/LCTSpatial/Sources/LCTSpatial/PhaseColor.swift @@ -0,0 +1,35 @@ +import Foundation +import simd + +public enum PhaseColor { + /// Maps phase around a perceptual color wheel and magnitude to luminance. + public static func rgba( + value: Complex32, + maximumMagnitude: Float, + gamma: Float = 0.55, + alpha: Float = 1 + ) -> SIMD4 { + let magnitude = pow( + min(value.magnitude / max(maximumMagnitude, .leastNonzeroMagnitude), 1), gamma) + let hue = (value.phase + .pi) / (2 * .pi) + let rgb = hsvToRGB(hue: hue, saturation: 0.82, value: 0.18 + 0.82 * magnitude) + return SIMD4(rgb.x, rgb.y, rgb.z, alpha * magnitude) + } + + private static func hsvToRGB(hue: Float, saturation: Float, value: Float) -> SIMD3 { + let scaled = (hue - floor(hue)) * 6 + let sector = Int(floor(scaled)) % 6 + let fraction = scaled - floor(scaled) + let p = value * (1 - saturation) + let q = value * (1 - fraction * saturation) + let t = value * (1 - (1 - fraction) * saturation) + switch sector { + case 0: return .init(value, t, p) + case 1: return .init(q, value, p) + case 2: return .init(p, value, t) + case 3: return .init(p, q, value) + case 4: return .init(t, p, value) + default: return .init(value, p, q) + } + } +} diff --git a/swift/LCTSpatial/Sources/LCTSpatial/SL2CMatrix.swift b/swift/LCTSpatial/Sources/LCTSpatial/SL2CMatrix.swift new file mode 100644 index 0000000..8b6e665 --- /dev/null +++ b/swift/LCTSpatial/Sources/LCTSpatial/SL2CMatrix.swift @@ -0,0 +1,168 @@ +import Foundation + +public enum SL2CCoefficient: String, CaseIterable, Codable, Hashable, Sendable { + case a, b, c, d +} + +/// A 2×2 complex matrix constrained by `ad - bc = 1` when used as an LCT control. +@frozen +public struct SL2CMatrix: Codable, Hashable, Sendable { + public var a: Complex32 + public var b: Complex32 + public var c: Complex32 + public var d: Complex32 + + @inlinable + public init(a: Complex32, b: Complex32, c: Complex32, d: Complex32) { + self.a = a + self.b = b + self.c = c + self.d = d + } + + public static let identity = Self(a: .one, b: .zero, c: .zero, d: .one) + public static let fourier = Self(a: .zero, b: .one, c: -.one, d: .zero) + + public static func fractionalFourier(angle: Float) -> Self { + let cosine = Complex32(real: cos(angle)) + let sine = Complex32(real: sin(angle)) + return .init(a: cosine, b: sine, c: -sine, d: cosine) + } + + public static func fresnel(distance: Complex32) -> Self { + .init(a: .one, b: distance, c: .zero, d: .one) + } + + @inlinable + public var determinant: Complex32 { + a * d - b * c + } + + @inlinable + public func isUnimodular(tolerance: Float = 1e-4) -> Bool { + (determinant - .one).magnitude <= tolerance + } + + public subscript(coefficient: SL2CCoefficient) -> Complex32 { + get { + switch coefficient { + case .a: a + case .b: b + case .c: c + case .d: d + } + } + set { + switch coefficient { + case .a: a = newValue + case .b: b = newValue + case .c: c = newValue + case .d: d = newValue + } + } + } +} + +public enum SL2CConstraintError: Error, Equatable, Sendable { + case noStablePivot +} + +/// Maintains determinant one while a person continuously scrubs any matrix entry. +/// +/// The controller prefers the selected dependent coefficient (normally `d`). If +/// that solve becomes ill-conditioned, it pivots to the best available entry. +public struct SL2CConstraint: Sendable { + public private(set) var matrix: SL2CMatrix + public private(set) var dependent: SL2CCoefficient + public var lockDeterminant: Bool + public var pivotEpsilon: Float + + public init( + matrix: SL2CMatrix = .identity, + dependent: SL2CCoefficient = .d, + lockDeterminant: Bool = true, + pivotEpsilon: Float = 1e-4 + ) { + self.matrix = matrix + self.dependent = dependent + self.lockDeterminant = lockDeterminant + self.pivotEpsilon = pivotEpsilon + } + + @discardableResult + public mutating func set( + _ coefficient: SL2CCoefficient, + to value: Complex32 + ) throws -> SL2CMatrix { + matrix[coefficient] = value + guard lockDeterminant else { return matrix } + + let preferred = coefficient == dependent ? nil : dependent + let selected = + try preferred.flatMap { candidate in + pivotMagnitude(for: candidate, in: matrix) >= pivotEpsilon ? candidate : nil + } ?? bestPivot(excluding: coefficient) + + matrix[selected] = solvedValue(for: selected, in: matrix) + dependent = selected + return matrix + } + + public mutating func replace(with matrix: SL2CMatrix, dependent: SL2CCoefficient = .d) { + self.matrix = matrix + self.dependent = dependent + } + + private func bestPivot(excluding edited: SL2CCoefficient) throws -> SL2CCoefficient { + guard + let best = SL2CCoefficient.allCases + .filter({ $0 != edited }) + .map({ ($0, pivotMagnitude(for: $0, in: matrix)) }) + .filter({ $0.1 >= pivotEpsilon }) + .max(by: { $0.1 < $1.1 })?.0 + else { + throw SL2CConstraintError.noStablePivot + } + return best + } + + private func pivotMagnitude(for coefficient: SL2CCoefficient, in matrix: SL2CMatrix) -> Float { + switch coefficient { + case .d: matrix.a.magnitude + case .a: matrix.d.magnitude + case .c: matrix.b.magnitude + case .b: matrix.c.magnitude + } + } + + private func solvedValue(for coefficient: SL2CCoefficient, in matrix: SL2CMatrix) -> Complex32 { + switch coefficient { + case .d: + (.one + matrix.b * matrix.c) / matrix.a + case .a: + (.one + matrix.b * matrix.c) / matrix.d + case .c: + (matrix.a * matrix.d - .one) / matrix.b + case .b: + (matrix.a * matrix.d - .one) / matrix.c + } + } +} + +public enum CanonicalPairTransform { + /// Applies the canonical matrix to paired fields of any dimensionality. + public static func apply( + _ matrix: SL2CMatrix, + q: [Complex32], + p: [Complex32] + ) -> (q: [Complex32], p: [Complex32]) { + precondition(q.count == p.count, "q and p must have the same dimensionality") + var transformedQ = [Complex32](repeating: .zero, count: q.count) + var transformedP = [Complex32](repeating: .zero, count: p.count) + for index in q.indices { + transformedQ[index] = matrix.a * q[index] + matrix.b * p[index] + transformedP[index] = matrix.c * q[index] + matrix.d * p[index] + } + return (transformedQ, transformedP) + } +} diff --git a/swift/LCTSpatial/Sources/LCTSpatialMetal/MetalCanonicalPreview.swift b/swift/LCTSpatial/Sources/LCTSpatialMetal/MetalCanonicalPreview.swift new file mode 100644 index 0000000..a750545 --- /dev/null +++ b/swift/LCTSpatial/Sources/LCTSpatialMetal/MetalCanonicalPreview.swift @@ -0,0 +1,171 @@ +import Foundation +import LCTSpatial +@preconcurrency import Metal +@preconcurrency import RealityKit + +private struct PreviewSourceVertex { + var position: SIMD3 + var normal: SIMD3 +} + +private struct PreviewVertex { + var position: SIMD3 + var normal: SIMD3 + var color: SIMD4 +} + +/// A RealityKit mesh whose real and imaginary canonical previews are updated +/// directly by a Metal compute kernel. +/// +/// The preview treats mesh position as `q` and its scaled normal as the paired +/// field `p`, then displays `q′ = aq + bp`. It is a responsive canonical-map +/// aid; use `MetalLCTTransformer` for the actual sampled-field transform. +@MainActor +public final class MetalCanonicalPreview { + public let lowLevelMesh: LowLevelMesh + public let meshResource: MeshResource + + private let device: any MTLDevice + private let commandQueue: any MTLCommandQueue + private let pipeline: any MTLComputePipelineState + private let sourceBuffer: any MTLBuffer + private let vertexCount: Int + + public init( + geometry: MeshGeometry, + device: (any MTLDevice)? = nil, + boundsExtent: Float = 8 + ) throws { + guard let resolvedDevice = device ?? MTLCreateSystemDefaultDevice() else { + throw MetalLCTError.metalUnavailable + } + guard let commandQueue = resolvedDevice.makeCommandQueue() else { + throw MetalLCTError.commandQueueUnavailable + } + let library = try MetalLCTTransformer.makeLibrary(device: resolvedDevice) + guard let function = library.makeFunction(name: "canonical_preview_mesh") else { + throw MetalLCTError.commandEncodingFailed + } + let pipeline = try resolvedDevice.makeComputePipelineState(function: function) + + let sourceVertices = zip(geometry.positions, geometry.normals).map { + PreviewSourceVertex(position: $0.0, normal: $0.1) + } + let sourceByteCount = sourceVertices.count * MemoryLayout.stride + guard + let sourceBuffer = sourceVertices.withUnsafeBytes({ bytes in + resolvedDevice.makeBuffer( + bytes: bytes.baseAddress!, + length: sourceByteCount, + options: .storageModeShared + ) + }) + else { + throw MetalLCTError.bufferAllocationFailed + } + + let attributes = [ + LowLevelMesh.Attribute( + semantic: .position, + format: .float3, + offset: MemoryLayout.offset(of: \.position)! + ), + LowLevelMesh.Attribute( + semantic: .normal, + format: .float3, + offset: MemoryLayout.offset(of: \.normal)! + ), + LowLevelMesh.Attribute( + semantic: .color, + format: .float4, + offset: MemoryLayout.offset(of: \.color)! + ), + ] + let layouts = [ + LowLevelMesh.Layout(bufferIndex: 0, bufferStride: MemoryLayout.stride) + ] + let descriptor = LowLevelMesh.Descriptor( + vertexCapacity: sourceVertices.count * 2, + vertexAttributes: attributes, + vertexLayouts: layouts, + indexCapacity: geometry.indices.count * 2, + indexType: .uint32 + ) + let lowLevelMesh = try LowLevelMesh(descriptor: descriptor) + lowLevelMesh.withUnsafeMutableIndices { rawIndices in + let indices = rawIndices.bindMemory(to: UInt32.self) + for index in geometry.indices.indices { + indices[index] = geometry.indices[index] + indices[index + geometry.indices.count] = + geometry.indices[index] + UInt32(sourceVertices.count) + } + } + let bounds = BoundingBox( + min: SIMD3(repeating: -boundsExtent), + max: SIMD3(repeating: boundsExtent) + ) + lowLevelMesh.parts.append( + .init( + indexOffset: 0, + indexCount: geometry.indices.count, + topology: .triangle, + materialIndex: 0, + bounds: bounds + ) + ) + lowLevelMesh.parts.append( + .init( + indexOffset: geometry.indices.count, + indexCount: geometry.indices.count, + topology: .triangle, + materialIndex: 1, + bounds: bounds + ) + ) + + self.device = resolvedDevice + self.commandQueue = commandQueue + self.pipeline = pipeline + self.sourceBuffer = sourceBuffer + vertexCount = sourceVertices.count + self.lowLevelMesh = lowLevelMesh + meshResource = try MeshResource(from: lowLevelMesh) + + try update(matrix: .identity) + } + + public func update( + matrix: SL2CMatrix, + companionScale: Float = 0.18, + showsImaginary: Bool = true + ) throws { + guard + let commandBuffer = commandQueue.makeCommandBuffer(), + let encoder = commandBuffer.makeComputeCommandEncoder() + else { + throw MetalLCTError.commandEncodingFailed + } + + let outputBuffer = lowLevelMesh.replace(bufferIndex: 0, using: commandBuffer) + var a = SIMD2(matrix.a.real, matrix.a.imaginary) + var b = SIMD2(matrix.b.real, matrix.b.imaginary) + var parameters = SIMD4(companionScale, showsImaginary ? 1 : 0, 0, 0) + var count = UInt32(vertexCount) + + encoder.setComputePipelineState(pipeline) + encoder.setBuffer(sourceBuffer, offset: 0, index: 0) + encoder.setBuffer(outputBuffer, offset: 0, index: 1) + encoder.setBytes(&a, length: MemoryLayout>.stride, index: 2) + encoder.setBytes(&b, length: MemoryLayout>.stride, index: 3) + encoder.setBytes(¶meters, length: MemoryLayout>.stride, index: 4) + encoder.setBytes(&count, length: MemoryLayout.stride, index: 5) + + let width = min(pipeline.maxTotalThreadsPerThreadgroup, 256) + encoder.dispatchThreads( + MTLSize(width: vertexCount, height: 1, depth: 1), + threadsPerThreadgroup: MTLSize(width: width, height: 1, depth: 1) + ) + encoder.endEncoding() + commandBuffer.commit() + } +} diff --git a/swift/LCTSpatial/Sources/LCTSpatialMetal/MetalLCTTransformer.swift b/swift/LCTSpatial/Sources/LCTSpatialMetal/MetalLCTTransformer.swift new file mode 100644 index 0000000..4c51102 --- /dev/null +++ b/swift/LCTSpatial/Sources/LCTSpatialMetal/MetalLCTTransformer.swift @@ -0,0 +1,161 @@ +import Foundation +@_exported import LCTSpatial +@preconcurrency import Metal + +public enum MetalLCTError: Error, Sendable { + case metalUnavailable + case shaderResourceMissing + case commandQueueUnavailable + case bufferAllocationFailed + case commandEncodingFailed + case commandFailed(String) + case complexScalingUnsupported +} + +/// A Metal implementation of the dimension-agnostic separable LCT kernel. +/// +/// One compute pass is dispatched per field axis, keeping a cubic field at +/// O(rank × n^(rank+1)) instead of evaluating every input/output voxel pair. +public final class MetalLCTTransformer: @unchecked Sendable { + public let device: any MTLDevice + + private let commandQueue: any MTLCommandQueue + private let axisPipeline: any MTLComputePipelineState + private let singularAxisPipeline: any MTLComputePipelineState + + public convenience init() throws { + guard let device = MTLCreateSystemDefaultDevice() else { + throw MetalLCTError.metalUnavailable + } + try self.init(device: device) + } + + public init(device: any MTLDevice) throws { + self.device = device + guard let commandQueue = device.makeCommandQueue() else { + throw MetalLCTError.commandQueueUnavailable + } + self.commandQueue = commandQueue + + let library = try Self.makeLibrary(device: device) + guard let function = library.makeFunction(name: "lct_axis_pass") else { + throw MetalLCTError.commandEncodingFailed + } + guard let singularFunction = library.makeFunction(name: "lct_singular_axis_pass") else { + throw MetalLCTError.commandEncodingFailed + } + axisPipeline = try device.makeComputePipelineState(function: function) + singularAxisPipeline = try device.makeComputePipelineState(function: singularFunction) + } + + public func transform( + _ field: ComplexField, + matrix: SL2CMatrix, + maximumKernelExponent: Float = 12 + ) throws -> ComplexField { + if matrix == .identity { return field } + let usesSingularBranch = matrix.b.magnitude <= 1e-6 + if usesSingularBranch, abs(matrix.d.imaginary) > 1e-5 { + throw MetalLCTError.complexScalingUnsupported + } + precondition(MemoryLayout.stride == MemoryLayout>.stride) + + let byteCount = field.count * MemoryLayout.stride + guard + let firstBuffer = field.values.withUnsafeBytes({ bytes in + device.makeBuffer( + bytes: bytes.baseAddress!, + length: byteCount, + options: .storageModeShared + ) + }), + let secondBuffer = device.makeBuffer(length: byteCount, options: .storageModeShared), + let commandBuffer = commandQueue.makeCommandBuffer() + else { + throw MetalLCTError.bufferAllocationFailed + } + + var input = firstBuffer + var output = secondBuffer + var a = SIMD2(matrix.a.real, matrix.a.imaginary) + var b = SIMD2(matrix.b.real, matrix.b.imaginary) + var c = SIMD2(matrix.c.real, matrix.c.imaginary) + var d = SIMD2(matrix.d.real, matrix.d.imaginary) + var maximumExponent = maximumKernelExponent + + for axis in field.shape.indices { + guard let encoder = commandBuffer.makeComputeCommandEncoder() else { + throw MetalLCTError.commandEncodingFailed + } + let length = field.shape[axis] + let elementStride = field.shape.dropFirst(axis + 1).reduce(1, *) + let block = length * elementStride + var dimensions = SIMD4( + UInt32(length), + UInt32(elementStride), + UInt32(block), + UInt32(field.count) + ) + + let selectedPipeline = usesSingularBranch ? singularAxisPipeline : axisPipeline + encoder.setComputePipelineState(selectedPipeline) + encoder.setBuffer(input, offset: 0, index: 0) + encoder.setBuffer(output, offset: 0, index: 1) + if usesSingularBranch { + encoder.setBytes(&c, length: MemoryLayout>.stride, index: 2) + encoder.setBytes(&d, length: MemoryLayout>.stride, index: 3) + encoder.setBytes(&dimensions, length: MemoryLayout>.stride, index: 4) + encoder.setBytes(&maximumExponent, length: MemoryLayout.stride, index: 5) + } else { + encoder.setBytes(&a, length: MemoryLayout>.stride, index: 2) + encoder.setBytes(&b, length: MemoryLayout>.stride, index: 3) + encoder.setBytes(&d, length: MemoryLayout>.stride, index: 4) + encoder.setBytes(&dimensions, length: MemoryLayout>.stride, index: 5) + encoder.setBytes(&maximumExponent, length: MemoryLayout.stride, index: 6) + } + + let width = min(selectedPipeline.maxTotalThreadsPerThreadgroup, 256) + encoder.dispatchThreads( + MTLSize(width: field.count, height: 1, depth: 1), + threadsPerThreadgroup: MTLSize(width: width, height: 1, depth: 1) + ) + encoder.endEncoding() + swap(&input, &output) + } + + commandBuffer.commit() + commandBuffer.waitUntilCompleted() + guard commandBuffer.status == .completed else { + throw MetalLCTError.commandFailed( + commandBuffer.error?.localizedDescription ?? "unknown Metal error") + } + + let pointer = input.contents().bindMemory(to: Complex32.self, capacity: field.count) + let values = Array(UnsafeBufferPointer(start: pointer, count: field.count)) + return try ComplexField(shape: field.shape, values: values) + } + + static func makeLibrary(device: any MTLDevice) throws -> any MTLLibrary { + // Xcode compiles package Metal resources into the target's default + // metallib. SwiftPM's command-line test runner instead leaves the source + // available as a resource, so keep source compilation as a portable + // fallback for library consumers and tests. + if let library = try? device.makeDefaultLibrary(bundle: Bundle.module) { + return library + } + + let candidates = [ + Bundle.module.url(forResource: "LCTKernels", withExtension: "metal"), + Bundle.module.url( + forResource: "LCTKernels", + withExtension: "metal", + subdirectory: "Shaders" + ), + ] + guard let url = candidates.compactMap({ $0 }).first else { + throw MetalLCTError.shaderResourceMissing + } + let source = try String(contentsOf: url, encoding: .utf8) + return try device.makeLibrary(source: source, options: nil) + } +} diff --git a/swift/LCTSpatial/Sources/LCTSpatialMetal/Shaders/LCTKernels.metal b/swift/LCTSpatial/Sources/LCTSpatialMetal/Shaders/LCTKernels.metal new file mode 100644 index 0000000..0ca9bbc --- /dev/null +++ b/swift/LCTSpatial/Sources/LCTSpatialMetal/Shaders/LCTKernels.metal @@ -0,0 +1,171 @@ +#include +using namespace metal; + +inline float2 complex_add(float2 lhs, float2 rhs) { + return lhs + rhs; +} + +inline float2 complex_mul(float2 lhs, float2 rhs) { + return float2( + lhs.x * rhs.x - lhs.y * rhs.y, + lhs.x * rhs.y + lhs.y * rhs.x + ); +} + +inline float2 complex_div(float2 lhs, float2 rhs) { + float denominator = dot(rhs, rhs); + return float2( + (lhs.x * rhs.x + lhs.y * rhs.y) / denominator, + (lhs.y * rhs.x - lhs.x * rhs.y) / denominator + ); +} + +inline float2 complex_exp_bounded(float2 value, float maximumReal) { + float scale = exp(clamp(value.x, -maximumReal, maximumReal)); + return scale * float2(cos(value.y), sin(value.y)); +} + +inline float2 complex_sqrt(float2 value) { + const float radius = length(value); + const float realPart = sqrt(max((radius + value.x) * 0.5f, 0.0f)); + const float imaginaryMagnitude = sqrt(max((radius - value.x) * 0.5f, 0.0f)); + const float imaginaryPart = value.y < 0.0f ? -imaginaryMagnitude : imaginaryMagnitude; + return float2(realPart, imaginaryPart); +} + +/// Applies one separable LCT axis to a row-major complex field. +kernel void lct_axis_pass( + device const float2 *input [[buffer(0)]], + device float2 *output [[buffer(1)]], + constant float2 &a [[buffer(2)]], + constant float2 &b [[buffer(3)]], + constant float2 &d [[buffer(4)]], + constant uint4 &dimensions [[buffer(5)]], + constant float &maximumExponent [[buffer(6)]], + uint gid [[thread_position_in_grid]]) +{ + const uint length = dimensions.x; + const uint elementStride = dimensions.y; + const uint totalCount = dimensions.w; + if (gid >= totalCount) { + return; + } + + const uint outputCoordinate = (gid / elementStride) % length; + const uint lineStart = gid - outputCoordinate * elementStride; + const float center = float(length - 1) * 0.5f; + const float y = float(outputCoordinate) - center; + const float2 denominator = b * float(length); + const float2 piI = float2(0.0f, M_PI_F); + float2 sum = 0.0f; + + for (uint inputCoordinate = 0; inputCoordinate < length; ++inputCoordinate) { + const float x = float(inputCoordinate) - center; + float2 numerator = a * (x * x); + numerator += float2(-2.0f * x * y, 0.0f); + numerator += d * (y * y); + const float2 phase = complex_mul(piI, complex_div(numerator, denominator)); + const float2 kernelValue = complex_exp_bounded(phase, maximumExponent); + const uint sourceIndex = lineStart + inputCoordinate * elementStride; + sum = complex_add(sum, complex_mul(input[sourceIndex], kernelValue)); + } + + output[gid] = sum * rsqrt(float(length)); +} + +/// Handles the finite-grid b = 0 branch as a chirped real-axis scaling: +/// sqrt(d) exp(iπ c d y² / n) f(dy), with linear interpolation. +kernel void lct_singular_axis_pass( + device const float2 *input [[buffer(0)]], + device float2 *output [[buffer(1)]], + constant float2 &c [[buffer(2)]], + constant float2 &d [[buffer(3)]], + constant uint4 &dimensions [[buffer(4)]], + constant float &maximumExponent [[buffer(5)]], + uint gid [[thread_position_in_grid]]) +{ + const uint length = dimensions.x; + const uint elementStride = dimensions.y; + const uint totalCount = dimensions.w; + if (gid >= totalCount) { + return; + } + + const uint outputCoordinate = (gid / elementStride) % length; + const uint lineStart = gid - outputCoordinate * elementStride; + const float center = float(length - 1) * 0.5f; + const float y = float(outputCoordinate) - center; + const float sourceCoordinate = d.x * y + center; + if (sourceCoordinate < 0.0f || sourceCoordinate > float(length - 1)) { + output[gid] = 0.0f; + return; + } + + const uint lower = uint(floor(sourceCoordinate)); + const uint upper = min(lower + 1, length - 1); + const float fraction = sourceCoordinate - float(lower); + const float2 lowerValue = input[lineStart + lower * elementStride]; + const float2 upperValue = input[lineStart + upper * elementStride]; + const float2 sample = mix(lowerValue, upperValue, fraction); + const float2 piI = float2(0.0f, M_PI_F); + const float2 phase = complex_mul( + piI, + complex_mul(c, d) * (y * y / float(length)) + ); + const float2 chirp = complex_exp_bounded(phase, maximumExponent); + output[gid] = complex_mul(complex_mul(complex_sqrt(d), chirp), sample); +} + +struct PreviewSourceVertex { + float3 position; + float3 normal; +}; + +struct PreviewVertex { + float3 position; + float3 normal; + float4 color; +}; + +inline float3 phase_rgb(float phase, float luminance) { + float hue = fract((phase + M_PI_F) / (2.0f * M_PI_F)); + float3 shifted = abs(fract(hue + float3(0.0f, 2.0f / 3.0f, 1.0f / 3.0f)) * 6.0f - 3.0f); + float3 rgb = clamp(shifted - 1.0f, 0.0f, 1.0f); + return mix(float3(luminance * 0.18f), rgb * luminance, 0.86f); +} + +/// Updates a duplicated RealityKit mesh with the real and imaginary parts of +/// q' = a q + b p, where q is position and p is a scaled normal field. +kernel void canonical_preview_mesh( + device const PreviewSourceVertex *source [[buffer(0)]], + device PreviewVertex *output [[buffer(1)]], + constant float2 &a [[buffer(2)]], + constant float2 &b [[buffer(3)]], + constant float4 ¶meters [[buffer(4)]], + constant uint &vertexCount [[buffer(5)]], + uint gid [[thread_position_in_grid]]) +{ + if (gid >= vertexCount) { + return; + } + + const PreviewSourceVertex sourceVertex = source[gid]; + const float3 companion = sourceVertex.normal * parameters.x; + const float3 realPosition = a.x * sourceVertex.position + b.x * companion; + const float3 imaginaryPosition = a.y * sourceVertex.position + b.y * companion; + const float3 phaseProbe = normalize(float3(1.0f, 1.6180339f, 2.6180339f)); + const float realProbe = dot(realPosition, phaseProbe); + const float imaginaryProbe = dot(imaginaryPosition, phaseProbe); + const float phase = atan2(imaginaryProbe, realProbe); + const float magnitude = length(float2(realProbe, imaginaryProbe)); + const float luminance = 0.25f + 0.75f * (1.0f - exp(-magnitude)); + const float3 rgb = phase_rgb(phase, luminance); + + output[gid].position = realPosition; + output[gid].normal = sourceVertex.normal; + output[gid].color = float4(rgb, 1.0f); + + output[gid + vertexCount].position = imaginaryPosition; + output[gid + vertexCount].normal = sourceVertex.normal; + output[gid + vertexCount].color = float4(rgb, 0.28f * parameters.y); +} diff --git a/swift/LCTSpatial/Tests/LCTSpatialMetalTests/MetalLCTTransformerTests.swift b/swift/LCTSpatial/Tests/LCTSpatialMetalTests/MetalLCTTransformerTests.swift new file mode 100644 index 0000000..bb9e19b --- /dev/null +++ b/swift/LCTSpatial/Tests/LCTSpatialMetalTests/MetalLCTTransformerTests.swift @@ -0,0 +1,41 @@ +import LCTSpatial +import Metal +import Testing + +@testable import LCTSpatialMetal + +@Test func metalMatchesCPUReferenceAcrossThreeAxes() throws { + guard let device = MTLCreateSystemDefaultDevice() else { return } + let values = (0..<27).map { index in + Complex32(real: Float(index % 5) / 5, imaginary: Float(index % 3) / 7) + } + let field = try ComplexField(shape: [3, 3, 3], values: values) + let matrix = SL2CMatrix.fractionalFourier(angle: 0.63) + let reference = try DiscreteLCT.transform(field, matrix: matrix) + let actual = try MetalLCTTransformer(device: device).transform(field, matrix: matrix) + + #expect(actual.shape == reference.shape) + for (actualValue, expectedValue) in zip(actual.values, reference.values) { + #expect((actualValue - expectedValue).magnitude < 2e-4) + } +} + +@Test func metalMatchesCPUSingularBranch() throws { + guard let device = MTLCreateSystemDefaultDevice() else { return } + let values = (0..<25).map { index in + Complex32(real: Float(index % 7) / 7, imaginary: Float(index % 4) / 9) + } + let field = try ComplexField(shape: [5, 5], values: values) + let lens = SL2CMatrix( + a: .init(real: 1.25), + b: .zero, + c: .init(real: -0.28, imaginary: 0.05), + d: .init(real: 0.8) + ) + let reference = try DiscreteLCT.transform(field, matrix: lens) + let actual = try MetalLCTTransformer(device: device).transform(field, matrix: lens) + + for (actualValue, expectedValue) in zip(actual.values, reference.values) { + #expect((actualValue - expectedValue).magnitude < 3e-4) + } +} diff --git a/swift/LCTSpatial/Tests/LCTSpatialTests/LCTSpatialTests.swift b/swift/LCTSpatial/Tests/LCTSpatialTests/LCTSpatialTests.swift new file mode 100644 index 0000000..2cdb446 --- /dev/null +++ b/swift/LCTSpatial/Tests/LCTSpatialTests/LCTSpatialTests.swift @@ -0,0 +1,85 @@ +import Foundation +import Testing + +@testable import LCTSpatial + +@Test func complexArithmetic() { + let lhs = Complex32(real: 2, imaginary: 3) + let rhs = Complex32(real: -1, imaginary: 4) + #expect(lhs * rhs == Complex32(real: -14, imaginary: 5)) + let quotient = (lhs * rhs) / rhs + #expect(abs(quotient.real - lhs.real) < 1e-5) + #expect(abs(quotient.imaginary - lhs.imaginary) < 1e-5) +} + +@Test func determinantLockUpdatesD() throws { + var lock = SL2CConstraint() + try lock.set(.b, to: .init(real: 0.4, imaginary: 0.2)) + try lock.set(.c, to: .init(real: -0.3, imaginary: 0.1)) + #expect(lock.dependent == .d) + #expect(lock.matrix.isUnimodular(tolerance: 1e-5)) +} + +@Test func determinantLockPivotsNearZeroA() throws { + var lock = SL2CConstraint(matrix: .fourier, dependent: .d) + try lock.set(.a, to: .zero) + #expect(lock.dependent != .d) + #expect(lock.matrix.isUnimodular(tolerance: 1e-5)) +} + +@Test func canonicalPairIsDimensionAgnostic() { + let q = [Complex32(real: 1), .init(real: 2), .init(real: 3), .init(real: 4)] + let p = [Complex32(real: -1), .init(real: -2), .init(real: -3), .init(real: -4)] + let transformed = CanonicalPairTransform.apply(.fourier, q: q, p: p) + #expect(transformed.q == p) + #expect(transformed.p == q.map(-)) +} + +@Test func fourierOfCenteredImpulseHasFlatMagnitude() throws { + var values = [Complex32](repeating: .zero, count: 5) + values[2] = .one + let field = try ComplexField(shape: [5], values: values) + let transformed = try DiscreteLCT.transform(field, matrix: .fourier) + let expected = 1 / sqrt(Float(5)) + for value in transformed.values { + #expect(abs(value.magnitude - expected) < 1e-5) + } +} + +@Test func multidimensionalIdentityPreservesField() throws { + let values = (0..<24).map { Complex32(real: Float($0), imaginary: Float(-$0)) } + let field = try ComplexField(shape: [2, 3, 4], values: values) + #expect(try DiscreteLCT.transform(field, matrix: .identity) == field) +} + +@Test func singularBranchAppliesChirpWithoutChangingMagnitude() throws { + let values = (0..<7).map { Complex32(real: Float($0 + 1) / 7) } + let field = try ComplexField(shape: [7], values: values) + let lens = SL2CMatrix( + a: .one, + b: .zero, + c: .init(real: 0.35), + d: .one + ) + let transformed = try DiscreteLCT.transform(field, matrix: lens) + + for (actual, expected) in zip(transformed.values, values) { + #expect(abs(actual.magnitude - expected.magnitude) < 1e-5) + } + #expect(transformed.values[0].phase != transformed.values[3].phase) +} + +@Test func objImportAndVoxelization() throws { + let source = """ + v -1 -1 0 + v 1 -1 0 + v 0 1 0 + f 1 2 3 + """ + let mesh = try OBJMeshLoader.parse(source) + #expect(mesh.positions.count == 3) + #expect(mesh.indices == [0, 1, 2]) + let field = try MeshVoxelizer.surfaceField(from: mesh, resolution: 8) + #expect(field.shape == [8, 8, 8]) + #expect(field.maxMagnitude > 0) +} diff --git a/swift/LCTVision/LCTVision.xcodeproj/project.pbxproj b/swift/LCTVision/LCTVision.xcodeproj/project.pbxproj new file mode 100644 index 0000000..1bd6ffd --- /dev/null +++ b/swift/LCTVision/LCTVision.xcodeproj/project.pbxproj @@ -0,0 +1,372 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 097BB7D5AF76D7D8B233B80E /* LCTSpatialMetal in Frameworks */ = {isa = PBXBuildFile; productRef = 1B777115542AD3C84ECEF01C /* LCTSpatialMetal */; }; + 0C260994CE402301BE4B195A /* SpatialScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0311C66D86888655DE7FEAE0 /* SpatialScene.swift */; }; + 268B9F80D84F5AFA48A5E8F6 /* LCTStudioModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DD3BE2B5DF963834C2621E /* LCTStudioModel.swift */; }; + 3352F9F40C93B7436F22A8E9 /* LCTSpatial in Frameworks */ = {isa = PBXBuildFile; productRef = BD04D0F971106BE502570B8B /* LCTSpatial */; }; + 5C5690C121DC57E7C61576DF /* DemoGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA40AF487128C05684C2270D /* DemoGeometry.swift */; }; + 82B8E954FB528192A3CA7D58 /* ControlDeck.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA59CB26418F5B49D577B49 /* ControlDeck.swift */; }; + 85BB40B156332BA8ECD2AEEB /* LCTVisionApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9258A03ADDF7B22AD57D7778 /* LCTVisionApp.swift */; }; + 9250E6A40D4DB3D264CB18D8 /* StudioView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D07A4A16F26A52A2A7C7CA5 /* StudioView.swift */; }; + B7B6D4F14E80717A6D967D67 /* SpectralCloud.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CDD047EB89CC0A4D27095A0 /* SpectralCloud.swift */; }; + C5F192F70A8E9E1061540C2C /* ArgandPad.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC189FEA16B29874D18522C9 /* ArgandPad.swift */; }; + D68EF6FAF2E9F9FA659CC5F8 /* LCTPreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A48CCE09849E483EE5E5F4D /* LCTPreset.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 0311C66D86888655DE7FEAE0 /* SpatialScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpatialScene.swift; sourceTree = ""; }; + 04DD3BE2B5DF963834C2621E /* LCTStudioModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCTStudioModel.swift; sourceTree = ""; }; + 1D07A4A16F26A52A2A7C7CA5 /* StudioView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudioView.swift; sourceTree = ""; }; + 2A48CCE09849E483EE5E5F4D /* LCTPreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCTPreset.swift; sourceTree = ""; }; + 407B22E37670A10D799AC7CE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 4CA59CB26418F5B49D577B49 /* ControlDeck.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlDeck.swift; sourceTree = ""; }; + 8D0D7482BA8D41F2BBB9E9BF /* LCTVision.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = LCTVision.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 9258A03ADDF7B22AD57D7778 /* LCTVisionApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCTVisionApp.swift; sourceTree = ""; }; + 9CDD047EB89CC0A4D27095A0 /* SpectralCloud.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpectralCloud.swift; sourceTree = ""; }; + CD6AFC1F5FD72FF06B2BE189 /* LCTSpatial */ = {isa = PBXFileReference; lastKnownFileType = folder; name = LCTSpatial; path = ../LCTSpatial; sourceTree = SOURCE_ROOT; }; + DA40AF487128C05684C2270D /* DemoGeometry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoGeometry.swift; sourceTree = ""; }; + EC189FEA16B29874D18522C9 /* ArgandPad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArgandPad.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + E528DFC6FF4DEF67D557AEE3 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 3352F9F40C93B7436F22A8E9 /* LCTSpatial in Frameworks */, + 097BB7D5AF76D7D8B233B80E /* LCTSpatialMetal in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 3A15CA113650B2BA2B746F71 /* Products */ = { + isa = PBXGroup; + children = ( + 8D0D7482BA8D41F2BBB9E9BF /* LCTVision.app */, + ); + name = Products; + sourceTree = ""; + }; + 6169708FC6FB87418782F289 /* Packages */ = { + isa = PBXGroup; + children = ( + CD6AFC1F5FD72FF06B2BE189 /* LCTSpatial */, + ); + name = Packages; + sourceTree = ""; + }; + 958CA8ADFC1A19925A321973 /* LCTVision */ = { + isa = PBXGroup; + children = ( + EC189FEA16B29874D18522C9 /* ArgandPad.swift */, + 4CA59CB26418F5B49D577B49 /* ControlDeck.swift */, + DA40AF487128C05684C2270D /* DemoGeometry.swift */, + 407B22E37670A10D799AC7CE /* Info.plist */, + 2A48CCE09849E483EE5E5F4D /* LCTPreset.swift */, + 04DD3BE2B5DF963834C2621E /* LCTStudioModel.swift */, + 9258A03ADDF7B22AD57D7778 /* LCTVisionApp.swift */, + 0311C66D86888655DE7FEAE0 /* SpatialScene.swift */, + 9CDD047EB89CC0A4D27095A0 /* SpectralCloud.swift */, + 1D07A4A16F26A52A2A7C7CA5 /* StudioView.swift */, + ); + path = LCTVision; + sourceTree = ""; + }; + C0A7780FDC3F53B3D698DE20 = { + isa = PBXGroup; + children = ( + 958CA8ADFC1A19925A321973 /* LCTVision */, + 6169708FC6FB87418782F289 /* Packages */, + 3A15CA113650B2BA2B746F71 /* Products */, + ); + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 94836008DB781ACCB4984C1F /* LCTVision */ = { + isa = PBXNativeTarget; + buildConfigurationList = E603CC0192D67091D39CBD71 /* Build configuration list for PBXNativeTarget "LCTVision" */; + buildPhases = ( + B5531478A646568403351A1A /* Sources */, + E528DFC6FF4DEF67D557AEE3 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = LCTVision; + packageProductDependencies = ( + BD04D0F971106BE502570B8B /* LCTSpatial */, + 1B777115542AD3C84ECEF01C /* LCTSpatialMetal */, + ); + productName = LCTVision; + productReference = 8D0D7482BA8D41F2BBB9E9BF /* LCTVision.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 1FC20AA316E7FAF6540D4419 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + }; + }; + buildConfigurationList = 6C91947218B97916050A91D8 /* Build configuration list for PBXProject "LCTVision" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = C0A7780FDC3F53B3D698DE20; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 59E162961AFC9BA4B68DB6F3 /* XCLocalSwiftPackageReference "../LCTSpatial" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 3A15CA113650B2BA2B746F71 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 94836008DB781ACCB4984C1F /* LCTVision */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + B5531478A646568403351A1A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C5F192F70A8E9E1061540C2C /* ArgandPad.swift in Sources */, + 82B8E954FB528192A3CA7D58 /* ControlDeck.swift in Sources */, + 5C5690C121DC57E7C61576DF /* DemoGeometry.swift in Sources */, + D68EF6FAF2E9F9FA659CC5F8 /* LCTPreset.swift in Sources */, + 268B9F80D84F5AFA48A5E8F6 /* LCTStudioModel.swift in Sources */, + 85BB40B156332BA8ECD2AEEB /* LCTVisionApp.swift in Sources */, + 0C260994CE402301BE4B195A /* SpatialScene.swift in Sources */, + B7B6D4F14E80717A6D967D67 /* SpectralCloud.swift in Sources */, + 9250E6A40D4DB3D264CB18D8 /* StudioView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 0278E08EA4D0345B9BF04F7E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LCTVision/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.aloksingh.lctvision; + PRODUCT_NAME = "LCT Vision"; + SDKROOT = xros; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 7; + }; + name = Release; + }; + 69C28F25B53792B85F57CE51 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = xros; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + XROS_DEPLOYMENT_TARGET = 2.0; + }; + name = Debug; + }; + 710DC7F615970FA11961B751 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = xros; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + XROS_DEPLOYMENT_TARGET = 2.0; + }; + name = Release; + }; + AB0DEAD1C12917DEF8F94C52 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LCTVision/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.aloksingh.lctvision; + PRODUCT_NAME = "LCT Vision"; + SDKROOT = xros; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 7; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 6C91947218B97916050A91D8 /* Build configuration list for PBXProject "LCTVision" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 69C28F25B53792B85F57CE51 /* Debug */, + 710DC7F615970FA11961B751 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + E603CC0192D67091D39CBD71 /* Build configuration list for PBXNativeTarget "LCTVision" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AB0DEAD1C12917DEF8F94C52 /* Debug */, + 0278E08EA4D0345B9BF04F7E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 59E162961AFC9BA4B68DB6F3 /* XCLocalSwiftPackageReference "../LCTSpatial" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../LCTSpatial; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 1B777115542AD3C84ECEF01C /* LCTSpatialMetal */ = { + isa = XCSwiftPackageProductDependency; + productName = LCTSpatialMetal; + }; + BD04D0F971106BE502570B8B /* LCTSpatial */ = { + isa = XCSwiftPackageProductDependency; + productName = LCTSpatial; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 1FC20AA316E7FAF6540D4419 /* Project object */; +} diff --git a/swift/LCTVision/LCTVision.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/swift/LCTVision/LCTVision.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/swift/LCTVision/LCTVision.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/swift/LCTVision/LCTVision.xcodeproj/xcshareddata/xcschemes/LCTVision.xcscheme b/swift/LCTVision/LCTVision.xcodeproj/xcshareddata/xcschemes/LCTVision.xcscheme new file mode 100644 index 0000000..ccebcf8 --- /dev/null +++ b/swift/LCTVision/LCTVision.xcodeproj/xcshareddata/xcschemes/LCTVision.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/swift/LCTVision/LCTVision/ArgandPad.swift b/swift/LCTVision/LCTVision/ArgandPad.swift new file mode 100644 index 0000000..66e0212 --- /dev/null +++ b/swift/LCTVision/LCTVision/ArgandPad.swift @@ -0,0 +1,139 @@ +import LCTSpatial +import SwiftUI + +struct ArgandPad: View { + let coefficient: SL2CCoefficient + let value: Complex32 + let isDependent: Bool + let range: Float + let accent: Color + let onChange: (Complex32) -> Void + + @State private var isDragging = false + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Text(coefficient.rawValue.uppercased()) + .font(.caption.monospaced().weight(.bold)) + .foregroundStyle(accent) + if isDependent { + Text("AUTO") + .font(.system(size: 8, weight: .bold, design: .monospaced)) + .foregroundStyle(.black) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(accent, in: Capsule()) + } + Spacer() + Text(shortValue) + .font(.system(size: 9, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + } + + TimelineView(.animation(minimumInterval: 1 / 24)) { timeline in + Canvas { context, size in + drawGrid(context: &context, size: size, date: timeline.date) + } + } + .frame(width: 120, height: 94) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { gesture in + isDragging = true + onChange(value(at: gesture.location, size: CGSize(width: 120, height: 94))) + } + .onEnded { _ in isDragging = false } + ) + .hoverEffect(.highlight) + .accessibilityLabel("Complex coefficient \(coefficient.rawValue)") + .accessibilityValue(value.description) + } + .padding(9) + .background( + RoundedRectangle(cornerRadius: 15, style: .continuous) + .fill(.black.opacity(isDragging ? 0.42 : 0.28)) + .stroke(accent.opacity(isDependent ? 0.72 : 0.28), lineWidth: isDependent ? 1.2 : 0.7) + ) + .animation(.easeOut(duration: 0.12), value: isDragging) + } + + private var shortValue: String { + String(format: "%+.2f %+.2fi", value.real, value.imaginary) + } + + private func value(at location: CGPoint, size: CGSize) -> Complex32 { + let normalizedX = Float(location.x / size.width * 2 - 1) + let normalizedY = Float(1 - location.y / size.height * 2) + return .init( + real: min(max(normalizedX, -1), 1) * range, + imaginary: min(max(normalizedY, -1), 1) * range + ) + } + + private func point(for value: Complex32, size: CGSize) -> CGPoint { + let x = CGFloat(min(max(value.real / range, -1), 1)) + let y = CGFloat(min(max(value.imaginary / range, -1), 1)) + return CGPoint(x: (x + 1) * size.width / 2, y: (1 - y) * size.height / 2) + } + + private func drawGrid(context: inout GraphicsContext, size: CGSize, date: Date) { + let bounds = CGRect(origin: .zero, size: size) + context.fill( + Path(roundedRect: bounds, cornerRadius: 10), + with: .linearGradient( + Gradient(colors: [.black.opacity(0.78), accent.opacity(0.11)]), + startPoint: .zero, + endPoint: CGPoint(x: size.width, y: size.height) + ) + ) + + for index in 1..<6 { + let x = size.width * CGFloat(index) / 6 + let y = size.height * CGFloat(index) / 6 + var vertical = Path() + vertical.move(to: CGPoint(x: x, y: 0)) + vertical.addLine(to: CGPoint(x: x, y: size.height)) + context.stroke(vertical, with: .color(accent.opacity(0.10)), lineWidth: 0.5) + var horizontal = Path() + horizontal.move(to: CGPoint(x: 0, y: y)) + horizontal.addLine(to: CGPoint(x: size.width, y: y)) + context.stroke(horizontal, with: .color(accent.opacity(0.10)), lineWidth: 0.5) + } + + var axes = Path() + axes.move(to: CGPoint(x: size.width / 2, y: 0)) + axes.addLine(to: CGPoint(x: size.width / 2, y: size.height)) + axes.move(to: CGPoint(x: 0, y: size.height / 2)) + axes.addLine(to: CGPoint(x: size.width, y: size.height / 2)) + context.stroke(axes, with: .color(accent.opacity(0.48)), lineWidth: 0.8) + + let scanPhase = date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 2.2) / 2.2 + let scanY = size.height * scanPhase + var scan = Path() + scan.move(to: CGPoint(x: 0, y: scanY)) + scan.addLine(to: CGPoint(x: size.width, y: scanY)) + context.stroke(scan, with: .color(accent.opacity(0.17)), lineWidth: 1) + + let location = point(for: value, size: size) + context.fill( + Path(ellipseIn: CGRect(x: location.x - 11, y: location.y - 11, width: 22, height: 22)), + with: .radialGradient( + Gradient(colors: [accent.opacity(0.48), .clear]), + center: location, + startRadius: 0, + endRadius: 11 + ) + ) + context.fill( + Path(ellipseIn: CGRect(x: location.x - 3.5, y: location.y - 3.5, width: 7, height: 7)), + with: .color(.white) + ) + context.stroke( + Path(ellipseIn: CGRect(x: location.x - 5.5, y: location.y - 5.5, width: 11, height: 11)), + with: .color(accent), + lineWidth: 1.5 + ) + } +} diff --git a/swift/LCTVision/LCTVision/ControlDeck.swift b/swift/LCTVision/LCTVision/ControlDeck.swift new file mode 100644 index 0000000..66b961b --- /dev/null +++ b/swift/LCTVision/LCTVision/ControlDeck.swift @@ -0,0 +1,113 @@ +import LCTSpatial +import SwiftUI + +struct ControlDeck: View { + @EnvironmentObject private var model: LCTStudioModel + + private let accents: [SL2CCoefficient: Color] = [ + .a: .mint, + .b: .cyan, + .c: .purple, + .d: .orange, + ] + + var body: some View { + VStack(spacing: 10) { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("SL(2,C) SCRUB DECK") + .font(.caption.monospaced().weight(.bold)) + .foregroundStyle(.white) + HStack(spacing: 6) { + Circle() + .fill(model.hasMetal ? Color.mint : .yellow) + .frame(width: 6, height: 6) + Text(model.hasMetal ? "METAL FIELD ONLINE" : "CPU REFERENCE") + .font(.system(size: 9, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + Spacer() + determinantReadout + } + + HStack(spacing: 9) { + ForEach(SL2CCoefficient.allCases, id: \.self) { coefficient in + ArgandPad( + coefficient: coefficient, + value: model.value(for: coefficient), + isDependent: model.dependent == coefficient, + range: 2, + accent: accents[coefficient] ?? .white + ) { value in + model.set(coefficient, to: value) + } + } + } + + HStack(spacing: 8) { + Button { + model.togglePlayback() + } label: { + Label( + model.isPlaying ? "Pause" : "Orbit", + systemImage: model.isPlaying ? "pause.fill" : "play.fill") + } + .tint(model.isPlaying ? .orange : .mint) + + ForEach(LCTPreset.allCases) { preset in + Button(preset.title) { model.apply(preset) } + .tint(model.selectedPreset == preset ? .cyan : .gray) + } + + Divider().frame(height: 22) + + Button { + model.presentsImporter = true + } label: { + Label(model.isImporting ? "Sampling…" : "Import OBJ", systemImage: "cube.transparent") + } + .disabled(model.isImporting) + + Menu("\(model.resolution)³") { + ForEach([10, 14, 18, 22], id: \.self) { resolution in + Button("\(resolution)³ field") { model.rebuildResolution(resolution) } + } + } + + Toggle("Imaginary ghost", isOn: $model.showsImaginary) + .toggleStyle(.button) + .tint(.indigo) + } + .font(.caption.weight(.medium)) + .buttonStyle(.bordered) + .controlSize(.small) + } + .padding(13) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 22, style: .continuous) + .stroke(.white.opacity(0.14), lineWidth: 0.7) + } + .frame(maxWidth: 650) + } + + private var determinantReadout: some View { + HStack(spacing: 8) { + VStack(alignment: .trailing, spacing: 1) { + Text("UNIMODULAR LOCK") + .font(.system(size: 8, weight: .bold, design: .monospaced)) + .foregroundStyle(.mint) + Text("det = \(model.determinant.description)") + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundStyle(.white.opacity(0.84)) + } + Image(systemName: "lock.fill") + .font(.caption) + .foregroundStyle(.mint) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.mint.opacity(0.08), in: Capsule()) + } +} diff --git a/swift/LCTVision/LCTVision/DemoGeometry.swift b/swift/LCTVision/LCTVision/DemoGeometry.swift new file mode 100644 index 0000000..df62e23 --- /dev/null +++ b/swift/LCTVision/LCTVision/DemoGeometry.swift @@ -0,0 +1,45 @@ +import LCTSpatial +import simd + +enum DemoGeometry { + static func torus( + majorSegments: Int = 54, + minorSegments: Int = 22, + majorRadius: Float = 0.62, + minorRadius: Float = 0.24 + ) throws -> MeshGeometry { + var positions: [SIMD3] = [] + var normals: [SIMD3] = [] + var indices: [UInt32] = [] + positions.reserveCapacity(majorSegments * minorSegments) + normals.reserveCapacity(positions.capacity) + + for majorIndex in 0..(majorRadius * cos(u), majorRadius * sin(u), 0) + for minorIndex in 0..(cos(u) * cos(v), sin(u) * cos(v), sin(v)) + ) + positions.append(center + minorRadius * normal) + normals.append(normal) + } + } + + for majorIndex in 0.. + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + LCT Vision + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + UIApplicationSceneManifest + + UIApplicationPreferredDefaultSceneSessionRole + UIWindowSceneSessionRoleVolumetricApplication + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + + UILaunchScreen + + + diff --git a/swift/LCTVision/LCTVision/LCTPreset.swift b/swift/LCTVision/LCTVision/LCTPreset.swift new file mode 100644 index 0000000..cdf6925 --- /dev/null +++ b/swift/LCTVision/LCTVision/LCTPreset.swift @@ -0,0 +1,36 @@ +import LCTSpatial + +enum LCTPreset: String, CaseIterable, Identifiable { + case identity + case fourier + case fractional + case fresnel + case complexLens + + var id: Self { self } + + var title: String { + switch self { + case .identity: "Identity" + case .fourier: "Fourier" + case .fractional: "FrFT" + case .fresnel: "Fresnel" + case .complexLens: "Complex lens" + } + } + + var matrix: SL2CMatrix { + switch self { + case .identity: + .identity + case .fourier: + .fourier + case .fractional: + .fractionalFourier(angle: .pi / 4) + case .fresnel: + .fresnel(distance: .init(real: 0.72)) + case .complexLens: + .fresnel(distance: .init(real: 0.72, imaginary: 0.16)) + } + } +} diff --git a/swift/LCTVision/LCTVision/LCTStudioModel.swift b/swift/LCTVision/LCTVision/LCTStudioModel.swift new file mode 100644 index 0000000..e3a3d52 --- /dev/null +++ b/swift/LCTVision/LCTVision/LCTStudioModel.swift @@ -0,0 +1,179 @@ +import Combine +import Foundation +import LCTSpatial +import LCTSpatialMetal + +@MainActor +final class LCTStudioModel: ObservableObject { + @Published private(set) var matrix = SL2CMatrix.identity + @Published private(set) var dependent: SL2CCoefficient = .d + @Published private(set) var outputField: ComplexField + @Published private(set) var sourceGeometry: MeshGeometry + @Published private(set) var sourceName = "Torus study" + @Published private(set) var isTransforming = false + @Published private(set) var status = "Ready · sampled-field LCT" + @Published private(set) var selectedPreset: LCTPreset? = .identity + @Published private(set) var isPlaying = false + @Published var showsImaginary = true + @Published var presentsImporter = false + @Published private(set) var isImporting = false + @Published var resolution = 14 + + private var constraint = SL2CConstraint() + private var sourceField: ComplexField + private let transformer: MetalLCTTransformer? + private var transformTask: Task? + private var playbackTask: Task? + private var generation = 0 + + init() { + let geometry = try! DemoGeometry.torus() + let field = try! MeshVoxelizer.surfaceField(from: geometry, resolution: 14) + sourceGeometry = geometry + sourceField = field + outputField = field + transformer = try? MetalLCTTransformer() + } + + var determinant: Complex32 { matrix.determinant } + var hasMetal: Bool { transformer != nil } + + func value(for coefficient: SL2CCoefficient) -> Complex32 { + matrix[coefficient] + } + + func set(_ coefficient: SL2CCoefficient, to value: Complex32) { + stopPlayback() + do { + matrix = try constraint.set(coefficient, to: value) + dependent = constraint.dependent + selectedPreset = nil + status = "Scrubbing \(coefficient.rawValue) · det locked" + scheduleTransform() + } catch { + status = "No stable unimodular pivot" + } + } + + func apply(_ preset: LCTPreset) { + stopPlayback() + let preferredDependent: SL2CCoefficient = preset == .fourier ? .c : .d + constraint.replace(with: preset.matrix, dependent: preferredDependent) + matrix = preset.matrix + dependent = preferredDependent + selectedPreset = preset + status = "\(preset.title) preset" + scheduleTransform(immediate: true) + } + + func rebuildResolution(_ newResolution: Int) { + resolution = newResolution + status = "Sampling \(newResolution)³ field…" + let geometry = sourceGeometry + Task { + do { + let field = try await Task.detached { + try MeshVoxelizer.surfaceField(from: geometry, resolution: newResolution) + }.value + sourceField = field + scheduleTransform(immediate: true) + } catch { + status = "Sampling failed: \(error.localizedDescription)" + } + } + } + + func togglePlayback() { + if isPlaying { + stopPlayback() + status = "Scrub animation paused" + scheduleTransform(immediate: true) + return + } + + isPlaying = true + selectedPreset = .fractional + status = "FrFT orbit · live" + playbackTask = Task { + var frame = 0 + while !Task.isCancelled { + let angle = 2 * Float.pi * Float(frame % 96) / 96 + let next = SL2CMatrix.fractionalFourier(angle: angle) + constraint.replace(with: next, dependent: .d) + matrix = next + dependent = .d + scheduleTransform(immediate: true) + frame += 1 + try? await Task.sleep(for: .milliseconds(90)) + } + } + } + + func importOBJ(from url: URL) { + stopPlayback() + isImporting = true + status = "Importing \(url.lastPathComponent)…" + let resolution = resolution + Task { + let scoped = url.startAccessingSecurityScopedResource() + defer { + if scoped { url.stopAccessingSecurityScopedResource() } + } + do { + let result = try await Task.detached { () -> (MeshGeometry, ComplexField) in + let geometry = try OBJMeshLoader.load(contentsOf: url) + let field = try MeshVoxelizer.surfaceField(from: geometry, resolution: resolution) + return (geometry, field) + }.value + sourceGeometry = result.0 + sourceField = result.1 + sourceName = url.deletingPathExtension().lastPathComponent + isImporting = false + status = "Imported \(url.lastPathComponent)" + scheduleTransform(immediate: true) + } catch { + isImporting = false + status = "Import failed: \(error.localizedDescription)" + } + } + } + + private func stopPlayback() { + playbackTask?.cancel() + playbackTask = nil + isPlaying = false + } + + private func scheduleTransform(immediate: Bool = false) { + generation += 1 + let requestedGeneration = generation + transformTask?.cancel() + let field = sourceField + let matrix = matrix + let transformer = transformer + isTransforming = true + + transformTask = Task { + if !immediate { + try? await Task.sleep(for: .milliseconds(42)) + } + guard !Task.isCancelled else { return } + do { + let transformed = try await Task.detached { + if let transformer { + return try transformer.transform(field, matrix: matrix) + } + return try DiscreteLCT.transform(field, matrix: matrix) + }.value + guard !Task.isCancelled, requestedGeneration == generation else { return } + outputField = transformed + isTransforming = false + status = transformer == nil ? "CPU reference · live" : "Metal field · live" + } catch { + guard requestedGeneration == generation else { return } + isTransforming = false + status = "Transform unavailable: \(error.localizedDescription)" + } + } + } +} diff --git a/swift/LCTVision/LCTVision/LCTVisionApp.swift b/swift/LCTVision/LCTVision/LCTVisionApp.swift new file mode 100644 index 0000000..1916739 --- /dev/null +++ b/swift/LCTVision/LCTVision/LCTVisionApp.swift @@ -0,0 +1,15 @@ +import SwiftUI + +@main +struct LCTVisionApp: App { + @StateObject private var model = LCTStudioModel() + + var body: some Scene { + WindowGroup { + StudioView() + .environmentObject(model) + } + .windowStyle(.volumetric) + .defaultSize(width: 1.32, height: 0.86, depth: 0.72, in: .meters) + } +} diff --git a/swift/LCTVision/LCTVision/SpatialScene.swift b/swift/LCTVision/LCTVision/SpatialScene.swift new file mode 100644 index 0000000..a5c6ea9 --- /dev/null +++ b/swift/LCTVision/LCTVision/SpatialScene.swift @@ -0,0 +1,92 @@ +import LCTSpatial +import LCTSpatialMetal +import RealityKit +import UIKit + +@MainActor +final class SpatialScene: ObservableObject { + let root = Entity() + + private var preview: MetalCanonicalPreview? + private var previewEntity: ModelEntity? + private var spectralCloud: SpectralCloud? + private var geometrySignature = 0 + + func install(geometry: MeshGeometry, matrix: SL2CMatrix, outputField: ComplexField) throws { + root.children.removeAll() + geometrySignature = signature(for: geometry) + + let preview = try MetalCanonicalPreview(geometry: geometry) + let materials = makePreviewMaterials() + let previewEntity = ModelEntity(mesh: preview.meshResource, materials: materials) + previewEntity.position = SIMD3(-0.31, 0.03, 0) + previewEntity.scale = SIMD3(repeating: 0.36) + previewEntity.name = "Canonical preview" + root.addChild(previewEntity) + + let cloud = SpectralCloud() + cloud.root.position = SIMD3(0.31, 0.03, 0) + cloud.update(field: outputField) + root.addChild(cloud.root) + + addAxisFrame(at: SIMD3(-0.31, 0.03, 0), color: .systemTeal) + addAxisFrame(at: SIMD3(0.31, 0.03, 0), color: .systemIndigo) + + self.preview = preview + self.previewEntity = previewEntity + spectralCloud = cloud + try preview.update(matrix: matrix) + } + + func update( + geometry: MeshGeometry, + matrix: SL2CMatrix, + field: ComplexField, + showsImaginary: Bool + ) { + if signature(for: geometry) != geometrySignature { + try? install(geometry: geometry, matrix: matrix, outputField: field) + } + try? preview?.update(matrix: matrix, showsImaginary: showsImaginary) + spectralCloud?.update(field: field) + } + + private func makePreviewMaterials() -> [any Material] { + var real = SimpleMaterial() + real.color = .init(tint: UIColor(red: 0.17, green: 0.95, blue: 0.78, alpha: 0.96)) + real.roughness = 0.28 + real.metallic = 0.18 + + var imaginary = UnlitMaterial() + imaginary.color = .init(tint: UIColor(red: 0.38, green: 0.52, blue: 1, alpha: 0.25)) + imaginary.blending = .transparent(opacity: 0.28) + return [real, imaginary] + } + + private func addAxisFrame(at origin: SIMD3, color: UIColor) { + var material = UnlitMaterial() + material.color = .init(tint: color.withAlphaComponent(0.38)) + let dimensions: [(SIMD3, SIMD3)] = [ + (SIMD3(0.52, 0.0015, 0.0015), SIMD3(0, -0.29, 0)), + (SIMD3(0.0015, 0.52, 0.0015), SIMD3(-0.29, 0, 0)), + (SIMD3(0.0015, 0.0015, 0.52), SIMD3(-0.29, -0.29, 0)), + ] + for (size, offset) in dimensions { + let axis = ModelEntity(mesh: .generateBox(size: size), materials: [material]) + axis.position = origin + offset + root.addChild(axis) + } + } + + private func signature(for geometry: MeshGeometry) -> Int { + var hasher = Hasher() + hasher.combine(geometry.positions.count) + hasher.combine(geometry.indices.count) + if let first = geometry.positions.first { + hasher.combine(first.x) + hasher.combine(first.y) + hasher.combine(first.z) + } + return hasher.finalize() + } +} diff --git a/swift/LCTVision/LCTVision/SpectralCloud.swift b/swift/LCTVision/LCTVision/SpectralCloud.swift new file mode 100644 index 0000000..768aa61 --- /dev/null +++ b/swift/LCTVision/LCTVision/SpectralCloud.swift @@ -0,0 +1,93 @@ +import LCTSpatial +import RealityKit +import UIKit + +@MainActor +final class SpectralCloud { + let root = Entity() + + private let points: [ModelEntity] + private let materials: [UnlitMaterial] + private let maximumPoints: Int + private var signature: Int = 0 + + init(maximumPoints: Int = 520, phaseBins: Int = 18) { + self.maximumPoints = maximumPoints + let sphere = MeshResource.generateSphere(radius: 0.008) + let phaseMaterials = (0.. maximum * 0.065 } + .sorted { $0.element.magnitude > $1.element.magnitude } + .prefix(maximumPoints) + let shape = field.shape + guard shape.count == 3 else { return } + + for (displayIndex, sample) in ranked.enumerated() { + let point = points[displayIndex] + let flat = sample.offset + let yz = shape[1] * shape[2] + let x = flat / yz + let y = (flat % yz) / shape[2] + let z = flat % shape[2] + let coordinate = SIMD3( + normalizedCoordinate(x, length: shape[0]), + normalizedCoordinate(y, length: shape[1]), + normalizedCoordinate(z, length: shape[2]) + ) + let magnitude = pow(sample.element.magnitude / maximum, 0.55) + let phaseUnit = (sample.element.phase + .pi) / (2 * .pi) + let materialIndex = min( + materials.count - 1, + max(0, Int(phaseUnit * Float(materials.count))) + ) + point.position = coordinate * 0.58 + point.scale = SIMD3(repeating: 0.55 + 1.55 * magnitude) + point.model?.materials = [materials[materialIndex]] + point.isEnabled = true + } + + for point in points.dropFirst(ranked.count) { + point.isEnabled = false + } + } + + private func normalizedCoordinate(_ index: Int, length: Int) -> Float { + guard length > 1 else { return 0 } + return Float(index) / Float(length - 1) - 0.5 + } +} diff --git a/swift/LCTVision/LCTVision/StudioView.swift b/swift/LCTVision/LCTVision/StudioView.swift new file mode 100644 index 0000000..aac427c --- /dev/null +++ b/swift/LCTVision/LCTVision/StudioView.swift @@ -0,0 +1,92 @@ +import RealityKit +import SwiftUI +import UniformTypeIdentifiers + +struct StudioView: View { + @EnvironmentObject private var model: LCTStudioModel + @StateObject private var scene = SpatialScene() + + var body: some View { + ZStack { + RealityView { content in + try? scene.install( + geometry: model.sourceGeometry, + matrix: model.matrix, + outputField: model.outputField + ) + content.add(scene.root) + } update: { _ in + scene.update( + geometry: model.sourceGeometry, + matrix: model.matrix, + field: model.outputField, + showsImaginary: model.showsImaginary + ) + } + + VStack(spacing: 0) { + header + HStack { + viewLabel("CANONICAL MAP", detail: "instant preview", color: .mint) + Spacer() + viewLabel("SAMPLED FIELD", detail: "amplitude × phase", color: .indigo) + } + .padding(.horizontal, 34) + .padding(.top, 12) + Spacer() + ControlDeck() + .padding(.bottom, 30) + } + .padding(20) + } + .fileImporter( + isPresented: $model.presentsImporter, + allowedContentTypes: [UTType(filenameExtension: "obj")!], + allowsMultipleSelection: false + ) { result in + guard case .success(let urls) = result, let url = urls.first else { return } + model.importOBJ(from: url) + } + } + + private func viewLabel(_ title: String, detail: String, color: Color) -> some View { + HStack(spacing: 7) { + RoundedRectangle(cornerRadius: 1) + .fill(color) + .frame(width: 3, height: 25) + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(.system(size: 9, weight: .bold, design: .monospaced)) + .foregroundStyle(color) + Text(detail) + .font(.system(size: 8, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + } + + private var header: some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text("LCT // SPATIAL ATLAS") + .font(.headline.monospaced().weight(.semibold)) + Text("canonical preview") + .foregroundStyle(.mint) + + Text(" · ") + .foregroundStyle(.secondary) + + Text("sampled-field transform") + .foregroundStyle(.indigo) + } + Spacer() + VStack(alignment: .trailing, spacing: 4) { + Text(model.sourceName) + .font(.subheadline.weight(.medium)) + Text(model.status) + .font(.caption.monospaced()) + .foregroundStyle(model.isTransforming ? .yellow : .secondary) + } + } + .padding(14) + .glassBackgroundEffect() + } +} diff --git a/swift/LCTVision/README.md b/swift/LCTVision/README.md new file mode 100644 index 0000000..0323525 --- /dev/null +++ b/swift/LCTVision/README.md @@ -0,0 +1,32 @@ +# LCT Vision + +A volumetric visionOS instrument for scrubbing an `SL(2,C)` LCT while comparing +two deliberately distinct views: + +- the left mesh is a zero-latency canonical-map preview; +- the right phase-colored cloud is the actual separable transform of a sampled + three-dimensional complex field. + +Generate the Xcode project and build for the installed simulator: + +```sh +xcodegen generate +xcodebuild \ + -project LCTVision.xcodeproj \ + -scheme LCTVision \ + -sdk xrsimulator \ + -destination 'platform=visionOS Simulator,name=Apple Vision Pro,OS=26.5' \ + CODE_SIGNING_ALLOWED=NO \ + build +``` + +Open `LCTVision.xcodeproj`, select an Apple Vision Pro destination, and run the +`LCTVision` scheme. The app starts with a front-facing torus study. Drag in any +of the four complex planes to update that coefficient; the orange `AUTO` chip +marks the coefficient currently solved to keep `ad - bc = 1`. Presets and the +continuous FrFT orbit are available along the bottom of the deck. + +The app imports Wavefront OBJ meshes, normalizes them into its study volume, +samples their triangle surfaces, and sends the field through `LCTSpatialMetal`. +The Swift library itself accepts any triangular `MeshGeometry`; OBJ is only the +sample app's current document-picker format. diff --git a/swift/LCTVision/project.yml b/swift/LCTVision/project.yml new file mode 100644 index 0000000..77c51f5 --- /dev/null +++ b/swift/LCTVision/project.yml @@ -0,0 +1,50 @@ +name: LCTVision + +options: + bundleIdPrefix: com.aloksingh + deploymentTarget: + visionOS: "2.0" + createIntermediateGroups: true + +packages: + LCTSpatial: + path: ../LCTSpatial + +targets: + LCTVision: + type: application + platform: visionOS + info: + path: LCTVision/Info.plist + properties: + CFBundleDisplayName: LCT Vision + UIApplicationSceneManifest: + UIApplicationPreferredDefaultSceneSessionRole: UIWindowSceneSessionRoleVolumetricApplication + UIApplicationSupportsMultipleScenes: true + UISceneConfigurations: {} + UILaunchScreen: {} + sources: + - path: LCTVision + dependencies: + - package: LCTSpatial + product: LCTSpatial + - package: LCTSpatial + product: LCTSpatialMetal + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.aloksingh.lctvision + PRODUCT_NAME: LCT Vision + MARKETING_VERSION: 0.1.0 + CURRENT_PROJECT_VERSION: 1 + SWIFT_VERSION: 6.0 + GENERATE_INFOPLIST_FILE: NO + TARGETED_DEVICE_FAMILY: 7 + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO + +schemes: + LCTVision: + build: + targets: + LCTVision: all + run: + config: Debug