diff --git a/SwiftDraw/Sources/Renderer/Renderer.SFSymbol.swift b/SwiftDraw/Sources/Renderer/Renderer.SFSymbol.swift index a731408..0b5abf4 100644 --- a/SwiftDraw/Sources/Renderer/Renderer.SFSymbol.swift +++ b/SwiftDraw/Sources/Renderer/Renderer.SFSymbol.swift @@ -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) @@ -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.. 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) @@ -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) + } +} diff --git a/SwiftDraw/Tests/Renderer/Renderer.SFSymbolTests.swift b/SwiftDraw/Tests/Renderer/Renderer.SFSymbolTests.swift index 7256f3e..ed2c004 100644 --- a/SwiftDraw/Tests/Renderer/Renderer.SFSymbolTests.swift +++ b/SwiftDraw/Tests/Renderer/Renderer.SFSymbolTests.swift @@ -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..