From 71b42a85e3d22caddce63176af0756b504670836 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Sun, 14 Jun 2026 23:13:17 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(#248):=20=E5=A3=81=E7=B4=99=E3=83=AD?= =?UTF-8?q?=E3=83=BC=E3=83=87=E3=82=A3=E3=83=B3=E3=82=B0=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E3=82=B8=E3=82=B1=E3=83=BC=E3=82=BF=E3=82=92=E3=82=B4=E3=83=BC?= =?UTF-8?q?=E3=83=AB=E3=83=89=E3=82=BD=E3=83=8A=E3=83=BC=E3=83=AA=E3=83=B3?= =?UTF-8?q?=E3=82=B0=E3=81=AB=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit プレーンな Progress(.circular) を、Lyra の ripple と歌詞ハイライトの ゴールドグラデーションを踏襲した「ソナーリング」インジケータに置き換え。 - Canvas + TimelineView(.animation) で GPU 駆動描画(RippleView と同方式) - 同心リングが core から rim へ拡散しながらフェード(明滅は sin で対称) - ダークハロー + ゴールドグラデの二重ストローク、frosted disc 背景 + 影で、明るい壁紙でも暗い壁紙でも視認可能 - #252 の条件付きツリー除去(showLoadingIndicator==false で完全に消す)を維持。 accessibilityIdentifier / allowsHitTesting(false) も維持 - 中央配置で top-leading の歌詞領域を侵さない - 出現/消滅は scale+opacity でフェード - 明暗 2 背景の #Preview を追加 --- Sources/VersionHandler/Resources/version.txt | 2 +- .../Views/Overlay/OverlayContentView.swift | 170 ++++++++++++++++-- 2 files changed, 160 insertions(+), 12 deletions(-) diff --git a/Sources/VersionHandler/Resources/version.txt b/Sources/VersionHandler/Resources/version.txt index 8c3aaee..af5475d 100644 --- a/Sources/VersionHandler/Resources/version.txt +++ b/Sources/VersionHandler/Resources/version.txt @@ -1 +1 @@ -2.13.20 +2.13.21 diff --git a/Sources/Views/Overlay/OverlayContentView.swift b/Sources/Views/Overlay/OverlayContentView.swift index 3ea35ad..56e2bf7 100644 --- a/Sources/Views/Overlay/OverlayContentView.swift +++ b/Sources/Views/Overlay/OverlayContentView.swift @@ -1,3 +1,4 @@ +import Foundation import Presenters import SwiftUI @@ -41,19 +42,145 @@ private struct WallpaperLoadingOverlay: View { @ObservedObject var presenter: WallpaperPresenter var body: some View { - // Conditional inclusion (not `.opacity(0)`) so the spinner is removed - // from the view tree when hidden. A `ProgressView` kept in the tree - // drives a continuous CADisplayLink animation even while invisible, - // which idle-burns CPU/GPU on every machine running lyra (#252). - if presenter.showLoadingIndicator { - ProgressView() - .progressViewStyle(.circular) - .controlSize(.large) - .tint(.white) - .accessibilityIdentifier("wallpaper-loading-indicator") - .allowsHitTesting(false) + // The static `ZStack` host is always in the tree, but the animated + // `SonarLoadingIndicator` (a `TimelineView` driving a per-frame Canvas) + // is included only while loading. Conditional inclusion — not + // `.opacity(0)` — is mandatory: an invisible-but-present timeline keeps + // redrawing every frame and idle-burns CPU/GPU on every machine running + // lyra (#252). The host is removed-when-hidden in spirit: nothing inside + // animates until the indicator is inserted. + ZStack { + if presenter.showLoadingIndicator { + SonarLoadingIndicator() + // Centered in the overlay, clear of the top-leading lyrics + // (48pt inset), so it never fights the lyric column. + .accessibilityIdentifier("wallpaper-loading-indicator") + .allowsHitTesting(false) + .transition(.scale(scale: 0.82).combined(with: .opacity)) + } + } + .animation(.easeInOut(duration: 0.4), value: presenter.showLoadingIndicator) + } +} + +// MARK: - Sonar loading indicator + +/// Indeterminate loading indicator in Lyra's visual language: gold sonar rings +/// expand and fade outward from a pulsing core, echoing the ripple effect and +/// the gold-gradient lyric highlight. Rendered on a `Canvas` driven by +/// `TimelineView(.animation)` so the motion is GPU-driven rather than +/// timer-driven, matching `RippleView`. The whole view exists only while the +/// download is in flight (see `WallpaperLoadingOverlay`), so there is no idle +/// timeline to pause here. +private struct SonarLoadingIndicator: View { + var body: some View { + TimelineView(.animation) { timeline in + Canvas { context, size in + draw(&context, size: size, time: timeline.date.timeIntervalSinceReferenceDate) + } + } + .frame(width: SonarMetrics.diameter, height: SonarMetrics.diameter) + .background(backing) + } + + /// Frosted disc + faint rim + drop shadow. This self-contained contrast + /// backing is what lets the rings read over BOTH a very bright and a very + /// dark wallpaper instead of relying on a single fixed tint. + private var backing: some View { + Circle() + .fill(.ultraThinMaterial) + .overlay { + Circle().strokeBorder(.white.opacity(0.18), lineWidth: 1) + } + .shadow(color: .black.opacity(0.4), radius: 14, y: 6) + } + + private func draw(_ context: inout GraphicsContext, size: CGSize, time: TimeInterval) { + let center = CGPoint(x: size.width / 2, y: size.height / 2) + let maxRadius = size.width / 2 - SonarMetrics.rimInset + drawSonarRings(&context, center: center, maxRadius: maxRadius, time: time) + drawCore(&context, center: center, time: time) + } + + /// Concentric rings, each offset in phase, expanding from the core to the + /// rim. Each ring is drawn twice — a soft dark halo underneath and a gold + /// gradient core on top — so it stays legible against bright backgrounds. + private func drawSonarRings( + _ context: inout GraphicsContext, center: CGPoint, maxRadius: CGFloat, time: TimeInterval + ) { + for index in 0.. Gradient { + Gradient(stops: stops.map { Gradient.Stop(color: $0.color.opacity(alpha), location: $0.location) }) + } } #if DEBUG @@ -67,4 +194,25 @@ private struct WallpaperLoadingOverlay: View { .frame(width: 800, height: 500) .background(.black) } + + #Preview("Loading Indicator") { + // Side-by-side bright / dark stand-in wallpapers to judge that the + // indicator reads clearly against either extreme. + HStack(spacing: 0) { + ZStack { + LinearGradient( + colors: [.white, Color(red: 0.95, green: 0.92, blue: 0.80)], + startPoint: .top, endPoint: .bottom) + SonarLoadingIndicator() + } + ZStack { + LinearGradient( + colors: [.black, Color(red: 0.10, green: 0.10, blue: 0.16)], + startPoint: .top, endPoint: .bottom) + SonarLoadingIndicator() + } + } + .frame(width: 520, height: 320) + .ignoresSafeArea() + } #endif From b5274ad13c3485ec802a6f90d4f0dfc23816a995 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Mon, 15 Jun 2026 00:44:03 +0900 Subject: [PATCH 2/3] feat(#248): rotating gold geodesic sphere wallpaper loading indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the gold sonar rings with a slowly rotating gold geodesic sphere — a Goldberg polyhedron (12 pentagons + 30 hexagons, "a soccer ball with a few extra faces"), the dual of a freq-2 icosphere. The thin gold wireframe is depth-cued (near struts brighter/thicker, far ones fainter/thinner) and each strut carries a soft dark halo so it reads over both bright and dark wallpapers WITHOUT any backing disc. A subtle "Downloading wallpaper" caption sits below the sphere — thin, letter-spaced gold with a dark shadow, telling the user what the wait is for without pulling focus from the wireframe. The wireframe geometry is rotation-independent, so it is built once (GeodesicGeometry.edges) and only the per-frame projection changes (#252 spirit). Conditional inclusion (not .opacity(0)) is preserved so no idle TimelineView burns GPU when not loading. GeodesicGeometry / Vertex3D are exposed as internal (not private) so the pure geometry is unit-tested via @testable import Views: exactly 120 dual edges, every endpoint on the unit sphere, no degenerate struts. --- .../Views/Overlay/OverlayContentView.swift | 278 ++++++++++++------ Tests/ViewsTests/GeodesicGeometryTests.swift | 41 +++ 2 files changed, 222 insertions(+), 97 deletions(-) create mode 100644 Tests/ViewsTests/GeodesicGeometryTests.swift diff --git a/Sources/Views/Overlay/OverlayContentView.swift b/Sources/Views/Overlay/OverlayContentView.swift index 56e2bf7..c3d64ae 100644 --- a/Sources/Views/Overlay/OverlayContentView.swift +++ b/Sources/Views/Overlay/OverlayContentView.swift @@ -43,7 +43,7 @@ private struct WallpaperLoadingOverlay: View { var body: some View { // The static `ZStack` host is always in the tree, but the animated - // `SonarLoadingIndicator` (a `TimelineView` driving a per-frame Canvas) + // `GeodesicLoadingIndicator` (a `TimelineView` driving a per-frame Canvas) // is included only while loading. Conditional inclusion — not // `.opacity(0)` — is mandatory: an invisible-but-present timeline keeps // redrawing every frame and idle-burns CPU/GPU on every machine running @@ -51,7 +51,7 @@ private struct WallpaperLoadingOverlay: View { // animates until the indicator is inserted. ZStack { if presenter.showLoadingIndicator { - SonarLoadingIndicator() + LoadingIndicatorContent() // Centered in the overlay, clear of the top-leading lyrics // (48pt inset), so it never fights the lyric column. .accessibilityIdentifier("wallpaper-loading-indicator") @@ -63,123 +63,207 @@ private struct WallpaperLoadingOverlay: View { } } -// MARK: - Sonar loading indicator +/// The loading indicator's on-screen content: the rotating gold sphere with a +/// subtle caption beneath it naming what the wait is for. Composed once and +/// reused by both the live overlay and the SwiftUI preview. +private struct LoadingIndicatorContent: View { + var body: some View { + VStack(spacing: 20) { // breathing room between the sphere and its caption + GeodesicLoadingIndicator() + LoadingCaption() + } + } +} + +/// Subtle caption under the sphere telling the user what the wait is for. Thin, +/// letter-spaced gold with a soft dark shadow so it stays legible over BOTH a +/// bright and a dark wallpaper (mirrors the sphere's dark-halo strategy) without +/// pulling focus from the rotating wireframe. +private struct LoadingCaption: View { + var body: some View { + Text("Downloading wallpaper") + .font(.system(size: 12, weight: .medium)) + .tracking(2.5) + .foregroundStyle(GeodesicGold.bright.opacity(0.9)) + .shadow(color: .black.opacity(0.55), radius: 3, y: 1) + } +} + +// MARK: - Geodesic loading indicator -/// Indeterminate loading indicator in Lyra's visual language: gold sonar rings -/// expand and fade outward from a pulsing core, echoing the ripple effect and -/// the gold-gradient lyric highlight. Rendered on a `Canvas` driven by -/// `TimelineView(.animation)` so the motion is GPU-driven rather than +/// Indeterminate loading indicator in Lyra's visual language: a gold geodesic +/// sphere — a Goldberg polyhedron (12 pentagons + 30 hexagons, a soccer ball +/// with a few extra faces) — slowly rotating in 3D. Rendered on a `Canvas` +/// driven by `TimelineView(.animation)` so the motion is GPU-driven rather than /// timer-driven, matching `RippleView`. The whole view exists only while the /// download is in flight (see `WallpaperLoadingOverlay`), so there is no idle -/// timeline to pause here. -private struct SonarLoadingIndicator: View { +/// timeline to pause here. The wireframe geometry is rotation-independent and +/// built a single time (`GeodesicGeometry.edges`); only the projection changes +/// per frame. +private struct GeodesicLoadingIndicator: View { var body: some View { TimelineView(.animation) { timeline in Canvas { context, size in draw(&context, size: size, time: timeline.date.timeIntervalSinceReferenceDate) } } - .frame(width: SonarMetrics.diameter, height: SonarMetrics.diameter) - .background(backing) - } - - /// Frosted disc + faint rim + drop shadow. This self-contained contrast - /// backing is what lets the rings read over BOTH a very bright and a very - /// dark wallpaper instead of relying on a single fixed tint. - private var backing: some View { - Circle() - .fill(.ultraThinMaterial) - .overlay { - Circle().strokeBorder(.white.opacity(0.18), lineWidth: 1) - } - .shadow(color: .black.opacity(0.4), radius: 14, y: 6) + .frame(width: GeodesicMetrics.diameter, height: GeodesicMetrics.diameter) } private func draw(_ context: inout GraphicsContext, size: CGSize, time: TimeInterval) { let center = CGPoint(x: size.width / 2, y: size.height / 2) - let maxRadius = size.width / 2 - SonarMetrics.rimInset - drawSonarRings(&context, center: center, maxRadius: maxRadius, time: time) - drawCore(&context, center: center, time: time) + let radius = size.width / 2 - GeodesicMetrics.rimInset + let angle = time * GeodesicMetrics.spinRate + // Project every edge, then paint back-to-front so the near panels of the + // sphere sit on top of the far ones (cheap painter's-algorithm depth). + let edges = + GeodesicGeometry.edges + .map { + ( + project($0.0, center: center, radius: radius, angle: angle), + project($0.1, center: center, radius: radius, angle: angle) + ) + } + .sorted { ($0.0.depth + $0.1.depth) < ($1.0.depth + $1.1.depth) } + for (p, q) in edges { + drawEdge(&context, p: p, q: q) + } } - /// Concentric rings, each offset in phase, expanding from the core to the - /// rim. Each ring is drawn twice — a soft dark halo underneath and a gold - /// gradient core on top — so it stays legible against bright backgrounds. - private func drawSonarRings( - _ context: inout GraphicsContext, center: CGPoint, maxRadius: CGFloat, time: TimeInterval - ) { - for index in 0.. 0). + private func project(_ v: Vertex3D, center: CGPoint, radius: CGFloat, angle: Double) + -> (point: CGPoint, depth: Double) + { + let x1 = v.x * cos(angle) + v.z * sin(angle) + let z1 = -v.x * sin(angle) + v.z * cos(angle) + let y2 = v.y * cos(GeodesicMetrics.tilt) - z1 * sin(GeodesicMetrics.tilt) + let z2 = v.y * sin(GeodesicMetrics.tilt) + z1 * cos(GeodesicMetrics.tilt) + return ( + CGPoint(x: center.x + radius * CGFloat(x1), y: center.y - radius * CGFloat(y2)), z2 + ) } - /// Pulsing gold core — the "sound source" the rings emanate from. - private func drawCore(_ context: inout GraphicsContext, center: CGPoint, time: TimeInterval) { - let pulse = 0.85 + 0.15 * sin(time * 2 * .pi / SonarMetrics.corePulsePeriod) - let radius = SonarMetrics.coreRadius * CGFloat(pulse) - let rect = CGRect( - x: center.x - radius, y: center.y - radius, width: radius * 2, height: radius * 2) - let dot = Path(ellipseIn: rect) - context.fill( - dot, - with: .radialGradient( - GoldSonar.core, center: center, startRadius: 0, endRadius: radius)) + /// A single strut. Each is drawn twice — a soft dark halo underneath and the + /// gold line on top — so it stays legible over BOTH a bright and a dark + /// wallpaper without any backing disc (#248). Far struts are thinner and + /// fainter, near struts thicker and brighter, giving the wireframe depth. + private func drawEdge( + _ context: inout GraphicsContext, + p: (point: CGPoint, depth: Double), q: (point: CGPoint, depth: Double) + ) { + let depth = ((p.depth + q.depth) / 2 + 1) / 2 // 0 far … 1 near + let alpha = 0.18 + depth * 0.82 + let lineWidth = GeodesicMetrics.minLineWidth + CGFloat(depth) * GeodesicMetrics.lineWidthRange + var path = Path() + path.move(to: p.point) + path.addLine(to: q.point) + context.stroke( + path, with: .color(.black.opacity(0.16 + depth * 0.24)), + lineWidth: lineWidth + GeodesicMetrics.haloPadding) + context.stroke( + path, + with: .color((depth > 0.5 ? GeodesicGold.bright : GeodesicGold.mid).opacity(alpha)), + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)) } } -private enum SonarMetrics { - static let diameter: CGFloat = 120 +private enum GeodesicMetrics { + static let diameter: CGFloat = 196 static let rimInset: CGFloat = 10 - static let coreRadius: CGFloat = 11 - static let ringCount = 3 - static let ringPeriod: Double = 2.4 - static let ringLineWidth: CGFloat = 2.5 - static let haloLineWidth: CGFloat = 5 - static let corePulsePeriod: Double = 1.7 + static let tilt: Double = 0.42 // radians — fixed 3/4 view + static let spinRate: Double = 2.0 // radians/sec (≈3.1 s per turn) + static let minLineWidth: CGFloat = 0.5 + static let lineWidthRange: CGFloat = 1.1 + static let haloPadding: CGFloat = 1.1 } -/// Gold palette mirrored from the lyric-highlight gradient +/// Two solid gold tones mirrored from the lyric-highlight gradient /// (`#B8942D → #EDCF73 → #FFEB99 → #CCA64D → #A68038`) so the indicator shares -/// Lyra's signature gold identity. -private enum GoldSonar { - static let stops: [Gradient.Stop] = [ - .init(color: Color(red: 0.722, green: 0.580, blue: 0.176), location: 0.00), - .init(color: Color(red: 0.929, green: 0.812, blue: 0.451), location: 0.30), - .init(color: Color(red: 1.000, green: 0.922, blue: 0.600), location: 0.55), - .init(color: Color(red: 0.800, green: 0.651, blue: 0.302), location: 0.78), - .init(color: Color(red: 0.651, green: 0.502, blue: 0.220), location: 1.00), - ] - - /// Bright warm center fading to deep gold — a glowing core, not a flat dot. - static let core = Gradient(colors: [ - Color(red: 1.000, green: 0.953, blue: 0.780), - Color(red: 1.000, green: 0.922, blue: 0.600), - Color(red: 0.722, green: 0.580, blue: 0.176), - ]) - - /// The gold gradient with a uniform opacity baked into every stop, so a - /// ring can fade as it travels (gradient shadings carry no separate alpha). - static func faded(_ alpha: Double) -> Gradient { - Gradient(stops: stops.map { Gradient.Stop(color: $0.color.opacity(alpha), location: $0.location) }) +/// Lyra's signature gold identity. `bright` is used for near struts, `mid` for +/// far ones. +private enum GeodesicGold { + static let bright = Color(red: 1.000, green: 0.922, blue: 0.600) + static let mid = Color(red: 0.929, green: 0.812, blue: 0.451) +} + +/// A point on the unit sphere. Internal (not `private`) so the pure geometry in +/// `GeodesicGeometry` can be unit-tested via `@testable import Views`. +struct Vertex3D { + let x, y, z: Double +} + +/// Wireframe edges of a gold geodesic sphere. The geometry is the DUAL of a +/// once-subdivided icosphere: start from an icosahedron, subdivide each of its +/// 20 triangles into 4 (an 80-triangle "icosphere"), then connect the centroid +/// of every triangle to its edge-neighbours. The result is a Goldberg +/// polyhedron — 12 pentagons + 30 hexagons, a soccer ball with a few extra +/// faces. Geometry is independent of rotation, so it is built once and reused +/// for every frame. Internal (not `private`) so the edge generation can be +/// unit-tested via `@testable import Views`. +enum GeodesicGeometry { + static let edges: [(Vertex3D, Vertex3D)] = buildEdges() + + private static func normalized(_ v: Vertex3D) -> Vertex3D { + let length = (v.x * v.x + v.y * v.y + v.z * v.z).squareRoot() + return Vertex3D(x: v.x / length, y: v.y / length, z: v.z / length) + } + + /// Order-independent key for an undirected vertex pair. + private static func key(_ a: Int, _ b: Int) -> Int64 { + a < b ? (Int64(a) << 32) | Int64(b) : (Int64(b) << 32) | Int64(a) + } + + private static func buildEdges() -> [(Vertex3D, Vertex3D)] { + let t = (1 + 5.0.squareRoot()) / 2 // golden ratio + var verts: [Vertex3D] = [ + Vertex3D(x: -1, y: t, z: 0), Vertex3D(x: 1, y: t, z: 0), + Vertex3D(x: -1, y: -t, z: 0), Vertex3D(x: 1, y: -t, z: 0), + Vertex3D(x: 0, y: -1, z: t), Vertex3D(x: 0, y: 1, z: t), + Vertex3D(x: 0, y: -1, z: -t), Vertex3D(x: 0, y: 1, z: -t), + Vertex3D(x: t, y: 0, z: -1), Vertex3D(x: t, y: 0, z: 1), + Vertex3D(x: -t, y: 0, z: -1), Vertex3D(x: -t, y: 0, z: 1), + ].map(normalized) + let base: [[Int]] = [ + [0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11], + [1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8], + [3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9], + [4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1], + ] + var cache: [Int64: Int] = [:] + func mid(_ a: Int, _ b: Int) -> Int { + let k = key(a, b) + if let cached = cache[k] { return cached } + let va = verts[a] + let vb = verts[b] + verts.append( + normalized( + Vertex3D(x: (va.x + vb.x) / 2, y: (va.y + vb.y) / 2, z: (va.z + vb.z) / 2))) + cache[k] = verts.count - 1 + return verts.count - 1 + } + let faces = base.flatMap { f -> [[Int]] in + let ab = mid(f[0], f[1]) + let bc = mid(f[1], f[2]) + let ca = mid(f[2], f[0]) + return [[f[0], ab, ca], [f[1], bc, ab], [f[2], ca, bc], [ab, bc, ca]] + } + let centroids = faces.map { f -> Vertex3D in + let a = verts[f[0]] + let b = verts[f[1]] + let c = verts[f[2]] + return normalized( + Vertex3D(x: (a.x + b.x + c.x) / 3, y: (a.y + b.y + c.y) / 3, z: (a.z + b.z + c.z) / 3)) + } + var edgeFaces: [Int64: [Int]] = [:] + for (index, f) in faces.enumerated() { + for (u, v) in [(f[0], f[1]), (f[1], f[2]), (f[2], f[0])] { + edgeFaces[key(u, v), default: []].append(index) + } + } + return edgeFaces.values.compactMap { + $0.count == 2 ? (centroids[$0[0]], centroids[$0[1]]) : nil + } } } @@ -203,13 +287,13 @@ private enum GoldSonar { LinearGradient( colors: [.white, Color(red: 0.95, green: 0.92, blue: 0.80)], startPoint: .top, endPoint: .bottom) - SonarLoadingIndicator() + LoadingIndicatorContent() } ZStack { LinearGradient( colors: [.black, Color(red: 0.10, green: 0.10, blue: 0.16)], startPoint: .top, endPoint: .bottom) - SonarLoadingIndicator() + LoadingIndicatorContent() } } .frame(width: 520, height: 320) diff --git a/Tests/ViewsTests/GeodesicGeometryTests.swift b/Tests/ViewsTests/GeodesicGeometryTests.swift new file mode 100644 index 0000000..5593f17 --- /dev/null +++ b/Tests/ViewsTests/GeodesicGeometryTests.swift @@ -0,0 +1,41 @@ +import Testing + +@testable import Views + +/// The loading indicator's wireframe is generated by pure, deterministic +/// geometry (an icosahedron → freq-2 icosphere → its Goldberg-polyhedron dual). +/// A wrong face index or a broken subdivision would silently yield a malformed +/// sphere that still "renders", so the invariants are asserted directly. +@Suite("Geodesic loading-indicator geometry") +struct GeodesicGeometryTests { + private func length(_ v: Vertex3D) -> Double { + (v.x * v.x + v.y * v.y + v.z * v.z).squareRoot() + } + + private func distance(_ a: Vertex3D, _ b: Vertex3D) -> Double { + ((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y) + (a.z - b.z) * (a.z - b.z)) + .squareRoot() + } + + @Test("dual of the freq-2 icosphere has exactly 120 edges") + func edgeCount() { + // 80 triangles → 120 manifold edges → 120 dual struts. Any other count + // means the face list or subdivision is broken. + #expect(GeodesicGeometry.edges.count == 120) + } + + @Test("every strut endpoint lies on the unit sphere") + func verticesOnUnitSphere() { + for (a, b) in GeodesicGeometry.edges { + #expect(abs(length(a) - 1) < 1e-9) + #expect(abs(length(b) - 1) < 1e-9) + } + } + + @Test("no strut is degenerate — endpoints are distinct") + func noDegenerateEdges() { + for (a, b) in GeodesicGeometry.edges { + #expect(distance(a, b) > 1e-6) + } + } +} From 228ddac2c546b76dbb800a7bd636d212efee8a54 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Mon, 15 Jun 2026 00:44:03 +0900 Subject: [PATCH 3/3] chore(#248): bump version to 2.14.0 --- Sources/VersionHandler/Resources/version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/VersionHandler/Resources/version.txt b/Sources/VersionHandler/Resources/version.txt index af5475d..edcfe40 100644 --- a/Sources/VersionHandler/Resources/version.txt +++ b/Sources/VersionHandler/Resources/version.txt @@ -1 +1 @@ -2.13.21 +2.14.0