Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions SwiftDraw/Sources/Renderer/Renderer.SFSymbol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ public struct SFSymbolRenderer {
template.black.appendPaths(pathsRegular, from: bounds, isLegacy: isLegacyInsets)
}

template.normalizeVariants()
template.setSize(size)

let element = try XML.Formatter.SVG(formatter: formatter).makeElement(from: template.svg)
Expand Down Expand Up @@ -382,6 +383,128 @@ struct SFSymbolTemplate {
self.black = try Variant(svg: svg, kind: "Black")
}

/// Normalizes path segments across all three weight variants so they are interpolatable.
/// Handles two cases:
/// 1. Different segment counts: inserts degenerate cubics to align paths
/// 2. Same count but different types: promotes lines to degenerate cubics
mutating func normalizeVariants() {
let pathCount = min(ultralight.contents.paths.count,
regular.contents.paths.count,
black.contents.paths.count)
for i in 0..<pathCount {
Self.normalizeSegments(
&ultralight.contents.paths[i].segments,
&regular.contents.paths[i].segments,
&black.contents.paths[i].segments
)
}
}

static func normalizeSegments(
_ a: inout [DOM.Path.Segment],
_ b: inout [DOM.Path.Segment],
_ c: inout [DOM.Path.Segment]
) {
// Phase 1: Align segment counts by inserting degenerate segments
alignSegmentCounts(&a, &b, &c)

// Phase 2: Promote lines to cubics where types differ
guard a.count == b.count && b.count == c.count else { return }
promoteLinesToCubics(&a, &b, &c)
}

/// Walks through three segment arrays simultaneously, inserting degenerate cubic
/// segments where one variant has an extra segment the others don't.
private static func alignSegmentCounts(
_ a: inout [DOM.Path.Segment],
_ b: inout [DOM.Path.Segment],
_ c: inout [DOM.Path.Segment]
) {
var ia = 0, ib = 0, ic = 0
var curA = (x: DOM.Coordinate(0), y: DOM.Coordinate(0))
var curB = (x: DOM.Coordinate(0), y: DOM.Coordinate(0))
var curC = (x: DOM.Coordinate(0), y: DOM.Coordinate(0))

while ia < a.count && ib < b.count && ic < c.count {
let ta = a[ia].commandType
let tb = b[ib].commandType
let tc = c[ic].commandType

if ta == tb && tb == tc {
curA = a[ia].endPoint ?? curA
curB = b[ib].endPoint ?? curB
curC = c[ic].endPoint ?? curC
ia += 1; ib += 1; ic += 1
continue
}

// Check if one variant has an extra segment. Try skipping each one
// to see if it restores alignment with the other two.
if tb == tc, ia + 1 < a.count, a[ia + 1].commandType == tb {
// a has extra segment at ia; insert degenerate in b and c
b.insert(degenerateCubic(at: curB), at: ib)
c.insert(degenerateCubic(at: curC), at: ic)
curA = a[ia].endPoint ?? curA
ia += 1; ib += 1; ic += 1
continue
}
if ta == tc, ib + 1 < b.count, b[ib + 1].commandType == ta {
a.insert(degenerateCubic(at: curA), at: ia)
c.insert(degenerateCubic(at: curC), at: ic)
curB = b[ib].endPoint ?? curB
ia += 1; ib += 1; ic += 1
continue
}
if ta == tb, ic + 1 < c.count, c[ic + 1].commandType == ta {
a.insert(degenerateCubic(at: curA), at: ia)
b.insert(degenerateCubic(at: curB), at: ib)
curC = c[ic].endPoint ?? curC
ia += 1; ib += 1; ic += 1
continue
}

// No simple alignment found, just advance all
curA = a[ia].endPoint ?? curA
curB = b[ib].endPoint ?? curB
curC = c[ic].endPoint ?? curC
ia += 1; ib += 1; ic += 1
}
}

/// Promotes line segments to degenerate cubics where variants disagree on type.
private static func promoteLinesToCubics(
_ a: inout [DOM.Path.Segment],
_ b: inout [DOM.Path.Segment],
_ c: inout [DOM.Path.Segment]
) {
var curA = (x: DOM.Coordinate(0), y: DOM.Coordinate(0))
var curB = (x: DOM.Coordinate(0), y: DOM.Coordinate(0))
var curC = (x: DOM.Coordinate(0), y: DOM.Coordinate(0))

for i in 0..<a.count {
let sa = a[i], sb = b[i], sc = c[i]

if sa.commandType != sb.commandType || sb.commandType != sc.commandType {
let hasCubic = sa.isCubic || sb.isCubic || sc.isCubic
let allLineOrCubic = (sa.isLine || sa.isCubic) && (sb.isLine || sb.isCubic) && (sc.isLine || sc.isCubic)

if hasCubic && allLineOrCubic {
if sa.isLine { a[i] = sa.promoteToCubic(from: curA) }
if sb.isLine { b[i] = sb.promoteToCubic(from: curB) }
if sc.isLine { c[i] = sc.promoteToCubic(from: curC) }
}
}

curA = a[i].endPoint ?? curA
curB = b[i].endPoint ?? curB
curC = c[i].endPoint ?? curC
}
}

private static func degenerateCubic(at point: (x: DOM.Coordinate, y: DOM.Coordinate)) -> DOM.Path.Segment {
.cubic(x1: point.x, y1: point.y, x2: point.x, y2: point.y, x: point.x, y: point.y, space: .absolute)
}

mutating func setSize(_ size: SFSymbolRenderer.SizeCategory) {
typeReference.attributes.transform = [.translate(tx: 0, ty: size.yOffset)]
ultralight.setSize(size)
Expand Down Expand Up @@ -652,3 +775,45 @@ private extension DOM.Path {
}
}
}

extension DOM.Path.Segment {

enum CommandType: Equatable {
case move, line, cubic, close, other
}

var commandType: CommandType {
switch self {
case .move: return .move
case .line, .horizontal, .vertical: return .line
case .cubic, .cubicSmooth: return .cubic
case .close: return .close
default: return .other
}
}

var isLine: Bool { commandType == .line }
var isCubic: Bool { commandType == .cubic }

var endPoint: (x: DOM.Coordinate, y: DOM.Coordinate)? {
switch self {
case .move(let x, let y, _), .line(let x, let y, _):
return (x, y)
case .cubic(_, _, _, _, let x, let y, _):
return (x, y)
case .horizontal(let x, _):
return (x, 0) // y stays same, caller tracks
case .vertical(let y, _):
return (0, y) // x stays same, caller tracks
default:
return nil
}
}

/// Promotes a line segment to a degenerate cubic curve.
/// The control points are placed at the start and end to create a straight line.
func promoteToCubic(from current: (x: DOM.Coordinate, y: DOM.Coordinate)) -> DOM.Path.Segment {
guard let end = endPoint else { return self }
return .cubic(x1: current.x, y1: current.y, x2: end.x, y2: end.y, x: end.x, y: end.y, space: .absolute)
}
}
108 changes: 108 additions & 0 deletions SwiftDraw/Tests/Renderer/Renderer.SFSymbolTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,114 @@ final class RendererSFSymbolTests: XCTestCase {
)
}
#endif

// MARK: - Segment Normalization Tests

func testNormalizeSegments_PromotesLineToCubic() {
// When one variant has a cubic and another has a line at the same position,
// the line should be promoted to a degenerate cubic
var a: [DOM.Path.Segment] = [
.move(x: 0, y: 0, space: .absolute),
.cubic(x1: 1, y1: 0, x2: 2, y2: 1, x: 3, y: 1, space: .absolute),
.close
]
var b: [DOM.Path.Segment] = [
.move(x: 0, y: 0, space: .absolute),
.line(x: 3, y: 1, space: .absolute),
.close
]
var c: [DOM.Path.Segment] = [
.move(x: 0, y: 0, space: .absolute),
.line(x: 3, y: 1, space: .absolute),
.close
]

SFSymbolTemplate.normalizeSegments(&a, &b, &c)

// b and c should now have cubics instead of lines
XCTAssertTrue(b[1].isCubic)
XCTAssertTrue(c[1].isCubic)
// All should have same count
XCTAssertEqual(a.count, b.count)
XCTAssertEqual(b.count, c.count)
}

func testNormalizeSegments_InsertsDegenerate() {
// When one variant has an extra segment, a degenerate cubic should be
// inserted in the other variants to align them
var a: [DOM.Path.Segment] = [
.move(x: 0, y: 0, space: .absolute),
.line(x: 5, y: 0, space: .absolute),
.cubic(x1: 5, y1: 0, x2: 7, y2: 2, x: 10, y: 5, space: .absolute),
.line(x: 10, y: 10, space: .absolute),
.close
]
var b: [DOM.Path.Segment] = [
.move(x: 0, y: 0, space: .absolute),
.line(x: 5, y: 0, space: .absolute),
.line(x: 10, y: 10, space: .absolute),
.close
]
var c: [DOM.Path.Segment] = [
.move(x: 0, y: 0, space: .absolute),
.line(x: 5, y: 0, space: .absolute),
.line(x: 10, y: 10, space: .absolute),
.close
]

SFSymbolTemplate.normalizeSegments(&a, &b, &c)

// All should now have the same number of segments
XCTAssertEqual(a.count, b.count, "Segment counts should match after normalization")
XCTAssertEqual(b.count, c.count, "Segment counts should match after normalization")
// All should have same command types at each position
for i in 0..<a.count {
XCTAssertEqual(a[i].commandType, b[i].commandType, "Command types should match at index \(i)")
XCTAssertEqual(b[i].commandType, c[i].commandType, "Command types should match at index \(i)")
}
}

func testNormalizeSegments_AlreadyMatching() {
// When all three variants already match, no changes should be made
var a: [DOM.Path.Segment] = [
.move(x: 0, y: 0, space: .absolute),
.line(x: 10, y: 10, space: .absolute),
.close
]
var b = a
var c = a

SFSymbolTemplate.normalizeSegments(&a, &b, &c)

XCTAssertEqual(a.count, 3)
XCTAssertEqual(b.count, 3)
XCTAssertEqual(c.count, 3)
}

func testCommandType() {
XCTAssertEqual(DOM.Path.Segment.move(x: 0, y: 0, space: .absolute).commandType, .move)
XCTAssertEqual(DOM.Path.Segment.line(x: 0, y: 0, space: .absolute).commandType, .line)
XCTAssertEqual(DOM.Path.Segment.cubic(x1: 0, y1: 0, x2: 0, y2: 0, x: 0, y: 0, space: .absolute).commandType, .cubic)
XCTAssertEqual(DOM.Path.Segment.close.commandType, .close)
XCTAssertEqual(DOM.Path.Segment.horizontal(x: 0, space: .absolute).commandType, .line)
XCTAssertEqual(DOM.Path.Segment.vertical(y: 0, space: .absolute).commandType, .line)
}

func testPromoteToCubic() {
let line = DOM.Path.Segment.line(x: 10, y: 20, space: .absolute)
let promoted = line.promoteToCubic(from: (x: 0, y: 0))

if case .cubic(let x1, let y1, let x2, let y2, let x, let y, _) = promoted {
XCTAssertEqual(x1, 0) // control1 at start
XCTAssertEqual(y1, 0)
XCTAssertEqual(x2, 10) // control2 at end
XCTAssertEqual(y2, 20)
XCTAssertEqual(x, 10) // endpoint
XCTAssertEqual(y, 20)
} else {
XCTFail("Expected cubic segment")
}
}
}

private extension DOM.SVG {
Expand Down