Skip to content

Commit

Permalink
Improve bezier paths
Browse files Browse the repository at this point in the history
  • Loading branch information
tomasf committed Sep 5, 2024
1 parent 3a00d63 commit cb6a477
Show file tree
Hide file tree
Showing 7 changed files with 154 additions and 26 deletions.
4 changes: 2 additions & 2 deletions Sources/SwiftSCAD/Operations/Extrude/ExtrudedAlong.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ public extension Geometry2D {
/// - path: A 2D or 3D `BezierPath` representing the path along which to extrude the shape.
/// - convexity: The maximum number of surfaces a straight line can intersect the result. This helps OpenSCAD preview the geometry correctly, but has no effect on final rendering.

func extruded<V: Vector>(along path: BezierPath<V>, convexity: Int = 2) -> any Geometry3D {
func extruded<V: Vector>(along path: BezierPath<V>, in range: ClosedRange<BezierPath.Position>? = nil, convexity: Int = 2) -> any Geometry3D {
EnvironmentReader { environment in
let points = path.points(facets: environment.facets).map(\.vector3D)
let points = path.points(in: range ?? path.positionRange, facets: environment.facets).map(\.vector3D)
let isClosed = points[0].distance(to: points.last!) < 0.0001
let rotations = ([.up] + points.paired().map { $1 - $0 })
.paired().map(AffineTransform3D.rotation(from:to:))
Expand Down
4 changes: 4 additions & 0 deletions Sources/SwiftSCAD/Shapes/2D/Polygon/Polygon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ public extension Polygon {
.init(provider: ReversedPolygonPoints(innerProvider: pointsProvider))
}

public init(_ bezierPath: BezierPath2D, in range: ClosedRange<BezierPath.Position>) {
self.init(provider: BezierPathRange(bezierPath: bezierPath, range: range))
}

static func +(_ lhs: Polygon, _ rhs: Polygon) -> Polygon {
lhs.appending(rhs)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,12 @@ internal struct ReversedPolygonPoints: PolygonPointsProvider {
innerProvider.points(in: environment).reversed()
}
}

internal struct BezierPathRange: PolygonPointsProvider {
let bezierPath: BezierPath2D
let range: ClosedRange<BezierPath.Position>

func points(in environment: Environment) -> [Vector2D] {
bezierPath.points(in: range, facets: environment.facets)
}
}
24 changes: 22 additions & 2 deletions Sources/SwiftSCAD/Values/Bezier/BezierCurve.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ internal struct BezierCurve <V: Vector>: Sendable {
self.controlPoints = controlPoints
}

private func point(at fraction: Double) -> V {
internal func point(at fraction: Double) -> V {
var workingPoints = controlPoints
while workingPoints.count > 1 {
workingPoints = workingPoints.paired().map { $0.point(alongLineTo: $1, at: fraction) }
Expand All @@ -30,6 +30,26 @@ internal struct BezierCurve <V: Vector>: Sendable {
+ points(in: midFraction..<range.upperBound, segmentLength: segmentLength)
}

private func points(in range: Range<Double>, segmentCount: Int) -> [V] {
let segmentLength = (range.upperBound - range.lowerBound) / Double(segmentCount)
return (0...segmentCount).map { f in
point(at: range.lowerBound + Double(f) * segmentLength)
}
}

func points(in range: Range<Double>, facets: Environment.Facets) -> [V] {
guard controlPoints.count > 2 else {
return controlPoints
}

switch facets {
case .fixed (let count):
return points(in: range, segmentCount: count)
case .dynamic(_, let minSize):
return points(in: range, segmentLength: minSize)
}
}

private func points(segmentLength: Double) -> [V] {
return [point(at: 0)] + points(in: 0..<1, segmentLength: segmentLength) + [point(at: 1)]
}
Expand All @@ -54,7 +74,7 @@ internal struct BezierCurve <V: Vector>: Sendable {
}
}

func transform<T: AffineTransform>(using transform: T) -> Self where T == V.Transform, T.Vector == V {
func transformed<T: AffineTransform>(using transform: T) -> Self where T == V.Transform, T.Vector == V {
Self(controlPoints: controlPoints.map { transform.apply(to: $0) })
}
}
114 changes: 114 additions & 0 deletions Sources/SwiftSCAD/Values/Bezier/BezierPath+Operations.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import Foundation

public extension BezierPath {
/// A typealias representing a position along a Bézier path.
///
/// `BezierPath.Position` is a `Double` value that represents a fractional position along a Bézier path.
/// The integer part of the value represents the index of the Bézier curve within the path,
/// and the fractional part represents a position within that specific curve.
///
/// For example:
/// - `0.0` represents the start of the first curve.
/// - `1.0` represents the start of the second curve.
/// - `1.5` represents the midpoint of the second curve.
///
/// This type is used for navigating and interpolating points along a multi-curve Bézier path.
typealias Position = Double

/// The valid range of positions within this path
var positionRange: ClosedRange<Position> {
0...Position(curves.count)
}

/// Generates a sequence of points representing the path.
///
/// - Parameter facets: The desired level of detail for the generated points, affecting the smoothness of curves.
/// - Returns: An array of points that approximate the Bezier path.
func points(facets: Environment.Facets) -> [V] {
return [startPoint] + curves.flatMap {
$0.points(facets: facets)[1...]
}
}

/// Calculates the total length of the Bézier path.
///
/// - Parameter facets: The desired level of detail for the generated points, which influences the accuracy
/// of the length calculation. More detailed facet values result in more points being generated, leading to a more
/// accurate length approximation.
/// - Returns: A `Double` value representing the total length of the Bézier path.
func length(facets: Environment.Facets) -> Double {
points(facets: facets)
.paired()
.map { $0.distance(to: $1) }
.reduce(0, +)
}

/// Applies the given 2D affine transform to the `BezierPath`.
///
/// - Parameter transform: The affine transform to apply.
/// - Returns: A new `BezierPath` instance with the transformed points.
func transformed<T: AffineTransform>(using transform: T) -> BezierPath where T.Vector == V, T == V.Transform {
BezierPath(
startPoint: transform.apply(to: startPoint),
curves: curves.map { $0.transformed(using: transform) }
)
}

/// Returns the point at a given position along the path
func point(at position: Position) -> V {
assert(positionRange ~= position)
guard !curves.isEmpty else { return startPoint }

let curveIndex = min(Int(floor(position)), curves.count - 1)
let fraction = position - Double(curveIndex)
return curves[curveIndex].point(at: fraction)
}

internal func points(in pathFractionRange: ClosedRange<Position>, facets: Environment.Facets) -> [V] {
let (fromCurveIndex, fromFraction) = pathFractionRange.lowerBound.indexAndFraction(curveCount: curves.count)
let (toCurveIndex, toFraction) = pathFractionRange.upperBound.indexAndFraction(curveCount: curves.count)

return curves[fromCurveIndex...toCurveIndex].enumerated().flatMap { index, curve in
let startFraction = (index == fromCurveIndex) ? fromFraction : 0.0
let endFraction = (index == toCurveIndex) ? toFraction : 1.0
return curve.points(in: 0..<endFraction, facets: facets)
}
}


/// Calculates the transformation when rotated and translated along the Bézier path up to a specified position.
///
/// This method computes the transformation that includes both rotation and translation,
/// as an object moves along the Bézier path up to a given position specified by `position`. The transformation
/// accounts for the rotations necessary to align the object with the path's direction.
///
/// - Parameters:
/// - position: The position along the path where the transformation is calculated. This value is of type
/// - facets: The desired level of detail for the generated points, affecting the smoothness and accuracy of the path traversal
/// - Returns: A `V.Transform` representing the combined rotation and translation needed to move an object along the
/// Bézier path to the specified position.
func transform(at position: Position, facets: Environment.Facets) -> V.Transform {
guard !curves.isEmpty else { return .translation(startPoint) }
return .init(
points(in: 0...position, facets: facets).map(\.vector3D)
.paired().map(-)
.paired().map(AffineTransform3D.rotation(from:to:))
.reduce(AffineTransform3D.identity) { $0.concatenated(with: $1) }
.translated(point(at: position).vector3D)
)
}
}

fileprivate extension BezierPath.Position {
func indexAndFraction(curveCount: Int) -> (Int, Double) {
if self < 0 {
return (0, self)
} else if self >= Double(curveCount) {
return (curveCount - 1, self - Double(curveCount - 1))
} else {
let index = floor(self)
let fraction = self - index
return (Int(index), fraction)
}
}
}
23 changes: 1 addition & 22 deletions Sources/SwiftSCAD/Values/Bezier/BezierPath.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public struct BezierPath <V: Vector>: Sendable {
curves.last?.controlPoints.last ?? startPoint
}

private init(startPoint: V, curves: [BezierCurve<V>]) {
internal init(startPoint: V, curves: [BezierCurve<V>]) {
self.startPoint = startPoint
self.curves = curves
}
Expand Down Expand Up @@ -100,25 +100,4 @@ public struct BezierPath <V: Vector>: Sendable {
public func closed() -> BezierPath {
addingLine(to: startPoint)
}

/// Generates a sequence of points representing the path.
///
/// - Parameter facets: The desired level of detail for the generated points, affecting the smoothness of curves.
/// - Returns: An array of points that approximate the Bezier path.
public func points(facets: Environment.Facets) -> [V] {
return [startPoint] + curves.map { curve in
Array(curve.points(facets: facets)[1...])
}.joined()
}

/// Applies the given 2D affine transform to the `BezierPath`.
///
/// - Parameter transform: The affine transform to apply.
/// - Returns: A new `BezierPath` instance with the transformed points.
public func transform<T: AffineTransform>(using transform: T) -> BezierPath where T.Vector == V, T == V.Transform {
BezierPath(
startPoint: transform.apply(to: startPoint),
curves: curves.map { $0.transform(using: transform) }
)
}
}
2 changes: 2 additions & 0 deletions Sources/SwiftSCAD/Values/Transforms/AffineTransform.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,7 @@ public protocol AffineTransform: Sendable {
static func scaling(_ v: Vector) -> Self
static func rotation(_ r: Rotation) -> Self

func translated(_ v: Vector) -> Self

init(_ transform3d: AffineTransform3D)
}

0 comments on commit cb6a477

Please sign in to comment.