Skip to content

Commit 031de3a

Browse files
committed
render CircularWaveformRenderer .ring as a true annulus across all styles
The ring path is now built as two subpaths (outer envelope + inner circle), and both the CGContext and SwiftUI rendering paths fill / clip it with the even-odd rule so the inner disk is correctly excluded for filled and gradient styles. The envelope also stays at least 1 device pixel thick at silence, mirroring LinearWaveformRenderer's minimum-amplitude behavior, so the ring remains visible regardless of input. Drops the "experimental" label on .ring now that all styles render correctly.
1 parent 4335a23 commit 031de3a

3 files changed

Lines changed: 100 additions & 30 deletions

File tree

Sources/DSWaveformImage/Renderers/CircularWaveformRenderer.swift

Lines changed: 87 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import CoreGraphics
55
Draws a circular 2D amplitude envelope of the samples provided.
66

77
Draws either a filled circle, or a hollow ring, depending on the provided `Kind`. Defaults to drawing a `.circle`.
8-
`Kind.ring` is currently experimental.
98
Can be customized further via the configuration `Waveform.Style`.
109
*/
1110

@@ -14,17 +13,28 @@ public struct CircularWaveformRenderer: WaveformRenderer {
1413
/// Draws waveform as a circular amplitude envelope.
1514
case circle
1615

17-
/// **Experimental!** (Will) draw waveform as a ring-shaped amplitude envelope.
18-
/// Associated value will define the percentage of desired "hollowness" inside, or in other words the ring's thickness / diameter in relation to the overall diameter.
16+
/// Draws waveform as a ring-shaped amplitude envelope, where the modulated outer envelope
17+
/// extends from a fixed inner circle outward toward the maximum radius.
18+
/// The associated value sets the inner circle's radius as a fraction of the overall radius
19+
/// (e.g. `0.5` = inner radius is half of the maximum). Clamped to `(0...1)` is the caller's
20+
/// responsibility — `0` collapses to `.circle`, `1` collapses to a zero-thickness ring.
1921
case ring(CGFloat)
2022
}
2123

22-
private let kind: Kind
24+
public let kind: Kind
2325

2426
public init(kind: Kind = .circle) {
2527
self.kind = kind
2628
}
2729

30+
/// Whether `fill` / `clip` over this renderer's path needs the even-odd rule to produce the
31+
/// intended region. `true` for `.ring`, because the path consists of an outer envelope and an
32+
/// inner circle subpath whose subtraction can't be guaranteed by winding direction alone.
33+
public var prefersEvenOddFillRule: Bool {
34+
if case .ring = kind { return true }
35+
return false
36+
}
37+
2838
public func path(samples: [Float], with configuration: Waveform.Configuration, lastOffset: Int, position: Waveform.Position = .middle) -> CGPath {
2939
switch kind {
3040
case .circle: return circlePath(samples: samples, with: configuration, lastOffset: lastOffset, position: position)
@@ -40,16 +50,33 @@ public struct CircularWaveformRenderer: WaveformRenderer {
4050
}
4151

4252
func style(context: CGContext, with configuration: Waveform.Configuration) {
43-
if case let .gradient(colors) = configuration.style {
44-
context.clip()
53+
// The ring path is two subpaths (outer envelope + inner circle). Region-based ops (fill,
54+
// clip) must use even-odd so the annulus is produced regardless of subpath direction —
55+
// non-zero winding would require both subpaths to wind opposite ways, which we can't
56+
// guarantee across CG versions for `addEllipse`.
57+
let isRing: Bool = { if case .ring = kind { return true } else { return false } }()
58+
59+
switch configuration.style {
60+
case let .gradient(colors):
61+
if isRing {
62+
context.clip(using: .evenOdd)
63+
} else {
64+
context.clip()
65+
}
4566
let colors = NSArray(array: colors.map { (color: DSColor) -> CGColor in color.cgColor }) as CFArray
4667
let colorSpace = CGColorSpaceCreateDeviceRGB()
4768
let gradient = CGGradient(colorsSpace: colorSpace, colors: colors, locations: nil)!
4869
context.drawLinearGradient(gradient,
4970
start: CGPoint(x: 0, y: 0),
5071
end: CGPoint(x: 0, y: configuration.size.height),
5172
options: .drawsAfterEndLocation)
52-
} else {
73+
74+
case let .filled(color) where isRing:
75+
context.setLineWidth(1.0 / configuration.scale)
76+
context.setFillColor(color.cgColor)
77+
context.fillPath(using: .evenOdd)
78+
79+
default:
5380
defaultStyle(context: context, with: configuration)
5481
}
5582
}
@@ -93,45 +120,77 @@ public struct CircularWaveformRenderer: WaveformRenderer {
93120
guard case let .ring(config) = kind else {
94121
fatalError("called with wrong kind")
95122
}
123+
guard !samples.isEmpty else { return CGMutablePath() }
96124

97125
let graphRect = CGRect(origin: .zero, size: configuration.size)
98126
let maxRadius = CGFloat(min(graphRect.maxX, graphRect.maxY) / 2.0) * configuration.verticalScalingFactor
99127
let innerRadius: CGFloat = maxRadius * config
128+
let ringThickness = maxRadius - innerRadius
129+
// Mirrors `LinearWaveformRenderer`'s `minimumGraphAmplitude` — guarantees the ring stays
130+
// at least 1 device pixel thick at silence (sample == 1 → invertedDbSample == 0), so the
131+
// ring is always visible. Clamped to `ringThickness` for the degenerate `.ring(1)` case
132+
// where there's no room to extend outward at all.
133+
let minimumRadialAmplitude: CGFloat = min(ringThickness, 1 / configuration.scale)
100134
let center = CGPoint(
101135
x: graphRect.maxX * position.offset(),
102136
y: graphRect.maxY * position.offset()
103137
)
104138
let path = CGMutablePath()
105139

106-
path.move(to: CGPoint(
107-
x: center.x + innerRadius * cos(0),
108-
y: center.y + innerRadius * sin(0)
109-
))
110-
111-
for (index, sample) in samples.enumerated() {
112-
let x = index + lastOffset
113-
let angle = CGFloat.pi * 2 * (CGFloat(index) / CGFloat(samples.count))
114-
115-
if case .striped = configuration.style, x % Int(configuration.scale) != 0 || x % stripeBucket(configuration) != 0 {
116-
// skip sub-pixels - any x value not scale aligned
117-
// skip any point that is not a multiple of our bucket width (width + spacing)
118-
path.move(to: CGPoint(
140+
if case .striped = configuration.style {
141+
// Each visible stripe is its own move(inner) + line(outer) subpath — drawn radially
142+
// at the sample's angle, with `defaultStyle` translating it into a stroked stripe.
143+
for (index, sample) in samples.enumerated() {
144+
let x = index + lastOffset
145+
if x % Int(configuration.scale) != 0 || x % stripeBucket(configuration) != 0 {
146+
continue
147+
}
148+
149+
let angle = CGFloat.pi * 2 * (CGFloat(index) / CGFloat(samples.count))
150+
let invertedDbSample = 1 - CGFloat(sample)
151+
let radialAmplitude = max(minimumRadialAmplitude, ringThickness * invertedDbSample)
152+
let inner = CGPoint(
119153
x: center.x + innerRadius * cos(angle),
120154
y: center.y + innerRadius * sin(angle)
121-
))
122-
continue
155+
)
156+
let outer = CGPoint(
157+
x: inner.x + radialAmplitude * cos(angle),
158+
y: inner.y + radialAmplitude * sin(angle)
159+
)
160+
path.move(to: inner)
161+
path.addLine(to: outer)
123162
}
163+
return path
164+
}
124165

166+
// Non-striped: build an annulus from two subpaths — the outer envelope and the inner
167+
// circle. `style(...)` uses the even-odd fill rule for filled/gradient so the inner
168+
// disk is excluded regardless of subpath winding direction; outlined / gradientOutlined
169+
// simply stroke both subpaths' outlines.
170+
for (index, sample) in samples.enumerated() {
171+
let angle = CGFloat.pi * 2 * (CGFloat(index) / CGFloat(samples.count))
125172
let invertedDbSample = 1 - CGFloat(sample) // sample is in dB, linearly normalized to [0, 1] (1 -> -50 dB)
126-
let pointOnCircle = CGPoint(
127-
x: center.x + innerRadius * cos(angle) + (maxRadius - innerRadius) * invertedDbSample * cos(angle),
128-
y: center.y + innerRadius * sin(angle) + (maxRadius - innerRadius) * invertedDbSample * sin(angle)
173+
let radialAmplitude = max(minimumRadialAmplitude, ringThickness * invertedDbSample)
174+
let radius = innerRadius + radialAmplitude
175+
let pointOnEnvelope = CGPoint(
176+
x: center.x + radius * cos(angle),
177+
y: center.y + radius * sin(angle)
129178
)
130-
131-
path.addLine(to: pointOnCircle)
179+
if index == 0 {
180+
path.move(to: pointOnEnvelope)
181+
} else {
182+
path.addLine(to: pointOnEnvelope)
183+
}
132184
}
133-
134185
path.closeSubpath()
186+
187+
path.addEllipse(in: CGRect(
188+
x: center.x - innerRadius,
189+
y: center.y - innerRadius,
190+
width: innerRadius * 2,
191+
height: innerRadius * 2
192+
))
193+
135194
return path
136195
}
137196

Sources/DSWaveformImageViews/SwiftUI/DefaultShapeStyler.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ struct DefaultShapeStyler {
77
func style(shape: WaveformShape, with configuration: Waveform.Configuration) -> some View {
88
switch configuration.style {
99
case let .filled(color):
10-
shape.fill(Color(color))
10+
shape.fill(Color(color), style: shape.fillStyle)
1111

1212
case let .outlined(color, lineWidth):
1313
shape.stroke(
@@ -20,7 +20,10 @@ struct DefaultShapeStyler {
2020

2121
case let .gradient(colors):
2222
shape
23-
.fill(LinearGradient(colors: colors.map(Color.init), startPoint: .bottom, endPoint: .top))
23+
.fill(
24+
LinearGradient(colors: colors.map(Color.init), startPoint: .bottom, endPoint: .top),
25+
style: shape.fillStyle
26+
)
2427

2528
case let .gradientOutlined(colors, lineWidth):
2629
shape.stroke(

Sources/DSWaveformImageViews/SwiftUI/WaveformShape.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ public struct WaveformShape: Shape {
4444
var isEmpty: Bool {
4545
samples.isEmpty
4646
}
47+
48+
/// SwiftUI fill style this shape's path expects. Uses even-odd when the renderer's path is
49+
/// built from multiple subpaths that need region subtraction (e.g. `CircularWaveformRenderer`
50+
/// in `.ring` kind). Defaults to non-zero for everyone else.
51+
public var fillStyle: FillStyle {
52+
let eoFill = (renderer as? CircularWaveformRenderer)?.prefersEvenOddFillRule ?? false
53+
return FillStyle(eoFill: eoFill)
54+
}
4755
}
4856

4957
private extension WaveformShape {

0 commit comments

Comments
 (0)