From fd02df54740d2aa286d160a055f17141f4172d43 Mon Sep 17 00:00:00 2001 From: Kyle Date: Fri, 30 Oct 2026 00:51:37 +0800 Subject: [PATCH 01/12] fix(path): correct callback ABI and ownership --- Sources/OpenRenderBox/Path/ORBPath.swift | 6 ++-- .../OpenRenderBox/Path/ORBPathCallbacks.swift | 16 ++++----- .../OpenRenderBox/Path/ORBPathStorage.swift | 6 ++-- .../OpenRenderBoxCxx/Path/NestedCallbacks.cpp | 9 ++--- Sources/OpenRenderBoxCxx/Path/ORBPath.cpp | 13 +++++--- Sources/OpenRenderBoxCxx/Path/ORBPath.h | 9 ++--- .../Path/ORBPathCallbacks.cpp | 4 +-- .../OpenRenderBoxCxx/Path/ORBPathCallbacks.h | 8 ++--- .../OpenRenderBoxCxx/Path/ORBPathStorage.cpp | 8 ++--- .../OpenRenderBoxCxx/Path/ORBPathStorage.h | 6 ++-- Sources/OpenRenderBoxCxx/Path/Storage.cpp | 4 +-- Sources/OpenRenderBoxCxx/Path/Storage.hpp | 10 +++--- .../PathCallbacksTests.swift | 5 ++- .../PathRegressionTests.swift | 33 +++++++++++++++++++ .../PathTests.swift | 6 ++-- 15 files changed, 90 insertions(+), 53 deletions(-) create mode 100644 Tests/OpenRenderBoxCompatibilityTests/PathRegressionTests.swift diff --git a/Sources/OpenRenderBox/Path/ORBPath.swift b/Sources/OpenRenderBox/Path/ORBPath.swift index 5242128..cb05a80 100644 --- a/Sources/OpenRenderBox/Path/ORBPath.swift +++ b/Sources/OpenRenderBox/Path/ORBPath.swift @@ -80,7 +80,7 @@ extension ORBPath { // MARK: - Memory Management extension ORBPath { - public func retain() { + public func retain() -> ORBPath { _openRenderBoxUnimplementedFailure() } @@ -108,7 +108,7 @@ extension ORBPath { _openRenderBoxUnimplementedFailure() } - public init(roundedRect rect: CGRect, topLeftRadius: CGFloat, bottomLeftRadius: CGFloat, bottomRightRadius: CGFloat, topRightRadius: CGFloat, style: ORBPath.RoundedCornerStyle, transform: UnsafePointer?) { + public init(roundedRect rect: CGRect, topLeftRadius: CGFloat, topRightRadius: CGFloat, bottomRightRadius: CGFloat, bottomLeftRadius: CGFloat, style: ORBPath.RoundedCornerStyle, transform: UnsafePointer?) { _openRenderBoxUnimplementedFailure() } } @@ -148,7 +148,7 @@ extension ORBPath { _openRenderBoxUnimplementedFailure() } - public func containsPoints(count: UInt64, points: UnsafePointer, eoFill: Bool, transform: UnsafePointer?) -> Bool { + public func containsPoints(count: UInt64, points: UnsafePointer, eoFill: Bool, transform: UnsafePointer?) -> UInt64 { _openRenderBoxUnimplementedFailure() } } diff --git a/Sources/OpenRenderBox/Path/ORBPathCallbacks.swift b/Sources/OpenRenderBox/Path/ORBPathCallbacks.swift index 4b10fff..09ef7c3 100644 --- a/Sources/OpenRenderBox/Path/ORBPathCallbacks.swift +++ b/Sources/OpenRenderBox/Path/ORBPathCallbacks.swift @@ -44,10 +44,10 @@ extension ORBPath { public var isEqual: ((UnsafeRawPointer, UnsafeRawPointer) -> Bool)? public var isEmpty: ((UnsafeRawPointer) -> Bool)? public var isSingleElement: ((UnsafeRawPointer) -> Bool)? - public var bezierOrder: ((UnsafeRawPointer) -> UInt32)? + public var bezierOrder: ((UnsafeRawPointer) -> Int)? public var boundingRect: ((UnsafeRawPointer) -> CGRect)? public var cgPath: ((UnsafeRawPointer) -> Unmanaged?)? - public var next: ((UnsafeRawPointer) -> UnsafePointer?)? + public var next: ((UnsafeRawPointer) -> ORBPath)? public init() { self.flags = ORBPath.CallbacksFlags() @@ -71,10 +71,10 @@ extension ORBPath { isEqual: ((UnsafeRawPointer, UnsafeRawPointer) -> Bool)?, isEmpty: ((UnsafeRawPointer) -> Bool)?, isSingleElement: ((UnsafeRawPointer) -> Bool)?, - bezierOrder: ((UnsafeRawPointer) -> UInt32)?, + bezierOrder: ((UnsafeRawPointer) -> Int)?, boundingRect: ((UnsafeRawPointer) -> CGRect)?, cgPath: ((UnsafeRawPointer) -> Unmanaged?)?, - next: ((UnsafeRawPointer) -> UnsafePointer?)? + next: ((UnsafeRawPointer) -> ORBPath)? ) { self.flags = flags self.retain = retain @@ -112,10 +112,10 @@ extension ORBPath { public var isEqual: ((UnsafeRawPointer, UnsafeRawPointer, UnsafePointer) -> Bool)? public var isEmpty: ((UnsafeRawPointer, UnsafePointer) -> Bool)? public var isSingleElement: ((UnsafeRawPointer, UnsafePointer) -> Bool)? - public var bezierOrder: ((UnsafeRawPointer, UnsafePointer) -> UInt32)? + public var bezierOrder: ((UnsafeRawPointer, UnsafePointer) -> Int)? public var boundingRect: ((UnsafeRawPointer, UnsafePointer) -> CGRect)? public var cgPath: ((UnsafeRawPointer, UnsafePointer) -> Unmanaged?)? - public var next: ((UnsafeRawPointer, UnsafePointer) -> UnsafePointer?)? + public var next: ((UnsafeRawPointer, UnsafePointer) -> ORBPath)? public init() { self.flags = ORBPath.CallbacksFlags() @@ -139,10 +139,10 @@ extension ORBPath { isEqual: ((UnsafeRawPointer, UnsafeRawPointer, UnsafePointer) -> Bool)?, isEmpty: ((UnsafeRawPointer, UnsafePointer) -> Bool)?, isSingleElement: ((UnsafeRawPointer, UnsafePointer) -> Bool)?, - bezierOrder: ((UnsafeRawPointer, UnsafePointer) -> UInt32)?, + bezierOrder: ((UnsafeRawPointer, UnsafePointer) -> Int)?, boundingRect: ((UnsafeRawPointer, UnsafePointer) -> CGRect)?, cgPath: ((UnsafeRawPointer, UnsafePointer) -> Unmanaged?)?, - next: ((UnsafeRawPointer, UnsafePointer) -> UnsafePointer?)? + next: ((UnsafeRawPointer, UnsafePointer) -> ORBPath)? ) { self.flags = flags self.retain = retain diff --git a/Sources/OpenRenderBox/Path/ORBPathStorage.swift b/Sources/OpenRenderBox/Path/ORBPathStorage.swift index 2a8a022..4e75f6a 100644 --- a/Sources/OpenRenderBox/Path/ORBPathStorage.swift +++ b/Sources/OpenRenderBox/Path/ORBPathStorage.swift @@ -6,7 +6,7 @@ public import OpenCoreGraphicsShims extension ORBPath.Storage { - public func initialize(capacity: UInt32, source: ORBPath.Storage?) { + public func initialize(capacity: Int, source: ORBPath.Storage?) { _openRenderBoxUnimplementedFailure() } @@ -18,7 +18,7 @@ extension ORBPath.Storage { _openRenderBoxUnimplementedFailure() } - public func append(element: ORBPath.Element, points: UnsafePointer, userInfo: UnsafeRawPointer?) -> Bool { + public func append(element: ORBPath.Element, points: UnsafePointer, userInfo: UnsafeRawPointer?) { _openRenderBoxUnimplementedFailure() } @@ -42,7 +42,7 @@ extension ORBPath.Storage { _openRenderBoxUnimplementedFailure() } - public var bezierOrder: UInt32 { + public var bezierOrder: Int { _openRenderBoxUnimplementedFailure() } diff --git a/Sources/OpenRenderBoxCxx/Path/NestedCallbacks.cpp b/Sources/OpenRenderBoxCxx/Path/NestedCallbacks.cpp index f1a0597..559b53b 100644 --- a/Sources/OpenRenderBoxCxx/Path/NestedCallbacks.cpp +++ b/Sources/OpenRenderBoxCxx/Path/NestedCallbacks.cpp @@ -36,7 +36,7 @@ struct Cache { Cache* gCache = nullptr; // Helper to call bezierOrder on callbacks -inline uint32_t callBezierOrder(const void* object, const ORBPathCallbacks* callbacks) { +inline size_t callBezierOrder(const void* object, const ORBPathCallbacks* callbacks) { if (callbacks->bezierOrder) { return callbacks->bezierOrder(object); } @@ -85,7 +85,7 @@ NestedCallbacks::NestedCallbacks(const ORBPathCallbacks* callbacks, size_t index return true; }; - extended.bezierOrder = +[](const void *object, const ORBPathCallbacksExtended *ext) -> uint32_t { + extended.bezierOrder = +[](const void *object, const ORBPathCallbacksExtended *ext) -> size_t { auto self = reinterpret_cast(ext); return callBezierOrder(object, self->originalCallbacks); }; @@ -94,10 +94,11 @@ NestedCallbacks::NestedCallbacks(const ORBPathCallbacks* callbacks, size_t index extended.boundingRect = nullptr; extended.cgPath = nullptr; - extended.next = +[](const void *object, const ORBPathCallbacksExtended *ext) -> const ORBPathCallbacksExtended* { + extended.next = +[](const void *object, const ORBPathCallbacksExtended *ext) -> ORBPath { auto self = reinterpret_cast(ext); auto nextCallbacks = NestedCallbacks::get(self->originalCallbacks, self->nestedIndex + 1); - return &nextCallbacks->extended; + return {reinterpret_cast(const_cast(object)), + reinterpret_cast(&nextCallbacks->extended)}; }; } diff --git a/Sources/OpenRenderBoxCxx/Path/ORBPath.cpp b/Sources/OpenRenderBoxCxx/Path/ORBPath.cpp index 30a3449..b4df8b0 100644 --- a/Sources/OpenRenderBoxCxx/Path/ORBPath.cpp +++ b/Sources/OpenRenderBoxCxx/Path/ORBPath.cpp @@ -39,17 +39,20 @@ const ORBPath ORBPathNull = { &empty_path_callbacks, }; -void ORBPathRetain(ORBPath path) { +ORBPath ORBPathRetain(ORBPath path) { auto retain = path.callbacks->retain; if (retain != nullptr) { - retain(&path); + path.storage = reinterpret_cast( + const_cast(retain(path.storage)) + ); } + return path; } void ORBPathRelease(ORBPath path) { auto release = path.callbacks->release; if (release != nullptr) { - release(&path); + release(path.storage); } } @@ -120,7 +123,7 @@ ORBPath ORBPathMakeRoundedRect(CGRect rect, CGFloat cornerWidth, CGFloat cornerH return path; } -ORBPath ORBPathMakeUnevenRoundedRect(CGRect rect, CGFloat topLeftRadius, CGFloat bottomLeftRadius, CGFloat bottomRightRadius, CGFloat topRightRadius, ORBPathRoundedCornerStyle style, const CGAffineTransform *transform) { +ORBPath ORBPathMakeUnevenRoundedRect(CGRect rect, CGFloat topLeftRadius, CGFloat topRightRadius, CGFloat bottomRightRadius, CGFloat bottomLeftRadius, ORBPathRoundedCornerStyle style, const CGAffineTransform *transform) { // TODO: Handle ORBRoundedCornerStyleContinuous with custom bezier curves CGMutablePathRef cgPath = CGPathCreateMutable(); @@ -264,7 +267,7 @@ bool ORBPathContainsPoint(ORBPath path, CGPoint point, bool eoFill) { return ORBPathContainsPoints(path, 1, &point, eoFill, nullptr); } -bool ORBPathContainsPoints(ORBPath path, uint64_t count, const CGPoint *points, bool eoFill, const CGAffineTransform *transform) { +uint64_t ORBPathContainsPoints(ORBPath path, uint64_t count, const CGPoint *points, bool eoFill, const CGAffineTransform *transform) { // TODO: Implement point containment testing with winding rule return false; } diff --git a/Sources/OpenRenderBoxCxx/Path/ORBPath.h b/Sources/OpenRenderBoxCxx/Path/ORBPath.h index 85269e3..5f77248 100644 --- a/Sources/OpenRenderBoxCxx/Path/ORBPath.h +++ b/Sources/OpenRenderBoxCxx/Path/ORBPath.h @@ -5,6 +5,7 @@ #pragma once #include +#include #if OPENRENDERBOX_CF_CGTYPES @@ -40,7 +41,7 @@ typedef ORB_ENUM(int32_t, ORBPathRoundedCornerStyle) { } ORB_SWIFT_NAME(ORBPath.RoundedCornerStyle); /// Callback type for path element enumeration -/// Returns true to stop enumeration, false to continue +/// Returns true to continue enumeration, false to stop typedef bool (*ORBPathApplyCallback)(void * info, ORBPathElement element, const CGFloat *points, const void * _Nullable userInfo); typedef struct ORBPathCallbacks ORBPathCallbacks ORB_SWIFT_NAME(ORBPath.ApplyCallback); @@ -61,7 +62,7 @@ ORB_EXPORT const ORBPath ORBPathNull ORB_SWIFT_NAME(ORBPath.null); ORB_EXPORT -void ORBPathRetain(ORBPath path) ORB_SWIFT_NAME(ORBPath.retain(self:)); +ORBPath ORBPathRetain(ORBPath path) ORB_SWIFT_NAME(ORBPath.retain(self:)); ORB_EXPORT void ORBPathRelease(ORBPath path) ORB_SWIFT_NAME(ORBPath.release(self:)); @@ -82,7 +83,7 @@ ORB_EXPORT ORBPath ORBPathMakeRoundedRect(CGRect rect, CGFloat cornerWidth, CGFloat cornerHeight, ORBPathRoundedCornerStyle style, const CGAffineTransform * _Nullable transform) ORB_SWIFT_NAME(ORBPath.init(roundedRect:cornerWidth:cornerHeight:style:transform:)); ORB_EXPORT -ORBPath ORBPathMakeUnevenRoundedRect(CGRect rect, CGFloat topLeftRadius, CGFloat bottomLeftRadius, CGFloat bottomRightRadius, CGFloat topRightRadius, ORBPathRoundedCornerStyle style, const CGAffineTransform * _Nullable transform) ORB_SWIFT_NAME(ORBPath.init(roundedRect:topLeftRadius:bottomLeftRadius:bottomRightRadius:topRightRadius:style:transform:)); +ORBPath ORBPathMakeUnevenRoundedRect(CGRect rect, CGFloat topLeftRadius, CGFloat topRightRadius, CGFloat bottomRightRadius, CGFloat bottomLeftRadius, ORBPathRoundedCornerStyle style, const CGAffineTransform * _Nullable transform) ORB_SWIFT_NAME(ORBPath.init(roundedRect:topLeftRadius:topRightRadius:bottomRightRadius:bottomLeftRadius:style:transform:)); #endif #if ORB_TARGET_OS_DARWIN @@ -121,7 +122,7 @@ ORB_EXPORT bool ORBPathContainsPoint(ORBPath path, CGPoint point, bool eoFill) ORB_SWIFT_NAME(ORBPath.contains(self:point:eoFill:)); ORB_EXPORT -bool ORBPathContainsPoints(ORBPath path, uint64_t count, const CGPoint *points, bool eoFill, const CGAffineTransform * _Nullable transform) ORB_SWIFT_NAME(ORBPath.containsPoints(self:count:points:eoFill:transform:)); +uint64_t ORBPathContainsPoints(ORBPath path, uint64_t count, const CGPoint *points, bool eoFill, const CGAffineTransform * _Nullable transform) ORB_SWIFT_NAME(ORBPath.containsPoints(self:count:points:eoFill:transform:)); #endif ORB_EXTERN_C_END diff --git a/Sources/OpenRenderBoxCxx/Path/ORBPathCallbacks.cpp b/Sources/OpenRenderBoxCxx/Path/ORBPathCallbacks.cpp index 360b503..c1691cc 100644 --- a/Sources/OpenRenderBoxCxx/Path/ORBPathCallbacks.cpp +++ b/Sources/OpenRenderBoxCxx/Path/ORBPathCallbacks.cpp @@ -18,7 +18,7 @@ typedef void (^CGPathApplyBlock2)(const CGPathElement * element, bool *stop); CG_EXTERN void CGPathApplyWithBlock2(CGPathRef path, CGPathApplyBlock2 CF_NOESCAPE block); namespace { - uint32_t cgpath_bezier_order(CGPathRef cgPath) { + size_t cgpath_bezier_order(CGPathRef cgPath) { size_t pointsCount = CGPathGetNumberOfPoints(cgPath); size_t elementsCount = CGPathGetNumberOfElements(cgPath); if (pointsCount > elementsCount) { return 3; } @@ -62,7 +62,7 @@ const ORBPathCallbacks ORBPathCGPathCallbacks = { .isSingleElement = +[](const void *object) -> bool { return false; }, - .bezierOrder = +[](const void *object) -> uint32_t { + .bezierOrder = +[](const void *object) -> size_t { return cgpath_bezier_order(static_cast(object)); }, .boundingRect = +[](const void *object) -> CGRect { diff --git a/Sources/OpenRenderBoxCxx/Path/ORBPathCallbacks.h b/Sources/OpenRenderBoxCxx/Path/ORBPathCallbacks.h index 63efd77..39c5d8a 100644 --- a/Sources/OpenRenderBoxCxx/Path/ORBPathCallbacks.h +++ b/Sources/OpenRenderBoxCxx/Path/ORBPathCallbacks.h @@ -39,14 +39,14 @@ typedef struct ORB_SWIFT_NAME(ORBPath.Callbacks) ORBPathCallbacks { bool (* _Nullable isEqual)(const void *object, const void *otherObject); bool (* _Nullable isEmpty)(const void *object); bool (* _Nullable isSingleElement)(const void *object); - uint32_t (* _Nullable bezierOrder)(const void *object); + size_t (* _Nullable bezierOrder)(const void *object); CGRect (* _Nullable boundingRect)(const void *object); #if ORB_TARGET_OS_DARWIN CGPathRef _Nullable (* _Nullable cgPath)(const void *object); #else void * _Nullable (* _Nullable cgPath)(const void *object); #endif - const struct ORBPathCallbacks * _Nullable (* _Nullable next)(const void *object); + ORBPath (* _Nullable next)(const void *object); } ORBPathCallbacks; // static_assert without message requires C23 or C++17 @@ -74,14 +74,14 @@ typedef struct ORB_SWIFT_NAME(ORBPath.CallbacksExtended) ORBPathCallbacksExtende bool (* _Nullable isEqual)(const void *object, const void *otherObject, const struct ORBPathCallbacksExtended *extended); bool (* _Nullable isEmpty)(const void *object, const struct ORBPathCallbacksExtended *extended); bool (* _Nullable isSingleElement)(const void *object, const struct ORBPathCallbacksExtended *extended); - uint32_t (* _Nullable bezierOrder)(const void *object, const struct ORBPathCallbacksExtended *extended); + size_t (* _Nullable bezierOrder)(const void *object, const struct ORBPathCallbacksExtended *extended); CGRect (* _Nullable boundingRect)(const void *object, const struct ORBPathCallbacksExtended *extended); #if ORB_TARGET_OS_DARWIN CGPathRef _Nullable (* _Nullable cgPath)(const void *object, const struct ORBPathCallbacksExtended *extended); #else void * _Nullable (* _Nullable cgPath)(const void *object, const struct ORBPathCallbacksExtended *extended); #endif - const struct ORBPathCallbacksExtended * _Nullable (* _Nullable next)(const void *object, const struct ORBPathCallbacksExtended *extended); + ORBPath (* _Nullable next)(const void *object, const struct ORBPathCallbacksExtended *extended); } ORBPathCallbacksExtended; // static_assert without message requires C23 or C++17 diff --git a/Sources/OpenRenderBoxCxx/Path/ORBPathStorage.cpp b/Sources/OpenRenderBoxCxx/Path/ORBPathStorage.cpp index 926a30c..092f162 100644 --- a/Sources/OpenRenderBoxCxx/Path/ORBPathStorage.cpp +++ b/Sources/OpenRenderBoxCxx/Path/ORBPathStorage.cpp @@ -21,7 +21,7 @@ bool append_element_callback(void * info, ORBPathElement element, const CGFloat } /* Path */ } /* ORB */ -void ORBPathStorageInit(ORBPathStorageRef dst, uint32_t capacity, ORBPathStorageRef source) { +void ORBPathStorageInit(ORBPathStorageRef dst, size_t capacity, ORBPathStorageRef source) { if (source != nullptr) { dst->storage = ORB::Path::Storage(capacity, source->storage); } else { @@ -37,8 +37,8 @@ void ORBPathStorageClear(ORBPathStorageRef storage) { storage->storage.clear(); } -bool ORBPathStorageAppendElement(ORBPathStorageRef storage, ORBPathElement element, const CGFloat * points, const void * userInfo) { - return storage->storage.append_element(element, points, userInfo); +void ORBPathStorageAppendElement(ORBPathStorageRef storage, ORBPathElement element, const CGFloat * points, const void * userInfo) { + storage->storage.append_element(element, points, userInfo); } bool ORBPathStorageAppendPath(ORBPathStorageRef storage, ORBPath path) { @@ -61,7 +61,7 @@ bool ORBPathStorageIsSingleElement(ORBPathStorageRef storage) { return storage->storage.flags().isSingleElement(); } -uint32_t ORBPathStorageGetBezierOrder(ORBPathStorageRef storage) { +size_t ORBPathStorageGetBezierOrder(ORBPathStorageRef storage) { return storage->storage.flags().bezierOrder(); } diff --git a/Sources/OpenRenderBoxCxx/Path/ORBPathStorage.h b/Sources/OpenRenderBoxCxx/Path/ORBPathStorage.h index 0dce577..ba91927 100644 --- a/Sources/OpenRenderBoxCxx/Path/ORBPathStorage.h +++ b/Sources/OpenRenderBoxCxx/Path/ORBPathStorage.h @@ -21,7 +21,7 @@ ORB_ASSUME_NONNULL_BEGIN ORB_EXTERN_C_BEGIN ORB_EXPORT -void ORBPathStorageInit(ORBPathStorageRef dst, uint32_t capacity, ORBPathStorageRef _Nullable source) ORB_SWIFT_NAME(ORBPathStorageRef.initialize(self:capacity:source:)); +void ORBPathStorageInit(ORBPathStorageRef dst, size_t capacity, ORBPathStorageRef _Nullable source) ORB_SWIFT_NAME(ORBPathStorageRef.initialize(self:capacity:source:)); ORB_EXPORT void ORBPathStorageDestroy(ORBPathStorageRef storage) ORB_SWIFT_NAME(ORBPathStorageRef.destroy(self:)); @@ -30,7 +30,7 @@ ORB_EXPORT void ORBPathStorageClear(ORBPathStorageRef storage) ORB_SWIFT_NAME(ORBPathStorageRef.clear(self:)); ORB_EXPORT -bool ORBPathStorageAppendElement(ORBPathStorageRef storage, ORBPathElement element, CGFloat const * points, const void * _Nullable userInfo) ORB_SWIFT_NAME(ORBPathStorageRef.append(self:element:points:userInfo:)); +void ORBPathStorageAppendElement(ORBPathStorageRef storage, ORBPathElement element, CGFloat const * points, const void * _Nullable userInfo) ORB_SWIFT_NAME(ORBPathStorageRef.append(self:element:points:userInfo:)); ORB_EXPORT bool ORBPathStorageAppendPath(ORBPathStorageRef, ORBPath) ORB_SWIFT_NAME(ORBPathStorageRef.append(self:path:)); @@ -48,7 +48,7 @@ ORB_EXPORT bool ORBPathStorageIsSingleElement(ORBPathStorageRef storage) ORB_SWIFT_NAME(getter:ORBPathStorageRef.isSingleElement(self:)); ORB_EXPORT -uint32_t ORBPathStorageGetBezierOrder(ORBPathStorageRef storage) ORB_SWIFT_NAME(getter:ORBPathStorageRef.bezierOrder(self:)); +size_t ORBPathStorageGetBezierOrder(ORBPathStorageRef storage) ORB_SWIFT_NAME(getter:ORBPathStorageRef.bezierOrder(self:)); #if ORB_TARGET_OS_DARWIN ORB_EXPORT diff --git a/Sources/OpenRenderBoxCxx/Path/Storage.cpp b/Sources/OpenRenderBoxCxx/Path/Storage.cpp index 30ad2ab..602ed49 100644 --- a/Sources/OpenRenderBoxCxx/Path/Storage.cpp +++ b/Sources/OpenRenderBoxCxx/Path/Storage.cpp @@ -17,7 +17,7 @@ namespace ORB { namespace Path { atomic_uint Storage::_last_identifier; -Storage::Storage(uint32_t capacity) { +Storage::Storage(size_t capacity) { _unknown = nullptr; if (capacity <= 63) { precondition_failure("invalid capacity"); @@ -27,7 +27,7 @@ Storage::Storage(uint32_t capacity) { _identifier = get_identifier(); } -Storage::Storage(uint32_t capacity, const Storage &storage): Storage(capacity) { +Storage::Storage(size_t capacity, const Storage &storage): Storage(capacity) { uint32_t originalCapity = flags().capacity(); StorageFlags sourceFlags = storage.flags(); diff --git a/Sources/OpenRenderBoxCxx/Path/Storage.hpp b/Sources/OpenRenderBoxCxx/Path/Storage.hpp index 265d4c3..c4674c4 100644 --- a/Sources/OpenRenderBoxCxx/Path/Storage.hpp +++ b/Sources/OpenRenderBoxCxx/Path/Storage.hpp @@ -43,7 +43,7 @@ struct StorageFlags { bool isCubicBezierOrder, bool isSingleElement, uint32_t size, - uint32_t capacity) + size_t capacity) : StorageFlags(StorageFlags() .withExternal(isExternal) .withQuadBezierOrder(isQuadBezierOrder) @@ -91,7 +91,7 @@ struct StorageFlags { } ORB_INLINE ORB_CONSTEXPR - uint32_t capacity() const ORB_NOEXCEPT { + size_t capacity() const ORB_NOEXCEPT { return (value >> 0x14); } @@ -121,7 +121,7 @@ struct StorageFlags { } ORB_INLINE ORB_CONSTEXPR - StorageFlags withCapacity(uint32_t capacity) const ORB_NOEXCEPT { + StorageFlags withCapacity(size_t capacity) const ORB_NOEXCEPT { return StorageFlags((value & 0xFFFFFFu) | ((capacity & 0xFFFu) << 0x14)); } @@ -147,8 +147,8 @@ class Storage { static atomic_uint _last_identifier; public: - Storage(uint32_t capacity); - Storage(uint32_t capacity, const ORB::Path::Storage &storage); + Storage(size_t capacity); + Storage(size_t capacity, const ORB::Path::Storage &storage); ~Storage(); bool operator == (const Storage &other) const ORB_NOEXCEPT; diff --git a/Tests/OpenRenderBoxCompatibilityTests/PathCallbacksTests.swift b/Tests/OpenRenderBoxCompatibilityTests/PathCallbacksTests.swift index 31bc75f..0d67875 100644 --- a/Tests/OpenRenderBoxCompatibilityTests/PathCallbacksTests.swift +++ b/Tests/OpenRenderBoxCompatibilityTests/PathCallbacksTests.swift @@ -4,10 +4,10 @@ import Testing -#if canImport(CoreGraphics) +#if canImport(CoreGraphics) && (OPENRENDERBOX_CF_CGTYPES || OPENRENDERBOX_COMPATIBILITY_TEST) import CoreGraphics -@Suite(.enabled(if: compatibilityTestEnabled)) +@Suite struct PathCallbacksTests { @Test func cgPathCallbacks() { @@ -19,7 +19,6 @@ struct PathCallbacksTests { #expect(callbacks.isEqual != nil) #expect(callbacks.isEmpty != nil) #expect(callbacks.isSingleElement != nil) - #expect(callbacks.bezierOrder != nil) #expect(callbacks.boundingRect != nil) #expect(callbacks.cgPath != nil) #expect(callbacks.next == nil) diff --git a/Tests/OpenRenderBoxCompatibilityTests/PathRegressionTests.swift b/Tests/OpenRenderBoxCompatibilityTests/PathRegressionTests.swift new file mode 100644 index 0000000..66672c0 --- /dev/null +++ b/Tests/OpenRenderBoxCompatibilityTests/PathRegressionTests.swift @@ -0,0 +1,33 @@ +// +// PathRegressionTests.swift +// OpenRenderBoxCompatibilityTests + +import Testing + +#if canImport(CoreGraphics) && (OPENRENDERBOX_CF_CGTYPES || OPENRENDERBOX_COMPATIBILITY_TEST) +import CoreGraphics + +@Suite +struct PathRegressionTests { + @Test + func retainUsesReturnedStorage() { + var destination: UInt64 = 0 + withUnsafePointer(to: &destination) { destination in + var source = UnsafeRawPointer(destination) + var callbacks = ORBPath.Callbacks() + callbacks.retain = { object in + object.assumingMemoryBound(to: UnsafeRawPointer.self).pointee + } + withUnsafePointer(to: &source) { source in + withUnsafePointer(to: &callbacks) { callbacks in + let path = ORBPath(storage: unsafeBitCast(source, to: ORBPath.Storage.self), callbacks: callbacks) + let retained = path.retain() + #expect(unsafeBitCast(retained.storage, to: UnsafeRawPointer.self) == UnsafeRawPointer(destination)) + #expect(retained.callbacks == callbacks) + } + } + } + } +} + +#endif diff --git a/Tests/OpenRenderBoxCompatibilityTests/PathTests.swift b/Tests/OpenRenderBoxCompatibilityTests/PathTests.swift index 2718190..238edb9 100644 --- a/Tests/OpenRenderBoxCompatibilityTests/PathTests.swift +++ b/Tests/OpenRenderBoxCompatibilityTests/PathTests.swift @@ -33,7 +33,7 @@ struct PathTests { @Test func createUnevenRoundedRectPath() { let rect = CGRect(x: 0, y: 0, width: 100, height: 100) - let path = ORBPath(roundedRect: rect, topLeftRadius: 5, bottomLeftRadius: 10, bottomRightRadius: 15, topRightRadius: 20, style: .continuous, transform: nil) + let path = ORBPath(roundedRect: rect, topLeftRadius: 5, topRightRadius: 10, bottomRightRadius: 15, bottomLeftRadius: 20, style: .continuous, transform: nil) path.release() } @@ -41,8 +41,8 @@ struct PathTests { func pathRetainRelease() { let rect = CGRect(x: 0, y: 0, width: 100, height: 100) let path = ORBPath(rect: rect, transform: nil) - path.retain() - path.release() + let retained = path.retain() + retained.release() path.release() } From 0ea98021491d1ff3a8a3543594a33b5bfd0cf96e Mon Sep 17 00:00:00 2001 From: Kyle Date: Fri, 30 Oct 2026 00:51:37 +0800 Subject: [PATCH 02/12] feat(path): implement native storage and nested callbacks --- .../OpenRenderBoxCxx/Path/NestedCallbacks.cpp | 372 +++++-------- .../OpenRenderBoxCxx/Path/NestedCallbacks.hpp | 58 +- Sources/OpenRenderBoxCxx/Path/ORBPath.cpp | 125 ++++- Sources/OpenRenderBoxCxx/Path/ORBPath.h | 25 + .../OpenRenderBoxCxx/Path/ORBPathStorage.cpp | 16 +- Sources/OpenRenderBoxCxx/Path/Storage.cpp | 524 ++++++++++++++---- Sources/OpenRenderBoxCxx/Path/Storage.hpp | 178 +++--- Sources/OpenRenderBoxShims/ORBShims.swift | 1 + .../OpenRenderBoxShims/OpenRenderBoxShims.h | 22 + .../ORBShims.swift | 1 + .../PathCallbacksTests.swift | 10 + .../PathStorageTests.swift | 180 +++--- .../PathTestSupport.swift | 81 +++ .../OpenRenderBoxCompatibilityTests/README.md | 10 +- Tests/OpenRenderBoxTests/Path/PathTests.swift | 10 +- 15 files changed, 1043 insertions(+), 570 deletions(-) create mode 100644 Tests/OpenRenderBoxCompatibilityTests/PathTestSupport.swift diff --git a/Sources/OpenRenderBoxCxx/Path/NestedCallbacks.cpp b/Sources/OpenRenderBoxCxx/Path/NestedCallbacks.cpp index 559b53b..946779d 100644 --- a/Sources/OpenRenderBoxCxx/Path/NestedCallbacks.cpp +++ b/Sources/OpenRenderBoxCxx/Path/NestedCallbacks.cpp @@ -2,308 +2,184 @@ // NestedCallbacks.cpp // OpenRenderBox -// TODO: Implement NestedCallbacks - #if OPENRENDERBOX_CF_CGTYPES #include "NestedCallbacks.hpp" -#include -#if ORB_TARGET_OS_DARWIN -#include -#endif #include +#include +#include +#include namespace ORB { namespace Path { - -#if ORB_TARGET_OS_DARWIN - namespace { struct CacheEntry { - const ORBPathCallbacks* callbacks; + const ORBPathCallbacks *callbacks; size_t index; - const NestedCallbacks* result; + const NestedCallbacks *result; }; struct Cache { - ORB::vector entries; - os_unfair_lock lock; - - Cache() : entries(), lock(OS_UNFAIR_LOCK_INIT) {} + ORB::vector entries; + std::mutex lock; }; -Cache* gCache = nullptr; - -// Helper to call bezierOrder on callbacks -inline size_t callBezierOrder(const void* object, const ORBPathCallbacks* callbacks) { - if (callbacks->bezierOrder) { - return callbacks->bezierOrder(object); - } - return 3; // Default: cubic bezier -} - -} // anonymous namespace - -NestedCallbacks::NestedCallbacks(const ORBPathCallbacks* callbacks, size_t index) - : extended{} - , originalCallbacks(callbacks) - , nestedIndex(index) -{ - // Set isExtended flag (byte at offset 0x2) - extended.flags.isExtended = 1; - - // Copy retain/release from original callbacks - extended.retain = reinterpret_cast(callbacks->retain); - extended.release = reinterpret_cast(callbacks->release); - - extended.apply = +[](const void *object, void *info, ORBPathApplyCallback callback, const ORBPathCallbacksExtended *ext) -> bool { - auto self = reinterpret_cast(ext); - auto original = self->originalCallbacks; - if (original->apply) { - return original->apply(object, info, callback); - } - return true; +} // namespace + +NestedCallbacks::NestedCallbacks(const ORBPathCallbacks *callbacks, size_t index) + : extended{}, originalCallbacks(callbacks), nestedIndex(index) { + extended.flags.isExtended = true; + extended.retain = callbacks->retain; + extended.release = callbacks->release; + extended.apply = +[](const void *object, void *info, ORBPathApplyCallback callback, const ORBPathCallbacksExtended *extended) { + auto &self = *reinterpret_cast(extended); + ApplyInfo context{info, callback, self.nestedIndex, 0, false}; + ORBPath path{reinterpret_cast(const_cast(object)), self.originalCallbacks}; + bool result = ORBPathApplyElements(path, &context, apply_elements_callback); + return result || context.reachedEnd; }; - - extended.isEqual = nullptr; - - extended.isEmpty = +[](const void *object, const ORBPathCallbacksExtended *ext) -> bool { - auto self = reinterpret_cast(ext); - auto original = self->originalCallbacks; - if (original->apply) { - // Use single_element_callback to detect emptiness - struct Info { - size_t count; - size_t depth; - bool sawClose; - } info = {0, 0, false}; - info.depth = self->nestedIndex; - original->apply(object, &info, NestedCallbacks::single_element_callback); - return info.count == 1; - } - return true; + extended.isSingleElement = +[](const void *object, const ORBPathCallbacksExtended *extended) { + auto &self = *reinterpret_cast(extended); + SingleInfo context{self.nestedIndex}; + ORBPath path{reinterpret_cast(const_cast(object)), self.originalCallbacks}; + ORBPathApplyElements(path, &context, single_element_callback); + return context.count == 1; }; - - extended.bezierOrder = +[](const void *object, const ORBPathCallbacksExtended *ext) -> size_t { - auto self = reinterpret_cast(ext); - return callBezierOrder(object, self->originalCallbacks); + extended.bezierOrder = +[](const void *object, const ORBPathCallbacksExtended *extended) -> size_t { + auto &self = *reinterpret_cast(extended); + ORBPath path{reinterpret_cast(const_cast(object)), self.originalCallbacks}; + return ORBPathGetBezierOrder(path); }; - - extended.isSingleElement = nullptr; - extended.boundingRect = nullptr; - extended.cgPath = nullptr; - - extended.next = +[](const void *object, const ORBPathCallbacksExtended *ext) -> ORBPath { - auto self = reinterpret_cast(ext); - auto nextCallbacks = NestedCallbacks::get(self->originalCallbacks, self->nestedIndex + 1); - return {reinterpret_cast(const_cast(object)), - reinterpret_cast(&nextCallbacks->extended)}; + extended.next = +[](const void *object, const ORBPathCallbacksExtended *extended) -> ORBPath { + auto &self = *reinterpret_cast(extended); + auto *next = NestedCallbacks::get(self.originalCallbacks, self.nestedIndex + 1); + return {reinterpret_cast(const_cast(object)), reinterpret_cast(&next->extended)}; }; } -const NestedCallbacks* NestedCallbacks::get(const ORBPathCallbacks* callbacks, size_t index) { - // Lazy initialization of global cache - static std::once_flag initFlag; - std::call_once(initFlag, []() { - gCache = new Cache(); - }); - - os_unfair_lock_lock(&gCache->lock); - - // Binary search for existing entry - auto& entries = gCache->entries; +const NestedCallbacks *NestedCallbacks::get(const ORBPathCallbacks *callbacks, size_t index) { + // Callback tables are process-wide identities and must remain valid for paths. + static Cache *cache = new Cache; + std::lock_guard guard(cache->lock); + auto &entries = cache->entries; + std::less less; size_t lo = 0; size_t hi = entries.size(); - while (lo < hi) { size_t mid = lo + (hi - lo) / 2; - const auto& entry = entries[mid]; - - if (entry.callbacks < callbacks) { - lo = mid + 1; - } else if (entry.callbacks > callbacks) { - hi = mid; - } else if (entry.index < index) { + const auto &entry = entries[mid]; + if (less(entry.callbacks, callbacks) || (entry.callbacks == callbacks && entry.index < index)) { lo = mid + 1; - } else if (entry.index > index) { + } else if (less(callbacks, entry.callbacks) || (entry.callbacks == callbacks && entry.index > index)) { hi = mid; } else { - // Found exact match - const NestedCallbacks* result = entry.result; - os_unfair_lock_unlock(&gCache->lock); - return result; + return entry.result; } } - - // Not found, create new NestedCallbacks - auto* nested = new NestedCallbacks(callbacks, index); - - // Insert at position 'lo' to maintain sorted order - entries.reserve(entries.size() + 1); - - // Shift elements to make room - if (lo < entries.size()) { - // Use memmove for efficiency - size_t count = entries.size() - lo; - memmove(&entries[lo + 1], &entries[lo], count * sizeof(CacheEntry)); + auto *result = new NestedCallbacks(callbacks, index); + size_t count = entries.size(); + entries.push_back(CacheEntry{}); + if (lo < count) { + std::memmove(&entries[lo + 1], &entries[lo], (count - lo) * sizeof(CacheEntry)); } - - // Insert new entry - entries[lo] = CacheEntry{callbacks, index, nested}; - - os_unfair_lock_unlock(&gCache->lock); - return nested; + entries[lo] = {callbacks, index, result}; + return result; } -// Mask for nesting elements (bits 17-20,21-24): used to detect depth changes -// From assembly: 0x1de0000 = bits for elements 17, 18, 19, 20, 22, 23, 24 -static constexpr uint32_t kNestingElementMask = 0x1de0000; - -// Helper to check if an element affects nesting depth -static inline bool isNestingElement(ORBPathElement element) { - if (element > 0x18) return false; - return ((1u << element) & kNestingElementMask) != 0; -} - -// Helper to check if element is in range 0x0-0x3 (move/line/quad/curve) -static inline bool isDrawingElement(ORBPathElement element) { - return element < 4; +bool NestedCallbacks::is_nesting_element(ORBPathElement element) ORB_NOEXCEPT { + auto type = static_cast(element); + return type <= 24 && ((1u << type) & 0x1de0000) != 0; } -// apply_elements_callback: tracks nesting depth during path enumeration -// Info structure: { context, callback, depth, elementCount, sawClose } -bool NestedCallbacks::apply_elements_callback(void* info, ORBPathElement element, const double* points, const void* userInfo) { - struct ApplyInfo { - void* context; - ORBPathApplyCallback callback; - size_t depth; - }; - auto* applyInfo = static_cast(info); - - if (element > 0x18) { - // Forward to user callback if we're at depth 0 - if (applyInfo->depth == 0) { - return applyInfo->callback(applyInfo->context, element, points, userInfo); +bool NestedCallbacks::apply_elements_callback(void *info, ORBPathElement element, const CGFloat *points, const void *userInfo) { + auto &context = *static_cast(info); + size_t depth = context.currentDepth; + if (is_nesting_element(element)) { + ++context.currentDepth; + } else if (static_cast(element) == 16) { + --context.currentDepth; + if (depth <= context.targetDepth) { + context.reachedEnd = depth == context.targetDepth; + return false; } - return false; - } - - uint32_t mask = 1u << element; - if (mask & kNestingElementMask) { - // Nesting element: increment depth - applyInfo->depth++; - return false; } - - if (element == 0x10) { // Close subpath - if (applyInfo->depth > 0) { - applyInfo->depth--; - } - return false; - } - - // Forward non-nesting elements at depth 0 - if (applyInfo->depth == 0) { - return applyInfo->callback(applyInfo->context, element, points, userInfo); - } - return false; + return depth < context.targetDepth || context.callback(context.context, element, points, userInfo); } -// single_element_callback: checks if path is a single element -// Info structure: { depth, elementCount, sawClose } -bool NestedCallbacks::single_element_callback(void* info, ORBPathElement element, const double* points, const void* userInfo) { - struct SingleInfo { - size_t depth; - size_t count; - bool sawClose; - }; - auto* singleInfo = static_cast(info); - - if (element > 0x18) { - // Elements > 0x18: count as elements at depth 0 - if (singleInfo->depth == singleInfo->count) { - singleInfo->count++; - if (element == 0x13) { // Specific element marker - singleInfo->sawClose = false; - } else { - singleInfo->sawClose = true; - } +bool NestedCallbacks::single_element_callback(void *info, ORBPathElement element, const CGFloat *, const void *) { + auto &context = *static_cast(info); + size_t depth = context.currentDepth; + auto type = static_cast(element); + if (is_nesting_element(element)) { + ++context.currentDepth; + if (depth == context.targetDepth) { + ++context.count; + context.pendingClose = type != 19; } - return false; - } - - uint32_t mask = 1u << element; - - // Handle nesting elements - if ((mask & 0xf000) != 0) { - // Elements 12-15 - if (singleInfo->depth == singleInfo->count) { - singleInfo->sawClose = true; - singleInfo->count++; + } else if (type == 16) { + --context.currentDepth; + if (depth == context.targetDepth) { + return false; } - return false; - } - - if (mask & kNestingElementMask) { - // Other nesting elements: increment depth - if (singleInfo->depth == singleInfo->count) { - singleInfo->count++; + } else if (depth == context.targetDepth) { + if (type < 4 || (type >= 12 && type <= 15)) { + context.pendingClose = true; + ++context.count; + } else if (type == 4) { + if (context.pendingClose) { + context.pendingClose = false; + ++context.count; + } + } else { + ++context.count; } - return false; } + return context.count < 2; +} - if (element == 0x10) { // Close subpath - if (singleInfo->depth > 0) { - singleInfo->depth--; - } - if (singleInfo->depth == singleInfo->count && singleInfo->sawClose) { - singleInfo->sawClose = false; - singleInfo->count++; +bool NestedCallbacks::apply_elements_fast(const Storage &storage, void *info) { + auto &context = *static_cast(info); + return storage.apply_elements_at_depth(context.context, context.callback, context.targetDepth); +} + +bool NestedCallbacks::single_element_fast(const Storage &storage, void *info) { + auto &context = *static_cast(info); + Storage::Iterator iterator; + if (context.targetDepth != 0) { + if (!first_element(storage, iterator, context.targetDepth)) { + return false; } - return false; + context.currentDepth = context.targetDepth; } - - // Drawing elements (0-3) - if (isDrawingElement(element)) { - if (singleInfo->depth == singleInfo->count) { - singleInfo->sawClose = true; - singleInfo->count++; + ORBPathElement element; + while (context.count < 2 && iterator.next_type(storage, element)) { + size_t depth = context.currentDepth; + const CGFloat unused = 0; + single_element_callback(info, element, &unused, nullptr); + if (static_cast(element) == 16 && depth == context.targetDepth) { + return false; } } - - // Return true if count >= 2 (stop enumeration) - return singleInfo->count >= 2; -} - -// apply_elements_fast: fast path for Storage -bool NestedCallbacks::apply_elements_fast(const Storage& storage, void* info) { - // TODO: Implement fast path using Storage iterator - (void)storage; - (void)info; - return true; -} - -// single_element_fast: fast path for single element detection on Storage -bool NestedCallbacks::single_element_fast(const Storage& storage, void* info) { - // TODO: Implement fast path using Storage iterator - (void)storage; - (void)info; return true; } -// first_element: find first element at given nesting depth -bool NestedCallbacks::first_element(const Storage& storage, void* iterator, size_t depth) { - // TODO: Implement using Storage iterator - (void)storage; - (void)iterator; - (void)depth; +bool NestedCallbacks::first_element(const Storage &storage, Storage::Iterator &iterator, size_t depth) { + size_t currentDepth = 0; + ORBPathElement element; + while (iterator.next_type(storage, element)) { + if (is_nesting_element(element)) { + if (++currentDepth == depth) { + return true; + } + } else if (static_cast(element) == 16) { + return false; + } + } return false; } -#endif /* ORB_TARGET_OS_DARWIN */ - -} /* namespace Path */ -} /* namespace ORB */ +} /* Path */ +} /* ORB */ #endif /* OPENRENDERBOX_CF_CGTYPES */ diff --git a/Sources/OpenRenderBoxCxx/Path/NestedCallbacks.hpp b/Sources/OpenRenderBoxCxx/Path/NestedCallbacks.hpp index f27a6a4..b720382 100644 --- a/Sources/OpenRenderBoxCxx/Path/NestedCallbacks.hpp +++ b/Sources/OpenRenderBoxCxx/Path/NestedCallbacks.hpp @@ -9,49 +9,51 @@ #if OPENRENDERBOX_CF_CGTYPES #include +#include "Storage.hpp" ORB_ASSUME_NONNULL_BEGIN namespace ORB { namespace Path { -class Storage; - -/// Extended callbacks wrapper for accessing nested paths. -/// Maintains a global cache to avoid creating duplicate wrappers. struct NestedCallbacks { - ORBPathCallbacksExtended extended; // 0x00-0x57 - const ORBPathCallbacks * _Nonnull originalCallbacks; // 0x58 - size_t nestedIndex; // 0x60 - - /// Get or create a NestedCallbacks for the given callbacks and nesting index - static const NestedCallbacks* get(const ORBPathCallbacks* callbacks, size_t index); - - /// Callback for apply_elements that tracks nesting depth - static bool apply_elements_callback(void* info, ORBPathElement element, const double* points, const void* userInfo); - - /// Callback for single_element detection - static bool single_element_callback(void* info, ORBPathElement element, const double* points, const void* userInfo); - - /// Fast path for applying elements on Storage - static bool apply_elements_fast(const Storage& storage, void* info); - - /// Fast path for single element detection on Storage - static bool single_element_fast(const Storage& storage, void* info); - - /// Find the first element at a given nesting depth - static bool first_element(const Storage& storage, void* iterator, size_t depth); + struct ApplyInfo { + void *context; + ORBPathApplyCallback callback; + size_t targetDepth; + size_t currentDepth; + bool reachedEnd; + }; + + struct SingleInfo { + size_t targetDepth = 0; + size_t currentDepth = 0; + size_t count = 0; + bool pendingClose = false; + }; + + ORBPathCallbacksExtended extended; + const ORBPathCallbacks *originalCallbacks; + size_t nestedIndex; + + static const NestedCallbacks *get(const ORBPathCallbacks *callbacks, size_t index); + static bool is_nesting_element(ORBPathElement element) ORB_NOEXCEPT; + static bool apply_elements_callback(void *info, ORBPathElement element, const CGFloat *points, const void * _Nullable userInfo); + static bool single_element_callback(void *info, ORBPathElement element, const CGFloat *points, const void * _Nullable userInfo); + static bool apply_elements_fast(const Storage &storage, void *info); + static bool single_element_fast(const Storage &storage, void *info); + static bool first_element(const Storage &storage, Storage::Iterator &iterator, size_t depth); private: - NestedCallbacks(const ORBPathCallbacks* callbacks, size_t index); + NestedCallbacks(const ORBPathCallbacks *callbacks, size_t index); }; static_assert(sizeof(NestedCallbacks) == 0x68, "NestedCallbacks size mismatch"); static_assert(offsetof(NestedCallbacks, originalCallbacks) == 0x58, "originalCallbacks offset mismatch"); static_assert(offsetof(NestedCallbacks, nestedIndex) == 0x60, "nestedIndex offset mismatch"); -} /* namespace Path */ -} /* namespace ORB */ +} /* Path */ +} /* ORB */ ORB_ASSUME_NONNULL_END diff --git a/Sources/OpenRenderBoxCxx/Path/ORBPath.cpp b/Sources/OpenRenderBoxCxx/Path/ORBPath.cpp index b4df8b0..2e45211 100644 --- a/Sources/OpenRenderBoxCxx/Path/ORBPath.cpp +++ b/Sources/OpenRenderBoxCxx/Path/ORBPath.cpp @@ -10,6 +10,9 @@ #include #include #include "Storage.hpp" +#include "NestedCallbacks.hpp" +#include +#include #include // Empty path callbacks (all null) - C++ internal linkage @@ -199,6 +202,32 @@ bool ORBPathIsEmpty(ORBPath path) { } } +bool ORBPathIsSingleElement(ORBPath path) { + if (path.callbacks == &empty_path_callbacks) { + return false; + } + if (path.callbacks->flags.isExtended) { + auto extended = reinterpret_cast(path.callbacks); + if (extended->isSingleElement != nullptr) { + return extended->isSingleElement(path.storage, extended); + } + } else if (path.callbacks->isSingleElement != nullptr) { + return path.callbacks->isSingleElement(path.storage); + } + + ORB::Path::NestedCallbacks::SingleInfo count; + ORBPathApplyElements(path, &count, ORB::Path::NestedCallbacks::single_element_callback); + return count.count == 1; +} + +size_t ORBPathGetBezierOrder(ORBPath path) { + if (path.callbacks->flags.isExtended) { + auto *extended = reinterpret_cast(path.callbacks); + return extended->bezierOrder ? extended->bezierOrder(path.storage, extended) : 3; + } + return path.callbacks->bezierOrder ? path.callbacks->bezierOrder(path.storage) : 3; +} + bool ORBPathApplyElements(ORBPath path, void *info, ORBPathApplyCallback callback) { if (callback == nullptr) { return true; @@ -221,40 +250,80 @@ bool ORBPathApplyElements(ORBPath path, void *info, ORBPathApplyCallback callbac bool ORBPathEqualToPath(ORBPath lhs, ORBPath rhs) { if (lhs.callbacks == rhs.callbacks) { - if (lhs.storage == rhs.storage) { - return true; - } - auto callbacks = lhs.callbacks; - if (callbacks->flags.isExtended) { - auto extended = reinterpret_cast(callbacks); - auto isEqualCallback = extended->isEqual; - if (isEqualCallback) { - return isEqualCallback(lhs.storage, rhs.storage, extended); - } - } else { - auto isEqualCallback = callbacks->isEqual; - if (isEqualCallback) { - return isEqualCallback(lhs.storage, rhs.storage); - } + if (lhs.storage == rhs.storage) { return true; } + if (lhs.callbacks->flags.isExtended) { + auto *extended = reinterpret_cast(lhs.callbacks); + if (extended->isEqual) { return extended->isEqual(lhs.storage, rhs.storage, extended); } + } else if (lhs.callbacks->isEqual) { + return lhs.callbacks->isEqual(lhs.storage, rhs.storage); } - // auto storage = ORB::Path::Storage(1088); - // TODO - return false; } else { if (lhs.callbacks == &empty_path_callbacks) { - if (lhs.storage == ORBPathNull.storage) { - return rhs.callbacks == &empty_path_callbacks && rhs.storage == ORBPathNull.storage; - } else { - return ORBPathIsEmpty(rhs); - } - } else if (rhs.callbacks == &empty_path_callbacks) { - if (rhs.storage == ORBPathNull.storage) { - return false; + return lhs.storage == ORBPathNull.storage ? false : ORBPathIsEmpty(rhs); + } + if (rhs.callbacks == &empty_path_callbacks) { + return rhs.storage == ORBPathNull.storage ? false : ORBPathIsEmpty(lhs); + } + } + using Storage = ORB::Path::Storage; + // Only the left stream is normalized by Storage, matching the callback ABI. + Storage storage(sizeof(Storage)); + ORBPathApplyElements(lhs, &storage, ORB::Path::append_element_callback); + struct Comparison { + const Storage &storage; + Storage::Iterator iterator; + } comparison{storage, {}}; + bool equal = ORBPathApplyElements(rhs, &comparison, +[](void *context, ORBPathElement type, const CGFloat *points, const void *info) -> bool { + auto &comparison = *static_cast(context); + Storage::Element element; + if (!comparison.iterator.next(comparison.storage, element) || type != element.type) { return false; } + for (size_t index = 0; index < Storage::argument_count(type); ++index) { + if (element.points[index] != points[index]) { return false; } + } + if (!info && !element.hasInfo) { return true; } + switch (type) { + case ORBPathElementCloseSubpath: + case ORBPathElementEnd: + case ORBPathElementTrimmed: + case ORBPathElementDilateGlyph: + case ORBPathElementOffset: + return true; + case ORBPathElementStroked: + case ORBPathElementClipStroke: { + Storage::StrokeInfo defaults{0, type == ORBPathElementClipStroke ? 1 : 0, 0, nullptr}; + auto &a = element.hasInfo ? element.metadata.stroke : defaults; + auto &b = info ? *static_cast(info) : defaults; + if (a.cap != b.cap || a.join != b.join || a.count != b.count) { return false; } + if (type == ORBPathElementStroked) { + auto *av = static_cast(a.values), *bv = static_cast(b.values); + for (size_t index = 0; index < a.count; ++index) { if (av[index] != bv[index]) { return false; } } } else { - return ORBPathIsEmpty(lhs); + auto *av = static_cast(a.values), *bv = static_cast(b.values); + for (size_t index = 0; index < a.count; ++index) { + if (av[index].type != bv[index].type) { return false; } + float ap[3], bp[3]; + std::memcpy(ap, av[index].values, sizeof(ap)); + std::memcpy(bp, bv[index].values, sizeof(bp)); + if (!std::equal(ap, ap + 3, bp)) { return false; } + } } + return true; } - } + case ORBPathElementBoolean: { + const uint8_t defaults[2] = {}; + auto *a = element.hasInfo ? element.metadata.boolean : defaults; + auto *b = info ? static_cast(info) : defaults; + return a[0] == b[0] && a[1] == b[1]; + } + default: { + CGAffineTransform a = CGAffineTransformIdentity; + if (element.hasInfo) { std::memcpy(&a, element.metadata.transform, sizeof(a)); } + auto &b = info ? *static_cast(info) : CGAffineTransformIdentity; + return CGAffineTransformEqualToTransform(a, b); + } + } + }); + return equal && comparison.iterator.offset >= storage.actual_size(); } #if ORB_TARGET_OS_DARWIN diff --git a/Sources/OpenRenderBoxCxx/Path/ORBPath.h b/Sources/OpenRenderBoxCxx/Path/ORBPath.h index 5f77248..d5067e0 100644 --- a/Sources/OpenRenderBoxCxx/Path/ORBPath.h +++ b/Sources/OpenRenderBoxCxx/Path/ORBPath.h @@ -26,12 +26,31 @@ typedef ORB_ENUM(int32_t, ORBPathElement) { ORBPathElementCloseSubpath = 4, ORBPathElementRect = 5, ORBPathElementRoundedRect = 6, + ORBPathElementEllipse = 7, ORBPathElementFixedRoundedRectCircular = 8, ORBPathElementFixedRoundedRectContinuous = 9, + ORBPathElementUnevenRoundedRectCircular = 10, + ORBPathElementUnevenRoundedRectContinuous = 11, + ORBPathElementRelativeArc = 12, + ORBPathElementArc = 13, + ORBPathElementArcCounterclockwise = 14, + ORBPathElementArcTo = 15, + ORBPathElementEnd = 16, + ORBPathElementTransformed = 17, + ORBPathElementTrimmed = 18, + ORBPathElementStroked = 19, + ORBPathElementBoolean = 20, + ORBPathElementBooleanSeparator = 21, + ORBPathElementDilateGlyph = 22, + ORBPathElementOffset = 23, + ORBPathElementClipStroke = 24, ORBPathElementInvalid = 25, } ORB_SWIFT_NAME(ORBPath.Element); +ORB_EXPORT +const uint8_t __ORBPathElementArgumentCount[25]; + /// Defines the shape of a rounded rectangle's corners. typedef ORB_ENUM(int32_t, ORBPathRoundedCornerStyle) { /// Quarter-circle rounded rect corners. @@ -103,6 +122,12 @@ static ORB_INLINE bool ORBPathIsNull(ORBPath path) ORB_SWIFT_NAME(getter:ORBPath ORB_EXPORT bool ORBPathIsEmpty(ORBPath path) ORB_SWIFT_NAME(getter:ORBPath.isEmpty(self:)); +ORB_EXPORT +bool ORBPathIsSingleElement(ORBPath path) ORB_SWIFT_NAME(getter:ORBPath.isSingleElement(self:)); + +ORB_EXPORT +size_t ORBPathGetBezierOrder(ORBPath path) ORB_SWIFT_NAME(getter:ORBPath.bezierOrder(self:)); + ORB_EXPORT bool ORBPathApplyElements(ORBPath path, void * info, _Nullable ORBPathApplyCallback callback) ORB_SWIFT_NAME(ORBPath.apply(self:info:callback:)); diff --git a/Sources/OpenRenderBoxCxx/Path/ORBPathStorage.cpp b/Sources/OpenRenderBoxCxx/Path/ORBPathStorage.cpp index 092f162..3a3595d 100644 --- a/Sources/OpenRenderBoxCxx/Path/ORBPathStorage.cpp +++ b/Sources/OpenRenderBoxCxx/Path/ORBPathStorage.cpp @@ -7,25 +7,24 @@ #include #include "Storage.hpp" #include +#include using namespace ORB; namespace ORB { namespace Path { -namespace { bool append_element_callback(void * info, ORBPathElement element, const CGFloat *points, const void * _Nullable userInfo) { reinterpret_cast(info)->append_element(element, points, userInfo); return true; } -} /* anonymous namespace */ } /* Path */ } /* ORB */ void ORBPathStorageInit(ORBPathStorageRef dst, size_t capacity, ORBPathStorageRef source) { if (source != nullptr) { - dst->storage = ORB::Path::Storage(capacity, source->storage); + new (dst) ORBPathStorage{ORB::Path::Storage(capacity, source->storage)}; } else { - dst->storage = ORB::Path::Storage(capacity); + new (dst) ORBPathStorage{ORB::Path::Storage(capacity)}; } } @@ -42,6 +41,14 @@ void ORBPathStorageAppendElement(ORBPathStorageRef storage, ORBPathElement eleme } bool ORBPathStorageAppendPath(ORBPathStorageRef storage, ORBPath path) { + if (path.storage == storage) { + ORB::Path::Storage snapshot(sizeof(ORB::Path::Storage)); + if (!ORBPathApplyElements(path, &snapshot, ORB::Path::append_element_callback)) { + return false; + } + storage->storage.append_storage(snapshot); + return true; + } return ORBPathApplyElements(path, storage, ORB::Path::append_element_callback); } @@ -72,7 +79,6 @@ CGRect ORBPathStorageGetBoundingRect(ORBPathStorageRef storage) { } CGPathRef ORBPathStorageGetCGPath(ORBPathStorageRef storage) { - // FIXME return storage->storage.cgpath(); } #endif diff --git a/Sources/OpenRenderBoxCxx/Path/Storage.cpp b/Sources/OpenRenderBoxCxx/Path/Storage.cpp index 602ed49..978abbe 100644 --- a/Sources/OpenRenderBoxCxx/Path/Storage.cpp +++ b/Sources/OpenRenderBoxCxx/Path/Storage.cpp @@ -4,151 +4,485 @@ #if OPENRENDERBOX_CF_CGTYPES -#include #include +#include "NestedCallbacks.hpp" #include "Storage.hpp" -#include - +#include +#include +#include +#include +#include #if ORB_TARGET_OS_DARWIN -#include +#include #endif +const uint8_t __ORBPathElementArgumentCount[25] = { + 2, 2, 4, 6, 0, 4, 4, 4, 6, 6, 8, 8, 5, 5, 5, 5, 0, 0, 2, 3, 0, 0, 2, 1, 1, +}; + namespace ORB { namespace Path { -atomic_uint Storage::_last_identifier; +namespace { +constexpr uint8_t elementFlags[] = { + 0, 0, 2, 4, 0, 0, 0, 4, 4, 4, 4, 4, 4, 4, 4, 4, 0, 0, 32, 0, 32, 0, 32, 36, 32, +}; -Storage::Storage(size_t capacity) { - _unknown = nullptr; - if (capacity <= 63) { - precondition_failure("invalid capacity"); +enum class InfoKind { none, affine, stroke, boolean, clipStroke }; + +InfoKind info_kind(ORBPathElement element) { + switch (static_cast(element)) { + case 4: case 16: case 18: case 22: case 23: return InfoKind::none; + case 19: return InfoKind::stroke; + case 20: return InfoKind::boolean; + case 24: return InfoKind::clipStroke; + default: return InfoKind::affine; } - uint32_t cappedCapacity = fmin(capacity - 16, 4095); - _flags = StorageFlags().withCapacity(cappedCapacity); - _identifier = get_identifier(); } -Storage::Storage(size_t capacity, const Storage &storage): Storage(capacity) { - uint32_t originalCapity = flags().capacity(); - StorageFlags sourceFlags = storage.flags(); +size_t scalar_size(uint8_t encoding) { + return encoding & 0x40 ? sizeof(float) : encoding & 0x20 ? sizeof(int16_t) : sizeof(CGFloat); +} - _flags = _flags - .withExternal(false) - .withQuadBezierOrder(sourceFlags.isQuadBezierOrder()) - .withCubicBezierOrder(sourceFlags.isCubicBezierOrder()) - .withSingleElement(sourceFlags.isSingleElement()) - // bit 4 and 5 is from sourceFlags - // bit 6 and bit 7 is zero - .withCapacity(sourceFlags.capacity()); +void check_values(uint8_t &encoding, const CGFloat *values, size_t count) { + for (size_t i = 0; encoding && i < count; ++i) { + CGFloat magnitude = std::abs(values[i]); + if (!(magnitude <= 32767)) { + encoding = 0; + } else if ((encoding & 0x20) && std::abs(std::floor(magnitude + 0.5) - magnitude) > static_cast(1e-5f)) { + encoding &= ~0x20; + } + } +} - const Storage* sourceStorage = storage.actual_storage(); - uint64_t sourceSize = storage.actual_size(); +template +T read_value(const uint8_t *bytes, size_t &offset) { + T value; + std::memcpy(&value, bytes + offset, sizeof(T)); + offset += sizeof(T); + return value; +} - if (originalCapity < sourceSize) { - // reserve_slow(sourceSize); - sourceStorage = external_storage(); - if (flags().isExternal()) { - // sourceStorage = external_storage()->0; - } +void read_values(const uint8_t *bytes, size_t &offset, uint8_t encoding, CGFloat *values, size_t count) { + for (size_t i = 0; i < count; ++i) { + values[i] = encoding & 0x40 ? read_value(bytes, offset) + : encoding & 0x20 ? read_value(bytes, offset) + : read_value(bytes, offset); + } +} + +#if ORB_TARGET_OS_DARWIN +const ORBPathCallbacks storageCallbacks = { + .flags = {}, + .retain = nullptr, + .release = nullptr, + .apply = +[](const void *object, void *info, ORBPathApplyCallback callback) { + return static_cast(object)->apply_elements(info, callback); + }, + .isEqual = nullptr, + .isEmpty = nullptr, + .isSingleElement = +[](const void *object) { + return static_cast(object)->flags().isSingleElement(); + }, + .bezierOrder = nullptr, + .boundingRect = nullptr, + .cgPath = nullptr, + .next = nullptr, +}; +#endif + +} // namespace + +std::atomic_uint Storage::_last_identifier; + +size_t Storage::argument_count(ORBPathElement element) ORB_NOEXCEPT { + auto index = static_cast(element); + return index < sizeof(__ORBPathElementArgumentCount) ? __ORBPathElementArgumentCount[index] : 0; +} + +Storage::Storage(size_t capacity) : _bounds(0), _flags(), _identifier(get_identifier()) { + if (capacity < 64) { + precondition_failure("invalid capacity"); } - memcpy((void *)external_storage(), (const void *)sourceStorage, sourceSize); - // // Update our capacity info with the size - // if (!(flags() & 0x1)) { - // // Inline storage - update size bits - // _flags = (flags() & ~(0xFFF << 8)) | (sourceSize << 8); - // } else { - // // External storage - store size externally - // *reinterpret_cast(this + 0x18) = sourceSize; - // } + _flags = _flags.withCapacity(static_cast(std::min(capacity - 16, 4095))); +} + +Storage::Storage(size_t capacity, const Storage &storage) : Storage(capacity) { + _flags = StorageFlags((_flags.value & ~0x3eu) | (storage._flags.value & 0x3eu)); + push_bytes(storage.actual_storage(), storage.actual_size()); } Storage::~Storage() { - if (_unknown != nullptr) { - auto oldValue = _unknown; - _unknown = nullptr; - // TODO + invalidate(); + if (_flags.isExternal()) { + std::free(_external_storage); } - // TODO: MapCache - if (flags().isExternal()) { - #if ORB_TARGET_OS_DARWIN - if (cachedCGPath() != nullptr) { - auto oldCache = cachedCGPath(); - _cached_cgPath = nullptr; - CFRelease(oldCache); +} + +const uint8_t *Storage::actual_storage() const ORB_NOEXCEPT { + return _flags.isExternal() ? _external_storage : reinterpret_cast(this) + 16; +} + +uint8_t *Storage::bytes() ORB_NOEXCEPT { + return const_cast(actual_storage()); +} + +size_t Storage::actual_size() const ORB_NOEXCEPT { + return _flags.isExternal() ? _external_size : _flags.size(); +} + +size_t Storage::capacity() const ORB_NOEXCEPT { + return _flags.isExternal() ? _external_capacity : _flags.capacity(); +} + +void Storage::set_size(size_t size) ORB_NOEXCEPT { + if (_flags.isExternal()) { + _external_size = size; + } else { + _flags = _flags.withSize(static_cast(size)); + } +} + +void Storage::reserve_slow(size_t size) { + size_t currentCapacity = capacity(); + size_t doubled = currentCapacity <= std::numeric_limits::max() / 2 ? currentCapacity * 2 : size; + size_t newCapacity = std::max(doubled, size); + #if ORB_TARGET_OS_DARWIN + newCapacity = malloc_good_size(newCapacity); + #endif + if (_flags.isExternal()) { + auto *newBytes = static_cast(std::realloc(_external_storage, newCapacity)); + if (!newBytes) { + precondition_failure("failed to allocate path storage"); + } + _external_storage = newBytes; + _external_capacity = newCapacity; + } else { + auto *newBytes = static_cast(std::malloc(newCapacity)); + if (!newBytes) { + precondition_failure("failed to allocate path storage"); } + size_t size = actual_size(); + std::memcpy(newBytes, actual_storage(), size); + _flags = _flags.withExternal(true); + _external_storage = newBytes; + _external_size = size; + _external_capacity = newCapacity; + #if ORB_TARGET_OS_DARWIN + _cached_cgPath = nullptr; #endif - free((void *)external_storage()); } } +void Storage::push_bytes(const void *values, size_t count) { + size_t oldSize = actual_size(); + if (count > std::numeric_limits::max() - oldSize) { + precondition_failure("path storage size overflow"); + } + size_t size = oldSize + count; + if (size > capacity()) { + reserve_slow(size); + } + if (count) { + std::memcpy(bytes() + oldSize, values, count); + } + set_size(size); +} + +void Storage::push_values(uint8_t encoding, const CGFloat *values, size_t count) { + if (!(encoding & 0x60)) { + push_bytes(values, count * sizeof(CGFloat)); + return; + } + for (size_t i = 0; i < count; ++i) { + if (encoding & 0x40) { + float value = static_cast(values[i]); + push_bytes(&value, sizeof(value)); + } else { + int16_t value = static_cast(std::floor(values[i] + 0.5)); + push_bytes(&value, sizeof(value)); + } + } +} + +void Storage::invalidate() { + uintptr_t bounds = __atomic_exchange_n(&_bounds, 0, __ATOMIC_ACQ_REL); + if (bounds & 1) { + delete[] reinterpret_cast(bounds & ~uintptr_t(3)); + } + #if ORB_TARGET_OS_DARWIN + if (_flags.isExternal()) { + CGPathRef path = __atomic_exchange_n(&_cached_cgPath, nullptr, __ATOMIC_ACQ_REL); + if (path) { + CGPathRelease(path); + } + } + #endif +} + bool Storage::operator==(const Storage &other) const ORB_NOEXCEPT { - // TODO - return false; + size_t size = actual_size(); + return size == other.actual_size() + && ((_flags.value ^ other._flags.value) & 0x3e) == 0 + && std::memcmp(actual_storage(), other.actual_storage(), size) == 0; } void Storage::clear() { - // TODO + invalidate(); + set_size(0); + _flags = StorageFlags(_flags.value & ~0x3eu); } -bool Storage::append_element(ORBPathElement element, const CGFloat *points, const void *info) { - if (element >= ORBPathElementInvalid) { +bool Storage::append_element(ORBPathElement element, const CGFloat *points, const void *userInfo) { + auto type = static_cast(element); + if (type >= sizeof(__ORBPathElementArgumentCount)) { precondition_failure("invalid path element: %d", element); } - // TODO: Implement element appending - precondition_failure("TODO"); + if ((type == 8 || type == 9) && points[4] == 0 && points[5] == 0) { + type = 6; + } else if ((type == 10 || type == 11) && points[4] == 0 && points[5] == 0 && points[6] == 0 && points[7] == 0) { + type = 6; + } + element = static_cast(type); + InfoKind kind = userInfo ? info_kind(element) : InfoKind::none; + if (kind == InfoKind::affine) { + auto *transform = static_cast(userInfo); + if (transform[0] == 1 && transform[1] == 0 && transform[2] == 0 && transform[3] == 1 && transform[4] == 0 && transform[5] == 0) { + kind = InfoKind::none; + } + } + uint8_t encoding = 0x60; + check_values(encoding, points, __ORBPathElementArgumentCount[type]); + if (kind == InfoKind::affine) { + check_values(encoding, static_cast(userInfo), 6); + } else if (kind == InfoKind::stroke) { + auto &stroke = *static_cast(userInfo); + check_values(encoding, static_cast(stroke.values), std::min(stroke.count, 4096)); + } + encoding = encoding & 0x20 ? 0x20 : encoding & 0x40; + encoding |= static_cast(type) | (kind == InfoKind::none ? 0 : 0x80); + + invalidate(); + _flags = _flags.withSingleElement(isEmpty()); + _flags = StorageFlags(_flags.value | elementFlags[type]); + if (kind == InfoKind::stroke || kind == InfoKind::clipStroke) { + auto &stroke = *static_cast(userInfo); + if (stroke.join == 1 || (kind == InfoKind::stroke && stroke.cap == 1)) { + _flags = _flags.withCubicBezierOrder(true); + } + } + push_bytes(&encoding, 1); + push_values(encoding, points, __ORBPathElementArgumentCount[type]); + switch (kind) { + case InfoKind::none: + break; + case InfoKind::affine: + push_values(encoding, static_cast(userInfo), 6); + break; + case InfoKind::boolean: + push_bytes(userInfo, 2); + break; + case InfoKind::stroke: + case InfoKind::clipStroke: { + auto &stroke = *static_cast(userInfo); + uint8_t cap = static_cast(stroke.cap); + uint8_t join = static_cast(stroke.join); + uint16_t count = static_cast(std::min(stroke.count, kind == InfoKind::stroke ? 4096 : 2048)); + push_bytes(&cap, 1); + push_bytes(&join, 1); + push_bytes(&count, 2); + if (kind == InfoKind::stroke) { + push_values(encoding, static_cast(stroke.values), count); + } else { + auto *records = static_cast(stroke.values); + for (size_t i = 0; i < count; ++i) { + uint8_t type = static_cast(records[i].type); + push_bytes(&type, 1); + push_bytes(records[i].values, sizeof(records[i].values)); + } + } + break; + } + } + if (type == 4 || type == 16) { + update_single_element(); + } + return true; +} + +void Storage::append_storage(const Storage &storage) { + if (storage.isEmpty()) { + return; + } + StorageFlags sourceFlags = storage._flags; + std::vector copy; + const uint8_t *source = storage.actual_storage(); + size_t count = storage.actual_size(); + if (this == &storage) { + copy.assign(source, source + count); + source = copy.data(); + } + invalidate(); + _flags = _flags.withSingleElement(isEmpty() && sourceFlags.isSingleElement()); + _flags = StorageFlags(_flags.value | (sourceFlags.value & 0x26)); + push_bytes(source, count); +} + +void Storage::update_single_element() { + if (_flags.value & 0x10) { + return; + } + NestedCallbacks::SingleInfo info{}; + NestedCallbacks::single_element_fast(*this, &info); + _flags = StorageFlags(_flags.value & ~0x18u); + if (info.currentDepth == 0 && info.count != 0) { + _flags = StorageFlags(_flags.value | (info.count == 1 ? 8 : 16)); + } +} + +bool Storage::Iterator::next(const Storage &storage, Element &element) { + if (offset >= storage.actual_size()) { + return false; + } + const uint8_t *bytes = storage.actual_storage(); + uint8_t encoding = read_value(bytes, offset); + element.type = static_cast(encoding & 0x1f); + read_values(bytes, offset, encoding, element.points, argument_count(element.type)); + InfoKind kind = encoding & 0x80 ? info_kind(element.type) : InfoKind::none; + element.hasInfo = kind != InfoKind::none; + switch (kind) { + case InfoKind::none: + break; + case InfoKind::affine: + read_values(bytes, offset, encoding, element.metadata.transform, 6); + break; + case InfoKind::boolean: + std::memcpy(element.metadata.boolean, bytes + offset, 2); + offset += 2; + break; + case InfoKind::stroke: + case InfoKind::clipStroke: { + auto &stroke = element.metadata.stroke; + stroke.cap = read_value(bytes, offset); + stroke.join = read_value(bytes, offset); + stroke.count = read_value(bytes, offset); + if (kind == InfoKind::stroke) { + element.values.resize(stroke.count); + read_values(bytes, offset, encoding, element.values.data(), stroke.count); + stroke.values = element.values.data(); + } else { + element.records.resize(stroke.count); + for (auto &record : element.records) { + record.type = read_value(bytes, offset); + std::memcpy(record.values, bytes + offset, sizeof(record.values)); + offset += sizeof(record.values); + } + stroke.values = element.records.data(); + } + break; + } + } + return true; +} + +bool Storage::Iterator::next_type(const Storage &storage, ORBPathElement &element) { + if (offset >= storage.actual_size()) { + return false; + } + const uint8_t *bytes = storage.actual_storage(); + uint8_t encoding = read_value(bytes, offset); + element = static_cast(encoding & 0x1f); + size_t width = scalar_size(encoding); + offset += argument_count(element) * width; + if (encoding & 0x80) { + switch (info_kind(element)) { + case InfoKind::none: break; + case InfoKind::affine: offset += 6 * width; break; + case InfoKind::boolean: offset += 2; break; + case InfoKind::stroke: + case InfoKind::clipStroke: { + offset += 2; + size_t count = read_value(bytes, offset); + offset += count * (static_cast(element) == 19 ? width : 13); + break; + } + } + } + return true; } bool Storage::apply_elements(void *info, ORBPathApplyCallback callback) const ORB_NOEXCEPT { - // TODO: Add fast-path checks for special callbacks: - // - append_element_callback → append_storage(info, this) - // - NestedCallbacks::apply_elements_callback → apply_elements_fast - // - NestedCallbacks::single_element_callback → single_element_fast - // - Mapper::apply_callback with flags check → MapCache::apply + if (!callback) { + return true; + } + if (callback == append_element_callback) { + static_cast(info)->append_storage(*this); + return true; + } + if (callback == NestedCallbacks::apply_elements_callback) { + return NestedCallbacks::apply_elements_fast(*this, info); + } + if (callback == NestedCallbacks::single_element_callback) { + return NestedCallbacks::single_element_fast(*this, info); + } return apply_elements_(info, callback); } bool Storage::apply_elements_(void *info, ORBPathApplyCallback callback) const ORB_NOEXCEPT { - // TODO: Implement actual element iteration over storage + Iterator iterator; + Element element; + while (iterator.next(*this, element)) { + if (!callback(info, element.type, element.points, element.info())) { + return false; + } + } + return true; +} + +bool Storage::apply_elements_at_depth(void *info, ORBPathApplyCallback callback, size_t depth) const ORB_NOEXCEPT { + if (depth == 0) { + return apply_elements_(info, callback); + } + Iterator iterator; + if (!NestedCallbacks::first_element(*this, iterator, depth)) { + return false; + } + Element element; + size_t currentDepth = depth; + while (iterator.next(*this, element)) { + if (NestedCallbacks::is_nesting_element(element.type)) { + ++currentDepth; + } else if (static_cast(element.type) == 16) { + if (currentDepth == depth) { + return true; + } + --currentDepth; + } + if (!callback(info, element.type, element.points, element.info())) { + return false; + } + } return true; } #if ORB_TARGET_OS_DARWIN + + CGPathRef Storage::cgpath() const ORB_NOEXCEPT { - if (flags().isInline()) { + if (_flags.isInline()) { return nullptr; } - CGPathRef cached = __atomic_load_n(&_cached_cgPath, __ATOMIC_SEQ_CST); - if (cached != nullptr) { + CGPathRef cached = __atomic_load_n(&_cached_cgPath, __ATOMIC_ACQUIRE); + if (cached) { return cached; } - static const ORBPathCallbacks callbacks = { - {}, - nullptr, - nullptr, - .apply = +[](const void *object, void *info, ORBPathApplyCallback callback) -> bool { - auto storage = reinterpret_cast(object); - return storage->apply_elements(info, callback); - }, - nullptr, - nullptr, - nullptr, - nullptr, - nullptr, - nullptr, - nullptr, - }; - ORBPath path = { - const_cast(reinterpret_cast(this)), - &callbacks - }; - CGPathRef new_path = ORBPathCopyCGPath(path); + ORBPath path{reinterpret_cast(const_cast(this)), &storageCallbacks}; + CGPathRef newPath = ORBPathCopyCGPath(path); CGPathRef expected = nullptr; - if (__atomic_compare_exchange_n(&_cached_cgPath, &expected, new_path, - false, __ATOMIC_ACQ_REL, __ATOMIC_ACQUIRE)) { - return new_path; - } else { - CGPathRelease(new_path); - return expected; + if (__atomic_compare_exchange_n(&_cached_cgPath, &expected, newPath, false, __ATOMIC_ACQ_REL, __ATOMIC_ACQUIRE)) { + return newPath; } + CGPathRelease(newPath); + return expected; } #endif diff --git a/Sources/OpenRenderBoxCxx/Path/Storage.hpp b/Sources/OpenRenderBoxCxx/Path/Storage.hpp index c4674c4..8682052 100644 --- a/Sources/OpenRenderBoxCxx/Path/Storage.hpp +++ b/Sources/OpenRenderBoxCxx/Path/Storage.hpp @@ -10,7 +10,10 @@ #include #include "../Util/assert.hpp" -#include +#include +#include +#include +#include #if ORB_TARGET_OS_DARWIN #include #endif @@ -29,6 +32,7 @@ struct StorageFlags { /// 3 -> isSingleElement /// [8, 20) -> size /// [20, 32) -> capacity + friend class Storage; uint32_t value; ORB_INLINE ORB_CONSTEXPR @@ -43,7 +47,7 @@ struct StorageFlags { bool isCubicBezierOrder, bool isSingleElement, uint32_t size, - size_t capacity) + uint32_t capacity) : StorageFlags(StorageFlags() .withExternal(isExternal) .withQuadBezierOrder(isQuadBezierOrder) @@ -85,13 +89,18 @@ struct StorageFlags { return (value & 8u) != 0; } + ORB_INLINE ORB_CONSTEXPR + bool requiresMapping() const ORB_NOEXCEPT { + return (value & 32u) != 0; + } + ORB_INLINE ORB_CONSTEXPR uint32_t size() const ORB_NOEXCEPT { return (value >> 8u) & 0xFFFu; } ORB_INLINE ORB_CONSTEXPR - size_t capacity() const ORB_NOEXCEPT { + uint32_t capacity() const ORB_NOEXCEPT { return (value >> 0x14); } @@ -121,8 +130,8 @@ struct StorageFlags { } ORB_INLINE ORB_CONSTEXPR - StorageFlags withCapacity(size_t capacity) const ORB_NOEXCEPT { - return StorageFlags((value & 0xFFFFFFu) | ((capacity & 0xFFFu) << 0x14)); + StorageFlags withCapacity(uint32_t capacity) const ORB_NOEXCEPT { + return StorageFlags((value & 0x000FFFFFu) | ((capacity & 0xFFFu) << 0x14)); } ORB_INLINE ORB_CONSTEXPR @@ -133,113 +142,114 @@ struct StorageFlags { class Storage { public: + struct StrokeInfo { + int32_t cap; + int32_t join; + size_t count; + const void * _Nullable values; + }; + + struct StrokeRecord { + int32_t type; + uint8_t values[12]; + }; + + struct Element { + CGFloat points[8] = {}; + ORBPathElement type = ORBPathElementMoveToPoint; + bool hasInfo = false; + union { + CGFloat transform[6]; + StrokeInfo stroke; + uint8_t boolean[2]; + } metadata = {}; + std::vector values; + std::vector records; + + const void * _Nullable info() const ORB_NOEXCEPT { + return hasInfo ? &metadata : nullptr; + } + }; + + struct Iterator { + size_t offset = 0; + bool next(const Storage &storage, Element &element); + bool next_type(const Storage &storage, ORBPathElement &element); + }; + + static size_t argument_count(ORBPathElement element) ORB_NOEXCEPT; + ORB_INLINE ORB_CONSTEXPR - const static atomic_uint& last_identifier() ORB_NOEXCEPT { + static const std::atomic_uint &last_identifier() ORB_NOEXCEPT { return _last_identifier; } ORB_INLINE - const static uint32_t get_identifier() ORB_NOEXCEPT { - return atomic_fetch_add_explicit(&_last_identifier, 1, memory_order_relaxed); + static uint32_t get_identifier() ORB_NOEXCEPT { + return _last_identifier.fetch_add(1, std::memory_order_relaxed) + 1; } -private: - static atomic_uint _last_identifier; - -public: Storage(size_t capacity); - Storage(size_t capacity, const ORB::Path::Storage &storage); + Storage(size_t capacity, const Storage &storage); + Storage(const Storage &) = delete; + Storage &operator=(const Storage &) = delete; ~Storage(); - bool operator == (const Storage &other) const ORB_NOEXCEPT; - + bool operator==(const Storage &other) const ORB_NOEXCEPT; void clear(); - bool append_element(ORBPathElement element, const CGFloat *points, const void * _Nullable userInfo); -// push_values(unsigned char, double const*, unsigned long) - // update_single_element() - - /// Apply callback to each path element (with fast-path checks) - bool apply_elements(void *info, ORBPathApplyCallback callback) const ORB_NOEXCEPT; - -private: - /// Core element iteration (no fast-path checks) - bool apply_elements_(void *info, ORBPathApplyCallback callback) const ORB_NOEXCEPT; - -public: + void append_storage(const Storage &storage); + bool apply_elements(void *info, ORBPathApplyCallback _Nullable callback) const ORB_NOEXCEPT; + bool apply_elements_at_depth(void *info, ORBPathApplyCallback callback, size_t depth) const ORB_NOEXCEPT; #if ORB_TARGET_OS_DARWIN - /// Get cached CGPath, lazily creating if needed (thread-safe with casal) CGPathRef _Nullable cgpath() const ORB_NOEXCEPT; #endif -public: - ORB_INLINE ORB_CONSTEXPR - void * unknown() const ORB_NOEXCEPT { - return _unknown; - } - - ORB_INLINE ORB_CONSTEXPR - StorageFlags flags() const ORB_NOEXCEPT { - return _flags; - } ORB_INLINE ORB_CONSTEXPR - uint32_t identifier() const ORB_NOEXCEPT { - return _identifier; - } - - ORB_INLINE ORB_CONSTEXPR - const Storage* external_storage() const ORB_NOEXCEPT { - return _external_storage; - } - - ORB_INLINE ORB_CONSTEXPR - uint64_t external_size() const ORB_NOEXCEPT { - return _external_size; - } - - ORB_INLINE ORB_CONSTEXPR - const Storage* actual_storage() const ORB_NOEXCEPT { - if (flags().isInline()) { - return this; - } else { - return external_storage(); - } - } + StorageFlags flags() const ORB_NOEXCEPT { return _flags; } ORB_INLINE ORB_CONSTEXPR - uint64_t actual_size() const ORB_NOEXCEPT { - if (flags().isInline()) { - return flags().size(); - } else { - return external_size(); - } - } + uint32_t identifier() const ORB_NOEXCEPT { return _identifier; } - ORB_INLINE ORB_CONSTEXPR - bool isEmpty() const ORB_NOEXCEPT { - return actual_size() == 0; - } + const uint8_t *actual_storage() const ORB_NOEXCEPT; + size_t actual_size() const ORB_NOEXCEPT; - #if ORB_TARGET_OS_DARWIN ORB_INLINE - CGPathRef cachedCGPath() const ORB_NOEXCEPT { - return _cached_cgPath; - } - #endif + bool isEmpty() const ORB_NOEXCEPT { return actual_size() == 0; } + private: - void * _unknown; // 0x00 + static std::atomic_uint _last_identifier; + uint8_t *bytes() ORB_NOEXCEPT; + size_t capacity() const ORB_NOEXCEPT; + void set_size(size_t size) ORB_NOEXCEPT; + void reserve_slow(size_t size); + void push_bytes(const void *values, size_t count); + void push_values(uint8_t encoding, const CGFloat *values, size_t count); + void invalidate(); + void update_single_element(); + bool apply_elements_(void *info, ORBPathApplyCallback callback) const ORB_NOEXCEPT; + + mutable uintptr_t _bounds; // 0x00: tagged bounds cache StorageFlags _flags; // 0x08 - uint32_t _identifier; // 0x0C - Storage* _external_storage; // 0x10 - uint64_t _external_size; // 0x18 - void * _reserved1; // 0x20 - void * _reserved2; // 0x28 - void * _reserved3; // 0x30 + uint32_t _identifier; // 0x0c + // Inline bytes start at 0x10 and can extend to the caller's capacity. + // These fields are live only when flags bit 0 selects external storage. + uint8_t *_external_storage; // 0x10 + size_t _external_size; // 0x18 + size_t _external_capacity; // 0x20 + mutable float _external_bounds[4]; // 0x28 #if ORB_TARGET_OS_DARWIN - mutable CGPathRef _cached_cgPath; // 0x38 - lazily cached CGPath + mutable CGPathRef _Nullable _cached_cgPath; // 0x38 + #else + void *_reserved; // 0x38 #endif }; + +static_assert(sizeof(Storage) == 64, "Storage header and external overlay must fit 64 bytes"); + +bool append_element_callback(void *info, ORBPathElement element, const CGFloat *points, const void * _Nullable userInfo); + } /* Path */ } /* ORB */ diff --git a/Sources/OpenRenderBoxShims/ORBShims.swift b/Sources/OpenRenderBoxShims/ORBShims.swift index 230c10d..6e1a28a 100644 --- a/Sources/OpenRenderBoxShims/ORBShims.swift +++ b/Sources/OpenRenderBoxShims/ORBShims.swift @@ -37,6 +37,7 @@ public typealias ORBDisplayListTransform = RBDisplayListTransform public typealias ORBDrawingState = RBDrawingState public typealias ORBLayer = RBLayer public typealias ORBLayerDelegate = RBLayerDelegate +public let __ORBPathElementArgumentCount = __RBPathElementArgumentCount public typealias ORBPath = RBPath public typealias ORBPathApplyCallback = RBPathApplyCallback public typealias ORBUUID = RBUUID diff --git a/Sources/OpenRenderBoxShimsCxx/include/RenderBox/OpenRenderBoxShims/OpenRenderBoxShims.h b/Sources/OpenRenderBoxShimsCxx/include/RenderBox/OpenRenderBoxShims/OpenRenderBoxShims.h index 120aabe..3768cd0 100644 --- a/Sources/OpenRenderBoxShimsCxx/include/RenderBox/OpenRenderBoxShims/OpenRenderBoxShims.h +++ b/Sources/OpenRenderBoxShimsCxx/include/RenderBox/OpenRenderBoxShims/OpenRenderBoxShims.h @@ -70,13 +70,30 @@ typedef RBPathStorageRef ORBPathStorageRef; #define ORBPathElementCloseSubpath RBPathElementCloseSubpath #define ORBPathElementRect RBPathElementRect #define ORBPathElementRoundedRect RBPathElementRoundedRect +#define ORBPathElementEllipse RBPathElementEllipse #define ORBPathElementFixedRoundedRectCircular RBPathElementFixedRoundedRectCircular #define ORBPathElementFixedRoundedRectContinuous RBPathElementFixedRoundedRectContinuous +#define ORBPathElementUnevenRoundedRectCircular RBPathElementUnevenRoundedRectCircular +#define ORBPathElementUnevenRoundedRectContinuous RBPathElementUnevenRoundedRectContinuous +#define ORBPathElementRelativeArc RBPathElementRelativeArc +#define ORBPathElementArc RBPathElementArc +#define ORBPathElementArcCounterclockwise RBPathElementArcCounterclockwise +#define ORBPathElementArcTo RBPathElementArcTo +#define ORBPathElementEnd RBPathElementEnd +#define ORBPathElementTransformed RBPathElementTransformed +#define ORBPathElementTrimmed RBPathElementTrimmed +#define ORBPathElementStroked RBPathElementStroked +#define ORBPathElementBoolean RBPathElementBoolean +#define ORBPathElementBooleanSeparator RBPathElementBooleanSeparator +#define ORBPathElementDilateGlyph RBPathElementDilateGlyph +#define ORBPathElementOffset RBPathElementOffset +#define ORBPathElementClipStroke RBPathElementClipStroke #define ORBPathElementInvalid RBPathElementInvalid #define ORBPathRoundedCornerStyleCircular RBPathRoundedCornerStyleCircular #define ORBPathRoundedCornerStyleContinuous RBPathRoundedCornerStyleContinuous + #define ORBPathEmpty RBPathEmpty #define ORBPathNull RBPathNull #define ORBPathRetain RBPathRetain @@ -86,6 +103,8 @@ typedef RBPathStorageRef ORBPathStorageRef; #define ORBPathMakeEllipse RBPathMakeEllipse #define ORBPathMakeRoundedRect RBPathMakeRoundedRect #define ORBPathMakeUnevenRoundedRect RBPathMakeUnevenRoundedRect +#define ORBPathIsSingleElement RBPathIsSingleElement +#define ORBPathGetBezierOrder RBPathGetBezierOrder #define ORBPathIsEmpty RBPathIsEmpty #define ORBPathApplyElements RBPathApplyElements #define ORBPathEqualToPath RBPathEqualToPath @@ -106,3 +125,6 @@ typedef RBPathStorageRef ORBPathStorageRef; #define ORBPathStorageGetBezierOrder RBPathStorageGetBezierOrder #define ORBPathStorageGetBoundingRect RBPathStorageGetBoundingRect #define ORBPathStorageGetCGPath RBPathStorageGetCGPath + + +#define __ORBPathElementArgumentCount __RBPathElementArgumentCount diff --git a/Tests/OpenRenderBoxCompatibilityTests/ORBShims.swift b/Tests/OpenRenderBoxCompatibilityTests/ORBShims.swift index 4080c01..232ff8d 100644 --- a/Tests/OpenRenderBoxCompatibilityTests/ORBShims.swift +++ b/Tests/OpenRenderBoxCompatibilityTests/ORBShims.swift @@ -13,6 +13,7 @@ public typealias ORBDisplayListContents = RBDisplayListContents public typealias ORBDisplayListInterpolator = RBDisplayListInterpolator public typealias ORBLayer = RBLayer public typealias ORBLayerDelegate = RBLayerDelegate +public let __ORBPathElementArgumentCount = __RBPathElementArgumentCount public typealias ORBPath = RBPath public typealias ORBPathApplyCallback = RBPathApplyCallback public typealias ORBUUID = RBUUID diff --git a/Tests/OpenRenderBoxCompatibilityTests/PathCallbacksTests.swift b/Tests/OpenRenderBoxCompatibilityTests/PathCallbacksTests.swift index 0d67875..4a4ff19 100644 --- a/Tests/OpenRenderBoxCompatibilityTests/PathCallbacksTests.swift +++ b/Tests/OpenRenderBoxCompatibilityTests/PathCallbacksTests.swift @@ -9,6 +9,16 @@ import CoreGraphics @Suite struct PathCallbacksTests { + @Test + func cgPathBezierOrder() { + let cgPath = CGMutablePath() + cgPath.move(to: .zero) + cgPath.addCurve(to: CGPoint(x: 10, y: 0), control1: CGPoint(x: 0, y: 10), control2: CGPoint(x: 10, y: 10)) + let path = ORBPath(cgPath: cgPath) + defer { path.release() } + #expect(path.bezierOrder == 3) + } + @Test func cgPathCallbacks() { let callbacks = ORBPath.Callbacks.cgPath diff --git a/Tests/OpenRenderBoxCompatibilityTests/PathStorageTests.swift b/Tests/OpenRenderBoxCompatibilityTests/PathStorageTests.swift index 4883f1b..e64371e 100644 --- a/Tests/OpenRenderBoxCompatibilityTests/PathStorageTests.swift +++ b/Tests/OpenRenderBoxCompatibilityTests/PathStorageTests.swift @@ -4,101 +4,125 @@ import Testing -#if canImport(CoreGraphics) +#if canImport(CoreGraphics) && (OPENRENDERBOX_CF_CGTYPES || OPENRENDERBOX_COMPATIBILITY_TEST) import CoreGraphics -@Suite(.enabled(if: compatibilityTestEnabled)) +@Suite struct PathStorageTests { - @Test - func storageIsEmpty() { - let path = ORBPath(rect: CGRect(x: 0, y: 0, width: 100, height: 100), transform: nil) - let storage = path.storage - storage.initialize(capacity: 96, source: nil) - #expect(storage.isEmpty == true) - storage.append(path: path) - #expect(storage.isEmpty == false) - storage.destroy() - path.release() + private struct ClipKeyframe: Equatable { + var tag: Int32 + var value: Float + var start: Float + var end: Float } - @Test - func storageIsSingleElement() { - let path = ORBPath(rect: CGRect(x: 0, y: 0, width: 100, height: 100), transform: nil) - let storage = path.storage - storage.initialize(capacity: 96, source: nil) - #expect(storage.isSingleElement == false) - storage.append(path: path) - #expect(storage.isSingleElement == true) - storage.destroy() - path.release() + private struct ClipInfo { + var flags: Int32 + var cap: Int32 + var count: Int + var keys: UnsafePointer? } - @Test func storageBezierOrder() { - let path = ORBPath(rect: CGRect(x: 0, y: 0, width: 100, height: 100), transform: nil) - let order = path.storage.bezierOrder - #expect(order == 1) - path.release() + withPathStorage { storage in + #expect(storage.bezierOrder == 1) + storage.append(element: .moveToPoint, points: [0, 0], userInfo: nil) + storage.append(element: .addQuadCurveToPoint, points: [1, 2, 3, 4], userInfo: nil) + #expect(storage.bezierOrder == 2) + storage.append(element: .addCurveToPoint, points: [5, 6, 7, 8, 9, 10], userInfo: nil) + #expect(storage.bezierOrder == 3) + storage.clear() + #expect(storage.bezierOrder == 1) + } } - @Test - func storageBoundingRect() { - let rect = CGRect(x: 10, y: 20, width: 100, height: 50) - let path = ORBPath(rect: rect, transform: nil) - let storage = path.storage - storage.initialize(capacity: 96, source: nil) - storage.append(path: path) - let boundingRect = path.storage.boundingRect - #expect(boundingRect.origin.x == rect.origin.x) - #expect(boundingRect.origin.y == rect.origin.y) - #expect(boundingRect.size.width == rect.size.width) - #expect(boundingRect.size.height == rect.size.height) - storage.destroy() - path.release() + @Test(arguments: [64, 256], [1, 40]) + func storageCopyPreservesElements(capacity: Int, lineCount: Int) { + withPathStorage(capacity: capacity) { source in + source.append(element: .moveToPoint, points: [1, 2], userInfo: nil) + for index in 1...lineCount { + source.append(element: .addLineToPoint, points: [CGFloat(index), CGFloat(index * 2)], userInfo: nil) + } + let expected = pathElements(source) + for destinationCapacity in [64, 256] { + withPathStorage(capacity: destinationCapacity, source: source) { copy in + #expect(pathElements(copy) == expected) + #expect(copy.isEqual(to: source)) + copy.append(element: .addLineToPoint, points: [100, 200], userInfo: nil) + #expect(pathElements(source) == expected) + } + } + withPathStorage(capacity: 64, source: source) { copy in + source.clear() + #expect(pathElements(copy) == expected) + } + } } - @Test(arguments: [ - (CGRect(x: 0, y: 0, width: 100, height: 100), CGRect(x: 0, y: 0, width: 100, height: 100), true), - (CGRect(x: 0, y: 0, width: 100, height: 100), CGRect(x: 0, y: 0, width: 200, height: 200), false), - (CGRect(x: 10, y: 20, width: 50, height: 50), CGRect(x: 10, y: 20, width: 50, height: 50), true), - ]) - func storageEquality(rect1: CGRect, rect2: CGRect, expectedEqual: Bool) { - let path1 = ORBPath(rect: rect1, transform: nil) - let path2 = ORBPath(rect: rect2, transform: nil) - let storage1 = path1.storage - let storage2 = path2.storage - storage1.initialize(capacity: 96, source: nil) - storage1.append(path: path1) - storage2.initialize(capacity: 96, source: nil) - storage2.append(path: path2) - #expect(storage1.isEqual(to: storage2) == expectedEqual) - storage1.destroy() - storage2.destroy() - path1.release() - path2.release() + @Test + func appendingStorageToItselfUsesSnapshot() { + withPathStorage { storage in + storage.append(element: .moveToPoint, points: [1, 2], userInfo: nil) + storage.append(element: .addLineToPoint, points: [3, 4], userInfo: nil) + let original = pathElements(storage) + withStoragePath(storage) { path in + #expect(storage.append(path: path)) + } + #expect(pathElements(storage) == original + original) + } } - @Test("Verify no crash or memleak of ORBPathStorageGetCGPath call", arguments: 1...20) - func storageGetCGPath(iteration: Int) { - let rect = CGRect(x: 0, y: 0, width: 100, height: 100) - let path = ORBPath(rect: rect, transform: nil) - let storage = path.storage - storage.initialize(capacity: 64, source: nil) - storage.append(path: path) - for _ in 1...7 { - storage.append(element: .moveToPoint, points: [50, 50], userInfo: nil) - storage.append(element: .addLineToPoint, points: [100, 50], userInfo: nil) - storage.append(element: .closeSubpath, points: [], userInfo: nil) - } - if let p = storage.cgPath { - _ = p.isEmpty - _ = p.boundingBox + @Test + func storageOwnsClipStrokeKeyframes() { + let expected = [ + ClipKeyframe(tag: 0, value: 0, start: 0, end: 0.5), + ClipKeyframe(tag: 0, value: 1, start: 0.5, end: 1), + ] + #expect(MemoryLayout.size == 16) + #expect(MemoryLayout.size == 24) + withPathStorage(capacity: 64) { storage in + var keys = expected + keys.withUnsafeBufferPointer { keys in + var metadata = ClipInfo(flags: 2, cap: 1, count: keys.count, keys: keys.baseAddress) + withUnsafePointer(to: &metadata) { metadata in + storage.append(element: .clipStroke, points: [0.25], userInfo: metadata) + } + } + keys[0].value = 42 + keys.removeAll() + storage.append(element: .rect, points: [0, 0, 10, 10], userInfo: nil) + storage.append(element: .end, points: [], userInfo: nil) + withPathStorage(capacity: 64, source: storage) { copy in + storage.clear() + var result: [ClipKeyframe] = [] + withUnsafeMutablePointer(to: &result) { result in + _ = copy.apply(info: result) { info, element, _, metadata in + if element == .clipStroke, let metadata { + let clip = metadata.assumingMemoryBound(to: ClipInfo.self).pointee + info.assumingMemoryBound(to: [ClipKeyframe].self).pointee = Array(UnsafeBufferPointer(start: clip.keys, count: clip.count)) + } + return true + } + } + #expect(result == expected) + } } - storage.destroy() - path.release() } + @Test + func storageStopsEnumeration() { + withPathStorage { storage in + storage.append(element: .moveToPoint, points: [1, 2], userInfo: nil) + storage.append(element: .addLineToPoint, points: [3, 4], userInfo: nil) + var count = 0 + let completed = storage.apply(info: &count) { info, _, _, _ in + info.assumingMemoryBound(to: Int.self).pointee += 1 + return false + } + #expect(!completed) + #expect(count == 1) + } + } } #endif - diff --git a/Tests/OpenRenderBoxCompatibilityTests/PathTestSupport.swift b/Tests/OpenRenderBoxCompatibilityTests/PathTestSupport.swift new file mode 100644 index 0000000..93538ed --- /dev/null +++ b/Tests/OpenRenderBoxCompatibilityTests/PathTestSupport.swift @@ -0,0 +1,81 @@ +// +// PathTestSupport.swift +// OpenRenderBoxCompatibilityTests + +#if canImport(CoreGraphics) && (OPENRENDERBOX_CF_CGTYPES || OPENRENDERBOX_COMPATIBILITY_TEST) +import CoreGraphics +import Foundation + +func withPathStorage( + capacity: Int = 96, + source: ORBPath.Storage? = nil, + _ body: (ORBPath.Storage) throws -> Result +) rethrows -> Result { + let bytes = UnsafeMutableRawPointer.allocate(byteCount: capacity, alignment: 16) + let storage = unsafeBitCast(bytes, to: ORBPath.Storage.self) + storage.initialize(capacity: capacity, source: source) + defer { + storage.destroy() + bytes.deallocate() + } + return try body(storage) +} + +func withStoragePath(_ storage: ORBPath.Storage, _ body: (ORBPath) throws -> Result) rethrows -> Result { + var callbacks = ORBPath.Callbacks() + callbacks.apply = { object, info, callback in + let storage = unsafeBitCast(object, to: ORBPath.Storage.self) + return storage.apply(info: info, callback: callback) + } + callbacks.isEmpty = { object in + unsafeBitCast(object, to: ORBPath.Storage.self).isEmpty + } + callbacks.isSingleElement = { object in + unsafeBitCast(object, to: ORBPath.Storage.self).isSingleElement + } + return try withUnsafePointer(to: &callbacks) { callbacks in + try body(ORBPath(storage: storage, callbacks: callbacks)) + } +} + +struct PathElementRecord: Equatable { + var element: Int32 + var points: [CGFloat] +} + +func recordPathElement( + info: UnsafeMutableRawPointer, + element: ORBPath.Element, + points: UnsafePointer, + userInfo: UnsafeRawPointer? +) -> Bool { + let count: Int + switch element { + case .moveToPoint, .addLineToPoint: count = 2 + case .addQuadCurveToPoint, .rect, .roundedRect, .ellipse: count = 4 + case .addCurveToPoint, .fixedRoundedRectCircular, .fixedRoundedRectContinuous: count = 6 + case .unevenRoundedRectCircular, .unevenRoundedRectContinuous: count = 8 + default: count = 0 + } + info.assumingMemoryBound(to: [PathElementRecord].self).pointee.append( + PathElementRecord(element: element.rawValue, points: Array(UnsafeBufferPointer(start: points, count: count))) + ) + return true +} + +func pathElements(_ storage: ORBPath.Storage) -> [PathElementRecord] { + var elements: [PathElementRecord] = [] + _ = withUnsafeMutablePointer(to: &elements) { + storage.apply(info: $0, callback: recordPathElement) + } + return elements +} + +func pathElements(_ path: ORBPath) -> [PathElementRecord] { + var elements: [PathElementRecord] = [] + _ = withUnsafeMutablePointer(to: &elements) { + path.apply(info: $0, callback: recordPathElement) + } + return elements +} +#endif diff --git a/Tests/OpenRenderBoxCompatibilityTests/README.md b/Tests/OpenRenderBoxCompatibilityTests/README.md index 78e259c..dda1f78 100644 --- a/Tests/OpenRenderBoxCompatibilityTests/README.md +++ b/Tests/OpenRenderBoxCompatibilityTests/README.md @@ -1,3 +1,11 @@ ## OpenRenderBoxCompatibilityTests -Test public API of OpenRenderBox and run it against with RenderBox on Apple Platform. +Run the callback and Storage suites against native OpenRenderBox: + +```sh +OPENRENDERBOX_USE_LOCAL_DEPS=1 OPENRENDERBOX_RENDERBOX=0 \ +OPENRENDERBOX_LIBRARY_EVOLUTION=0 swift test --filter 'PathCallbacksTests|PathStorageTests|PathRegressionTests' +``` + +Storage fixtures allocate separate aligned buffers. A primitive's `storage` +pointer is not a buffer that can be passed to Storage initialization APIs. diff --git a/Tests/OpenRenderBoxTests/Path/PathTests.swift b/Tests/OpenRenderBoxTests/Path/PathTests.swift index e0b5f95..c7d2cfa 100644 --- a/Tests/OpenRenderBoxTests/Path/PathTests.swift +++ b/Tests/OpenRenderBoxTests/Path/PathTests.swift @@ -5,15 +5,19 @@ // Created by Kyle on 12/21/25. // -#if canImport(Darwin) +#if canImport(Darwin) && OPENRENDERBOX_CF_CGTYPES +import Foundation +import OpenRenderBox import OpenRenderBoxCxx.Path import Testing @Suite struct PathTests { @Test - func demo() { - _ = ORB.Path.Storage(96) + func replacesStorageCapacityBits() { + let flags = ORB.Path.StorageFlags().withCapacity(15).withCapacity(16) + #expect(flags.capacity() == 16) } } + #endif From 562e0c0689e73b2b1e397bcdf675f2e0da94da14 Mon Sep 17 00:00:00 2001 From: Kyle Date: Fri, 30 Oct 2026 00:51:38 +0800 Subject: [PATCH 03/12] feat(path): implement primitive geometry and shape queries --- Sources/OpenRenderBoxCxx/Path/ORBPath.cpp | 691 ++++++++++++++++-- Sources/OpenRenderBoxCxx/Path/ORBPath.h | 106 ++- .../OpenRenderBoxCxx/Path/ORBPathStorage.cpp | 2 +- Sources/OpenRenderBoxCxx/Path/PathMapper.cpp | 214 ++++++ Sources/OpenRenderBoxCxx/Path/PathMapper.hpp | 23 + Sources/OpenRenderBoxCxx/Path/RoundedRect.cpp | 109 +++ Sources/OpenRenderBoxCxx/Path/RoundedRect.hpp | 9 + Sources/OpenRenderBoxCxx/Path/Storage.cpp | 63 +- Sources/OpenRenderBoxCxx/Path/Storage.hpp | 1 + .../OpenRenderBoxCxx/include/module.modulemap | 2 + Sources/OpenRenderBoxShims/ORBShims.swift | 11 + .../OpenRenderBoxShims/OpenRenderBoxShims.h | 23 + .../ORBShims.swift | 11 + .../PathArcMappingTests.swift | 60 ++ .../PathRegressionTests.swift | 211 +++++- .../PathStorageTests.swift | 81 ++ .../PathTestSupport.swift | 36 + .../PathTests.swift | 12 +- Tests/OpenRenderBoxTests/Path/PathTests.swift | 207 +++++- 19 files changed, 1784 insertions(+), 88 deletions(-) create mode 100644 Sources/OpenRenderBoxCxx/Path/PathMapper.cpp create mode 100644 Sources/OpenRenderBoxCxx/Path/PathMapper.hpp create mode 100644 Sources/OpenRenderBoxCxx/Path/RoundedRect.cpp create mode 100644 Sources/OpenRenderBoxCxx/Path/RoundedRect.hpp create mode 100644 Tests/OpenRenderBoxCompatibilityTests/PathArcMappingTests.swift diff --git a/Sources/OpenRenderBoxCxx/Path/ORBPath.cpp b/Sources/OpenRenderBoxCxx/Path/ORBPath.cpp index 2e45211..0485daa 100644 --- a/Sources/OpenRenderBoxCxx/Path/ORBPath.cpp +++ b/Sources/OpenRenderBoxCxx/Path/ORBPath.cpp @@ -11,9 +11,12 @@ #include #include "Storage.hpp" #include "NestedCallbacks.hpp" -#include -#include +#include "PathMapper.hpp" #include +#include +#include +#include +#include // Empty path callbacks (all null) - C++ internal linkage static const ORBPathCallbacks empty_path_callbacks = { @@ -61,37 +64,221 @@ void ORBPathRelease(ORBPath path) { #if ORB_TARGET_OS_DARWIN -ORBPath ORBPathCopyTransformedPath(ORBPath path, const CGAffineTransform *transform) { - if (ORBPathIsNull(path)) { - return ORBPathNull; - } - CGPathRef source = ORBPathCopyCGPath(path); - CGPathRef transformed = CGPathCreateCopyByTransformingPath(source, transform); - ORBPath result = ORBPathMakeWithCGPath(transformed); - CGPathRelease(transformed); - CGPathRelease(source); - return result; -} +CG_EXTERN bool CGPathIsPrimitive(CGPathRef path); +CG_EXTERN bool CGPathIsRectWithTransform( + CGPathRef path, + CGRect *rect, + CGAffineTransform *transform +); +CG_EXTERN bool CGPathIsEllipseWithTransform( + CGPathRef path, + CGRect *rect, + CGAffineTransform *transform +); +CG_EXTERN bool CGPathIsRoundedRectWithTransform( + CGPathRef path, + CGRect *rect, + CGFloat *cornerWidth, + CGFloat *cornerHeight, + CGAffineTransform *transform +); +CG_EXTERN void CGPathAddContinuousRoundedRect( + CGMutablePathRef path, + const CGAffineTransform * _Nullable transform, + CGRect rect, + CGFloat cornerWidth, + CGFloat cornerHeight +); + // MARK: - Path Creation namespace { +static_assert(sizeof(ORBPathRectShape) == 0x50); +static_assert(offsetof(ORBPathRectShape, transform) == 0x00); +static_assert(offsetof(ORBPathRectShape, rect) == 0x30); +static_assert(sizeof(ORBPathLineShape) == 0x20); +static_assert(sizeof(ORBPathFixedRoundedRectShape) == 0x68); +static_assert(offsetof(ORBPathFixedRoundedRectShape, cornerSize) == 0x50); +static_assert(offsetof(ORBPathFixedRoundedRectShape, style) == 0x60); +static_assert(sizeof(ORBPathStrokeStyle) == 0x30); +static_assert(offsetof(ORBPathStrokeStyle, dashCount) == 0x20); +static_assert(offsetof(ORBPathStrokeStyle, dash) == 0x28); +static_assert(sizeof(ORBPathStrokedShape) == 0x40); +static_assert(offsetof(ORBPathStrokedShape, style) == 0x10); +static_assert(sizeof(ORBPathShape) == 0x78); + +struct RectPathArguments { + CGRect rect; + uint8_t element; + uint8_t padding[7] {}; +}; + +struct UnevenRoundedRectPathArguments { + CGRect rect; + CGFloat radii[4]; + bool continuous; + uint8_t padding[7] {}; +}; + +struct EllipsePathArguments { + CGRect rect; +}; + +struct RoundedRectPathArguments { + CGRect rect; + CGSize cornerSize; + bool continuous; + uint8_t padding[7] {}; +}; + +static_assert(sizeof(RectPathArguments) == 0x28); +static_assert(sizeof(EllipsePathArguments) == 0x20); +static_assert(sizeof(RoundedRectPathArguments) == 0x38); + +template +struct SingleElementPathStorage { + uint32_t referenceCount { 1 }; + Arguments arguments; + bool hasTransform; + CGAffineTransform transform; + + SingleElementPathStorage( + const Arguments &arguments, + const CGAffineTransform *transform + ) : arguments(arguments), + hasTransform(transform != nullptr && !CGAffineTransformIsIdentity(*transform)), + transform(hasTransform ? *transform : CGAffineTransformIdentity) {} + + const CGAffineTransform *transformPointer() const { + return hasTransform ? &transform : nullptr; + } +}; + +using UnevenRoundedRectPathStorage = SingleElementPathStorage; +using RectPathStorage = SingleElementPathStorage; +using EllipsePathStorage = SingleElementPathStorage; +using RoundedRectPathStorage = SingleElementPathStorage; + +template +const void *retain_single_element_path(const void *object) { + auto storage = const_cast(static_cast(object)); + __atomic_fetch_add(&storage->referenceCount, 1, __ATOMIC_RELAXED); + return object; +} + +template +void release_single_element_path(const void *object) { + auto storage = const_cast(static_cast(object)); + if (__atomic_fetch_sub(&storage->referenceCount, 1, __ATOMIC_ACQ_REL) == 1) { + delete storage; + } +} + +template +bool single_element_paths_equal(const void *object, const void *otherObject) { + auto lhs = static_cast(object); + auto rhs = static_cast(otherObject); + if (lhs->hasTransform != rhs->hasTransform + || std::memcmp(&lhs->arguments, &rhs->arguments, sizeof(lhs->arguments)) != 0) { + return false; + } + return !lhs->hasTransform + || CGAffineTransformEqualToTransform(lhs->transform, rhs->transform); +} + +static const ORBPathCallbacks rect_path_callbacks = { + .flags = {}, + .retain = retain_single_element_path, + .release = release_single_element_path, + .apply = +[](const void *object, void *info, ORBPathApplyCallback callback) -> bool { + if (callback == nullptr) { + return true; + } + auto storage = static_cast(object); + return callback( + info, + static_cast(storage->arguments.element), + reinterpret_cast(&storage->arguments.rect), + storage->transformPointer() + ); + }, + .isEqual = single_element_paths_equal, + .isEmpty = +[](const void *) -> bool { return false; }, + .isSingleElement = +[](const void *) -> bool { return true; }, + .bezierOrder = +[](const void *) -> size_t { return 1; }, + .boundingRect = nullptr, + .cgPath = nullptr, + .next = nullptr, +}; + +static const ORBPathCallbacks ellipse_path_callbacks = { + .flags = {}, + .retain = retain_single_element_path, + .release = release_single_element_path, + .apply = +[](const void *object, void *info, ORBPathApplyCallback callback) -> bool { + if (callback == nullptr) { + return true; + } + auto storage = static_cast(object); + return callback( + info, + ORBPathElementEllipse, + reinterpret_cast(&storage->arguments.rect), + storage->transformPointer() + ); + }, + .isEqual = single_element_paths_equal, + .isEmpty = +[](const void *) -> bool { return false; }, + .isSingleElement = +[](const void *) -> bool { return true; }, + .bezierOrder = +[](const void *) -> size_t { return 3; }, + .boundingRect = nullptr, + .cgPath = nullptr, + .next = nullptr, +}; + +static const ORBPathCallbacks rounded_rect_path_callbacks = { + .flags = {}, + .retain = retain_single_element_path, + .release = release_single_element_path, + .apply = +[](const void *object, void *info, ORBPathApplyCallback callback) -> bool { + if (callback == nullptr) { + return true; + } + auto storage = static_cast(object); + return callback( + info, + storage->arguments.continuous + ? ORBPathElementFixedRoundedRectContinuous + : ORBPathElementFixedRoundedRectCircular, + reinterpret_cast(&storage->arguments.rect), + storage->transformPointer() + ); + }, + .isEqual = single_element_paths_equal, + .isEmpty = +[](const void *) -> bool { return false; }, + .isSingleElement = +[](const void *) -> bool { return true; }, + .bezierOrder = +[](const void *) -> size_t { return 3; }, + .boundingRect = nullptr, + .cgPath = nullptr, + .next = nullptr, +}; + ORBPath make_rect(CGRect rect, const CGAffineTransform *transform, ORBPathElement element) { if (CGRectIsNull(rect)) { - return ORBPathNull; - } - if (transform == nullptr || CGAffineTransformIsIdentity(*transform)) { - // TODO - return ORBPathNull; - } else { - // TODO - return ORBPathNull; + return ORBPathEmpty; } + RectPathArguments arguments = {}; + arguments.rect = rect; + arguments.element = static_cast(element); + auto storage = new RectPathStorage(arguments, transform); + return ORBPath { + reinterpret_cast(storage), + &rect_path_callbacks, + }; } } /* anonymous namespace */ -// TODO: TO be implemented natively - ORBPath ORBPathMakeWithCGPath(CGPathRef cgPath) { if (cgPath == nullptr) { return ORBPathNull; @@ -108,60 +295,76 @@ ORBPath ORBPathMakeRect(CGRect rect, const CGAffineTransform *transform) { } ORBPath ORBPathMakeEllipse(CGRect rect, const CGAffineTransform *transform) { - CGPathRef cgPath = CGPathCreateWithEllipseInRect(rect, transform); - ORBPath path = { - reinterpret_cast(const_cast(cgPath)), - &ORBPathCGPathCallbacks, + if (CGRectIsNull(rect)) { + return ORBPathEmpty; + } + EllipsePathArguments arguments = { rect }; + auto storage = new EllipsePathStorage(arguments, transform); + return ORBPath { + reinterpret_cast(storage), + &ellipse_path_callbacks, }; - return path; } ORBPath ORBPathMakeRoundedRect(CGRect rect, CGFloat cornerWidth, CGFloat cornerHeight, ORBPathRoundedCornerStyle style, const CGAffineTransform *transform) { - // TODO: Handle ORBRoundedCornerStyleContinuous with custom bezier curves - CGPathRef cgPath = CGPathCreateWithRoundedRect(rect, cornerWidth, cornerHeight, transform); - ORBPath path = { - reinterpret_cast(const_cast(cgPath)), - &ORBPathCGPathCallbacks, + if (cornerWidth == 0 && cornerHeight == 0) { + return make_rect(rect, transform, ORBPathElementRoundedRect); + } + if (CGRectIsNull(rect)) { + return ORBPathEmpty; + } + RoundedRectPathArguments arguments = {}; + arguments.rect = rect; + arguments.cornerSize = CGSizeMake(cornerWidth, cornerHeight); + arguments.continuous = style == ORBPathRoundedCornerStyleContinuous; + auto storage = new RoundedRectPathStorage(arguments, transform); + return ORBPath { + reinterpret_cast(storage), + &rounded_rect_path_callbacks, }; - return path; } ORBPath ORBPathMakeUnevenRoundedRect(CGRect rect, CGFloat topLeftRadius, CGFloat topRightRadius, CGFloat bottomRightRadius, CGFloat bottomLeftRadius, ORBPathRoundedCornerStyle style, const CGAffineTransform *transform) { - // TODO: Handle ORBRoundedCornerStyleContinuous with custom bezier curves - CGMutablePathRef cgPath = CGPathCreateMutable(); - - CGFloat minX = CGRectGetMinX(rect); - CGFloat minY = CGRectGetMinY(rect); - CGFloat maxX = CGRectGetMaxX(rect); - CGFloat maxY = CGRectGetMaxY(rect); - - // Start at top-left corner (after the rounded corner) - CGPathMoveToPoint(cgPath, transform, minX + topLeftRadius, minY); - - // Top edge and top-right corner - CGPathAddLineToPoint(cgPath, transform, maxX - topRightRadius, minY); - CGPathAddArc(cgPath, transform, maxX - topRightRadius, minY + topRightRadius, topRightRadius, -M_PI_2, 0, false); - - // Right edge and bottom-right corner - CGPathAddLineToPoint(cgPath, transform, maxX, maxY - bottomRightRadius); - CGPathAddArc(cgPath, transform, maxX - bottomRightRadius, maxY - bottomRightRadius, bottomRightRadius, 0, M_PI_2, false); - - // Bottom edge and bottom-left corner - CGPathAddLineToPoint(cgPath, transform, minX + bottomLeftRadius, maxY); - CGPathAddArc(cgPath, transform, minX + bottomLeftRadius, maxY - bottomLeftRadius, bottomLeftRadius, M_PI_2, M_PI, false); - - // Left edge and top-left corner - CGPathAddLineToPoint(cgPath, transform, minX, minY + topLeftRadius); - CGPathAddArc(cgPath, transform, minX + topLeftRadius, minY + topLeftRadius, topLeftRadius, M_PI, M_PI + M_PI_2, false); - - CGPathCloseSubpath(cgPath); - - ORBPath path = { - reinterpret_cast(cgPath), - &ORBPathCGPathCallbacks, + if (CGRectIsNull(rect)) { + return ORBPathEmpty; + } + if (topLeftRadius == 0 && topRightRadius == 0 && bottomRightRadius == 0 && bottomLeftRadius == 0) { + return make_rect(rect, transform, ORBPathElementRoundedRect); + } + static const ORBPathCallbacks callbacks = { + .flags = {}, + .retain = retain_single_element_path, + .release = release_single_element_path, + .apply = +[](const void *object, void *info, ORBPathApplyCallback callback) -> bool { + auto storage = static_cast(object); + return callback(info, storage->arguments.continuous + ? ORBPathElementUnevenRoundedRectContinuous : ORBPathElementUnevenRoundedRectCircular, + reinterpret_cast(&storage->arguments.rect), storage->transformPointer()); + }, + .isEqual = single_element_paths_equal, + .isEmpty = +[](const void *) -> bool { return false; }, + .isSingleElement = +[](const void *) -> bool { return true; }, + .bezierOrder = +[](const void *) -> size_t { return 3; }, + .boundingRect = nullptr, + .cgPath = nullptr, + .next = nullptr, }; - return path; + UnevenRoundedRectPathArguments args = {rect, {topLeftRadius, topRightRadius, bottomRightRadius, bottomLeftRadius}, style == ORBPathRoundedCornerStyleContinuous}; + return {reinterpret_cast(new UnevenRoundedRectPathStorage(args, transform)), &callbacks}; +} + +ORBPath ORBPathCopyTransformedPath(ORBPath path, const CGAffineTransform *transform) { + if (ORBPathIsNull(path)) { + return ORBPathNull; + } + CGPathRef source = ORBPathCopyCGPath(path); + CGPathRef transformed = CGPathCreateCopyByTransformingPath(source, transform); + ORBPath result = ORBPathMakeWithCGPath(transformed); + CGPathRelease(transformed); + CGPathRelease(source); + return result; } + #endif /* ORB_TARGET_OS_DARWIN */ bool ORBPathIsEmpty(ORBPath path) { @@ -248,6 +451,188 @@ bool ORBPathApplyElements(ORBPath path, void *info, ORBPathApplyCallback callbac } } +bool ORBPathApplyShape( + ORBPath path, + bool allowLine, + void *info, + ORBPathApplyShapeCallback callback +) { + if (callback == nullptr) { + return false; + } + +#if ORB_TARGET_OS_DARWIN + if (path.callbacks == &ORBPathCGPathCallbacks) { + auto cgPath = reinterpret_cast(path.storage); + if (!CGPathIsPrimitive(cgPath)) { + return false; + } + + CGRect rect = CGRectZero; + CGAffineTransform transform = CGAffineTransformIdentity; + ORBPathShape shape = {}; + if (CGPathIsRectWithTransform(cgPath, &rect, &transform)) { + shape.rect = { transform, rect }; + callback(info, ORBPathShapeTypeRect, &shape); + return true; + } + if (CGPathIsEllipseWithTransform(cgPath, &rect, &transform)) { + shape.ellipse = { transform, rect }; + callback(info, ORBPathShapeTypeEllipse, &shape); + return true; + } + + CGFloat cornerWidth = 0; + CGFloat cornerHeight = 0; + if (CGPathIsRoundedRectWithTransform( + cgPath, + &rect, + &cornerWidth, + &cornerHeight, + &transform + )) { + if (cornerWidth == 0 && cornerHeight == 0) { + shape.rect = { transform, rect }; + callback(info, ORBPathShapeTypeRect, &shape); + return true; + } + shape.fixedRoundedRect = { + transform, + rect, + CGSizeMake(cornerWidth, cornerHeight), + ORBPathRoundedCornerStyleCircular, + }; + callback(info, ORBPathShapeTypeFixedRoundedRect, &shape); + return true; + } + return false; + } +#endif + + if (ORBPathIsSingleElement(path)) { + struct ShapeInfo { + void *info; + ORBPathApplyShapeCallback callback; + ORBPath path; + bool matched; + } shapeInfo = { info, callback, path, false }; + + ORBPathApplyElements(path, &shapeInfo, +[]( + void *rawInfo, + ORBPathElement element, + const CGFloat *values, + const void *userInfo + ) -> bool { + auto info = static_cast(rawInfo); + switch (element) { + case ORBPathElementRect: + case ORBPathElementRoundedRect: + case ORBPathElementEllipse: + case ORBPathElementFixedRoundedRectCircular: + case ORBPathElementFixedRoundedRectContinuous: + case ORBPathElementUnevenRoundedRectCircular: + case ORBPathElementUnevenRoundedRectContinuous: + break; + default: + return false; + } + CGAffineTransform transform = userInfo == nullptr + ? CGAffineTransformIdentity + : *static_cast(userInfo); + CGRect rect = CGRectMake(values[0], values[1], values[2], values[3]); + ORBPathShape shape = {}; + ORBPathShapeType type; + switch (element) { + case ORBPathElementRect: + case ORBPathElementRoundedRect: + shape.rect = { transform, rect }; + type = ORBPathShapeTypeRect; + break; + case ORBPathElementEllipse: + shape.ellipse = { transform, rect }; + type = ORBPathShapeTypeEllipse; + break; + case ORBPathElementFixedRoundedRectCircular: + case ORBPathElementFixedRoundedRectContinuous: + shape.fixedRoundedRect = { + transform, + rect, + CGSizeMake(values[4], values[5]), + element == ORBPathElementFixedRoundedRectContinuous + ? ORBPathRoundedCornerStyleContinuous + : ORBPathRoundedCornerStyleCircular, + }; + type = ORBPathShapeTypeFixedRoundedRect; + break; + case ORBPathElementUnevenRoundedRectCircular: + case ORBPathElementUnevenRoundedRectContinuous: + shape.unevenRoundedRect = { + transform, + rect, + values[4], values[5], values[6], values[7], + element == ORBPathElementUnevenRoundedRectContinuous + ? ORBPathRoundedCornerStyleContinuous + : ORBPathRoundedCornerStyleCircular, + }; + type = ORBPathShapeTypeUnevenRoundedRect; + break; + default: + return false; + } + info->callback(info->info, type, &shape); + info->matched = true; + return false; + }); + return shapeInfo.matched; + } + + // RenderBox's 6.5.4 implementation takes this branch when the ABI + // parameter named `allowLine` is false. + if (!allowLine) { + struct LineInfo { + CGPoint points[2]; + uint64_t count; + } lineInfo = {}; + + bool applied = ORBPathApplyElements(path, &lineInfo, +[]( + void *rawInfo, + ORBPathElement element, + const CGFloat *values, + const void *userInfo + ) -> bool { + auto info = static_cast(rawInfo); + if (element == ORBPathElementMoveToPoint) { + if (info->count > 1) { + return false; + } + info->count = 0; + } else if (element != ORBPathElementAddLineToPoint) { + return false; + } + if (info->count != static_cast(element)) { + return false; + } + CGPoint point = reinterpret_cast(values)[0]; + if (userInfo != nullptr) { + point = CGPointApplyAffineTransform( + point, + *static_cast(userInfo) + ); + } + info->points[info->count++] = point; + return true; + }); + if (applied && lineInfo.count == 2) { + ORBPathShape shape = {}; + shape.line = { lineInfo.points[0], lineInfo.points[1] }; + callback(info, ORBPathShapeTypeLine, &shape); + return true; + } + } + + return false; +} + bool ORBPathEqualToPath(ORBPath lhs, ORBPath rhs) { if (lhs.callbacks == rhs.callbacks) { if (lhs.storage == rhs.storage) { return true; } @@ -328,18 +713,182 @@ bool ORBPathEqualToPath(ORBPath lhs, ORBPath rhs) { #if ORB_TARGET_OS_DARWIN CGPathRef ORBPathCopyCGPath(ORBPath path) { - // TODO: Return a retained copy of the CGPath - return nullptr; + if (path.callbacks == &empty_path_callbacks) { + return CGPathCreateMutable(); + } + + CGPathRef result = nullptr; + if (path.callbacks->flags.isExtended) { + auto extended = reinterpret_cast(path.callbacks); + if (extended->cgPath != nullptr) { + result = extended->cgPath(path.storage, extended); + } + } else if (path.callbacks->cgPath != nullptr) { + result = path.callbacks->cgPath(path.storage); + } + if (result != nullptr) { + return CGPathRetain(result); + } + + return ORB::Path::copy_cg_path(path); +} + +CGRect ORBPathGetBoundingRect(ORBPath path) { + if (path.callbacks->flags.isExtended) { + auto extended = reinterpret_cast(path.callbacks); + if (extended->boundingRect != nullptr) { + return extended->boundingRect(path.storage, extended); + } + } else if (path.callbacks->boundingRect != nullptr) { + return path.callbacks->boundingRect(path.storage); + } + + struct PrimitiveBounds { CGRect rect; bool matched; } bounds{CGRectNull, false}; + if (ORBPathIsSingleElement(path)) { + ORBPathApplyShape(path, true, &bounds, +[](void *context, ORBPathShapeType type, const ORBPathShape *shape) { + auto &bounds = *static_cast(context); + switch (type) { + case ORBPathShapeTypeRect: + case ORBPathShapeTypeEllipse: + case ORBPathShapeTypeFixedRoundedRect: + case ORBPathShapeTypeUnevenRoundedRect: + bounds.rect = CGRectApplyAffineTransform(shape->rect.rect, shape->rect.transform); + bounds.matched = true; + break; + case ORBPathShapeTypeNested: + bounds.rect = CGRectApplyAffineTransform(ORBPathGetBoundingRect(shape->nested.path), shape->nested.transform); + bounds.matched = true; + break; + default: break; + } + }); + if (bounds.matched) { return bounds.rect; } + } + + CGPathRef cgPath = ORBPathCopyCGPath(path); + CGRect result = CGPathGetPathBoundingBox(cgPath); + CGPathRelease(cgPath); + return result; } bool ORBPathContainsPoint(ORBPath path, CGPoint point, bool eoFill) { return ORBPathContainsPoints(path, 1, &point, eoFill, nullptr); } +namespace { +using FloatPoint = float __attribute__((ext_vector_type(2))); + +struct PathWinding { + FloatPoint queries[64]; + int winding[64] = {}; + size_t count; + FloatPoint minimum, maximum; + FloatPoint start = {}, current = {}; + + static FloatPoint narrow(CGPoint p) { return {static_cast(p.x), static_cast(p.y)}; } + + void line(FloatPoint end) { + auto from = current; + current = end; + auto delta = end - from; + if (std::abs(delta.y) < 0.0001f) { return; } + for (size_t i = 0; i < count; ++i) { + float t = (queries[i].y - from.y) / delta.y; + if (t >= 0 && t <= 1 && std::fma(delta.x, t, from.x) > queries[i].x) { + winding[i] += delta.y > 0 ? 1 : -1; + } + } + } + + void close() { + if (current.x != start.x || current.y != start.y) { line(start); } + } + + void cubic(CGPoint cp1, CGPoint cp2, CGPoint endpoint) { + auto a = current, b = narrow(cp1), c = narrow(cp2), d = narrow(endpoint); + float minX = std::min({a.x, b.x, c.x, d.x}), minY = std::min({a.y, b.y, c.y, d.y}); + float maxX = std::max({a.x, b.x, c.x, d.x}), maxY = std::max({a.y, b.y, c.y, d.y}); + if (minY > maximum.y || maxX < minimum.x || maxY < minimum.y) { current = d; return; } + if (minX > maximum.x) { line(d); return; } + auto e = c - (b + b) + a, f = b - (c + c) + d; + float x = std::max(std::abs(e.x), std::abs(f.x)); + float y = std::max(std::abs(e.y), std::abs(f.y)); + float estimate = std::ceil(std::sqrt(std::sqrt(x * x + y * y))); + if (!std::isfinite(estimate)) { current = d; return; } + int segments = static_cast(std::min(estimate, 1024.0f)); + if (segments >= 2) { + auto first = (b - a) * 3.0f; + FloatPoint second, third; + for (size_t axis = 0; axis != 2; ++axis) { + second[axis] = std::fma(3.0f, c[axis] - b[axis], -first[axis]); + third[axis] = d[axis] - (std::fma(3.0f, b[axis] - a[axis], a[axis]) + second[axis]); + } + float dt = 1.0f / segments, dt2 = dt * dt, dt3 = dt2 * dt; + FloatPoint delta1, delta2, delta3 = (third * 6.0f) * dt3; + second *= dt2; + for (size_t axis = 0; axis != 2; ++axis) { + delta2[axis] = std::fma(2.0f, second[axis], delta3[axis]); + delta1[axis] = std::fma(third[axis], dt3, std::fma(first[axis], dt, second[axis])); + } + for (int step = 1; step < segments; ++step) { + a += delta1; + delta1 += delta2; + delta2 += delta3; + line(a); + } + } + line(d); + } +}; +} // namespace + uint64_t ORBPathContainsPoints(ORBPath path, uint64_t count, const CGPoint *points, bool eoFill, const CGAffineTransform *transform) { - // TODO: Implement point containment testing with winding rule - return false; + if (count == 0) { return 0; } + PathWinding state{}; + state.count = std::min(count, 64); + state.minimum = {INFINITY, INFINITY}; + state.maximum = {-INFINITY, -INFINITY}; + for (size_t i = 0; i < state.count; ++i) { + CGPoint point = transform ? CGPointApplyAffineTransform(points[i], *transform) : points[i]; + state.queries[i] = PathWinding::narrow(point); + for (size_t axis = 0; axis != 2; ++axis) { + state.minimum[axis] = std::fmin(state.minimum[axis], state.queries[i][axis]); + state.maximum[axis] = std::fmax(state.maximum[axis], state.queries[i][axis]); + } + } + auto bounds = ORBPathGetBoundingRect(path); + bool pointQuery = state.minimum.x == state.maximum.x && state.minimum.y == state.maximum.y; + CGRect queryBounds = CGRectMake(state.minimum.x, state.minimum.y, state.maximum.x - state.minimum.x, state.maximum.y - state.minimum.y); + if (pointQuery ? !CGRectContainsPoint(bounds, CGPointMake(state.minimum.x, state.minimum.y)) : !CGRectIntersectsRect(bounds, queryBounds)) { return 0; } + auto cgPath = ORBPathCopyCGPath(path); + CGPathApply(cgPath, &state, +[](void *info, const CGPathElement *element) { + auto &state = *static_cast(info); + auto *p = element->points; + switch (element->type) { + case kCGPathElementMoveToPoint: + state.close(); + state.current = state.start = PathWinding::narrow(p[0]); + break; + case kCGPathElementAddLineToPoint: state.line(PathWinding::narrow(p[0])); break; + case kCGPathElementAddQuadCurveToPoint: { + CGPoint c1 = {std::fma(2.0, p[0].x, state.current.x) / 3, std::fma(2.0, p[0].y, state.current.y) / 3}; + CGPoint c2 = {std::fma(2.0, p[0].x, p[1].x) / 3, std::fma(2.0, p[0].y, p[1].y) / 3}; + state.cubic(c1, c2, p[1]); + break; + } + case kCGPathElementAddCurveToPoint: state.cubic(p[0], p[1], p[2]); break; + case kCGPathElementCloseSubpath: state.close(); break; + } + }); + state.close(); + CGPathRelease(cgPath); + uint64_t result = 0; + for (size_t i = 0; i < state.count; ++i) { + if (eoFill ? (state.winding[i] & 1) != 0 : state.winding[i] != 0) { result |= uint64_t(1) << i; } + } + return result; } + #endif /* ORB_TARGET_OS_DARWIN */ #endif /* OPENRENDERBOX_CF_CGTYPES */ diff --git a/Sources/OpenRenderBoxCxx/Path/ORBPath.h b/Sources/OpenRenderBoxCxx/Path/ORBPath.h index d5067e0..08980f9 100644 --- a/Sources/OpenRenderBoxCxx/Path/ORBPath.h +++ b/Sources/OpenRenderBoxCxx/Path/ORBPath.h @@ -5,13 +5,10 @@ #pragma once #include -#include #if OPENRENDERBOX_CF_CGTYPES - -#if ORB_TARGET_OS_DARWIN +#include #include -#endif ORB_ASSUME_NONNULL_BEGIN @@ -72,6 +69,93 @@ typedef struct ORBPath { const ORBPathCallbacks * callbacks; } ORBPath; +/// A primitive shape recognized by ``ORBPathApplyShape``. +typedef ORB_ENUM(int32_t, ORBPathShapeType) { + ORBPathShapeTypeRect = 0, + ORBPathShapeTypeEllipse = 1, + ORBPathShapeTypeLine = 2, + ORBPathShapeTypeFixedRoundedRect = 3, + ORBPathShapeTypeUnevenRoundedRect = 4, + ORBPathShapeTypeNested = 5, + ORBPathShapeTypeStroked = 6, + ORBPathShapeTypeTrimmed = 7, + ORBPathShapeTypeOffset = 8, +}; + +typedef struct ORBPathRectShape { + CGAffineTransform transform; + CGRect rect; +} ORBPathRectShape; + +typedef struct ORBPathFixedRoundedRectShape { + CGAffineTransform transform; + CGRect rect; + CGSize cornerSize; + ORBPathRoundedCornerStyle style; +} ORBPathFixedRoundedRectShape; + +typedef struct ORBPathUnevenRoundedRectShape { + CGAffineTransform transform; + CGRect rect; + CGFloat topLeftRadius; + CGFloat topRightRadius; + CGFloat bottomRightRadius; + CGFloat bottomLeftRadius; + ORBPathRoundedCornerStyle style; +} ORBPathUnevenRoundedRectShape; + +typedef struct ORBPathLineShape { + CGPoint start; + CGPoint end; +} ORBPathLineShape; + +typedef struct ORBPathStrokeStyle { + CGFloat lineWidth; + int32_t lineCap; + int32_t lineJoin; + CGFloat miterLimit; + CGFloat dashPhase; + uint64_t dashCount; + const CGFloat * _Nullable dash; +} ORBPathStrokeStyle; + +typedef struct ORBPathStrokedShape { + ORBPath path; + ORBPathStrokeStyle style; +} ORBPathStrokedShape; + +typedef struct ORBPathNestedShape { + ORBPath path; + CGAffineTransform transform; +} ORBPathNestedShape; + +typedef struct ORBPathTrimmedShape { + ORBPath path; + CGFloat from; + CGFloat to; +} ORBPathTrimmedShape; + +typedef union ORBPathShape { + ORBPathRectShape rect; + ORBPathRectShape ellipse; + ORBPathLineShape line; + ORBPathFixedRoundedRectShape fixedRoundedRect; + ORBPathUnevenRoundedRectShape unevenRoundedRect; + ORBPathStrokedShape stroked; + ORBPathNestedShape nested; + ORBPathTrimmedShape trimmed; +} ORBPathShape; + +#if defined(__cplusplus) || (defined(__STDC_VERSION__) && __STDC_VERSION__ >= 202311L) +static_assert(sizeof(ORBPath) == 0x10); +static_assert(offsetof(ORBPathUnevenRoundedRectShape, rect) == 0x30); +static_assert(offsetof(ORBPathUnevenRoundedRectShape, topLeftRadius) == 0x50); +static_assert(offsetof(ORBPathUnevenRoundedRectShape, style) == 0x70); +static_assert(sizeof(ORBPathShape) == 0x78); +#endif + +typedef void (*ORBPathApplyShapeCallback)(void * info, ORBPathShapeType type, const ORBPathShape *shape); + /// Global empty path (storage = null, callbacks = &ORBPathEmptyCallbacks) ORB_EXPORT const ORBPath ORBPathEmpty ORB_SWIFT_NAME(ORBPath.empty); @@ -90,7 +174,7 @@ void ORBPathRelease(ORBPath path) ORB_SWIFT_NAME(ORBPath.release(self:)); #if ORB_TARGET_OS_DARWIN ORB_EXPORT -ORBPath ORBPathMakeWithCGPath(CGPathRef cgPath) ORB_SWIFT_NAME(ORBPath.init(cgPath:)); +ORBPath ORBPathMakeWithCGPath(CGPathRef _Nullable cgPath) ORB_SWIFT_NAME(ORBPath.init(cgPath:)); ORB_EXPORT ORBPath ORBPathMakeRect(CGRect rect, const CGAffineTransform * _Nullable transform) ORB_SWIFT_NAME(ORBPath.init(rect:transform:)); @@ -105,17 +189,13 @@ ORB_EXPORT ORBPath ORBPathMakeUnevenRoundedRect(CGRect rect, CGFloat topLeftRadius, CGFloat topRightRadius, CGFloat bottomRightRadius, CGFloat bottomLeftRadius, ORBPathRoundedCornerStyle style, const CGAffineTransform * _Nullable transform) ORB_SWIFT_NAME(ORBPath.init(roundedRect:topLeftRadius:topRightRadius:bottomRightRadius:bottomLeftRadius:style:transform:)); #endif -#if ORB_TARGET_OS_DARWIN -// MARK: - Path Transformation - ORB_EXPORT -ORBPath ORBPathCopyTransformedPath(ORBPath path, const CGAffineTransform *transform) ORB_SWIFT_NAME(ORBPath.copy(self:transform:)); +ORBPath ORBPathCopyTransformedPath(ORBPath path, const CGAffineTransform * _Nullable transform) ORB_SWIFT_NAME(ORBPath.copy(self:transform:)); static ORB_INLINE bool ORBPathIsNull(ORBPath path) ORB_SWIFT_NAME(getter:ORBPath.isNull(self:)) { return path.storage == ORBPathNull.storage && path.callbacks == ORBPathNull.callbacks; } -#endif // MARK: - Path Operation @@ -131,6 +211,9 @@ size_t ORBPathGetBezierOrder(ORBPath path) ORB_SWIFT_NAME(getter:ORBPath.bezierO ORB_EXPORT bool ORBPathApplyElements(ORBPath path, void * info, _Nullable ORBPathApplyCallback callback) ORB_SWIFT_NAME(ORBPath.apply(self:info:callback:)); +ORB_EXPORT +bool ORBPathApplyShape(ORBPath path, bool allowLine, void * info, ORBPathApplyShapeCallback callback) ORB_SWIFT_NAME(ORBPath.applyShape(self:allowLine:info:callback:)); + ORB_EXPORT bool ORBPathEqualToPath(ORBPath lhs, ORBPath rhs) ORB_SWIFT_NAME(ORBPath.isEqual(self:to:)); @@ -139,6 +222,9 @@ bool ORBPathEqualToPath(ORBPath lhs, ORBPath rhs) ORB_SWIFT_NAME(ORBPath.isEqual #if ORB_TARGET_OS_DARWIN ORB_EXPORT CGPathRef ORBPathCopyCGPath(ORBPath path) ORB_SWIFT_NAME(getter:ORBPath.cgPath(self:)); + +ORB_EXPORT +CGRect ORBPathGetBoundingRect(ORBPath path) ORB_SWIFT_NAME(getter:ORBPath.boundingRect(self:)); #endif // MARK: - Point Containment diff --git a/Sources/OpenRenderBoxCxx/Path/ORBPathStorage.cpp b/Sources/OpenRenderBoxCxx/Path/ORBPathStorage.cpp index 3a3595d..1512ed4 100644 --- a/Sources/OpenRenderBoxCxx/Path/ORBPathStorage.cpp +++ b/Sources/OpenRenderBoxCxx/Path/ORBPathStorage.cpp @@ -75,7 +75,7 @@ size_t ORBPathStorageGetBezierOrder(ORBPathStorageRef storage) { #if ORB_TARGET_OS_DARWIN CGRect ORBPathStorageGetBoundingRect(ORBPathStorageRef storage) { - precondition_failure("TODO"); + return storage->storage.bounding_rect(); } CGPathRef ORBPathStorageGetCGPath(ORBPathStorageRef storage) { diff --git a/Sources/OpenRenderBoxCxx/Path/PathMapper.cpp b/Sources/OpenRenderBoxCxx/Path/PathMapper.cpp new file mode 100644 index 0000000..3a1c991 --- /dev/null +++ b/Sources/OpenRenderBoxCxx/Path/PathMapper.cpp @@ -0,0 +1,214 @@ +// +// PathMapper.cpp +// OpenRenderBox + +#include + +#if OPENRENDERBOX_CF_CGTYPES && ORB_TARGET_OS_DARWIN + +#include "PathMapper.hpp" +#include "RoundedRect.hpp" +#include "Storage.hpp" +#include +#include +#include +#include +#include +#include +#include + +namespace ORB { +namespace Path { +namespace { + +void add_relative_arc(CGMutablePathRef path, const CGAffineTransform *transform, + CGPoint center, CGFloat radius, CGFloat start, CGFloat delta) { + CGFloat sine = std::sin(start), cosine = std::cos(start); + CGPoint point{std::fma(radius, cosine, center.x), std::fma(radius, sine, center.y)}; + if (CGPathIsEmpty(path)) { + CGPathMoveToPoint(path, transform, point.x, point.y); + } else { + CGPathAddLineToPoint(path, transform, point.x, point.y); + } + if (!std::isfinite(delta)) { return; } + + CGFloat sign = delta < 0 ? -1 : 1; + CGFloat quarter = sign * M_PI_2; + CGFloat tangent = sign * 0.5522847498307932 * radius; + unsigned remaining = 1023; + auto curve = [&](CGFloat nextSine, CGFloat nextCosine, CGFloat factor) { + CGPoint end{std::fma(radius, nextCosine, center.x), std::fma(radius, nextSine, center.y)}; + CGPathAddCurveToPoint(path, transform, + std::fma(-sine, factor, point.x), std::fma(cosine, factor, point.y), + std::fma(nextSine, factor, end.x), std::fma(-nextCosine, factor, end.y), end.x, end.y); + point = end; + sine = nextSine; + cosine = nextCosine; + }; + while (delta > M_PI_2 || delta < -M_PI_2) { + if (remaining-- == 0) { return; } + curve(cosine * sign, -sine * sign, tangent); + start += quarter; + delta -= quarter; + } + // Compact storage rounds angles to Float. Ignore the residual after exact quarter turns. + if (std::abs(delta) <= static_cast(1e-5f)) { return; } + CGFloat half = delta * 0.5; + CGFloat factor = std::fma(-std::cos(half), 4, 4) * radius / (3 * std::sin(half)); + curve(std::sin(start + delta), std::cos(start + delta), factor); +} + +void add_arc(CGMutablePathRef path, const CGAffineTransform *transform, CGPoint center, + CGFloat radius, CGFloat start, CGFloat end, bool clockwise) { + constexpr CGFloat turn = 2 * M_PI; + if (CGPathIsEmpty(path) && start == 0 && + (clockwise ? end >= turn && end - turn < 1e-5 + : std::abs(end - turn) < static_cast(1e-5f))) { + constexpr CGFloat k = 0.5522847498; + CGFloat r = std::abs(radius), y = clockwise ? -r : r; + CGAffineTransform ellipse = CGAffineTransformMake(r, 0, 0, y, center.x, center.y); + if (transform) { ellipse = CGAffineTransformConcat(ellipse, *transform); } + CGPathMoveToPoint(path, &ellipse, 1, 0); + CGPathAddCurveToPoint(path, &ellipse, 1, k, k, 1, 0, 1); + CGPathAddCurveToPoint(path, &ellipse, -k, 1, -1, k, -1, 0); + CGPathAddCurveToPoint(path, &ellipse, -1, -k, -k, -1, 0, -1); + CGPathAddCurveToPoint(path, &ellipse, k, -1, 1, -k, 1, 0); + CGPathCloseSubpath(path); + return; + } + if (clockwise && start < end) { + start = std::fma(std::ceil((end - start) / turn), turn, start); + } else if (!clockwise && end < start) { + end = std::fma(std::ceil((start - end) / turn), turn, end); + } + add_relative_arc(path, transform, center, radius, start, end - start); +} + +struct Mapper { + CGMutablePathRef path = CGPathCreateMutable(); + + ~Mapper() { CGPathRelease(path); } + + bool apply(ORBPathElement element, const CGFloat *values, const void *info) { + auto type = static_cast(element); + auto *transform = static_cast(info); + switch (type) { + case 0: + CGPathMoveToPoint(path, transform, values[0], values[1]); + break; + case 1: + CGPathAddLineToPoint(path, transform, values[0], values[1]); + break; + case 2: + CGPathAddQuadCurveToPoint(path, transform, values[0], values[1], values[2], values[3]); + break; + case 3: + CGPathAddCurveToPoint(path, transform, values[0], values[1], values[2], values[3], values[4], values[5]); + break; + case 4: + CGPathCloseSubpath(path); + break; + case 5: + CGPathAddRect(path, transform, CGRectMake(values[0], values[1], values[2], values[3])); + break; + case 6: { + CGRect rect = CGRectStandardize(CGRectMake(values[0], values[1], values[2], values[3])); + CGFloat left = CGRectGetMinX(rect), right = CGRectGetMaxX(rect); + CGFloat top = CGRectGetMinY(rect), bottom = CGRectGetMaxY(rect); + CGPathMoveToPoint(path, transform, right, CGRectGetMidY(rect)); + CGPathAddLineToPoint(path, transform, right, bottom); + CGPathAddLineToPoint(path, transform, left, bottom); + CGPathAddLineToPoint(path, transform, left, top); + CGPathAddLineToPoint(path, transform, right, top); + CGPathCloseSubpath(path); + break; + } + case 7: + CGPathAddEllipseInRect(path, transform, CGRectMake(values[0], values[1], values[2], values[3])); + break; + case 8: case 9: case 10: case 11: + add_rounded_rect(path, element, values, transform); + break; + case 12: + add_relative_arc(path, transform, {values[0], values[1]}, values[2], values[3], values[4]); + break; + case 13: case 14: + add_arc(path, transform, {values[0], values[1]}, values[2], values[3], values[4], type == 13); + break; + case 15: + CGPathAddArcToPoint(path, transform, values[0], values[1], values[2], values[3], values[4]); + break; + default: + precondition_failure("unsupported path mapping element: %u", type); + } + return true; + } +}; + +} // namespace + +bool apply_mapping_element(void *context, ORBPathElement element, const CGFloat *values, const void *info) { + return static_cast(context)->apply(element, values, info); +} + +namespace { +struct MapCache { + std::mutex mutex; + std::map, std::shared_ptr> entries; +}; + +MapCache &map_cache() { + static auto *cache = new MapCache; + return *cache; +} +} // namespace + +void invalidate_mapped_storage(uint32_t identifier) { + auto &cache = map_cache(); + std::lock_guard guard(cache.mutex); + auto first = cache.entries.lower_bound({identifier, 0}); + while (first != cache.entries.end() && first->first.first == identifier) { + first = cache.entries.erase(first); + } +} + +bool apply_mapped_storage(const Storage &storage, void *context, size_t depth) { + auto &cache = map_cache(); + auto key = std::make_pair(storage.identifier(), depth); + std::shared_ptr mapped; + { + std::lock_guard guard(cache.mutex); + auto entry = cache.entries.find(key); + if (entry != cache.entries.end()) { mapped = entry->second; } + } + if (!mapped) { + Mapper mapper; + bool complete = storage.apply_elements_at_depth(&mapper, + +[](void *context, ORBPathElement element, const CGFloat *values, const void *info) { + return static_cast(context)->apply(element, values, info); + }, depth); + if (!complete) { return false; } + mapped = std::make_shared(sizeof(Storage)); + CGPathApply(mapper.path, mapped.get(), +[](void *context, const CGPathElement *element) { + static_cast(context)->append_element(static_cast(element->type), + reinterpret_cast(element->points), nullptr); + }); + std::lock_guard guard(cache.mutex); + mapped = cache.entries.emplace(key, mapped).first->second; + } + return mapped->apply_elements(context, apply_mapping_element); +} + +CGPathRef copy_cg_path(ORBPath path) { + Mapper mapper; + bool complete = ORBPathApplyElements(path, &mapper, apply_mapping_element); + if (!complete) { + precondition_failure("path enumeration stopped during conversion"); + } + return CGPathRetain(mapper.path); +} + +} /* Path */ +} /* ORB */ + +#endif diff --git a/Sources/OpenRenderBoxCxx/Path/PathMapper.hpp b/Sources/OpenRenderBoxCxx/Path/PathMapper.hpp new file mode 100644 index 0000000..6361d10 --- /dev/null +++ b/Sources/OpenRenderBoxCxx/Path/PathMapper.hpp @@ -0,0 +1,23 @@ +// +// PathMapper.hpp +// OpenRenderBox + +#pragma once + +#include + +#if OPENRENDERBOX_CF_CGTYPES && ORB_TARGET_OS_DARWIN + +#include + +namespace ORB { +namespace Path { +class Storage; +CGPathRef copy_cg_path(ORBPath path); +bool apply_mapping_element(void *context, ORBPathElement element, const CGFloat *values, const void *info); +bool apply_mapped_storage(const Storage &storage, void *context, size_t depth); +void invalidate_mapped_storage(uint32_t identifier); +} /* Path */ +} /* ORB */ + +#endif diff --git a/Sources/OpenRenderBoxCxx/Path/RoundedRect.cpp b/Sources/OpenRenderBoxCxx/Path/RoundedRect.cpp new file mode 100644 index 0000000..2a01899 --- /dev/null +++ b/Sources/OpenRenderBoxCxx/Path/RoundedRect.cpp @@ -0,0 +1,109 @@ +#include +#if OPENRENDERBOX_CF_CGTYPES && ORB_TARGET_OS_DARWIN +#include "RoundedRect.hpp" +#include +#include + +namespace ORB::Path { +namespace { +void constrain_pair(CGFloat &a, CGFloat &b, CGFloat length) { + CGFloat deficit = length - (a + b); + if (deficit < 0 && a != b) { + CGFloat reduction = std::min(-deficit, std::abs(a - b)); + (a > b ? a : b) -= reduction; + deficit += reduction; + } + if (deficit < 0) { + CGFloat delta = a * deficit / (a + b); + a += delta; + b += deficit - delta; + } +} + +struct EdgeCoefficients { + CGFloat start = static_cast(1.528665f); + CGFloat control1 = static_cast(1.08849f); + CGFloat control2 = static_cast(0.868407f); +}; + +EdgeCoefficients coefficients(CGFloat length, CGFloat a, CGFloat b) { + float sum = static_cast(a) + static_cast(b); + float ratio = (static_cast(std::abs(length)) - sum) / (sum * 0.528664947f); + if (!(ratio < 1)) { return {}; } + float t = std::max(0.0f, std::min(1.0f, ratio)); + return {std::fma(t, 0.528664947f, 1.0f), std::fma(t, 0.128490031f, 0.96f), std::fma(t, 0.0484070182f, 0.82f)}; +} +} // namespace + +void add_rounded_rect(CGMutablePathRef path, ORBPathElement element, const CGFloat *values, const CGAffineTransform *transform) { + CGRect rect = CGRectStandardize(CGRectMake(values[0], values[1], values[2], values[3])); + CGFloat w = rect.size.width, h = rect.size.height; + CGFloat rx[4], ry[4]; + if (element == ORBPathElementFixedRoundedRectCircular || element == ORBPathElementFixedRoundedRectContinuous) { + CGFloat limit = std::min(w, h) * 0.5; + std::fill(rx, rx + 4, std::min(limit, std::max(CGFloat(0), values[4]))); + std::fill(ry, ry + 4, std::min(limit, std::max(CGFloat(0), values[5]))); + } else { + CGFloat limit = std::max(w, h); + for (size_t i = 0; i != 4; ++i) { rx[i] = std::min(limit, std::max(CGFloat(0), values[4 + i])); } + constrain_pair(rx[0], rx[1], w); + constrain_pair(rx[3], rx[2], w); + constrain_pair(rx[0], rx[3], h); + constrain_pair(rx[1], rx[2], h); + std::copy(rx, rx + 4, ry); + } + CGFloat x0 = CGRectGetMinX(rect), y0 = CGRectGetMinY(rect); + CGFloat x1 = CGRectGetMaxX(rect), y1 = CGRectGetMaxY(rect); + bool emptyRadii = true; + for (size_t i = 0; i != 4; ++i) { emptyRadii &= rx[i] == 0 && ry[i] == 0; } + CGPathMoveToPoint(path, transform, x1, (y0 + ry[1] + y1 - ry[2]) * 0.5); + if (emptyRadii) { + CGPathAddLineToPoint(path, transform, x1, y1); + CGPathAddLineToPoint(path, transform, x0, y1); + CGPathAddLineToPoint(path, transform, x0, y0); + CGPathAddLineToPoint(path, transform, x1, y0); + CGPathCloseSubpath(path); + return; + } + bool continuous = element == ORBPathElementFixedRoundedRectContinuous || element == ORBPathElementUnevenRoundedRectContinuous; + // Each corner starts on its incoming edge and ends on the next edge. + const CGPoint origins[4] = {{x0, y0}, {x1, y0}, {x1, y1}, {x0, y1}}; + const CGPoint incoming[4] = {{0, 1}, {-1, 0}, {0, -1}, {1, 0}}; + const CGPoint outgoing[4] = {{1, 0}, {0, 1}, {-1, 0}, {0, -1}}; + EdgeCoefficients edges[4] = { + coefficients(w, rx[0], rx[1]), coefficients(h, ry[1], ry[2]), + coefficients(w, rx[2], rx[3]), coefficients(h, ry[3], ry[0]) + }; + for (size_t turn = 0; turn != 4; ++turn) { + size_t c = (turn + 2) % 4; + CGFloat a = incoming[c].x == 0 ? ry[c] : rx[c]; + CGFloat b = outgoing[c].x == 0 ? ry[c] : rx[c]; + auto point = [&](CGFloat u, CGFloat v) -> CGPoint { + return {origins[c].x + incoming[c].x * (u * a) + outgoing[c].x * (v * b), + origins[c].y + incoming[c].y * (u * a) + outgoing[c].y * (v * b)}; + }; + auto line = [&](CGFloat u, CGFloat v) { auto p = point(u, v); CGPathAddLineToPoint(path, transform, p.x, p.y); }; + auto curve = [&](CGFloat u1, CGFloat v1, CGFloat u2, CGFloat v2, CGFloat u3, CGFloat v3) { + auto p = point(u1, v1), q = point(u2, v2), r = point(u3, v3); + CGPathAddCurveToPoint(path, transform, p.x, p.y, q.x, q.y, r.x, r.y); + }; + if (continuous) { + auto before = edges[(c + 3) % 4], after = edges[c]; + constexpr CGFloat small = static_cast(0.0749114f); + constexpr CGFloat large = static_cast(0.631494f); + constexpr CGFloat middle1 = static_cast(0.16906f); + constexpr CGFloat middle2 = static_cast(0.372824f); + line(before.start, 0); + curve(before.control1, 0, before.control2, 0, large, small); + curve(middle2, middle1, middle1, middle2, small, large); + curve(0, after.control2, 0, after.control1, 0, after.start); + } else { + constexpr CGFloat k = 1 - 0.5522847498; + line(1, 0); + curve(k, 0, 0, k, 0, 1); + } + } + CGPathCloseSubpath(path); +} +} // namespace ORB::Path +#endif diff --git a/Sources/OpenRenderBoxCxx/Path/RoundedRect.hpp b/Sources/OpenRenderBoxCxx/Path/RoundedRect.hpp new file mode 100644 index 0000000..e0b4e08 --- /dev/null +++ b/Sources/OpenRenderBoxCxx/Path/RoundedRect.hpp @@ -0,0 +1,9 @@ +#pragma once + +#if OPENRENDERBOX_CF_CGTYPES && ORB_TARGET_OS_DARWIN +#include + +namespace ORB::Path { +void add_rounded_rect(CGMutablePathRef path, ORBPathElement element, const CGFloat *values, const CGAffineTransform *transform); +} +#endif diff --git a/Sources/OpenRenderBoxCxx/Path/Storage.cpp b/Sources/OpenRenderBoxCxx/Path/Storage.cpp index 978abbe..b4155bd 100644 --- a/Sources/OpenRenderBoxCxx/Path/Storage.cpp +++ b/Sources/OpenRenderBoxCxx/Path/Storage.cpp @@ -13,6 +13,7 @@ #include #include #if ORB_TARGET_OS_DARWIN +#include "PathMapper.hpp" #include #endif @@ -205,6 +206,11 @@ void Storage::push_values(uint8_t encoding, const CGFloat *values, size_t count) } void Storage::invalidate() { +#if ORB_TARGET_OS_DARWIN + if (_flags.requiresMapping()) { + invalidate_mapped_storage(_identifier); + } +#endif uintptr_t bounds = __atomic_exchange_n(&_bounds, 0, __ATOMIC_ACQ_REL); if (bounds & 1) { delete[] reinterpret_cast(bounds & ~uintptr_t(3)); @@ -424,6 +430,11 @@ bool Storage::apply_elements(void *info, ORBPathApplyCallback callback) const OR if (callback == NestedCallbacks::single_element_callback) { return NestedCallbacks::single_element_fast(*this, info); } +#if ORB_TARGET_OS_DARWIN + if (_flags.requiresMapping() && callback == apply_mapping_element) { + return apply_mapped_storage(*this, info, 0); + } +#endif return apply_elements_(info, callback); } @@ -439,6 +450,11 @@ bool Storage::apply_elements_(void *info, ORBPathApplyCallback callback) const O } bool Storage::apply_elements_at_depth(void *info, ORBPathApplyCallback callback, size_t depth) const ORB_NOEXCEPT { +#if ORB_TARGET_OS_DARWIN + if (_flags.requiresMapping() && callback == apply_mapping_element) { + return apply_mapped_storage(*this, info, depth); + } +#endif if (depth == 0) { return apply_elements_(info, callback); } @@ -465,7 +481,52 @@ bool Storage::apply_elements_at_depth(void *info, ORBPathApplyCallback callback, } #if ORB_TARGET_OS_DARWIN - +CGRect Storage::bounding_rect() const ORB_NOEXCEPT { + uintptr_t cache = __atomic_load_n(&_bounds, __ATOMIC_ACQUIRE); + if (cache) { + uintptr_t address = (cache & 2 ? reinterpret_cast(this) : 0) + cache; + auto *values = reinterpret_cast(address & ~uintptr_t(3)); + return CGRectMake(values[0], values[1], values[2], values[3]); + } + if (_flags.isSingleElement()) { + uint8_t encoding = *actual_storage(); + unsigned type = encoding & 0x1f; + if (!(encoding & 0x80) && type >= 5 && type <= 11) { + size_t offset = 1; + CGFloat values[4]; + read_values(actual_storage(), offset, encoding, values, 4); + return CGRectMake(values[0], values[1], values[2], values[3]); + } + } + ORBPath path{reinterpret_cast(const_cast(this)), &storageCallbacks}; + CGRect result = ORBPathGetBoundingRect(path); + float *values; + if (_flags.isExternal()) { + values = _external_bounds; + cache = 0x2a; + } else { + uintptr_t start = reinterpret_cast(actual_storage()); + uintptr_t end = start + capacity(); + uintptr_t aligned = (start + actual_size() + 3) & ~uintptr_t(3); + if (end >= aligned && end - aligned >= sizeof(float) * 4) { + values = reinterpret_cast(aligned); + cache = (aligned - reinterpret_cast(this)) | 2; + } else { + values = new float[4]; + cache = reinterpret_cast(values) | 1; + } + } + values[0] = static_cast(result.origin.x); + values[1] = static_cast(result.origin.y); + values[2] = static_cast(result.size.width); + values[3] = static_cast(result.size.height); + result = CGRectMake(values[0], values[1], values[2], values[3]); + uintptr_t expected = 0; + if (!__atomic_compare_exchange_n(&_bounds, &expected, cache, false, __ATOMIC_ACQ_REL, __ATOMIC_ACQUIRE) && (cache & 1)) { + delete[] values; + } + return result; +} CGPathRef Storage::cgpath() const ORB_NOEXCEPT { if (_flags.isInline()) { diff --git a/Sources/OpenRenderBoxCxx/Path/Storage.hpp b/Sources/OpenRenderBoxCxx/Path/Storage.hpp index 8682052..c9a9226 100644 --- a/Sources/OpenRenderBoxCxx/Path/Storage.hpp +++ b/Sources/OpenRenderBoxCxx/Path/Storage.hpp @@ -203,6 +203,7 @@ class Storage { bool apply_elements_at_depth(void *info, ORBPathApplyCallback callback, size_t depth) const ORB_NOEXCEPT; #if ORB_TARGET_OS_DARWIN + CGRect bounding_rect() const ORB_NOEXCEPT; CGPathRef _Nullable cgpath() const ORB_NOEXCEPT; #endif diff --git a/Sources/OpenRenderBoxCxx/include/module.modulemap b/Sources/OpenRenderBoxCxx/include/module.modulemap index c1389b4..aa25e37 100644 --- a/Sources/OpenRenderBoxCxx/include/module.modulemap +++ b/Sources/OpenRenderBoxCxx/include/module.modulemap @@ -44,6 +44,8 @@ module OpenRenderBoxCxx.Animation { module OpenRenderBoxCxx.Path { requires cplusplus header "../Path/NestedCallbacks.hpp" + header "../Path/PathMapper.hpp" + header "../Path/RoundedRect.hpp" header "../Path/Storage.hpp" export * } diff --git a/Sources/OpenRenderBoxShims/ORBShims.swift b/Sources/OpenRenderBoxShims/ORBShims.swift index 6e1a28a..9f3b42c 100644 --- a/Sources/OpenRenderBoxShims/ORBShims.swift +++ b/Sources/OpenRenderBoxShims/ORBShims.swift @@ -40,6 +40,17 @@ public typealias ORBLayerDelegate = RBLayerDelegate public let __ORBPathElementArgumentCount = __RBPathElementArgumentCount public typealias ORBPath = RBPath public typealias ORBPathApplyCallback = RBPathApplyCallback +public typealias ORBPathApplyShapeCallback = RBPathApplyShapeCallback +public typealias ORBPathFixedRoundedRectShape = RBPathFixedRoundedRectShape +public typealias ORBPathLineShape = RBPathLineShape +public typealias ORBPathNestedShape = RBPathNestedShape +public typealias ORBPathRectShape = RBPathRectShape +public typealias ORBPathShape = RBPathShape +public typealias ORBPathShapeType = RBPathShapeType +public typealias ORBPathStrokedShape = RBPathStrokedShape +public typealias ORBPathStrokeStyle = RBPathStrokeStyle +public typealias ORBPathTrimmedShape = RBPathTrimmedShape +public typealias ORBPathUnevenRoundedRectShape = RBPathUnevenRoundedRectShape public typealias ORBUUID = RBUUID public typealias ORBTransition = RBTransition public typealias ORBTransitionEffect = RBTransitionEffect diff --git a/Sources/OpenRenderBoxShimsCxx/include/RenderBox/OpenRenderBoxShims/OpenRenderBoxShims.h b/Sources/OpenRenderBoxShimsCxx/include/RenderBox/OpenRenderBoxShims/OpenRenderBoxShims.h index 3768cd0..cd18b4a 100644 --- a/Sources/OpenRenderBoxShimsCxx/include/RenderBox/OpenRenderBoxShims/OpenRenderBoxShims.h +++ b/Sources/OpenRenderBoxShimsCxx/include/RenderBox/OpenRenderBoxShims/OpenRenderBoxShims.h @@ -18,6 +18,17 @@ typedef RBPathCallbacks ORBPathCallbacks; typedef RBPathCallbacksFlags ORBPathCallbacksFlags; typedef RBPathCallbacksExtended ORBPathCallbacksExtended; typedef RBPathStorageRef ORBPathStorageRef; +typedef RBPathShapeType ORBPathShapeType; +typedef RBPathRectShape ORBPathRectShape; +typedef RBPathFixedRoundedRectShape ORBPathFixedRoundedRectShape; +typedef RBPathUnevenRoundedRectShape ORBPathUnevenRoundedRectShape; +typedef RBPathLineShape ORBPathLineShape; +typedef RBPathStrokeStyle ORBPathStrokeStyle; +typedef RBPathStrokedShape ORBPathStrokedShape; +typedef RBPathNestedShape ORBPathNestedShape; +typedef RBPathTrimmedShape ORBPathTrimmedShape; +typedef RBPathShape ORBPathShape; +typedef RBPathApplyShapeCallback ORBPathApplyShapeCallback; #define ORBBlendModeNormal RBBlendModeNormal #define ORBBlendModeMultiply RBBlendModeMultiply @@ -93,6 +104,15 @@ typedef RBPathStorageRef ORBPathStorageRef; #define ORBPathRoundedCornerStyleCircular RBPathRoundedCornerStyleCircular #define ORBPathRoundedCornerStyleContinuous RBPathRoundedCornerStyleContinuous +#define ORBPathShapeTypeRect RBPathShapeTypeRect +#define ORBPathShapeTypeEllipse RBPathShapeTypeEllipse +#define ORBPathShapeTypeLine RBPathShapeTypeLine +#define ORBPathShapeTypeFixedRoundedRect RBPathShapeTypeFixedRoundedRect +#define ORBPathShapeTypeUnevenRoundedRect RBPathShapeTypeUnevenRoundedRect +#define ORBPathShapeTypeNested RBPathShapeTypeNested +#define ORBPathShapeTypeStroked RBPathShapeTypeStroked +#define ORBPathShapeTypeTrimmed RBPathShapeTypeTrimmed +#define ORBPathShapeTypeOffset RBPathShapeTypeOffset #define ORBPathEmpty RBPathEmpty #define ORBPathNull RBPathNull @@ -103,8 +123,11 @@ typedef RBPathStorageRef ORBPathStorageRef; #define ORBPathMakeEllipse RBPathMakeEllipse #define ORBPathMakeRoundedRect RBPathMakeRoundedRect #define ORBPathMakeUnevenRoundedRect RBPathMakeUnevenRoundedRect +#define ORBPathCopyTransformedPath RBPathCopyTransformedPath #define ORBPathIsSingleElement RBPathIsSingleElement #define ORBPathGetBezierOrder RBPathGetBezierOrder +#define ORBPathApplyShape RBPathApplyShape +#define ORBPathGetBoundingRect RBPathGetBoundingRect #define ORBPathIsEmpty RBPathIsEmpty #define ORBPathApplyElements RBPathApplyElements #define ORBPathEqualToPath RBPathEqualToPath diff --git a/Tests/OpenRenderBoxCompatibilityTests/ORBShims.swift b/Tests/OpenRenderBoxCompatibilityTests/ORBShims.swift index 232ff8d..78e339b 100644 --- a/Tests/OpenRenderBoxCompatibilityTests/ORBShims.swift +++ b/Tests/OpenRenderBoxCompatibilityTests/ORBShims.swift @@ -16,6 +16,17 @@ public typealias ORBLayerDelegate = RBLayerDelegate public let __ORBPathElementArgumentCount = __RBPathElementArgumentCount public typealias ORBPath = RBPath public typealias ORBPathApplyCallback = RBPathApplyCallback +public typealias ORBPathApplyShapeCallback = RBPathApplyShapeCallback +public typealias ORBPathFixedRoundedRectShape = RBPathFixedRoundedRectShape +public typealias ORBPathLineShape = RBPathLineShape +public typealias ORBPathNestedShape = RBPathNestedShape +public typealias ORBPathRectShape = RBPathRectShape +public typealias ORBPathShape = RBPathShape +public typealias ORBPathShapeType = RBPathShapeType +public typealias ORBPathStrokedShape = RBPathStrokedShape +public typealias ORBPathStrokeStyle = RBPathStrokeStyle +public typealias ORBPathTrimmedShape = RBPathTrimmedShape +public typealias ORBPathUnevenRoundedRectShape = RBPathUnevenRoundedRectShape public typealias ORBUUID = RBUUID public typealias ORBTransition = RBTransition public typealias ORBTransitionEffect = RBTransitionEffect diff --git a/Tests/OpenRenderBoxCompatibilityTests/PathArcMappingTests.swift b/Tests/OpenRenderBoxCompatibilityTests/PathArcMappingTests.swift new file mode 100644 index 0000000..eed02d4 --- /dev/null +++ b/Tests/OpenRenderBoxCompatibilityTests/PathArcMappingTests.swift @@ -0,0 +1,60 @@ +// +// PathArcMappingTests.swift +// OpenRenderBoxCompatibilityTests + +#if canImport(CoreGraphics) && (OPENRENDERBOX_CF_CGTYPES || OPENRENDERBOX_COMPATIBILITY_TEST) +import CoreGraphics +import Foundation +import Testing + +struct PathArcMappingTests { + @Test(arguments: [ORBPath.Element.arc, .arcCounterclockwise]) + func quarterTurnUsesStableSegments(element: ORBPath.Element) { + withPathStorage { storage in + storage.append(element: element, points: [0, 0, 10, 0, .pi / 2], userInfo: nil) + withStoragePath(storage) { path in + let elements = cgPathElements(path.cgPath) + #expect(elements.count == (element == .arc ? 4 : 2)) + #expect(elements.first?.element == 0) + let end = path.cgPath.currentPoint + #expect(abs(end.x) < 0.00001) + #expect(abs(end.y - 10) < 0.00001) + } + } + } + + @Test(arguments: [ORBPath.Element.arc, .arcCounterclockwise]) + func fullTurnClosesTheCircle(element: ORBPath.Element) { + withPathStorage { storage in + storage.append(element: element, points: [0, 0, 10, 0, 2 * .pi], userInfo: nil) + withStoragePath(storage) { path in + let elements = cgPathElements(path.cgPath) + #expect(elements.count == 6) + #expect(elements.last?.element == 4) + #expect(path.boundingRect == CGRect(x: -10, y: -10, width: 20, height: 20)) + } + } + } + + @Test + func relativeArcPreservesTwoTurns() { + withPathStorage { storage in + storage.append(element: .relativeArc, points: [0, 0, 10, 0, 4 * .pi], userInfo: nil) + withStoragePath(storage) { path in + #expect(cgPathElements(path.cgPath).count == 9) + #expect(path.boundingRect == CGRect(x: -10, y: -10, width: 20, height: 20)) + } + } + } + + @Test + func relativeArcOmitsSmallResiduals() { + withPathStorage { storage in + storage.append(element: .relativeArc, points: [0, 0, 10, 0, 0.000001], userInfo: nil) + withStoragePath(storage) { path in + #expect(cgPathElements(path.cgPath) == [PathElementRecord(element: 0, points: [10, 0])]) + } + } + } +} +#endif diff --git a/Tests/OpenRenderBoxCompatibilityTests/PathRegressionTests.swift b/Tests/OpenRenderBoxCompatibilityTests/PathRegressionTests.swift index 66672c0..c8c2e92 100644 --- a/Tests/OpenRenderBoxCompatibilityTests/PathRegressionTests.swift +++ b/Tests/OpenRenderBoxCompatibilityTests/PathRegressionTests.swift @@ -9,6 +9,46 @@ import CoreGraphics @Suite struct PathRegressionTests { + private struct ElementSource { + var element: ORBPath.Element + var points: UnsafePointer + } + + private func withElementPath( + element: ORBPath.Element, + points: [CGFloat], + _ body: (ORBPath) throws -> Result + ) rethrows -> Result { + try points.withUnsafeBufferPointer { points in + var source = ElementSource(element: element, points: points.baseAddress!) + var callbacks = ORBPath.Callbacks() + callbacks.apply = { object, info, callback in + let source = object.assumingMemoryBound(to: ElementSource.self).pointee + return callback?(info, source.element, source.points, nil) ?? true + } + callbacks.isSingleElement = { _ in true } + return try withUnsafePointer(to: &source) { source in + try withUnsafePointer(to: &callbacks) { callbacks in + let path = ORBPath(storage: unsafeBitCast(source, to: ORBPath.Storage.self), callbacks: callbacks) + return try body(path) + } + } + } + } + + @Test(arguments: [ORBPath.Element.moveToPoint, .addLineToPoint, .closeSubpath]) + func shapeRecognitionAcceptsMinimalPayloads(element: ORBPath.Element) { + let values: [CGFloat] = element == .closeSubpath ? [0] : [1, 2] + withElementPath(element: element, points: values) { path in + var callbackCount = 0 + let recognized = path.applyShape(allowLine: true, info: &callbackCount) { info, _, _ in + info.assumingMemoryBound(to: Int.self).pointee += 1 + } + #expect(!recognized) + #expect(callbackCount == 0) + } + } + @Test func retainUsesReturnedStorage() { var destination: UInt64 = 0 @@ -28,6 +68,175 @@ struct PathRegressionTests { } } } -} + @Test + func batchContainmentPreservesAllMaskBits() { + let path = ORBPath(rect: CGRect(x: 0, y: 0, width: 10, height: 10), transform: nil) + defer { path.release() } + var points = Array(repeating: CGPoint(x: 20, y: 20), count: 65) + points[0] = CGPoint(x: 5, y: 5) + points[63] = CGPoint(x: 5, y: 5) + points[64] = CGPoint(x: 5, y: 5) + #expect(path.containsPoints(count: 0, points: points, eoFill: false, transform: nil) == 0) + #expect(path.containsPoints(count: 65, points: points, eoFill: false, transform: nil) == (UInt64(1) | (UInt64(1) << 63))) + } + + @Test + func batchContainmentTransformsQueryPoints() { + let path = ORBPath(rect: CGRect(x: 0, y: 0, width: 10, height: 10), transform: nil) + defer { path.release() } + var transform = CGAffineTransform(translationX: -10, y: -20) + let points = [CGPoint(x: 15, y: 25), CGPoint(x: 5, y: 5)] + #expect(path.containsPoints(count: 2, points: points, eoFill: false, transform: &transform) & 3 == 1) + } + + @Test + func containmentUsesTheRequestedWindingRule() { + let cgPath = CGMutablePath() + cgPath.addRect(CGRect(x: 0, y: 0, width: 20, height: 20)) + cgPath.addRect(CGRect(x: 5, y: 5, width: 10, height: 10)) + let path = ORBPath(cgPath: cgPath) + defer { path.release() } + #expect(path.contains(point: CGPoint(x: 10, y: 10), eoFill: false)) + #expect(!path.contains(point: CGPoint(x: 10, y: 10), eoFill: true)) + #expect(path.contains(point: CGPoint(x: 2, y: 2), eoFill: true)) + } + + @Test + func nullPrimitiveRectanglesUseEmptyStorage() { + let paths = [ + ORBPath(rect: .null, transform: nil), + ORBPath(ellipseIn: .null, transform: nil), + ORBPath(roundedRect: .null, cornerWidth: 4, cornerHeight: 5, style: .circular, transform: nil), + ORBPath(roundedRect: .null, topLeftRadius: 1, topRightRadius: 2, bottomRightRadius: 3, bottomLeftRadius: 4, style: .continuous, transform: nil), + ] + for path in paths { + #expect(unsafeBitCast(path.storage, to: UInt.self) == 0) + #expect(path.isEmpty) + path.release() + } + let null = ORBPath(cgPath: nil) + #expect(unsafeBitCast(null.storage, to: UInt.self) == 1) + null.release() + } + + @Test + func sentinelEqualityPreservesNullMarker() { + #expect(ORBPath.empty.isEqual(to: .null)) + #expect(ORBPath.null.isEqual(to: .empty)) + var callbacks = ORBPath.Callbacks() + callbacks.apply = { _, _, _ in true } + withUnsafePointer(to: &callbacks) { callbacks in + var payload: UInt64 = 0 + withUnsafePointer(to: &payload) { payload in + let custom = ORBPath(storage: unsafeBitCast(payload, to: ORBPath.Storage.self), callbacks: callbacks) + #expect(ORBPath.empty.isEqual(to: custom)) + #expect(custom.isEqual(to: .empty)) + #expect(!ORBPath.null.isEqual(to: custom)) + #expect(!custom.isEqual(to: .null)) + } + } + } + + @Test(arguments: [ + (CGPoint(x: 5, y: 0), false), + (CGPoint(x: 10, y: 5), false), + (CGPoint(x: 5, y: 10), false), + (CGPoint(x: 0, y: 5), true), + (CGPoint(x: 5, y: 5), true), + ]) + func ellipseBoundaryContainment(point: CGPoint, expected: Bool) { + let path = ORBPath(ellipseIn: CGRect(x: 0, y: 0, width: 10, height: 10), transform: nil) + defer { path.release() } + #expect(path.contains(point: point, eoFill: false) == expected) + } + + @Test + func equalityFallsBackToElementContents() { + let values: [CGFloat] = [1, 2, 30, 40] + withElementPath(element: .rect, points: values) { lhs in + withElementPath(element: .rect, points: values) { rhs in + #expect(lhs.callbacks != rhs.callbacks) + #expect(lhs.isEqual(to: rhs)) + let sameCallbacks = ORBPath(storage: rhs.storage, callbacks: lhs.callbacks) + #expect(lhs.isEqual(to: sameCallbacks)) + } + withElementPath(element: .rect, points: [1, 2, 30, 41]) { rhs in + #expect(!lhs.isEqual(to: rhs)) + } + withElementPath(element: .ellipse, points: values) { rhs in + #expect(!lhs.isEqual(to: rhs)) + } + } + } + + @Test + func genericEqualityUsesCompactStoragePrecision() { + let values: [CGFloat] = [0.1, 0.2, 30.3, 40.4] + let compactValues = values.map { CGFloat(Float($0)) } + withElementPath(element: .rect, points: values) { original in + withElementPath(element: .rect, points: compactValues) { compact in + #expect(original.isEqual(to: compact)) + #expect(!compact.isEqual(to: original)) + } + withElementPath(element: .rect, points: values) { other in + #expect(!original.isEqual(to: other)) + } + } + } + + @Test(arguments: [ORBPath.RoundedCornerStyle.circular, .continuous]) + func unevenRoundedRectanglePreservesPrimitiveStyle(style: ORBPath.RoundedCornerStyle) { + let rect = CGRect(x: 1, y: 2, width: 100, height: 80) + let path = ORBPath(roundedRect: rect, topLeftRadius: 5, topRightRadius: 10, bottomRightRadius: 15, bottomLeftRadius: 20, style: style, transform: nil) + defer { path.release() } + let elements = pathElements(path) + #expect(elements.count == 1) + #expect(elements.first?.element == (style == .circular ? ORBPath.Element.unevenRoundedRectCircular.rawValue : ORBPath.Element.unevenRoundedRectContinuous.rawValue)) + #expect(path.boundingRect == rect) + #expect(path.contains(point: CGPoint(x: 50, y: 40), eoFill: false)) + #expect(!path.contains(point: CGPoint(x: 1.01, y: 2.01), eoFill: false)) + var actualStyle = ORBPath.RoundedCornerStyle.circular + var actualType = ORBPathShapeType.rect + #expect(path.applyShape(allowLine: true, info: &actualStyle) { info, type, shape in + if type == .unevenRoundedRect { + info.assumingMemoryBound(to: ORBPath.RoundedCornerStyle.self).pointee = shape.pointee.unevenRoundedRect.style + } + }) + #expect(actualStyle == style) + #expect(path.applyShape(allowLine: true, info: &actualType) { info, type, _ in + info.assumingMemoryBound(to: ORBPathShapeType.self).pointee = type + }) + #expect(actualType == .unevenRoundedRect) + } + + @Test + func continuousRoundedRectangleGeometry() { + let path = ORBPath(roundedRect: CGRect(x: 0, y: 0, width: 100, height: 80), cornerWidth: 10, cornerHeight: 20, style: .continuous, transform: nil) + defer { path.release() } + let elements = cgPathElements(path.cgPath) + #expect(elements.count == 18) + #expect(elements.prefix(3).map(\.element) == [0, 1, 3]) + let actual = elements.prefix(3).flatMap(\.points) + let expected: [CGFloat] = [100, 40, 100, 49.426701068878174, + 100, 58.230199813842773, 100, 62.63185977935791, + 99.250885993242264, 67.370120286941528] + #expect(actual.count == expected.count) + for (actual, expected) in zip(actual, expected) { + #expect(abs(actual - expected) < 1e-10) + } + } + + @Test + func unevenRoundedRectangleStylesHaveDifferentGeometry() { + let rect = CGRect(x: 0, y: 0, width: 100, height: 80) + let circular = ORBPath(roundedRect: rect, topLeftRadius: 5, topRightRadius: 10, bottomRightRadius: 15, bottomLeftRadius: 20, style: .circular, transform: nil) + let continuous = ORBPath(roundedRect: rect, topLeftRadius: 5, topRightRadius: 10, bottomRightRadius: 15, bottomLeftRadius: 20, style: .continuous, transform: nil) + defer { + circular.release() + continuous.release() + } + #expect(circular.cgPath != continuous.cgPath) + } +} #endif diff --git a/Tests/OpenRenderBoxCompatibilityTests/PathStorageTests.swift b/Tests/OpenRenderBoxCompatibilityTests/PathStorageTests.swift index e64371e..be39195 100644 --- a/Tests/OpenRenderBoxCompatibilityTests/PathStorageTests.swift +++ b/Tests/OpenRenderBoxCompatibilityTests/PathStorageTests.swift @@ -22,6 +22,33 @@ struct PathStorageTests { var count: Int var keys: UnsafePointer? } + @Test + func storageIsEmpty() { + let path = ORBPath(rect: CGRect(x: 0, y: 0, width: 100, height: 100), transform: nil) + defer { path.release() } + withPathStorage { storage in + #expect(storage.isEmpty) + #expect(storage.append(path: path)) + #expect(!storage.isEmpty) + storage.clear() + #expect(storage.isEmpty) + #expect(pathElements(storage).isEmpty) + } + } + + @Test + func storageIsSingleElement() { + let path = ORBPath(rect: CGRect(x: 0, y: 0, width: 100, height: 100), transform: nil) + defer { path.release() } + withPathStorage { storage in + #expect(!storage.isSingleElement) + #expect(storage.append(path: path)) + #expect(storage.isSingleElement) + #expect(storage.append(path: path)) + #expect(!storage.isSingleElement) + } + } + @Test func storageBezierOrder() { withPathStorage { storage in @@ -36,6 +63,39 @@ struct PathStorageTests { } } + @Test + func storageBoundingRect() { + let rect = CGRect(x: 10, y: 20, width: 100, height: 50) + let path = ORBPath(rect: rect, transform: nil) + defer { path.release() } + withPathStorage { storage in + #expect(storage.append(path: path)) + #expect(storage.boundingRect == rect) + #expect(pathElements(storage) == pathElements(path)) + } + } + + @Test(arguments: [ + (CGRect(x: 0, y: 0, width: 100, height: 100), CGRect(x: 0, y: 0, width: 100, height: 100), true), + (CGRect(x: 0, y: 0, width: 100, height: 100), CGRect(x: 0, y: 0, width: 200, height: 200), false), + (CGRect(x: 10, y: 20, width: 50, height: 50), CGRect(x: 10, y: 20, width: 50, height: 50), true), + ]) + func storageEquality(rect1: CGRect, rect2: CGRect, expectedEqual: Bool) { + let path1 = ORBPath(rect: rect1, transform: nil) + let path2 = ORBPath(rect: rect2, transform: nil) + defer { + path1.release() + path2.release() + } + withPathStorage { storage1 in + withPathStorage { storage2 in + #expect(storage1.append(path: path1)) + #expect(storage2.append(path: path2)) + #expect(storage1.isEqual(to: storage2) == expectedEqual) + } + } + } + @Test(arguments: [64, 256], [1, 40]) func storageCopyPreservesElements(capacity: Int, lineCount: Int) { withPathStorage(capacity: capacity) { source in @@ -123,6 +183,27 @@ struct PathStorageTests { #expect(count == 1) } } + + @Test("Cached paths follow storage mutation", arguments: 1...4) + func storageGetCGPath(iteration: Int) throws { + let rect = CGRect(x: 0, y: 0, width: 100, height: 100) + let path = ORBPath(rect: rect, transform: nil) + defer { path.release() } + try withPathStorage(capacity: 64) { storage in + #expect(storage.append(path: path)) + for _ in 1...7 { + storage.append(element: .moveToPoint, points: [50, 50], userInfo: nil) + storage.append(element: .addLineToPoint, points: [100, 50], userInfo: nil) + storage.append(element: .closeSubpath, points: [], userInfo: nil) + } + let cached = try #require(storage.cgPath) + #expect(cached.boundingBoxOfPath == rect) + storage.append(element: .moveToPoint, points: [200, 200], userInfo: nil) + storage.append(element: .addLineToPoint, points: [300, 300], userInfo: nil) + #expect(storage.cgPath?.boundingBoxOfPath == CGRect(x: 0, y: 0, width: 300, height: 300)) + #expect(cached.boundingBoxOfPath == rect) + } + } } #endif diff --git a/Tests/OpenRenderBoxCompatibilityTests/PathTestSupport.swift b/Tests/OpenRenderBoxCompatibilityTests/PathTestSupport.swift index 93538ed..761fa69 100644 --- a/Tests/OpenRenderBoxCompatibilityTests/PathTestSupport.swift +++ b/Tests/OpenRenderBoxCompatibilityTests/PathTestSupport.swift @@ -6,6 +6,20 @@ import CoreGraphics import Foundation +// The curve reference data comes from RenderBox in iOS 18.5. +let referencePathGeometryEnabled: Bool = { + #if OPENRENDERBOX_COMPATIBILITY_TEST + #if os(iOS) + let version = ProcessInfo.processInfo.operatingSystemVersion + return version.majorVersion == 18 && version.minorVersion == 5 + #else + return false + #endif + #else + return true + #endif +}() + func withPathStorage( capacity: Int = 96, source: ORBPath.Storage? = nil, @@ -79,3 +93,25 @@ func pathElements(_ path: ORBPath) -> [PathElementRecord] { return elements } #endif + +#if canImport(CoreGraphics) && (OPENRENDERBOX_CF_CGTYPES || OPENRENDERBOX_COMPATIBILITY_TEST) +func cgPathElements(_ path: CGPath) -> [PathElementRecord] { + var elements: [PathElementRecord] = [] + path.applyWithBlock { element in + let element = element.pointee + let count: Int + switch element.type { + case .moveToPoint, .addLineToPoint: count = 1 + case .addQuadCurveToPoint: count = 2 + case .addCurveToPoint: count = 3 + case .closeSubpath: count = 0 + @unknown default: count = 0 + } + let values = UnsafeBufferPointer(start: element.points, count: count).flatMap { [$0.x, $0.y] } + elements.append(PathElementRecord(element: element.type.rawValue, points: values)) + } + return elements +} + +// Ignore commands that do not change the geometry of a closed clip result. +#endif diff --git a/Tests/OpenRenderBoxCompatibilityTests/PathTests.swift b/Tests/OpenRenderBoxCompatibilityTests/PathTests.swift index 238edb9..e526a94 100644 --- a/Tests/OpenRenderBoxCompatibilityTests/PathTests.swift +++ b/Tests/OpenRenderBoxCompatibilityTests/PathTests.swift @@ -4,10 +4,10 @@ import Testing -#if canImport(CoreGraphics) +#if canImport(CoreGraphics) && (OPENRENDERBOX_CF_CGTYPES || OPENRENDERBOX_COMPATIBILITY_TEST) import CoreGraphics -@Suite(.enabled(if: compatibilityTestEnabled)) +@Suite struct PathTests { @Test func createRectPath() { @@ -50,6 +50,12 @@ struct PathTests { (CGPoint(x: 50, y: 50), true), (CGPoint(x: 150, y: 150), false), (CGPoint(x: 0, y: 0), true), + (CGPoint(x: 50, y: 0), true), + (CGPoint(x: 100, y: 0), false), + (CGPoint(x: 100, y: 50), false), + (CGPoint(x: 50, y: 100), false), + (CGPoint(x: 0, y: 100), false), + (CGPoint(x: 0, y: 50), true), (CGPoint(x: 100, y: 100), false), (CGPoint(x: -1, y: 50), false), ]) @@ -82,7 +88,7 @@ struct PathTests { countPtr.pointee += 1 return true // continue iteration } - #expect(elementCount == 5) // move + 4 lines for rect + #expect(elementCount == 5) // Move, three lines, and close. path.release() } diff --git a/Tests/OpenRenderBoxTests/Path/PathTests.swift b/Tests/OpenRenderBoxTests/Path/PathTests.swift index c7d2cfa..4f3cb29 100644 --- a/Tests/OpenRenderBoxTests/Path/PathTests.swift +++ b/Tests/OpenRenderBoxTests/Path/PathTests.swift @@ -13,11 +13,216 @@ import Testing @Suite struct PathTests { + private struct ElementCapture { + var element: ORBPath.Element? + var hasTransform = false + } + + private struct ShapeCapture { + var type: ORBPathShapeType? + var transform: CGAffineTransform = .identity + var rect: CGRect = .zero + var cornerSize: CGSize = .zero + var style: ORBPath.RoundedCornerStyle = .circular + } + @Test func replacesStorageCapacityBits() { let flags = ORB.Path.StorageFlags().withCapacity(15).withCapacity(16) #expect(flags.capacity() == 16) } -} + @Test + func appliesRectangleShape() { + let rect = CGRect(x: 10, y: 20, width: 80, height: 40) + var transform = CGAffineTransform(translationX: 5, y: 7) + let path = ORBPath(rect: rect, transform: &transform) + defer { path.release() } + + var capture = ShapeCapture() + let recognized = path.applyShape(allowLine: true, info: &capture) { info, type, shape in + let capture = info.assumingMemoryBound(to: ShapeCapture.self) + capture.pointee.type = type + capture.pointee.transform = shape.pointee.rect.transform + capture.pointee.rect = shape.pointee.rect.rect + } + + #expect(recognized) + #expect(capture.type == .rect) + #expect(capture.transform == transform) + #expect(capture.rect == rect) + #expect(path.isSingleElement) + } + + @Test + func appliesEllipseShape() { + let rect = CGRect(x: 1, y: 2, width: 30, height: 20) + let path = ORBPath(ellipseIn: rect, transform: nil) + defer { path.release() } + + var capture = ShapeCapture() + let recognized = path.applyShape(allowLine: true, info: &capture) { info, type, shape in + let capture = info.assumingMemoryBound(to: ShapeCapture.self) + capture.pointee.type = type + capture.pointee.transform = shape.pointee.ellipse.transform + capture.pointee.rect = shape.pointee.ellipse.rect + } + + #expect(recognized) + #expect(capture.type == .ellipse) + #expect(capture.transform.isIdentity) + #expect(capture.rect == rect) + } + + @Test + func appliesFixedRoundedRectangleShape() { + let rect = CGRect(x: 3, y: 4, width: 50, height: 30) + let path = ORBPath( + roundedRect: rect, + cornerWidth: 8, + cornerHeight: 6, + style: .continuous, + transform: nil + ) + defer { path.release() } + + var capture = ShapeCapture() + let recognized = path.applyShape(allowLine: true, info: &capture) { info, type, shape in + let capture = info.assumingMemoryBound(to: ShapeCapture.self) + capture.pointee.type = type + capture.pointee.transform = shape.pointee.fixedRoundedRect.transform + capture.pointee.rect = shape.pointee.fixedRoundedRect.rect + capture.pointee.cornerSize = shape.pointee.fixedRoundedRect.cornerSize + capture.pointee.style = shape.pointee.fixedRoundedRect.style + } + + #expect(recognized) + #expect(capture.type == .fixedRoundedRect) + #expect(capture.transform.isIdentity) + #expect(capture.rect == rect) + #expect(capture.cornerSize == CGSize(width: 8, height: 6)) + #expect(capture.style == .continuous) + } + + @Test + func primitiveCallbacksMatchSingleElementLayout() { + let rect = CGRect(x: 2, y: 3, width: 40, height: 20) + var identity = CGAffineTransform.identity + let path = ORBPath(rect: rect, transform: &identity) + defer { path.release() } + + #expect(path.callbacks.pointee.boundingRect == nil) + #expect(path.callbacks.pointee.cgPath == nil) + + var capture = ElementCapture() + let applied = path.apply(info: &capture) { info, element, _, userInfo in + let capture = info.assumingMemoryBound(to: ElementCapture.self) + capture.pointee.element = element + capture.pointee.hasTransform = userInfo != nil + return true + } + #expect(applied) + #expect(capture.element == .rect) + #expect(!capture.hasTransform) + } + + @Test + func identityAndNilTransformsProduceEqualPrimitives() { + let rect = CGRect(x: 2, y: 3, width: 40, height: 20) + var identity = CGAffineTransform.identity + let lhs = ORBPath(rect: rect, transform: nil) + let rhs = ORBPath(rect: rect, transform: &identity) + defer { + lhs.release() + rhs.release() + } + + #expect(lhs.isEqual(to: rhs)) + } + + @Test + func appliesCoreGraphicsRectangleShape() { + let rect = CGRect(x: 10, y: 20, width: 80, height: 40) + var transform = CGAffineTransform(translationX: 5, y: 7) + let cgPath = CGPath(rect: rect, transform: &transform) + let path = ORBPath(cgPath: cgPath) + defer { path.release() } + + var capture = ShapeCapture() + #expect(path.applyShape(allowLine: true, info: &capture) { info, type, shape in + let capture = info.assumingMemoryBound(to: ShapeCapture.self) + capture.pointee.type = type + capture.pointee.transform = shape.pointee.rect.transform + capture.pointee.rect = shape.pointee.rect.rect + }) + #expect(capture.type == .rect) + #expect(capture.rect.applying(capture.transform) == cgPath.boundingBoxOfPath) + } + + @Test + func zeroCornerRoundedRectangleUsesRoundedRectElement() { + let rect = CGRect(x: 3, y: 4, width: 50, height: 30) + let path = ORBPath( + roundedRect: rect, + cornerWidth: 0, + cornerHeight: 0, + style: .continuous, + transform: nil + ) + defer { path.release() } + + var capture = ElementCapture() + #expect(path.apply(info: &capture) { info, element, _, _ in + info.assumingMemoryBound(to: ElementCapture.self).pointee.element = element + return true + }) + #expect(capture.element == .roundedRect) + + var shape = ShapeCapture() + #expect(path.applyShape(allowLine: true, info: &shape) { info, type, _ in + info.assumingMemoryBound(to: ShapeCapture.self).pointee.type = type + }) + #expect(shape.type == .rect) + } + + @Test + func continuousRoundedRectangleUsesContinuousGeometry() { + let rect = CGRect(x: 0, y: 0, width: 80, height: 50) + let circular = ORBPath( + roundedRect: rect, + cornerWidth: 12, + cornerHeight: 10, + style: .circular, + transform: nil + ) + let continuous = ORBPath( + roundedRect: rect, + cornerWidth: 12, + cornerHeight: 10, + style: .continuous, + transform: nil + ) + defer { + circular.release() + continuous.release() + } + + #expect(circular.cgPath != continuous.cgPath) + } + + @Test + func primitiveBoundingRectUsesPathAPI() { + let rect = CGRect(x: 1, y: 2, width: 30, height: 20) + var transform = CGAffineTransform(rotationAngle: .pi / 4) + let path = ORBPath(ellipseIn: rect, transform: &transform) + defer { path.release() } + + let expected = rect.applying(transform) + let actual = path.boundingRect + #expect(abs(actual.origin.x - expected.origin.x) < 0.0001) + #expect(abs(actual.origin.y - expected.origin.y) < 0.0001) + #expect(abs(actual.width - expected.width) < 0.0001) + #expect(abs(actual.height - expected.height) < 0.0001) + } +} #endif From 3bd47ec64a031fe21d1205a58605ff4492cb8f34 Mon Sep 17 00:00:00 2001 From: Kyle Date: Fri, 30 Oct 2026 00:51:38 +0800 Subject: [PATCH 04/12] feat(path): add transformed trimmed and stroked paths --- Sources/OpenRenderBoxCxx/Path/ORBPath.cpp | 112 +++++++- Sources/OpenRenderBoxCxx/Path/ORBPath.h | 6 + .../OpenRenderBoxCxx/Path/PathGeometry.hpp | 127 +++++++++ Sources/OpenRenderBoxCxx/Path/PathMapper.cpp | 259 +++++++++++++++++- .../OpenRenderBoxCxx/include/module.modulemap | 1 + .../OpenRenderBoxShims/OpenRenderBoxShims.h | 2 + .../PathWrapperTests.swift | 182 ++++++++++++ .../OpenRenderBoxCompatibilityTests/README.md | 27 +- 8 files changed, 701 insertions(+), 15 deletions(-) create mode 100644 Sources/OpenRenderBoxCxx/Path/PathGeometry.hpp create mode 100644 Tests/OpenRenderBoxCompatibilityTests/PathWrapperTests.swift diff --git a/Sources/OpenRenderBoxCxx/Path/ORBPath.cpp b/Sources/OpenRenderBoxCxx/Path/ORBPath.cpp index 0485daa..62ca70c 100644 --- a/Sources/OpenRenderBoxCxx/Path/ORBPath.cpp +++ b/Sources/OpenRenderBoxCxx/Path/ORBPath.cpp @@ -353,16 +353,87 @@ ORBPath ORBPathMakeUnevenRoundedRect(CGRect rect, CGFloat topLeftRadius, CGFloat return {reinterpret_cast(new UnevenRoundedRectPathStorage(args, transform)), &callbacks}; } -ORBPath ORBPathCopyTransformedPath(ORBPath path, const CGAffineTransform *transform) { - if (ORBPathIsNull(path)) { - return ORBPathNull; +namespace { +struct WrappedPathStorage { + uint32_t referenceCount = 1; + ORBPath path; + ORBPathElement element; + CGAffineTransform transform = CGAffineTransformIdentity; + CGFloat values[3] = {}; + int32_t cap = 0; + int32_t join = 0; + std::vector dash; + + WrappedPathStorage(ORBPath path, ORBPathElement element) + : path(ORBPathRetain(path)), element(element) {} + ~WrappedPathStorage() { ORBPathRelease(path); } +}; + +const ORBPathCallbacks wrapped_path_callbacks = { + .flags = {}, + .retain = retain_single_element_path, + .release = release_single_element_path, + .apply = +[](const void *object, void *info, ORBPathApplyCallback callback) -> bool { + auto &storage = *static_cast(object); + ORB::Path::Storage::StrokeInfo stroke{storage.cap, storage.join, storage.dash.size(), storage.dash.data()}; + const void *metadata = storage.element == ORBPathElementTransformed ? static_cast(&storage.transform) + : storage.element == ORBPathElementStroked ? &stroke : nullptr; + return callback(info, storage.element, storage.values, metadata) + && ORBPathApplyElements(storage.path, info, callback) + && callback(info, ORBPathElementEnd, storage.values, nullptr); + }, + .isEqual = +[](const void *a, const void *b) -> bool { + auto &lhs = *static_cast(a); + auto &rhs = *static_cast(b); + return lhs.element == rhs.element && CGAffineTransformEqualToTransform(lhs.transform, rhs.transform) + && std::equal(lhs.values, lhs.values + 3, rhs.values) && lhs.cap == rhs.cap && lhs.join == rhs.join + && lhs.dash == rhs.dash && ORBPathEqualToPath(lhs.path, rhs.path); + }, + .isEmpty = +[](const void *) -> bool { return false; }, + .isSingleElement = +[](const void *) -> bool { return true; }, + .bezierOrder = nullptr, + .boundingRect = nullptr, + .cgPath = nullptr, + .next = +[](const void *object) -> ORBPath { return static_cast(object)->path; }, +}; + +ORBPath nested_path(ORBPath path) { + if (path.callbacks->flags.isExtended) { + auto *extended = reinterpret_cast(path.callbacks); + if (extended->next) { return extended->next(path.storage, extended); } + } else if (path.callbacks->next) { + return path.callbacks->next(path.storage); } - CGPathRef source = ORBPathCopyCGPath(path); - CGPathRef transformed = CGPathCreateCopyByTransformingPath(source, transform); - ORBPath result = ORBPathMakeWithCGPath(transformed); - CGPathRelease(transformed); - CGPathRelease(source); - return result; + auto *callbacks = ORB::Path::NestedCallbacks::get(path.callbacks, 1); + return {path.storage, reinterpret_cast(&callbacks->extended)}; +} +} // namespace + +ORBPath ORBPathCopyTransformedPath(ORBPath path, const CGAffineTransform *transform) { + if (path.callbacks == &empty_path_callbacks) { return path; } + auto *storage = new WrappedPathStorage(path, ORBPathElementTransformed); + storage->transform = transform ? *transform : CGAffineTransformIdentity; + return {reinterpret_cast(storage), &wrapped_path_callbacks}; +} + +ORBPath ORBPathCopyStrokedPath(ORBPath path, CGFloat width, int32_t cap, int32_t join, CGFloat miter, CGFloat phase, const CGFloat *dash, size_t count) { + if (path.callbacks == &empty_path_callbacks) { return ORBPathEmpty; } + auto *storage = new WrappedPathStorage(path, ORBPathElementStroked); + storage->values[0] = width; + storage->values[1] = miter; + storage->values[2] = phase; + storage->cap = cap; + storage->join = join; + if (dash && count) { storage->dash.assign(dash, dash + count); } + return {reinterpret_cast(storage), &wrapped_path_callbacks}; +} + +ORBPath ORBPathCopyTrimmedPath(ORBPath path, CGFloat from, CGFloat to) { + if (path.callbacks == &empty_path_callbacks) { return ORBPathEmpty; } + auto *storage = new WrappedPathStorage(path, ORBPathElementTrimmed); + storage->values[0] = from; + storage->values[1] = to; + return {reinterpret_cast(storage), &wrapped_path_callbacks}; } #endif /* ORB_TARGET_OS_DARWIN */ @@ -524,6 +595,29 @@ bool ORBPathApplyShape( const void *userInfo ) -> bool { auto info = static_cast(rawInfo); + if (element == ORBPathElementTransformed || element == ORBPathElementTrimmed || element == ORBPathElementStroked) { + ORBPathShape shape = {}; + auto nested = nested_path(info->path); + ORBPathShapeType type; + if (element == ORBPathElementTransformed) { + shape.nested = {nested, userInfo ? *static_cast(userInfo) : CGAffineTransformIdentity}; + type = ORBPathShapeTypeNested; + } else if (element == ORBPathElementTrimmed) { + shape.trimmed = {nested, values[0], values[1]}; + type = ORBPathShapeTypeTrimmed; + } else { + ORB::Path::Storage::StrokeInfo stroke = {}; + if (userInfo) { stroke = *static_cast(userInfo); } + static const CGFloat emptyDash = 0; + size_t count = stroke.values ? stroke.count : 0; + auto *dash = count ? static_cast(stroke.values) : &emptyDash; + shape.stroked = {nested, {values[0], stroke.cap, stroke.join, values[1], values[2], count, dash}}; + type = ORBPathShapeTypeStroked; + } + info->callback(info->info, type, &shape); + info->matched = true; + return false; + } switch (element) { case ORBPathElementRect: case ORBPathElementRoundedRect: diff --git a/Sources/OpenRenderBoxCxx/Path/ORBPath.h b/Sources/OpenRenderBoxCxx/Path/ORBPath.h index 08980f9..a32d5f8 100644 --- a/Sources/OpenRenderBoxCxx/Path/ORBPath.h +++ b/Sources/OpenRenderBoxCxx/Path/ORBPath.h @@ -192,6 +192,12 @@ ORBPath ORBPathMakeUnevenRoundedRect(CGRect rect, CGFloat topLeftRadius, CGFloat ORB_EXPORT ORBPath ORBPathCopyTransformedPath(ORBPath path, const CGAffineTransform * _Nullable transform) ORB_SWIFT_NAME(ORBPath.copy(self:transform:)); +ORB_EXPORT +ORBPath ORBPathCopyStrokedPath(ORBPath path, CGFloat lineWidth, int32_t lineCap, int32_t lineJoin, CGFloat miterLimit, CGFloat dashPhase, const CGFloat * _Nullable dash, size_t dashCount) ORB_SWIFT_NAME(ORBPath.stroked(self:lineWidth:lineCap:lineJoin:miterLimit:dashPhase:dash:dashCount:)); + +ORB_EXPORT +ORBPath ORBPathCopyTrimmedPath(ORBPath path, CGFloat from, CGFloat to) ORB_SWIFT_NAME(ORBPath.trimmed(self:from:to:)); + static ORB_INLINE bool ORBPathIsNull(ORBPath path) ORB_SWIFT_NAME(getter:ORBPath.isNull(self:)) { return path.storage == ORBPathNull.storage && path.callbacks == ORBPathNull.callbacks; } diff --git a/Sources/OpenRenderBoxCxx/Path/PathGeometry.hpp b/Sources/OpenRenderBoxCxx/Path/PathGeometry.hpp new file mode 100644 index 0000000..33b68a4 --- /dev/null +++ b/Sources/OpenRenderBoxCxx/Path/PathGeometry.hpp @@ -0,0 +1,127 @@ +// +// PathGeometry.hpp +// OpenRenderBox + +#pragma once + +#include + +#if OPENRENDERBOX_CF_CGTYPES && ORB_TARGET_OS_DARWIN + +#include +#include + +namespace ORB::Path::Geometry { + +inline CGPoint mix(CGPoint a, CGPoint b, CGFloat t) { + return {std::fma(b.x - a.x, t, a.x), std::fma(b.y - a.y, t, a.y)}; +} + +inline CGFloat distance(CGPoint a, CGPoint b) { + CGFloat x = b.x - a.x, y = b.y - a.y; + return std::sqrt(x * x + y * y); +} + +struct Cubic { + CGPoint p[4]; + + void split(CGFloat t, Cubic &left, Cubic &right) const { + CGPoint a = mix(p[0], p[1], t), b = mix(p[1], p[2], t), c = mix(p[2], p[3], t); + CGPoint d = mix(a, b, t), e = mix(b, c, t), f = mix(d, e, t); + left = {{p[0], a, d, f}}; + right = {{f, e, c, p[3]}}; + } +}; + +struct Quadratic { + CGPoint p[3]; + + void split(CGFloat t, Quadratic &left, Quadratic &right) const { + CGPoint a = mix(p[0], p[1], t), b = mix(p[1], p[2], t); + CGPoint middle = mix(a, b, t); + left = {{p[0], a, middle}}; + right = {{middle, b, p[2]}}; + } +}; + +inline CGFloat quadratic_length(Quadratic curve, unsigned depth = 0) { + CGFloat x = (curve.p[0].x - curve.p[1].x) + (curve.p[2].x - curve.p[1].x); + CGFloat y = (curve.p[0].y - curve.p[1].y) + (curve.p[2].y - curve.p[1].y); + CGFloat curvature = x * x + y * y; + if (depth > 32 || curvature < 4 || !std::isfinite(curvature)) { + return distance(curve.p[0], curve.p[2]); + } + Quadratic left, right; + curve.split(0.5, left, right); + return quadratic_length(left, depth + 1) + quadratic_length(right, depth + 1); +} + +inline CGFloat quadratic_measure(Quadratic curve, CGFloat requested, bool findIndex) { + if (!findIndex) { return quadratic_length(curve); } + if (requested <= 0) { return 0; } + if (quadratic_length(curve) <= requested) { return 1; } + CGFloat lower = 0, upper = 1; + do { + CGFloat middle = (lower + upper) * 0.5; + Quadratic left, right; + curve.split(middle, left, right); + if (quadratic_length(left) > requested) { + upper = middle; + } else { + lower = middle; + } + } while (upper - lower > 0.0001); + return (lower + upper) * 0.5; +} + +struct CurveSpan { + Cubic curve; + CGFloat interval; +}; + +inline CGFloat curve_measure(Cubic curve, CGFloat requested, bool findIndex) { + CGFloat edge0 = distance(curve.p[0], curve.p[1]); + CGFloat edge2 = distance(curve.p[2], curve.p[3]); + if (!std::isfinite(edge0 + edge2) || (findIndex && requested == 0)) { + return 0; + } + CurveSpan pending[29]; + size_t count = 0; + CGFloat interval = 1, parameter = 0, total = 0; + for (unsigned iteration = 0; iteration < 1024; ++iteration) { + edge0 = distance(curve.p[0], curve.p[1]); + CGFloat edge1 = distance(curve.p[1], curve.p[2]); + edge2 = distance(curve.p[2], curve.p[3]); + CGFloat polygon = edge0 + edge2 + edge1; + CGFloat chord = distance(curve.p[0], curve.p[3]); + bool flat = polygon - chord <= 0.5 * interval; + if (findIndex) { + flat = flat && std::abs(edge0 - edge1) + std::abs(edge1 - edge2) < 0.5; + } + if (flat || count == 29) { + CGFloat length = (polygon + chord) * 0.5; + if (findIndex && total + length >= requested) { + return std::fmin(parameter + (requested - total) * interval / length, 1); + } + total += length; + if (count == 0) { + return findIndex ? 1 : total; + } + parameter += interval; + auto span = pending[--count]; + curve = span.curve; + interval = span.interval; + } else { + Cubic left, right; + curve.split(0.5, left, right); + interval *= 0.5; + pending[count++] = {right, interval}; + curve = left; + } + } + return 1; +} + +} // namespace ORB::Path::Geometry + +#endif diff --git a/Sources/OpenRenderBoxCxx/Path/PathMapper.cpp b/Sources/OpenRenderBoxCxx/Path/PathMapper.cpp index 3a1c991..836ed3f 100644 --- a/Sources/OpenRenderBoxCxx/Path/PathMapper.cpp +++ b/Sources/OpenRenderBoxCxx/Path/PathMapper.cpp @@ -7,6 +7,7 @@ #if OPENRENDERBOX_CF_CGTYPES && ORB_TARGET_OS_DARWIN #include "PathMapper.hpp" +#include "PathGeometry.hpp" #include "RoundedRect.hpp" #include "Storage.hpp" #include @@ -21,6 +22,8 @@ namespace ORB { namespace Path { namespace { +using namespace Geometry; + void add_relative_arc(CGMutablePathRef path, const CGAffineTransform *transform, CGPoint center, CGFloat radius, CGFloat start, CGFloat delta) { CGFloat sine = std::sin(start), cosine = std::cos(start); @@ -84,14 +87,233 @@ void add_arc(CGMutablePathRef path, const CGAffineTransform *transform, CGPoint add_relative_arc(path, transform, center, radius, start, end - start); } -struct Mapper { +struct Segment { + CGPathElementType type; + Cubic curve; + CGFloat length; +}; + +struct TrimInput { + std::vector segments; + CGPoint current = CGPointZero; + CGPoint start = CGPointZero; + CGFloat length = 0; + + void append(const CGPathElement &element) { + Cubic curve{{current, {}, {}, {}}}; + CGFloat segmentLength = 0; + switch (element.type) { + case kCGPathElementMoveToPoint: + current = start = element.points[0]; + curve.p[0] = current; + break; + case kCGPathElementAddLineToPoint: + curve.p[3] = element.points[0]; + segmentLength = distance(current, curve.p[3]); + current = curve.p[3]; + break; + case kCGPathElementAddQuadCurveToPoint: + curve.p[1] = {(current.x + 2 * element.points[0].x) / 3, (current.y + 2 * element.points[0].y) / 3}; + curve.p[2] = {(element.points[1].x + 2 * element.points[0].x) / 3, (element.points[1].y + 2 * element.points[0].y) / 3}; + curve.p[3] = element.points[1]; + segmentLength = curve_measure(curve, 0, false); + current = curve.p[3]; + break; + case kCGPathElementAddCurveToPoint: + std::copy_n(element.points, 3, curve.p + 1); + segmentLength = curve_measure(curve, 0, false); + current = curve.p[3]; + break; + case kCGPathElementCloseSubpath: + curve.p[3] = start; + segmentLength = distance(current, start); + current = start; + break; + } + length += segmentLength; + segments.push_back({element.type, curve, segmentLength}); + } +}; + +CGPathRef copy_trimmed_path(CGPathRef path, float from, float to) { + if (from <= 0 && to >= 1) { + return CGPathCreateCopy(path); + } + CGMutablePathRef result = CGPathCreateMutable(); + if (!(from < to)) { + return result; + } + TrimInput input; + CGPathApply(path, &input, +[](void *context, const CGPathElement *element) { + static_cast(context)->append(*element); + }); + CGFloat fromDistance = input.length * from, toDistance = input.length * to; + float consumed = 0; + bool needsMove = true, includesStart = false; + for (const auto &segment : input.segments) { + if (segment.type == kCGPathElementMoveToPoint) { + needsMove = true; + includesStart = false; + continue; + } + CGFloat begin = consumed, end = begin + segment.length; + consumed = static_cast(end); + if (segment.length == 0) { + if (segment.type == kCGPathElementCloseSubpath && includesStart && begin <= toDistance) { + CGPathCloseSubpath(result); + } + continue; + } + CGFloat first = fromDistance >= begin ? std::fmax((fromDistance - begin) / segment.length, 0) : 0; + CGFloat last = toDistance < end ? std::fmin((toDistance - begin) / segment.length, 1) : 1; + if (!(first < last)) { + continue; + } + if (needsMove) { + includesStart = first == 0; + } + if (segment.type == kCGPathElementAddLineToPoint || segment.type == kCGPathElementCloseSubpath) { + CGPoint start = mix(segment.curve.p[0], segment.curve.p[3], first); + CGPoint finish = mix(segment.curve.p[0], segment.curve.p[3], last); + if (needsMove) { + CGPathMoveToPoint(result, nullptr, start.x, start.y); + needsMove = false; + } + CGPathAddLineToPoint(result, nullptr, finish.x, finish.y); + if (segment.type == kCGPathElementCloseSubpath && last == 1 && includesStart) { + CGPathCloseSubpath(result); + } + } else { + Cubic curve = segment.curve; + if (first > 0 || last < 1) { + if (first > 0.001) { + first = curve_measure(curve, first * segment.length, true); + } + if (last < 0.999) { + last = curve_measure(curve, last * segment.length, true); + } + Cubic left, right; + if (first > 0.001) { + curve.split(first, left, right); + curve = right; + last = (last - first) / (1 - first); + } + if (last < 0.999) { + curve.split(last, left, right); + curve = left; + } + } + if (needsMove) { + CGPathMoveToPoint(result, nullptr, curve.p[0].x, curve.p[0].y); + needsMove = false; + } + CGPathAddCurveToPoint(result, nullptr, curve.p[1].x, curve.p[1].y, curve.p[2].x, curve.p[2].y, curve.p[3].x, curve.p[3].y); + } + } + return result; +} + +struct Frame { + unsigned type; CGMutablePathRef path = CGPathCreateMutable(); + CGAffineTransform transform = CGAffineTransformIdentity; + CGFloat values[3] = {}; + int32_t cap = 0; + int32_t join = 0; + std::vector dash; - ~Mapper() { CGPathRelease(path); } + explicit Frame(unsigned type) : type(type) {} + ~Frame() { + CGPathRelease(path); + } + + CGPathRef finish() { + switch (type) { + case 17: + return CGPathCreateCopyByTransformingPath(path, &transform); + case 18: + return copy_trimmed_path(path, static_cast(values[0]), static_cast(values[1])); + case 19: { + CGPathRef input = dash.empty() ? CGPathRetain(path) + : CGPathCreateCopyByDashingPath(path, nullptr, static_cast(values[2]), dash.data(), dash.size()); + CGPathRef result = CGPathCreateCopyByStrokingPath(input, nullptr, static_cast(values[0]), static_cast(cap), static_cast(join), static_cast(values[1])); + CGPathRelease(input); + return result; + } + default: + return CGPathRetain(path); + } + } +}; + +void append_element(CGMutablePathRef path, const CGPathElement &element) { + const CGPoint *p = element.points; + switch (element.type) { + case kCGPathElementMoveToPoint: CGPathMoveToPoint(path, nullptr, p[0].x, p[0].y); break; + case kCGPathElementAddLineToPoint: CGPathAddLineToPoint(path, nullptr, p[0].x, p[0].y); break; + case kCGPathElementAddQuadCurveToPoint: CGPathAddQuadCurveToPoint(path, nullptr, p[0].x, p[0].y, p[1].x, p[1].y); break; + case kCGPathElementAddCurveToPoint: CGPathAddCurveToPoint(path, nullptr, p[0].x, p[0].y, p[1].x, p[1].y, p[2].x, p[2].y); break; + case kCGPathElementCloseSubpath: CGPathCloseSubpath(path); break; + } +} + +struct Mapper { + std::vector> frames; + + Mapper() { frames.push_back(std::make_unique(0)); } + + Frame ¤t() { return *frames.back(); } + + bool flush(size_t index) { + Frame &frame = *frames[index]; + CGPathRef path = frame.finish(); + if (!path) { return false; } + struct AppendInfo { + Mapper *mapper; + size_t index; + } info{this, index - 1}; + CGPathApply(path, &info, +[](void *context, const CGPathElement *element) { + auto &info = *static_cast(context); + append_element(info.mapper->frames[info.index]->path, *element); + }); + CGPathRelease(path); + return true; + } + + void end_path(size_t index) { + Frame &frame = *frames[index]; + switch (frame.type) { + case 18: + flush(index); + break; + case 19: + flush(index); + CGPathRelease(frame.path); + frame.path = CGPathCreateMutable(); + end_path(index - 1); + break; + default: + break; + } + } + + void pop() { + if (frames.size() == 1) { + return; + } + unsigned type = current().type; + flush(frames.size() - 1); + frames.pop_back(); + if (type == 19) { + end_path(frames.size() - 1); + } + } bool apply(ORBPathElement element, const CGFloat *values, const void *info) { auto type = static_cast(element); auto *transform = static_cast(info); + size_t index = frames.size() - 1; + CGMutablePathRef path = current().path; switch (type) { case 0: CGPathMoveToPoint(path, transform, values[0], values[1]); @@ -138,6 +360,31 @@ struct Mapper { case 15: CGPathAddArcToPoint(path, transform, values[0], values[1], values[2], values[3], values[4]); break; + case 16: + pop(); + break; + case 17: case 18: case 19: { + auto frame = std::make_unique(type); + if (type == 17 && info) { + frame->transform = *transform; + } else if (type == 18) { + frame->values[0] = values[0]; + frame->values[1] = values[1]; + } else if (type == 19) { + std::copy_n(values, 3, frame->values); + if (info) { + const auto &stroke = *static_cast(info); + frame->cap = stroke.cap; + frame->join = stroke.join; + if (stroke.count) { + auto *dash = static_cast(stroke.values); + frame->dash.assign(dash, dash + stroke.count); + } + } + } + frames.push_back(std::move(frame)); + break; + } default: precondition_failure("unsupported path mapping element: %u", type); } @@ -188,8 +435,9 @@ bool apply_mapped_storage(const Storage &storage, void *context, size_t depth) { return static_cast(context)->apply(element, values, info); }, depth); if (!complete) { return false; } + while (mapper.frames.size() > 1) { mapper.pop(); } mapped = std::make_shared(sizeof(Storage)); - CGPathApply(mapper.path, mapped.get(), +[](void *context, const CGPathElement *element) { + CGPathApply(mapper.current().path, mapped.get(), +[](void *context, const CGPathElement *element) { static_cast(context)->append_element(static_cast(element->type), reinterpret_cast(element->points), nullptr); }); @@ -205,7 +453,10 @@ CGPathRef copy_cg_path(ORBPath path) { if (!complete) { precondition_failure("path enumeration stopped during conversion"); } - return CGPathRetain(mapper.path); + while (mapper.frames.size() > 1) { + mapper.pop(); + } + return CGPathRetain(mapper.current().path); } } /* Path */ diff --git a/Sources/OpenRenderBoxCxx/include/module.modulemap b/Sources/OpenRenderBoxCxx/include/module.modulemap index aa25e37..34a22f1 100644 --- a/Sources/OpenRenderBoxCxx/include/module.modulemap +++ b/Sources/OpenRenderBoxCxx/include/module.modulemap @@ -44,6 +44,7 @@ module OpenRenderBoxCxx.Animation { module OpenRenderBoxCxx.Path { requires cplusplus header "../Path/NestedCallbacks.hpp" + header "../Path/PathGeometry.hpp" header "../Path/PathMapper.hpp" header "../Path/RoundedRect.hpp" header "../Path/Storage.hpp" diff --git a/Sources/OpenRenderBoxShimsCxx/include/RenderBox/OpenRenderBoxShims/OpenRenderBoxShims.h b/Sources/OpenRenderBoxShimsCxx/include/RenderBox/OpenRenderBoxShims/OpenRenderBoxShims.h index cd18b4a..d3c2287 100644 --- a/Sources/OpenRenderBoxShimsCxx/include/RenderBox/OpenRenderBoxShims/OpenRenderBoxShims.h +++ b/Sources/OpenRenderBoxShimsCxx/include/RenderBox/OpenRenderBoxShims/OpenRenderBoxShims.h @@ -124,6 +124,8 @@ typedef RBPathApplyShapeCallback ORBPathApplyShapeCallback; #define ORBPathMakeRoundedRect RBPathMakeRoundedRect #define ORBPathMakeUnevenRoundedRect RBPathMakeUnevenRoundedRect #define ORBPathCopyTransformedPath RBPathCopyTransformedPath +#define ORBPathCopyStrokedPath RBPathCopyStrokedPath +#define ORBPathCopyTrimmedPath RBPathCopyTrimmedPath #define ORBPathIsSingleElement RBPathIsSingleElement #define ORBPathGetBezierOrder RBPathGetBezierOrder #define ORBPathApplyShape RBPathApplyShape diff --git a/Tests/OpenRenderBoxCompatibilityTests/PathWrapperTests.swift b/Tests/OpenRenderBoxCompatibilityTests/PathWrapperTests.swift new file mode 100644 index 0000000..e5245c3 --- /dev/null +++ b/Tests/OpenRenderBoxCompatibilityTests/PathWrapperTests.swift @@ -0,0 +1,182 @@ +// +// PathWrapperTests.swift +// OpenRenderBoxCompatibilityTests + +import Testing + +#if canImport(CoreGraphics) && (OPENRENDERBOX_CF_CGTYPES || OPENRENDERBOX_COMPATIBILITY_TEST) +import CoreGraphics + +@Suite +struct PathWrapperTests { + private func makeLine() -> ORBPath { + let cgPath = CGMutablePath() + cgPath.move(to: .zero) + cgPath.addLine(to: CGPoint(x: 10, y: 0)) + return ORBPath(cgPath: cgPath) + } + + @Test + func transformedPathRetainsSource() { + let source = makeLine() + var transform = CGAffineTransform(a: 2, b: 0, c: 0, d: 3, tx: 20, ty: 30) + let path = source.copy(transform: &transform) + transform = .identity + source.release() + defer { path.release() } + #expect(path.isSingleElement) + #expect(pathElements(path).map(\.element) == [17, 0, 1, 16]) + #expect(cgPathElements(path.cgPath) == [ + PathElementRecord(element: 0, points: [20, 30]), + PathElementRecord(element: 1, points: [40, 30]), + ]) + } + + @Test + func retainedNestedShapeSurvivesWrapperRelease() { + let source = makeLine() + var transform = CGAffineTransform(translationX: 20, y: 30) + let path = source.copy(transform: &transform) + source.release() + var nested = ORBPath.empty + var type = ORBPathShapeType.rect + #expect(path.applyShape(allowLine: true, info: &type) { info, type, _ in + info.assumingMemoryBound(to: ORBPathShapeType.self).pointee = type + }) + #expect(type == .nested) + #expect(path.applyShape(allowLine: true, info: &nested) { info, type, shape in + if type == .nested { + info.assumingMemoryBound(to: ORBPath.self).pointee = shape.pointee.nested.path.retain() + } + }) + path.release() + defer { nested.release() } + #expect(cgPathElements(nested.cgPath) == [ + PathElementRecord(element: 0, points: [0, 0]), + PathElementRecord(element: 1, points: [10, 0]), + ]) + } + + @Test + func nestedWrappersKeepChildDepth() { + let source = makeLine() + let trimmed = source.trimmed(from: 0.25, to: 0.75) + source.release() + var transform = CGAffineTransform(translationX: 20, y: 30) + let path = trimmed.copy(transform: &transform) + trimmed.release() + #expect(path.isSingleElement) + #expect(pathElements(path).map(\.element) == [17, 18, 0, 1, 16, 16]) + #expect(cgPathElements(path.cgPath) == [ + PathElementRecord(element: 0, points: [22.5, 30]), + PathElementRecord(element: 1, points: [27.5, 30]), + ]) + var child = ORBPath.empty + #expect(path.applyShape(allowLine: true, info: &child) { info, type, shape in + if type == .nested { + info.assumingMemoryBound(to: ORBPath.self).pointee = shape.pointee.nested.path.retain() + } + }) + path.release() + defer { child.release() } + var shapeType = ORBPathShapeType.rect + #expect(child.applyShape(allowLine: true, info: &shapeType) { info, type, _ in + info.assumingMemoryBound(to: ORBPathShapeType.self).pointee = type + }) + #expect(shapeType == .trimmed) + #expect(cgPathElements(child.cgPath) == [ + PathElementRecord(element: 0, points: [2.5, 0]), + PathElementRecord(element: 1, points: [7.5, 0]), + ]) + } + + @Test + func trimmedPathPreservesSegmentBoundaries() { + let cgPath = CGMutablePath() + cgPath.move(to: .zero) + cgPath.addLine(to: CGPoint(x: 10, y: 0)) + cgPath.addLine(to: CGPoint(x: 10, y: 10)) + let source = ORBPath(cgPath: cgPath) + let path = source.trimmed(from: 0.25, to: 0.75) + source.release() + defer { path.release() } + #expect(path.isSingleElement) + #expect(pathElements(path).map(\.element) == [18, 0, 1, 1, 16]) + #expect(cgPathElements(path.cgPath) == [ + PathElementRecord(element: 0, points: [5, 0]), + PathElementRecord(element: 1, points: [10, 0]), + PathElementRecord(element: 1, points: [10, 5]), + ]) + var shapeType = ORBPathShapeType.rect + #expect(path.applyShape(allowLine: true, info: &shapeType) { info, type, _ in + info.assumingMemoryBound(to: ORBPathShapeType.self).pointee = type + }) + #expect(shapeType == .trimmed) + } + + @Test(.enabled(if: referencePathGeometryEnabled), arguments: [false, true]) + func trimmedCurvesMatchSegmentGeometry(quadratic: Bool) { + let cgPath = CGMutablePath() + cgPath.move(to: .zero) + if quadratic { + cgPath.addQuadCurve(to: CGPoint(x: 10, y: 0), control: CGPoint(x: 5, y: 10)) + } else { + cgPath.addCurve(to: CGPoint(x: 10, y: 0), control1: CGPoint(x: 0, y: 10), control2: CGPoint(x: 10, y: 10)) + } + let source = ORBPath(cgPath: cgPath) + let path = source.trimmed(from: 0.25, to: 0.75) + source.release() + defer { path.release() } + let elements = cgPathElements(path.cgPath) + #expect(elements.map(\.element) == [0, 3]) + let actual = elements.flatMap(\.points) + let expected: [CGFloat] = quadratic + ? [1.9837460658183548, 3.1804424409067495, + 3.9947165820760118, 5.6066815333809306, + 6.0056870983336692, 5.6065191755005594, + 8.0166576145913258, 3.1799553672656353] + : [1.0906342200107548, 4.8932050683032262, + 3.0138364775468443, 8.3689316438989252, + 6.9861635224531566, 8.3689316438989252, + 8.9093657799892458, 4.8932050683032253] + #expect(actual.count == expected.count) + for (actual, expected) in zip(actual, expected) { + #expect(abs(actual - expected) < 1e-10) + } + } + + @Test(arguments: [(0.75, 0.25), (0.0, 0.0), (1.0, 1.0)]) + func emptyTrimProducesNoGeometry(from: Double, to: Double) { + let source = makeLine() + let path = source.trimmed(from: from, to: to) + source.release() + defer { path.release() } + #expect(path.cgPath.isEmpty) + } + + @Test(arguments: [Int32(0), 1, 2]) + func strokedPathUsesLineCap(cap: Int32) { + let source = makeLine() + let path = source.stroked(lineWidth: 4, lineCap: cap, lineJoin: 0, miterLimit: 10, dashPhase: 0, dash: nil, dashCount: 0) + source.release() + defer { path.release() } + #expect(path.isSingleElement) + #expect(pathElements(path).map(\.element) == [19, 0, 1, 16]) + let bounds = cap == 0 ? CGRect(x: 0, y: -2, width: 10, height: 4) : CGRect(x: -2, y: -2, width: 14, height: 4) + #expect(path.cgPath.boundingBoxOfPath == bounds) + var shapeType = ORBPathShapeType.rect + #expect(path.applyShape(allowLine: true, info: &shapeType) { info, type, _ in + info.assumingMemoryBound(to: ORBPathShapeType.self).pointee = type + }) + #expect(shapeType == .stroked) + var hasEmptyDashBuffer = false + #expect(path.applyShape(allowLine: true, info: &hasEmptyDashBuffer) { info, type, shape in + if type == .stroked { + let style = shape.pointee.stroked.style + info.assumingMemoryBound(to: Bool.self).pointee = style.dashCount == 0 && style.dash != nil + } + }) + #expect(hasEmptyDashBuffer) + } +} +#endif diff --git a/Tests/OpenRenderBoxCompatibilityTests/README.md b/Tests/OpenRenderBoxCompatibilityTests/README.md index dda1f78..7992395 100644 --- a/Tests/OpenRenderBoxCompatibilityTests/README.md +++ b/Tests/OpenRenderBoxCompatibilityTests/README.md @@ -1,11 +1,34 @@ ## OpenRenderBoxCompatibilityTests -Run the callback and Storage suites against native OpenRenderBox: +Run the Path suites against native OpenRenderBox: ```sh OPENRENDERBOX_USE_LOCAL_DEPS=1 OPENRENDERBOX_RENDERBOX=0 \ -OPENRENDERBOX_LIBRARY_EVOLUTION=0 swift test --filter 'PathCallbacksTests|PathStorageTests|PathRegressionTests' +OPENRENDERBOX_LIBRARY_EVOLUTION=0 swift test --filter Path ``` +Run the same public API suites against the host RenderBox: + +```sh +OPENRENDERBOX_USE_LOCAL_DEPS=1 OPENRENDERBOX_RENDERBOX=1 \ +OPENRENDERBOX_COMPATIBILITY_TEST=1 OPENRENDERBOX_LIBRARY_EVOLUTION=0 \ +swift test --filter 'OpenRenderBoxCompatibilityTests.*Path' +``` + +The local dependency options require sibling OpenCoreGraphics and +DarwinPrivateFrameworks checkouts. Native Path suites run without enabling +other unfinished compatibility suites. + +The exact curve-trimming fixtures +use RenderBox from iOS 18.5 (22F77). +They run against native OpenRenderBox and Apple RenderBox on iOS 18.5. +Other Apple releases skip those fixtures because their curve subdivision +can differ. The suites also cover callback cancellation, Storage lifetime, +primitive queries, arcs, and wrapper geometry. +To test the reference runtime, open `Package.swift` in Xcode with the +Apple compatibility environment above, select the +`OpenRenderBoxCompatibilityTests` package scheme, and run the Path suites on +an iOS 18.5 Simulator. + Storage fixtures allocate separate aligned buffers. A primitive's `storage` pointer is not a buffer that can be passed to Storage initialization APIs. From f932d5c6a3c9d97d15b67048e502ca943887c98d Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 2 Nov 2026 01:47:56 +0800 Subject: [PATCH 05/12] fix: simplify graphics configuration and isolate imports --- Package.resolved | 4 ++-- Package.swift | 13 ++++++++----- .../OpenRenderBox/Animation/ORBSymbolUpdate.swift | 2 +- Sources/OpenRenderBox/Export.swift | 7 +++++++ Sources/OpenRenderBoxCxx/include/module.modulemap | 6 +++--- .../OpenRenderBoxCompatibilityTests/ORBShims.swift | 3 +++ 6 files changed, 24 insertions(+), 11 deletions(-) diff --git a/Package.resolved b/Package.resolved index f44abb3..609e4a0 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "afb2d7d71fc5958731e2203564551e6c4fd33689a5df8057159b3b0e0ab9762d", + "originHash" : "1477c74d879eeac85b29c7bc660a95d816c9b92adc30152ebfdd49d35535b059", "pins" : [ { "identity" : "darwinprivateframeworks", @@ -16,7 +16,7 @@ "location" : "https://github.com/OpenSwiftUIProject/OpenCoreGraphics", "state" : { "branch" : "main", - "revision" : "0d7a0d0d9e236cbf9dea2f10a5b0b88c8789d9e4" + "revision" : "3440823f9b2580dcd3795373214521efc37628b8" } }, { diff --git a/Package.swift b/Package.swift index 6387a61..e926f83 100644 --- a/Package.swift +++ b/Package.swift @@ -155,12 +155,11 @@ let compatibilityTestCondition = envBoolValue("COMPATIBILITY_TEST", default: fal let useLocalDeps = envBoolValue("USE_LOCAL_DEPS") let renderBoxCondtion = envBoolValue("RENDERBOX", default: buildForDarwinPlatform && !isSPIBuild) -/// CGFloat and CGRect def in CFCGTypes.h will conflict with Foundation's CGSize/CGRect def on Linux. -/// macOS: true -> no issue -/// macOS: false -> use Swift implementation with OpenCoreGraphics Swift CGPath -/// Linux: true + No CGPathRef support in ORBPath -> confilict with Foundation def -/// Linux: false -> use Swift implementation with OpenCoreGraphics Swift CGPath +// C geometry types conflict with Foundation's Swift geometry types on Linux. +// Disable this flag to use Swift Path instead of the C++ implementation. let cfCGTypes = envBoolValue("CF_CGTYPES", default: buildForDarwinPlatform) +// Use OPENSWIFTUI_COREGRAPHICS to select the same backend in ORB and OCG shims. +let coreGraphicsCondition = envBoolValue("COREGRAPHICS", default: buildForDarwinPlatform) // MARK: - Shared Settings @@ -190,6 +189,10 @@ if cfCGTypes { sharedSwiftSettings.append(.define("OPENRENDERBOX_CF_CGTYPES")) } +if coreGraphicsCondition { + sharedSwiftSettings.append(.define("OPENRENDERBOX_COREGRAPHICS")) +} + // MARK: - Extension extension Target { diff --git a/Sources/OpenRenderBox/Animation/ORBSymbolUpdate.swift b/Sources/OpenRenderBox/Animation/ORBSymbolUpdate.swift index 5b468b4..9ea0392 100644 --- a/Sources/OpenRenderBox/Animation/ORBSymbolUpdate.swift +++ b/Sources/OpenRenderBox/Animation/ORBSymbolUpdate.swift @@ -4,7 +4,7 @@ #if !canImport(ObjectiveC) || !OPENRENDERBOX_CF_CGTYPES public import OpenCoreGraphicsShims -public import OpenRenderBoxCxx +public import OpenRenderBoxCxx.C #endif #if !canImport(ObjectiveC) diff --git a/Sources/OpenRenderBox/Export.swift b/Sources/OpenRenderBox/Export.swift index 4bfb9e2..6a26528 100644 --- a/Sources/OpenRenderBox/Export.swift +++ b/Sources/OpenRenderBox/Export.swift @@ -3,5 +3,12 @@ // OpenRenderBox #if SWIFT_PACKAGE +#if OPENRENDERBOX_COREGRAPHICS @_exported public import OpenRenderBoxCxx +#if canImport(ObjectiveC) +@_exported public import OpenRenderBoxCxx.ObjC +#endif +#else +@_exported public import OpenRenderBoxCxx.C +#endif #endif diff --git a/Sources/OpenRenderBoxCxx/include/module.modulemap b/Sources/OpenRenderBoxCxx/include/module.modulemap index 34a22f1..fbf2dc5 100644 --- a/Sources/OpenRenderBoxCxx/include/module.modulemap +++ b/Sources/OpenRenderBoxCxx/include/module.modulemap @@ -22,10 +22,10 @@ module OpenRenderBoxCxx.C { header "OpenRenderBox/ORBSymbolRenderingMode.h" header "OpenRenderBox/ORBUUID.h" header "OpenRenderBox/ORBVersion.h" - export * } -module OpenRenderBoxCxx.Color { +// These modules import system CoreGraphics and must be selected explicitly. +explicit module OpenRenderBoxCxx.Color { requires cplusplus header "../Color/ColorSpace.hpp" export * @@ -59,7 +59,7 @@ module OpenRenderBoxCxx.Util { export * } -module OpenRenderBoxCxx.ObjC { +explicit module OpenRenderBoxCxx.ObjC { requires objc header "OpenRenderBox/ORBAnimation.h" header "OpenRenderBox/ORBSymbolAnimator.h" diff --git a/Tests/OpenRenderBoxCompatibilityTests/ORBShims.swift b/Tests/OpenRenderBoxCompatibilityTests/ORBShims.swift index 78e339b..4408cac 100644 --- a/Tests/OpenRenderBoxCompatibilityTests/ORBShims.swift +++ b/Tests/OpenRenderBoxCompatibilityTests/ORBShims.swift @@ -44,6 +44,9 @@ public let compatibilityTestEnabled = true #else @_exported import OpenRenderBox @_exported import OpenRenderBoxCxx +#if canImport(ObjectiveC) +@_exported import OpenRenderBoxCxx.ObjC +#endif public let compatibilityTestEnabled = false #endif From 428745e18c251a40a618009c901f0b8d1c02fd5a Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 7 Nov 2026 18:58:23 +0800 Subject: [PATCH 06/12] build(deps): update OpenCoreGraphics --- Package.resolved | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.resolved b/Package.resolved index 609e4a0..d9ee8ff 100644 --- a/Package.resolved +++ b/Package.resolved @@ -16,7 +16,7 @@ "location" : "https://github.com/OpenSwiftUIProject/OpenCoreGraphics", "state" : { "branch" : "main", - "revision" : "3440823f9b2580dcd3795373214521efc37628b8" + "revision" : "de4866d03ab727b5204633a2c29cb1412dd90ee6" } }, { From 9f32c807b74d40bde2da3238a13d45d6015def90 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 7 Nov 2026 19:01:21 +0800 Subject: [PATCH 07/12] feat(path): add boolean offset and glyph operations --- Sources/OpenRenderBoxCxx/Path/PathMapper.cpp | 531 +++++++++++++++++- .../PathStorageTests.swift | 84 ++- 2 files changed, 607 insertions(+), 8 deletions(-) diff --git a/Sources/OpenRenderBoxCxx/Path/PathMapper.cpp b/Sources/OpenRenderBoxCxx/Path/PathMapper.cpp index 836ed3f..b6a315d 100644 --- a/Sources/OpenRenderBoxCxx/Path/PathMapper.cpp +++ b/Sources/OpenRenderBoxCxx/Path/PathMapper.cpp @@ -213,18 +213,508 @@ CGPathRef copy_trimmed_path(CGPathRef path, float from, float to) { return result; } + +struct Dilator { + CGMutablePathRef path; + CGPoint amount; + CGPoint current = CGPointZero; + CGPoint start = CGPointZero; + bool needsMove = true; + + CGPoint begin(CGPoint end) { + CGFloat x = end.y < current.y ? -amount.x : end.y > current.y ? amount.x : 0; + CGFloat y = end.x < current.x ? amount.y : end.x > current.x ? -amount.y : 0; + CGPoint offset{x, amount.y + y}; + if (needsMove) { + CGPathMoveToPoint(path, nullptr, current.x + offset.x, current.y + offset.y); + needsMove = false; + } else { + CGPathAddLineToPoint(path, nullptr, current.x + offset.x, current.y + offset.y); + } + return offset; + } + + void line(CGPoint end) { + CGPoint offset = begin(end); + CGPathAddLineToPoint(path, nullptr, end.x + offset.x, end.y + offset.y); + current = end; + } + + void close() { + if (!needsMove) { + if (!CGPointEqualToPoint(current, start)) { + line(start); + } + CGPathCloseSubpath(path); + needsMove = true; + } + } + + void move(CGPoint point) { + close(); + current = start = point; + } + + void quad(CGPoint control, CGPoint end, unsigned axis = 0) { + if (axis < 2) { + CGFloat first = axis == 0 ? current.x : current.y; + CGFloat middle = axis == 0 ? control.x : control.y; + CGFloat last = axis == 0 ? end.x : end.y; + // Preserve the source consumer's split rule, including its denominator. + CGFloat t = (first - middle) / (first - last); + if (first != last && t >= 0.0001 && t <= 0.9999) { + CGPoint a = mix(current, control, t), b = mix(control, end, t); + quad(a, mix(a, b, t), axis + 1); + control = b; + } + quad(control, end, axis + 1); + return; + } + CGPoint offset = begin(end); + CGPathAddQuadCurveToPoint(path, nullptr, control.x + offset.x, control.y + offset.y, end.x + offset.x, end.y + offset.y); + current = end; + } + + void cubic(Cubic curve, unsigned axis = 0) { + if (axis < 2) { + auto coordinate = [axis](CGPoint p) { return axis == 0 ? p.x : p.y; }; + CGFloat p0 = coordinate(curve.p[0]), p1 = coordinate(curve.p[1]); + CGFloat p2 = coordinate(curve.p[2]), p3 = coordinate(curve.p[3]); + CGFloat a = std::fma(p1 - p2, 3, p3 - p0); + CGFloat b = 2 * (p2 - (p1 + p1) + p0), c = p1 - p0; + CGFloat discriminant = std::fma(b, b, a * (-4 * c)); + if (discriminant >= 0) { + CGFloat sum = b + std::copysign(std::sqrt(discriminant), b); + CGFloat q = -0.5 * sum; + CGFloat first = a != 0 ? q / a : -1; + CGFloat second = q != 0 ? (-2 * c) / sum : -1; + if (second < first) { + std::swap(first, second); + } + if (first < 0.0001) { + first = second; + second = 2; + } + if (first >= 0.0001 && first <= 0.9999) { + Cubic left, right; + curve.split(first, left, right); + cubic(left, axis + 1); + curve = right; + if (1 - second >= 0.0001) { + curve.split((second - first) / (1 - first), left, right); + cubic(left, axis + 1); + curve = right; + } + } + } + cubic(curve, axis + 1); + return; + } + CGPoint offset = begin(curve.p[3]); + CGPathAddCurveToPoint(path, nullptr, curve.p[1].x + offset.x, curve.p[1].y + offset.y, + curve.p[2].x + offset.x, curve.p[2].y + offset.y, + curve.p[3].x + offset.x, curve.p[3].y + offset.y); + current = curve.p[3]; + } +}; + +struct DilationInput { + struct Part { + CGPathElementType type; + CGPoint points[4]; + }; + struct Subpath { + std::vector parts; + }; + std::vector subpaths; + CGPoint current = CGPointZero; + CGPoint start = CGPointZero; + CGFloat orientation = 0; + + void close_orientation() { + orientation += current.x * start.y - current.y * start.x; + current = start; + } + + void append(const CGPathElement &element) { + if (element.type == kCGPathElementMoveToPoint) { + close_orientation(); + current = start = element.points[0]; + subpaths.emplace_back(); + return; + } + if (element.type == kCGPathElementCloseSubpath) { + close_orientation(); + subpaths.emplace_back(); + return; + } + if (subpaths.empty()) { + subpaths.emplace_back(); + } + unsigned count = element.type == kCGPathElementAddCurveToPoint ? 3 : element.type == kCGPathElementAddQuadCurveToPoint ? 2 : 1; + Part part{element.type, {current}}; + std::copy_n(element.points, count, part.points + 1); + for (unsigned i = 0; i < count; ++i) { + orientation += current.x * element.points[i].y - current.y * element.points[i].x; + current = element.points[i]; + } + subpaths.back().parts.push_back(part); + } +}; + +CGPathRef copy_dilated_path(CGPathRef path, CGPoint amount) { + DilationInput input; + CGPathApply(path, &input, +[](void *context, const CGPathElement *element) { + static_cast(context)->append(*element); + }); + input.close_orientation(); + CGMutablePathRef result = CGPathCreateMutable(); + Dilator dilator{result, amount}; + bool reverse = input.orientation < 0; + for (auto &subpath : input.subpaths) { + if (subpath.parts.empty()) { + continue; + } + if (reverse) { + std::reverse(subpath.parts.begin(), subpath.parts.end()); + for (auto &part : subpath.parts) { + unsigned count = part.type == kCGPathElementAddCurveToPoint ? 3 : part.type == kCGPathElementAddQuadCurveToPoint ? 2 : 1; + std::reverse(part.points, part.points + count + 1); + } + } + dilator.move(subpath.parts[0].points[0]); + for (const auto &part : subpath.parts) { + switch (part.type) { + case kCGPathElementAddLineToPoint: dilator.line(part.points[1]); break; + case kCGPathElementAddQuadCurveToPoint: dilator.quad(part.points[1], part.points[2]); break; + case kCGPathElementAddCurveToPoint: dilator.cubic({{part.points[0], part.points[1], part.points[2], part.points[3]}}); break; + default: break; + } + } + dilator.close(); + } + return result; +} + +CGPoint add(CGPoint a, CGPoint b) { return {a.x + b.x, a.y + b.y}; } +CGPoint subtract(CGPoint a, CGPoint b) { return {a.x - b.x, a.y - b.y}; } +CGPoint scale(CGPoint a, CGFloat b) { return {a.x * b, a.y * b}; } +CGFloat dot(CGPoint a, CGPoint b) { return std::fma(a.y, b.y, a.x * b.x); } +CGFloat cross(CGPoint a, CGPoint b) { return std::fma(a.x, b.y, -a.y * b.x); } +CGPoint normalize(CGPoint a) { + if (a.x == 0 && a.y == 0) { return a; } + CGFloat length = std::sqrt(dot(a, a)); + return {a.x / length, a.y / length}; +} +CGPoint multiply_add(CGPoint a, CGFloat b, CGPoint c) { + return {std::fma(a.x, b, c.x), std::fma(a.y, b, c.y)}; +} + +struct OffsetCubic { + Cubic curve; + CGPoint linear, quadratic, cubic; + + explicit OffsetCubic(Cubic curve) : curve(curve) { + CGPoint a = subtract(curve.p[1], curve.p[0]); + CGPoint b = subtract(curve.p[2], curve.p[1]); + CGPoint c = subtract(curve.p[3], curve.p[2]); + linear = scale(a, 3); + quadratic = scale(subtract(b, a), 3); + cubic = add(subtract(c, scale(b, 2)), a); + } + + CGPoint evaluate(CGFloat t) const { + return multiply_add(multiply_add(multiply_add(cubic, t, quadratic), t, linear), t, curve.p[0]); + } + + CGPoint derivative(CGFloat t) const { + if (t == 0 || t == 1) { + for (unsigned i = 1; i < 4; ++i) { + CGPoint tangent = t == 0 ? subtract(curve.p[i], curve.p[0]) : subtract(curve.p[3], curve.p[3 - i]); + if (dot(tangent, tangent) > 1e-8 || i == 3) { return tangent; } + } + } + return multiply_add(multiply_add(cubic, 3 * t, scale(quadratic, 2)), t, linear); + } + + bool near(CGPoint point, CGFloat t) const { + auto squared_distance = [&](CGFloat parameter) { + // Subtract first to match the polynomial evaluator's rounding. + CGPoint v = multiply_add(multiply_add(multiply_add(cubic, parameter, quadratic), parameter, linear), parameter, subtract(curve.p[0], point)); + return dot(v, v); + }; + constexpr CGFloat toleranceSquared = 0.1 * 0.1; + CGFloat first = squared_distance(0), last = squared_distance(1), middle = squared_distance(t); + if (first < toleranceSquared || last < toleranceSquared || middle < toleranceSquared) { return true; } + if (!(middle < first && middle < last)) { return false; } + constexpr CGFloat ratio = 0.6180339887498949; + CGFloat left = 0, right = 1; + CGFloat x = t > 0.5 ? t * ratio : t; + CGFloat y = t > 0.5 ? t : std::fma(1 - ratio, 1 - t, t); + CGFloat fx = x == t ? middle : squared_distance(x); + CGFloat fy = y == t ? middle : squared_distance(y); + if (fx < toleranceSquared || fy < toleranceSquared) { return true; } + while (right - left > 3e-8 * (x + y)) { + if (fy < fx) { + left = x; + x = std::fma(ratio, y - right, right); + fx = squared_distance(x); + if (fx <= toleranceSquared) { return true; } + } else { + right = y; + y = std::fma(ratio, x - left, left); + fy = squared_distance(y); + if (fy <= toleranceSquared) { return true; } + } + std::swap(x, y); + std::swap(fx, fy); + } + return false; + } +}; + +struct Offsetter { + CGMutablePathRef path; + CGFloat amount; + CGPoint start = CGPointZero; + CGPoint current = CGPointZero; + CGPoint firstDirection = CGPointZero; + CGPoint lastDirection = CGPointZero; + unsigned state = 0; + + CGPoint normal(CGPoint tangent) const { + CGPoint unit = normalize(tangent); + return {-unit.y * amount, unit.x * amount}; + } + + void emit_line(CGPoint p) { CGPathAddLineToPoint(path, nullptr, p.x, p.y); } + void emit_cubic(Cubic c) { + CGPathAddCurveToPoint(path, nullptr, c.p[1].x, c.p[1].y, c.p[2].x, c.p[2].y, c.p[3].x, c.p[3].y); + } + + void join(CGPoint point, CGPoint direction) { + if (dot(lastDirection, direction) > 0.99) { return; } + CGPoint oldNormal{-lastDirection.y * amount, lastDirection.x * amount}; + CGPoint newNormal{-direction.y * amount, direction.x * amount}; + CGPoint delta = subtract(oldNormal, newNormal); + CGPoint end = add(point, newNormal); + if (dot(delta, delta) < 0.05) { emit_line(end); return; } + CGPoint sum = add(lastDirection, direction); + CGFloat length = std::sqrt(dot(sum, sum)); + CGFloat factor = -4.0 / 3.0; + if (length >= 0.001) { + CGFloat dx = lastDirection.x - direction.x, dy = lastDirection.y - direction.y; + if (std::abs(dy) > std::abs(dx)) { + factor = ((8.0 / 3.0) / length - 4.0 / 3.0) * sum.x / dy; + } else { + factor = (4.0 / 3.0 - (8.0 / 3.0) / length) * sum.y / dx; + } + } + CGPoint control1 = multiply_add(lastDirection, amount * factor, add(point, oldNormal)); + CGPoint control2 = multiply_add(direction, -amount * factor, end); + emit_cubic({{CGPointZero, control1, control2, end}}); + } + + void begin(CGPoint point, CGPoint tangent) { + CGPoint direction = normalize(tangent); + if (state == 4) { + join(point, direction); + } else { + state = 4; + firstDirection = direction; + CGPoint p = add(point, normal(tangent)); + CGPathMoveToPoint(path, nullptr, p.x, p.y); + } + lastDirection = direction; + } + + bool line_segment(CGPoint a, CGPoint b) { + CGPoint tangent = subtract(b, a); + if (!(dot(tangent, tangent) > 1e-8)) { + if (state == 2) { state = 3; } + return false; + } + begin(a, tangent); + emit_line(add(b, normal(tangent))); + return true; + } + + void fit(Cubic curve, int depth) { + OffsetCubic source(curve); + CGPoint firstTangent = source.derivative(0), lastTangent = source.derivative(1); + CGPoint first = add(curve.p[0], normal(firstTangent)); + CGPoint last = add(curve.p[3], normal(lastTangent)); + CGPoint chord = subtract(curve.p[3], curve.p[0]); + if (dot(chord, chord) <= 1e-8) { emit_line(last); return; } + CGPoint middleTangent = source.derivative(0.5); + CGPoint middle = add(source.evaluate(0.5), normal(middleTangent)); + CGPoint adjusted = multiply_add(subtract(scale(middle, 2), add(first, last)), 1.0 / 6.0, middle); + auto intersection = [&](CGPoint point, CGPoint tangent) { + CGFloat determinant = cross(tangent, middleTangent); + if (std::abs(determinant) < 0.0001) { return adjusted; } + CGFloat t = cross(subtract(adjusted, point), middleTangent) / determinant; + return multiply_add(tangent, t, point); + }; + Cubic result{{first, intersection(first, firstTangent), intersection(last, lastTangent), last}}; + if (--depth >= 0) { + OffsetCubic candidate(result); + for (CGFloat t : {0.5, 0.3, 0.7, 0.1, 0.9}) { + CGPoint expected = t == 0.5 ? middle : add(source.evaluate(t), normal(source.derivative(t))); + bool finite = std::isfinite(expected.x) && std::isfinite(expected.y); + for (auto p : result.p) { finite = finite && std::isfinite(p.x) && std::isfinite(p.y); } + if (finite && !candidate.near(expected, t)) { + Cubic left, right; + curve.split(0.5, left, right); + fit(left, depth); + fit(right, depth); + return; + } + } + } + emit_cubic(result); + } + + void offset_curve(Cubic curve) { + CGPoint a = subtract(curve.p[1], curve.p[0]), b = subtract(curve.p[2], curve.p[1]); + CGPoint c = subtract(curve.p[3], curve.p[2]); + if (dot(a, b) >= 0 && dot(b, c) >= 0) { fit(curve, 4); return; } + if (dot(a, a) <= 1e-8 && dot(b, b) <= 1e-8 && dot(c, c) <= 1e-8) { + emit_line(add(curve.p[3], normal(OffsetCubic(curve).derivative(1)))); + return; + } + Cubic left, right; + curve.split(0.5, left, right); + auto equal = [](const Cubic &a, const Cubic &b) { + for (unsigned i = 0; i < 4; ++i) { + if (!(a.p[i].x == b.p[i].x || (std::isnan(a.p[i].x) && std::isnan(b.p[i].x))) || + !(a.p[i].y == b.p[i].y || (std::isnan(a.p[i].y) && std::isnan(b.p[i].y)))) { return false; } + } + return true; + }; + if (!equal(curve, left)) { offset_curve(left); } + if (!equal(curve, right)) { offset_curve(right); } + } + + bool cubic_segment(Cubic curve) { + CGPoint a = subtract(curve.p[1], curve.p[0]), b = subtract(curve.p[2], curve.p[1]); + CGPoint c = subtract(curve.p[3], curve.p[2]); + if (dot(a, a) <= 1e-8 && dot(b, b) <= 1e-8 && dot(c, c) <= 1e-8) { + if (state == 2) { state = 3; } + return false; + } + OffsetCubic source(curve); + begin(curve.p[0], source.derivative(0)); + lastDirection = normalize(source.derivative(1)); + if (cross(a, b) * cross(b, c) <= 0) { + CGFloat roots[2] = {-1, -1}; + for (unsigned axis = 0; axis < 2; ++axis) { + CGFloat quadratic = axis == 0 ? source.quadratic.x : source.quadratic.y; + CGFloat cubic = axis == 0 ? source.cubic.x : source.cubic.y; + CGFloat t = -quadratic / (3 * cubic); + if (cubic != 0 && t > 0 && t < 1) { roots[axis] = t; } + } + if (roots[0] == -1) { std::swap(roots[0], roots[1]); } + if (roots[1] != -1) { + if (std::abs(roots[0] - roots[1]) < 1e-6) { roots[1] = -1; } + else if (roots[1] < roots[0]) { std::swap(roots[0], roots[1]); } + } + if (roots[0] != -1) { + Cubic left, right; + curve.split(roots[0], left, right); + offset_curve(left); + curve = right; + if (roots[1] != -1) { + curve.split((roots[1] - roots[0]) / (1 - roots[0]), left, right); + offset_curve(left); + curve = right; + } + } + } + offset_curve(curve); + return true; + } + + void apply(const CGPathElement &element) { + switch (element.type) { + case kCGPathElementMoveToPoint: + start = current = element.points[0]; state = 2; break; + case kCGPathElementAddLineToPoint: + if (state && line_segment(current, element.points[0])) { current = element.points[0]; } + break; + case kCGPathElementAddQuadCurveToPoint: { + CGPoint control = element.points[0], end = element.points[1]; + Cubic curve{{current, multiply_add(subtract(current, control), 1.0 / 3.0, control), + multiply_add(subtract(end, control), 1.0 / 3.0, control), end}}; + if (state && cubic_segment(curve)) { current = end; } + break; + } + case kCGPathElementAddCurveToPoint: + if (state && cubic_segment({{current, element.points[0], element.points[1], element.points[2]}})) { current = element.points[2]; } + break; + case kCGPathElementCloseSubpath: + if (state >= 2) { + if (state == 4) { + line_segment(current, start); + join(start, firstDirection); + CGPathCloseSubpath(path); + } + state = 1; current = start; + } + break; + } + } +}; + +CGPathRef copy_offset_path(CGPathRef path, CGFloat amount) { + CGMutablePathRef result = CGPathCreateMutable(); + Offsetter offsetter{result, amount}; + CGPathApply(path, &offsetter, +[](void *context, const CGPathElement *element) { + static_cast(context)->apply(*element); + }); + return result; +} + struct Frame { unsigned type; CGMutablePathRef path = CGPathCreateMutable(); + CGPathRef accumulated = nullptr; CGAffineTransform transform = CGAffineTransformIdentity; CGFloat values[3] = {}; int32_t cap = 0; int32_t join = 0; + uint8_t operation = 0; + bool evenOdd = false; + size_t emittedElements = 0; std::vector dash; explicit Frame(unsigned type) : type(type) {} ~Frame() { CGPathRelease(path); + CGPathRelease(accumulated); + } + + void end_boolean() { + bool hasPath = !CGPathIsEmpty(path); + if (!accumulated) { + if (operation != 0 && hasPath) { + accumulated = CGPathCreateCopy(path); + CGPathRelease(path); + path = CGPathCreateMutable(); + } + return; + } + CGPathRef next = nullptr; + switch (operation) { + case 1: next = hasPath ? CGPathCreateCopyByIntersectingPath(accumulated, path, evenOdd) : nullptr; break; + case 2: next = hasPath ? CGPathCreateCopyByUnioningPath(accumulated, path, evenOdd) : CGPathRetain(accumulated); break; + case 3: next = hasPath ? CGPathCreateCopyBySubtractingPath(accumulated, path, evenOdd) : CGPathRetain(accumulated); break; + case 4: next = hasPath ? CGPathCreateCopyBySymmetricDifferenceOfPath(accumulated, path, evenOdd) : CGPathRetain(accumulated); break; + case 5: next = hasPath ? CGPathCreateCopyOfLineByIntersectingPath(accumulated, path, evenOdd) : nullptr; break; + case 6: next = hasPath ? CGPathCreateCopyOfLineBySubtractingPath(accumulated, path, evenOdd) : nullptr; break; + default: return; + } + CGPathRelease(accumulated); + accumulated = next; } CGPathRef finish() { @@ -240,6 +730,13 @@ struct Frame { CGPathRelease(input); return result; } + case 20: + end_boolean(); + return operation == 0 ? CGPathCreateCopyByNormalizing(path, evenOdd) : CGPathRetain(accumulated); + case 22: + return copy_dilated_path(path, CGPointMake(values[0], values[1])); + case 23: + return copy_offset_path(path, values[0]); default: return CGPathRetain(path); } @@ -259,7 +756,6 @@ void append_element(CGMutablePathRef path, const CGPathElement &element) { struct Mapper { std::vector> frames; - Mapper() { frames.push_back(std::make_unique(0)); } Frame ¤t() { return *frames.back(); } @@ -271,11 +767,15 @@ struct Mapper { struct AppendInfo { Mapper *mapper; size_t index; - } info{this, index - 1}; + size_t skip; + size_t count = 0; + } info{this, index - 1, frame.type == 23 ? frame.emittedElements : 0}; CGPathApply(path, &info, +[](void *context, const CGPathElement *element) { auto &info = *static_cast(context); + if (info.count++ < info.skip) { return; } append_element(info.mapper->frames[info.index]->path, *element); }); + frame.emittedElements = info.count; CGPathRelease(path); return true; } @@ -283,7 +783,10 @@ struct Mapper { void end_path(size_t index) { Frame &frame = *frames[index]; switch (frame.type) { - case 18: + case 20: + frame.end_boolean(); + break; + case 18: case 22: flush(index); break; case 19: @@ -292,6 +795,11 @@ struct Mapper { frame.path = CGPathCreateMutable(); end_path(index - 1); break; + case 23: + // Offset endpath forwards the boundary without resetting segment state. + flush(index); + end_path(index - 1); + break; default: break; } @@ -302,9 +810,9 @@ struct Mapper { return; } unsigned type = current().type; - flush(frames.size() - 1); + bool hasResult = flush(frames.size() - 1); frames.pop_back(); - if (type == 19) { + if (type == 19 || type == 23 || (type == 20 && hasResult)) { end_path(frames.size() - 1); } } @@ -363,11 +871,13 @@ struct Mapper { case 16: pop(); break; - case 17: case 18: case 19: { + case 17: case 18: case 19: case 20: case 22: case 23: { auto frame = std::make_unique(type); if (type == 17 && info) { frame->transform = *transform; - } else if (type == 18) { + } else if (type == 23) { + frame->values[0] = values[0]; + } else if (type == 18 || type == 22) { frame->values[0] = values[0]; frame->values[1] = values[1]; } else if (type == 19) { @@ -381,10 +891,17 @@ struct Mapper { frame->dash.assign(dash, dash + stroke.count); } } + } else if (type == 20 && info) { + auto *flags = static_cast(info); + frame->operation = flags[0]; + frame->evenOdd = (flags[1] & 1) != 0; } frames.push_back(std::move(frame)); break; } + case 21: + end_path(frames.size() - 1); + break; default: precondition_failure("unsupported path mapping element: %u", type); } diff --git a/Tests/OpenRenderBoxCompatibilityTests/PathStorageTests.swift b/Tests/OpenRenderBoxCompatibilityTests/PathStorageTests.swift index be39195..d99e7dd 100644 --- a/Tests/OpenRenderBoxCompatibilityTests/PathStorageTests.swift +++ b/Tests/OpenRenderBoxCompatibilityTests/PathStorageTests.swift @@ -22,6 +22,7 @@ struct PathStorageTests { var count: Int var keys: UnsafePointer? } + @Test func storageIsEmpty() { let path = ORBPath(rect: CGRect(x: 0, y: 0, width: 100, height: 100), transform: nil) @@ -169,6 +170,88 @@ struct PathStorageTests { } } + @Test + func glyphDilationPreservesClosedGeometry() { + withPathStorage { storage in + storage.append(element: .dilateGlyph, points: [2, 3], userInfo: nil) + storage.append(element: .rect, points: [0, 0, 10, 10], userInfo: nil) + storage.append(element: .end, points: [], userInfo: nil) + withStoragePath(storage) { path in + let actual = cgPathElements(path.cgPath) + #expect(actual == [ + PathElementRecord(element: 0, points: [0, 0]), + PathElementRecord(element: 1, points: [10, 0]), + PathElementRecord(element: 1, points: [12, 3]), + PathElementRecord(element: 1, points: [12, 13]), + PathElementRecord(element: 1, points: [10, 16]), + PathElementRecord(element: 1, points: [0, 16]), + PathElementRecord(element: 1, points: [-2, 13]), + PathElementRecord(element: 1, points: [-2, 3]), + PathElementRecord(element: 4, points: []), + ]) + } + } + } + + @Test(arguments: [CGFloat(2), -2]) + func offsetCurveUsesMappedStorage(amount: CGFloat) { + withPathStorage(capacity: 64) { storage in + storage.append(element: .offset, points: [amount], userInfo: nil) + storage.append(element: .moveToPoint, points: [10, 20], userInfo: nil) + storage.append(element: .addCurveToPoint, points: [10, 30, 20, 30, 20, 20], userInfo: nil) + storage.append(element: .end, points: [], userInfo: nil) + withStoragePath(storage) { path in + let cgPath = path.cgPath + let expected: [PathElementRecord] = amount > 0 + ? [PathElementRecord(element: 0, points: [8, 20]), + PathElementRecord(element: 3, points: [8, 32.666667938232422, 22, 32.666667938232422, 22, 20])] + : [PathElementRecord(element: 0, points: [12, 20]), + PathElementRecord(element: 3, points: [12, 27.333333969116211, 18, 27.333333969116211, 18, 20])] + #expect(cgPathElements(cgPath) == expected) + storage.append(element: .rect, points: [100, 100, 10, 10], userInfo: nil) + #expect(path.cgPath.boundingBoxOfPath.maxX == 110) + #expect(cgPathElements(cgPath) == expected) + } + } + } + + @Test(arguments: [ORBPath.Element.boolean, .offset, .transformed]) + func nestedBooleanPreservesPathBoundaries(inner: ORBPath.Element) { + withPathStorage { storage in + let intersection: [UInt8] = [1, 0] + intersection.withUnsafeBufferPointer { flags in + storage.append(element: .boolean, points: [], userInfo: flags.baseAddress) + } + if inner == .boolean { + let normalize: [UInt8] = [0, 0] + normalize.withUnsafeBufferPointer { flags in + storage.append(element: .boolean, points: [], userInfo: flags.baseAddress) + } + } else if inner == .offset { + storage.append(element: .offset, points: [0], userInfo: nil) + } else { + var transform = CGAffineTransform.identity + withUnsafePointer(to: &transform) { transform in + storage.append(element: .transformed, points: [], userInfo: transform) + } + } + storage.append(element: .rect, points: [0, 0, 10, 10], userInfo: nil) + storage.append(element: .end, points: [], userInfo: nil) + storage.append(element: .rect, points: [5, 5, 10, 10], userInfo: nil) + storage.append(element: .end, points: [], userInfo: nil) + withStoragePath(storage) { path in + let cgPath = path.cgPath + let coalescesPaths = inner == .transformed + #expect(cgPath.boundingBoxOfPath == (coalescesPaths + ? CGRect(x: 0, y: 0, width: 15, height: 15) + : CGRect(x: 5, y: 5, width: 5, height: 5))) + #expect(cgPath.contains(CGPoint(x: 7, y: 7))) + #expect(cgPath.contains(CGPoint(x: 2, y: 2)) == coalescesPaths) + #expect(cgPath.contains(CGPoint(x: 12, y: 12)) == coalescesPaths) + } + } + } + @Test func storageStopsEnumeration() { withPathStorage { storage in @@ -205,5 +288,4 @@ struct PathStorageTests { } } } - #endif From ed813dc1beccfee4e7245f37b40485afb615502f Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 7 Nov 2026 19:01:54 +0800 Subject: [PATCH 08/12] feat(path): implement clip stroke geometry --- .../Path/ClipStrokeKeyframes.hpp | 264 ++++++++ .../OpenRenderBoxCxx/Path/PathClipStroke.cpp | 599 ++++++++++++++++++ .../OpenRenderBoxCxx/Path/PathClipStroke.hpp | 35 + Sources/OpenRenderBoxCxx/Path/PathMapper.cpp | 17 +- .../OpenRenderBoxCxx/include/module.modulemap | 2 + .../PathStorageTests.swift | 131 ++++ .../PathTestSupport.swift | 43 ++ .../OpenRenderBoxCompatibilityTests/README.md | 6 +- 8 files changed, 1092 insertions(+), 5 deletions(-) create mode 100644 Sources/OpenRenderBoxCxx/Path/ClipStrokeKeyframes.hpp create mode 100644 Sources/OpenRenderBoxCxx/Path/PathClipStroke.cpp create mode 100644 Sources/OpenRenderBoxCxx/Path/PathClipStroke.hpp diff --git a/Sources/OpenRenderBoxCxx/Path/ClipStrokeKeyframes.hpp b/Sources/OpenRenderBoxCxx/Path/ClipStrokeKeyframes.hpp new file mode 100644 index 0000000..10f8edb --- /dev/null +++ b/Sources/OpenRenderBoxCxx/Path/ClipStrokeKeyframes.hpp @@ -0,0 +1,264 @@ +// +// ClipStrokeKeyframes.hpp +// OpenRenderBox + +#pragma once + +#include "PathClipStroke.hpp" + +#if OPENRENDERBOX_CF_CGTYPES && ORB_TARGET_OS_DARWIN + +#include +#include +#include +#include +#include +#include +#if defined(__aarch64__) +#include +#endif + +namespace ORB::Path::ClipStroke { + +using Keyframe = ClipStrokeKeyframe; + +struct SingleKeyframe { + double distance; + float value; + int32_t sourceIndex; + int32_t tag; +}; +static_assert(sizeof(Keyframe) == 16); +static_assert(sizeof(SingleKeyframe) == 24); + +inline float normalizeProgress(uint32_t flags, float progress) { + if (flags & 2) { + if (!(flags & 4)) progress = std::fmax(progress, 0.0f); + progress = std::fmin(progress, 1.0f); + } + if (flags & 1) progress = 1.0f - progress; + return progress; +} + +inline bool valueClipped(uint32_t flags, float progress, float value) { + if (flags & 1) { + if ((flags & 4) && value >= 1.0f) return false; + return std::fmax(progress, 0.0f) >= value; + } + if ((flags & 4) && value <= 0.0f) return false; + return std::fmin(progress, 1.0f) <= value; +} + +inline float reciprocal(float value) { +#if defined(__aarch64__) + float32x2_t input = vset_lane_f32(value, vdup_n_f32(0), 0); + float32x2_t result = vrecpe_f32(input); + result = vmul_f32(vrecps_f32(input, result), result); + result = vmul_f32(result, vrecps_f32(input, result)); + return vget_lane_f32(result, 0); +#else + return 1.0f / value; +#endif +} + +inline bool makeKeyframes(const Keyframe *input, size_t count, + double subpathLength, float progress, + std::vector &output, double &rate) { + if (count == 0) return false; + output.clear(); + output.reserve(count * 2); + float minimum = std::numeric_limits::infinity(); + float maximum = -minimum; + for (size_t i = 0; i != count; ++i) { + const auto &key = input[i]; + for (float position : {key.start, key.end}) { + const float fraction = position - std::floor(position); + output.push_back({subpathLength * double(fraction), key.value, + int32_t(i), key.tag}); + } + if (key.value < minimum) minimum = key.value; + if (maximum < key.value) maximum = key.value; + } + // Equal distances have no secondary key; the target uses an unstable sort. + std::sort(output.begin(), output.end(), [](const auto &a, const auto &b) { + return a.distance < b.distance; + }); + double totalDistance = 0; + float totalValue = 0; + for (size_t i = 1; i != output.size(); ++i) { + const float difference = std::fabs(output[i].value - output[i - 1].value); + if (!(difference < 0.0001f)) { + totalDistance += output[i].distance; + totalDistance -= output[i - 1].distance; + totalValue += difference; + } + } + rate = totalValue > 0 ? totalDistance * double(reciprocal(totalValue)) : 0; + float previous = output[0].value; + float current = output[1].value; + for (size_t i = 1; i + 1 < output.size(); ++i) { + const float next = output[i + 1].value; + if (minimum < current && current < maximum) { + float low = current < previous ? current : previous; + if (next < low) low = next; + float high = previous < current ? current : previous; + if (high < next) high = next; + if (low > progress || high < progress) + output[i].value = std::numeric_limits::infinity(); + } + // The simplifier compares original neighboring values. + previous = current; + current = next; + } + return !output.empty(); +} + +struct Cursor { + const std::vector &keys; + double subpathLength; + size_t index = 1; + SingleKeyframe previous; + SingleKeyframe next; + + Cursor(const std::vector &values, double length) + : keys(values), subpathLength(length), previous(values.back()), + next(values.front()) { + previous.distance -= length; + } + + float initialValue() const { + const float t = float(previous.distance / (next.distance - previous.distance)); + return std::fma(previous.value - next.value, t, previous.value); + } + + float valueAt(double distance) const { + const float t = float((distance - previous.distance) / + (next.distance - previous.distance)); + return std::fma(next.value - previous.value, t, previous.value); + } + + void advance(double distance) { + while (next.distance <= distance && index < keys.size()) { + const SingleKeyframe candidate = keys[index]; + if (candidate.distance < next.distance) break; + ++index; + if (std::isfinite(candidate.value)) { + previous = next; + next = candidate; + } + } + if (next.distance <= distance && index == keys.size()) { + previous = next; + if (index != 0) { + next = keys.front(); + next.distance += subpathLength; + ++index; + } + } + } +}; + +struct DistanceMap { + struct Entry { + double sourceStart; + double destinationStart; + double sourceLength; + }; + std::vector entries; + + void append(double source, double destination, double length) { + if (!entries.empty()) { + auto &last = entries.back(); + if (std::fabs((last.sourceStart - source) + last.sourceLength) < 1 && + std::fabs((last.sourceLength - destination) + last.destinationStart) < 1) { + last.sourceLength = (length + source) - last.sourceStart; + return; + } + } + entries.push_back({source, destination, length}); + } + + double operator()(double source) const { + const auto found = std::lower_bound(entries.begin(), entries.end(), source, + [](const Entry &entry, double value) { + return (entry.sourceStart + 0.0001) + entry.sourceLength < value; + }); + if (found != entries.end()) { + const double offset = source - found->sourceStart; + if (!(offset < -0.0001)) return found->destinationStart + offset; + } + return std::numeric_limits::infinity(); + } +}; + +inline double signedCircularDifference(float difference) { + if (difference > 0.5f) return double(difference) - 1; + if (difference < -0.5f) return double(difference) + 1; + return double(difference); +} + +struct TaggedSplit { + double firstStart; + float firstEnd; + float secondStart; + double secondEnd; +}; + +inline bool selectTaggedSplit(const std::vector &keys, size_t index, + uint32_t flags, float progress, TaggedSplit &result) { + if (index == 0 || index + 1 >= keys.size() || !(keys[index].tag & 1)) return false; + if (valueClipped(flags, progress, keys[index - 1].value) || + !valueClipped(flags, progress, keys[index + 1].value)) return false; + const auto &key = keys[index]; + const bool reverse = signedCircularDifference(key.end - key.start) < 0; + const float start = reverse ? key.end : key.start; + const float end = reverse ? key.start : key.end; + double nearestBefore = std::numeric_limits::infinity(); + double nearestAfter = nearestBefore; + double pairedBefore = 0; + double pairedAfter = 0; + for (size_t i = 0; i != keys.size(); ++i) { + if (i == index) continue; + const auto &other = keys[i]; + for (int endpoint = 0; endpoint != 2; ++endpoint) { + const float position = endpoint == 0 ? other.start : other.end; + const float pair = endpoint == 0 ? other.end : other.start; + const double before = signedCircularDifference(start - position); + const double after = signedCircularDifference(position - end); + if (before >= 0 && before < nearestBefore) { + nearestBefore = before; + pairedBefore = pair; + } + if (after >= 0 && after < nearestAfter) { + nearestAfter = after; + pairedAfter = pair; + } + } + } + if (!std::isfinite(nearestBefore) || !std::isfinite(nearestAfter)) return false; + result = {pairedBefore, end, start, pairedAfter}; + return true; +} + +inline double positionDistance(double position, double pathLength) { + return (position - std::floor(position)) * pathLength; +} + +inline std::vector remapKeyframes(const std::vector &keys, + size_t splitIndex, double sourceLength, + double closedDestinationLength, + const DistanceMap &map) { + std::vector result; + const double inverseLength = 1 / closedDestinationLength; + for (size_t i = 0; i != keys.size(); ++i) { + Keyframe key = keys[i]; + key.start = float(map(positionDistance(key.start, sourceLength)) * inverseLength); + key.end = float(map(positionDistance(key.end, sourceLength)) * inverseLength); + if (i == splitIndex) key.tag &= ~1; + if (std::isfinite(key.start) && std::isfinite(key.end)) result.push_back(key); + } + return result; +} + +} // namespace ORB::Path::ClipStroke +#endif diff --git a/Sources/OpenRenderBoxCxx/Path/PathClipStroke.cpp b/Sources/OpenRenderBoxCxx/Path/PathClipStroke.cpp new file mode 100644 index 0000000..2e21189 --- /dev/null +++ b/Sources/OpenRenderBoxCxx/Path/PathClipStroke.cpp @@ -0,0 +1,599 @@ +// +// PathClipStroke.cpp +// OpenRenderBox + +#include "PathClipStroke.hpp" + +#if OPENRENDERBOX_CF_CGTYPES && ORB_TARGET_OS_DARWIN + +#include "ClipStrokeKeyframes.hpp" +#include "PathGeometry.hpp" +#include +#include +#include +#include + +namespace ORB::Path { +namespace { + +using Geometry::Cubic; +using Geometry::curve_measure; +using Geometry::distance; +using Geometry::mix; +using ClipStroke::Keyframe; + +CGPoint add(CGPoint a, CGPoint b) { return {a.x + b.x, a.y + b.y}; } +CGPoint subtract(CGPoint a, CGPoint b) { return {a.x - b.x, a.y - b.y}; } +CGPoint multiply(CGPoint a, double b) { return {a.x * b, a.y * b}; } +CGPoint scale_add(CGPoint a, CGPoint b, double c) { return {std::fma(b.x, c, a.x), std::fma(b.y, c, a.y)}; } +double dot(CGPoint a, CGPoint b) { return std::fma(a.x, b.x, a.y * b.y); } +CGPoint unit(CGPoint a) { return multiply(a, 1 / std::sqrt(a.x * a.x + a.y * a.y)); } +bool equal(CGPoint a, CGPoint b) { return a.x == b.x && a.y == b.y; } +bool finite(CGPoint p) { return std::isfinite(p.x) && std::isfinite(p.y); } +CGPoint apply_transform(CGPoint p, const CGAffineTransform *transform) { + if (!transform) return p; + const auto &t = *transform; + return {std::fma(t.c, p.y, std::fma(t.a, p.x, t.tx)), + std::fma(t.d, p.y, std::fma(t.b, p.x, t.ty))}; +} + +struct Segment { + unsigned degree = 1; + CGPoint points[4] = {}; + double length = 0; + + CGPoint end() const { return points[degree]; } + + Cubic cubic() const { + if (degree == 3) return {{points[0], points[1], points[2], points[3]}}; + return {{points[0], mix(points[0], points[1], 2.0 / 3), + mix(points[2], points[1], 2.0 / 3), points[2]}}; + } + + double measure() const { + if (degree == 1) return distance(points[0], points[1]); + if (degree == 2) return Geometry::quadratic_measure({{points[0], points[1], points[2]}}, 0, false); + return curve_measure(cubic(), 0, false); + } + + double index(double value) const { + if (degree == 1) return std::fmin(std::fmax(value / measure(), 0), 1); + if (degree == 2) return Geometry::quadratic_measure({{points[0], points[1], points[2]}}, value, true); + return curve_measure(cubic(), value, true); + } + + void split(double t, Segment &left, Segment &right) const { + left.degree = right.degree = degree; + CGPoint work[4]; + std::copy_n(points, degree + 1, work); + left.points[0] = work[0]; + right.points[degree] = work[degree]; + for (unsigned level = 1; level <= degree; ++level) { + for (unsigned i = 0; i <= degree - level; ++i) work[i] = mix(work[i], work[i + 1], t); + left.points[level] = work[0]; + right.points[degree - level] = work[degree - level]; + } + left.length = left.measure(); + right.length = right.measure(); + } + + CGPoint direction(bool atEnd) const { + if (atEnd) { + for (unsigned i = degree; i > 0; --i) { + CGPoint result = subtract(points[degree], points[i - 1]); + if (dot(result, result) > 1e-8) return result; + } + } else { + for (unsigned i = 1; i <= degree; ++i) { + CGPoint result = subtract(points[i], points[0]); + if (dot(result, result) > 1e-8) return result; + } + } + return subtract(points[degree], points[0]); + } + + void replay(CGMutablePathRef path, const CGAffineTransform *transform = nullptr) const { + CGPoint p[4]; + for (unsigned i = 1; i <= degree; ++i) p[i] = apply_transform(points[i], transform); + if (degree == 1) CGPathAddLineToPoint(path, nullptr, p[1].x, p[1].y); + else if (degree == 2) CGPathAddQuadCurveToPoint(path, nullptr, p[1].x, p[1].y, p[2].x, p[2].y); + else CGPathAddCurveToPoint(path, nullptr, p[1].x, p[1].y, p[2].x, p[2].y, p[3].x, p[3].y); + } +}; + +struct Subpath { + CGPoint start = CGPointZero; + std::vector segments; + double length = 0; + + CGPoint end() const { return segments.empty() ? start : segments.back().end(); } + + void append_line(CGPoint point) { + Segment segment{1, {end(), point}}; + segment.length = segment.measure(); + length += segment.length; + segments.push_back(segment); + } + + double append(Segment segment, double suppliedLength = INFINITY) { + bool nonzero = false; + for (unsigned i = 0; i < segment.degree; ++i) { + CGPoint edge = subtract(segment.points[i + 1], segment.points[i]); + if (dot(edge, edge) > 1e-8) nonzero = true; + } + if (!nonzero) return length; + if (segments.empty()) start = segment.points[0]; + else if (!equal(end(), segment.points[0])) append_line(segment.points[0]); + segment.length = std::isfinite(suppliedLength) ? suppliedLength : segment.measure(); + if (!std::isfinite(segment.length)) return length; + double begin = length; + length += segment.length; + segments.push_back(segment); + return begin; + } + + void close() { + if (!segments.empty() && !equal(end(), start)) append_line(start); + } + + void replay(CGMutablePathRef path, bool close, const CGAffineTransform *transform = nullptr) const { + if (segments.empty()) return; + CGPoint point = apply_transform(start, transform); + if (close) CGPathMoveToPoint(path, nullptr, point.x, point.y); + else CGPathAddLineToPoint(path, nullptr, point.x, point.y); + for (const auto &segment : segments) segment.replay(path, transform); + if (close) CGPathCloseSubpath(path); + } + + void append_range(const Subpath &source, double from, double to, ClipStroke::DistanceMap *map = nullptr) { + double offset = 0; + for (const auto &segment : source.segments) { + double end = offset + segment.length; + auto appendPiece = [&](double begin, double finish) { + begin = std::fmin(std::fmax(begin, offset), end); + finish = std::fmin(std::fmax(finish, offset), end); + if (finish - begin < 0.0001) return; + double t0 = begin - offset < 0.0001 ? 0 : segment.index(begin - offset); + double t1 = finish - offset > segment.length - 0.0001 ? 1 : segment.index(finish - offset); + double destination; + double sourceLength = finish - begin; + if (t0 <= 0.0001 && t1 >= 0.9999) { + destination = append(segment, segment.length); + sourceLength = segment.length; + } else { + Segment piece = segment, left, right; + bool splitEnd = t1 < 0.9999; + if (t0 > 0.0001) { + piece.split(t0, left, right); + piece = right; + if (splitEnd) t1 = (t1 - t0) / (1 - t0); + } + if (splitEnd) { + piece.split(t1, left, right); + piece = left; + } + destination = append(piece); + } + if (map) map->append(begin, destination, sourceLength); + }; + if (to <= from) { + appendPiece(offset, to); + appendPiece(from, end); + } else appendPiece(from, to); + offset = end; + } + } + + void append_logical_range(const Subpath &source, double from, double to) { + if (from > to) { + append_range(source, from, source.length); + append_range(source, 0, to); + } else append_range(source, from, to); + } +}; + +struct Clipper { + CGMutablePathRef output; + const ClipStrokeInfo &info; + float progress; + const Subpath &subpath; + const Subpath *customCap; + double rate; + ClipStroke::Cursor cursor; + double consumed = 0; + float value; + bool clipped; + bool emitted = false; + CGPoint lastPoint = CGPointZero; + CGPoint lastDirection = CGPointZero; + double lastSpan = 0; + CGPoint firstPoint = {INFINITY, INFINITY}; + CGPoint firstDirection = CGPointZero; + double firstSpan = 0; + // A contour can emit its first segment before cap() records its start. + float firstValue; + + Clipper(CGMutablePathRef output, const ClipStrokeInfo &info, float progress, + const Subpath &subpath, const Subpath *cap, + const std::vector &keys, double rate) + : output(output), info(info), progress(progress), subpath(subpath), customCap(cap), rate(rate), + cursor(keys, subpath.length), value(cursor.initialValue()), + clipped(ClipStroke::valueClipped(info.flags, progress, value)), firstValue(value) {} + + double span() const { + if (cursor.next.tag & 1) return INFINITY; + double amount = std::abs(cursor.next.distance - cursor.previous.distance); + return std::fmin(amount, subpath.length - amount); + } + + void emit(const Segment &segment) { + bool nonzero = false; + for (unsigned i = 0; i < segment.degree; ++i) { + CGPoint edge = subtract(segment.points[i + 1], segment.points[i]); + if (dot(edge, edge) > 1e-8) nonzero = true; + } + if (!nonzero) return; + if (!emitted) { + firstPoint = segment.points[0]; + firstDirection = multiply(segment.direction(false), -1); + CGPathMoveToPoint(output, nullptr, firstPoint.x, firstPoint.y); + emitted = true; + } + segment.replay(output); + lastPoint = segment.end(); + lastDirection = segment.direction(true); + lastSpan = span(); + } + + void line(CGPoint point) { CGPathAddLineToPoint(output, nullptr, point.x, point.y); } + void cubic(CGPoint a, CGPoint b, CGPoint c) { + CGPathAddCurveToPoint(output, nullptr, a.x, a.y, b.x, b.y, c.x, c.y); + } + + void cap(CGPoint point, CGPoint direction, double nextSpan) { + if (!emitted) { + firstPoint = point; + firstDirection = direction; + firstSpan = nextSpan; + firstValue = value; + return; + } + if (info.flags & 8) { + line(point); + lastPoint = point; + lastDirection = multiply(direction, -1); + lastSpan = nextSpan; + return; + } + CGPoint previousDirection = unit(lastDirection); + CGPoint nextDirection = unit(direction); + CGPoint axis = lastSpan > nextSpan ? previousDirection : nextDirection; + double projection = dot(axis, subtract(point, lastPoint)); + CGPoint from = projection > 0 ? scale_add(lastPoint, axis, projection) : lastPoint; + CGPoint to = projection > 0 ? point : scale_add(point, axis, -projection); + if (!(info.flags & 2)) { + float overshoot = 0; + if (!(info.flags & 1) && value >= 1) overshoot = progress - value; + else if ((info.flags & 1) && value <= 0) overshoot = value - progress; + if (overshoot > 0) { + CGPoint shift = add(previousDirection, nextDirection); + double amount = rate * double(overshoot) / distance(CGPointZero, shift); + from = scale_add(from, shift, amount); + to = scale_add(to, shift, amount); + } + } + if (!equal(from, lastPoint)) line(from); + if (customCap && !customCap->segments.empty()) { + CGPoint source = subtract(customCap->end(), customCap->start); + CGPoint target = subtract(to, from); + double sourceLength = std::sqrt(source.x * source.x + source.y * source.y); + double targetLength = std::sqrt(target.x * target.x + target.y * target.y); + source = {source.x / sourceLength, source.y / sourceLength}; + target = {target.x / targetLength, target.y / targetLength}; + double scale = targetLength / sourceLength; + double a = (source.x * target.x + source.y * target.y) * scale; + double b = (-source.y * target.x + source.x * target.y) * scale; + CGAffineTransform transform = CGAffineTransformMake(a, b, -b, a, from.x, from.y); + transform = CGAffineTransformTranslate(transform, -customCap->start.x, -customCap->start.y); + customCap->replay(output, false, &transform); + } else { + CGPoint gap = subtract(to, from); + CGPoint normal = {-axis.y, axis.x}; + if (dot(CGPoint{-gap.y, gap.x}, axis) < 0) normal = multiply(normal, -1); + double radius = distance(from, to) * 0.5; + CGPoint lateral = multiply(normal, radius); + CGPoint forward = multiply(axis, radius); + CGPoint center = scale_add(from, normal, -radius); + switch (info.cap) { + case 1: { + constexpr double k = 0.5522847498307936; + CGPoint middle = add(center, forward); + cubic(scale_add(from, forward, k), scale_add(middle, lateral, k), middle); + CGPoint finish = subtract(center, lateral); + cubic(scale_add(middle, lateral, -k), scale_add(finish, forward, k), finish); + break; + } + case 2: + line(add(from, forward)); + line(add(subtract(center, lateral), forward)); + line(subtract(center, lateral)); + break; + case 3: case 5: + line(add(center, forward)); + line(subtract(center, lateral)); + break; + case 4: case 6: + line(add(from, forward)); + line(center); + line(add(subtract(center, lateral), forward)); + line(subtract(center, lateral)); + break; + default: + line(to); + } + } + if (!equal(to, point)) line(point); + lastPoint = point; + lastDirection = multiply(direction, -1); + lastSpan = nextSpan; + } + + void clip(const Segment &segment, double length, float nextValue) { + bool nextClipped = ClipStroke::valueClipped(info.flags, progress, nextValue); + if (clipped == nextClipped) { + if (!clipped) emit(segment); + } else { + double fraction = 0; + if (value != nextValue) fraction = double((std::fmin(std::fmax(progress, 0.0f), 1.0f) - value) / (nextValue - value)); + double t = segment.index(fraction * length); + if (t > 0.9999) { + if (!clipped) emit(segment); + } else { + Segment left, right; + segment.split(t, left, right); + if (clipped) { + cap(right.points[0], multiply(right.direction(false), -1), span()); + emit(right); + } else if (t > 0.0001) emit(left); + clipped = nextClipped; + } + } + value = nextValue; + } + + bool undershoot() { + float minimum = std::numeric_limits::infinity(); + double from = 0, to = 0; + for (const auto &key : cursor.keys) { + if (key.value < minimum) { + minimum = key.value; + from = key.distance; + to = INFINITY; + } else if (key.value == minimum) { + to = key.distance; + } + } + for (const auto &key : cursor.keys) { + if (std::isfinite(key.value) && key.value != minimum && + key.distance >= from && key.distance < to) { + std::swap(from, to); + break; + } + } + + Subpath selected; + selected.append_logical_range(subpath, from, to); + if (selected.segments.empty()) return false; + + CGPoint firstTangent = CGPointZero; + CGPoint lastTangent = CGPointZero; + for (const auto &segment : selected.segments) { + bool first = equal(firstTangent, CGPointZero); + CGPoint tangent; + if (segment.degree == 2) { + CGPoint edge = subtract(segment.points[1], segment.points[0]); + CGPoint linear = add(edge, edge); + CGPoint quadratic = subtract(segment.points[2], add(segment.points[1], edge)); + tangent = scale_add(linear, quadratic, first ? 0 : 2); + } else if (segment.degree == 3) { + unsigned endpoint = first ? 0 : 3; + unsigned adjacent = first ? 1 : 2; + auto difference = [&](unsigned point) { + return first ? subtract(segment.points[point], segment.points[endpoint]) + : subtract(segment.points[endpoint], segment.points[point]); + }; + tangent = difference(adjacent); + if (tangent.x * tangent.x + tangent.y * tangent.y <= 1e-8) { + tangent = difference(first ? 2 : 1); + if (tangent.x * tangent.x + tangent.y * tangent.y <= 1e-8) + tangent = difference(first ? 3 : 0); + } + } else tangent = subtract(segment.points[1], segment.points[0]); + if (first) firstTangent = tangent; + else lastTangent = tangent; + } + // A single segment only sets the first tangent in the source consumer. + if (equal(firstTangent, CGPointZero) || equal(lastTangent, CGPointZero)) return false; + float amount = minimum - progress; + if (info.flags & 1) amount = -amount; + if (!(amount > 0)) return false; + + CGPoint axis = scale_add(multiply(unit(lastTangent), -1), firstTangent, + 1 / distance(CGPointZero, firstTangent)); + double offset = (rate * double(amount)) / distance(CGPointZero, axis); + CGPoint translation = multiply(axis, offset); + CGPoint shiftedStart = scale_add(selected.start, axis, offset); + CGPoint shiftedEnd = add(selected.end(), translation); + CGPathMoveToPoint(output, nullptr, shiftedStart.x, shiftedStart.y); + emitted = true; + CGAffineTransform transform = CGAffineTransformMakeTranslation(translation.x, translation.y); + selected.replay(output, false, &transform); + lastPoint = shiftedEnd; + lastDirection = lastTangent; + lastSpan = 0; + value = minimum; + cap(shiftedStart, multiply(firstTangent, -1), 0); + CGPathCloseSubpath(output); + emitted = false; + return true; + } + + void run() { + bool split = cursor.next.distance > cursor.previous.distance; + if (split && (info.flags & 6) == 4 && + ((info.flags & 1) ? progress > 1 : progress < 0) && undershoot()) return; + for (const auto &input : subpath.segments) { + if (!split) { + emit(input); + continue; + } + Segment remaining = input; + double begin = consumed, end = begin + input.length, length = input.length; + while (cursor.next.distance < end) { + double t = remaining.index(cursor.next.distance - begin); + if (t > 0.0001) { + Segment left, right; + remaining.split(t, left, right); + clip(left, left.length, cursor.next.value); + length -= left.length; + remaining = right; + begin = cursor.next.distance; + } + double previous = cursor.next.distance; + cursor.advance(previous); + if (!(cursor.next.distance > previous)) break; + } + clip(remaining, length, cursor.valueAt(end)); + consumed = end; + } + if (emitted) { + if (clipped || ClipStroke::valueClipped(info.flags, progress, firstValue)) { + clipped = true; + if (finite(firstPoint)) { + value = firstValue; + cap(firstPoint, firstDirection, firstSpan); + } + } + CGPathCloseSubpath(output); + } + } +}; + +struct ClipInput { + CGMutablePathRef output; + const ClipStrokeInfo &info; + float progress; + Subpath subpath; + size_t subpathIndex = 0; + size_t keyIndex = 0; + CGPoint current = CGPointZero; + + void run(const Subpath &path, const Subpath *cap, const std::vector &inputKeys) { + std::vector keys; + double rate = 0; + if (ClipStroke::makeKeyframes(inputKeys.data(), inputKeys.size(), path.length, progress, keys, rate)) { + Clipper(output, info, progress, path, cap, keys, rate).run(); + } else path.replay(output, true); + } + + void process(std::vector keys) { + Subpath customCap; + size_t selected = keys.size(); + float selectedValue = INFINITY; + float sign = info.flags & 1 ? 1 : -1; + for (size_t i = 0; i < keys.size(); ++i) { + float value = keys[i].value * sign; + if ((keys[i].tag & 2) && value < selectedValue) { + selected = i; + selectedValue = value; + } + } + const Subpath *cap = nullptr; + if (selected < keys.size() && std::isfinite(selectedValue)) { + float first = std::fmin(keys[selected].start, keys[selected].end); + float last = std::fmax(keys[selected].start, keys[selected].end); + bool reverse = false; + for (size_t i = 0; i < keys.size(); ++i) { + if (i == selected) continue; + for (float position : {keys[i].start, keys[i].end}) { + if (first <= position && position < last) reverse = true; + } + } + if (reverse) std::swap(first, last); + customCap.append_logical_range(subpath, + ClipStroke::positionDistance(first, subpath.length), + ClipStroke::positionDistance(last, subpath.length)); + cap = &customCap; + } + for (size_t i = 0; i < keys.size(); ++i) { + ClipStroke::TaggedSplit split; + if (!ClipStroke::selectTaggedSplit(keys, i, info.flags, progress, split)) continue; + Subpath first, second; + ClipStroke::DistanceMap firstMap, secondMap; + first.append_range(subpath, + ClipStroke::positionDistance(split.firstStart, subpath.length), + ClipStroke::positionDistance(split.firstEnd, subpath.length), &firstMap); + first.close(); + auto firstKeys = ClipStroke::remapKeyframes(keys, i, subpath.length, first.length, firstMap); + run(first, cap, firstKeys); + second.append_range(subpath, + ClipStroke::positionDistance(split.secondStart, subpath.length), + ClipStroke::positionDistance(split.secondEnd, subpath.length), &secondMap); + second.close(); + keys = ClipStroke::remapKeyframes(keys, i, subpath.length, second.length, secondMap); + subpath = std::move(second); + i = 0; + } + run(subpath, cap, keys); + } + + void close() { + if (!subpath.segments.empty()) { + subpath.close(); + while (keyIndex < info.count && std::floor(info.keys[keyIndex].start) < float(subpathIndex)) ++keyIndex; + size_t first = keyIndex; + while (keyIndex < info.count && std::floor(info.keys[keyIndex].start) <= float(subpathIndex)) ++keyIndex; + std::vector keys; + if (first < keyIndex) keys.assign(info.keys + first, info.keys + keyIndex); + process(std::move(keys)); + subpath = {}; + } + ++subpathIndex; + } + + void append(const CGPathElement &element) { + if (element.type == kCGPathElementMoveToPoint) { + if (!subpath.segments.empty()) close(); + current = subpath.start = element.points[0]; + } else if (element.type == kCGPathElementCloseSubpath) { + current = subpath.start; + close(); + } else { + unsigned degree = unsigned(element.type); + Segment segment; + segment.degree = degree; + segment.points[0] = current; + std::copy_n(element.points, degree, segment.points + 1); + current = segment.end(); + subpath.append(segment); + } + } +}; + +} // namespace + +CGPathRef copy_clip_stroke_path(CGPathRef path, float progress, const ClipStrokeInfo &info) { + progress = ClipStroke::normalizeProgress(info.flags, progress); + if (progress == float(1 & ~info.flags)) return CGPathCreateCopy(path); + CGMutablePathRef result = CGPathCreateMutable(); + ClipInput input{result, info, progress, {}}; + CGPathApply(path, &input, +[](void *context, const CGPathElement *element) { + static_cast(context)->append(*element); + }); + if (!input.subpath.segments.empty()) input.close(); + return result; +} + +} // namespace ORB::Path + +#endif diff --git a/Sources/OpenRenderBoxCxx/Path/PathClipStroke.hpp b/Sources/OpenRenderBoxCxx/Path/PathClipStroke.hpp new file mode 100644 index 0000000..30360d8 --- /dev/null +++ b/Sources/OpenRenderBoxCxx/Path/PathClipStroke.hpp @@ -0,0 +1,35 @@ +// +// PathClipStroke.hpp +// OpenRenderBox + +#pragma once + +#include + +#if OPENRENDERBOX_CF_CGTYPES && ORB_TARGET_OS_DARWIN + +#include +#include +#include + +namespace ORB::Path { + +struct ClipStrokeKeyframe { + int32_t tag; + float value; + float start; + float end; +}; + +struct ClipStrokeInfo { + uint32_t flags; + int32_t cap; + size_t count; + const ClipStrokeKeyframe *keys; +}; + +CGPathRef copy_clip_stroke_path(CGPathRef path, float progress, const ClipStrokeInfo &info); + +} // namespace ORB::Path + +#endif diff --git a/Sources/OpenRenderBoxCxx/Path/PathMapper.cpp b/Sources/OpenRenderBoxCxx/Path/PathMapper.cpp index b6a315d..bbfed34 100644 --- a/Sources/OpenRenderBoxCxx/Path/PathMapper.cpp +++ b/Sources/OpenRenderBoxCxx/Path/PathMapper.cpp @@ -7,6 +7,7 @@ #if OPENRENDERBOX_CF_CGTYPES && ORB_TARGET_OS_DARWIN #include "PathMapper.hpp" +#include "PathClipStroke.hpp" #include "PathGeometry.hpp" #include "RoundedRect.hpp" #include "Storage.hpp" @@ -686,6 +687,8 @@ struct Frame { bool evenOdd = false; size_t emittedElements = 0; std::vector dash; + uint32_t clipFlags = 0; + std::vector clipKeys; explicit Frame(unsigned type) : type(type) {} ~Frame() { @@ -737,6 +740,8 @@ struct Frame { return copy_dilated_path(path, CGPointMake(values[0], values[1])); case 23: return copy_offset_path(path, values[0]); + case 24: + return copy_clip_stroke_path(path, float(values[0]), {clipFlags, cap, clipKeys.size(), clipKeys.data()}); default: return CGPathRetain(path); } @@ -786,7 +791,7 @@ struct Mapper { case 20: frame.end_boolean(); break; - case 18: case 22: + case 18: case 22: case 24: flush(index); break; case 19: @@ -871,10 +876,18 @@ struct Mapper { case 16: pop(); break; - case 17: case 18: case 19: case 20: case 22: case 23: { + case 17: case 18: case 19: case 20: case 22: case 23: case 24: { auto frame = std::make_unique(type); if (type == 17 && info) { frame->transform = *transform; + } else if (type == 24) { + frame->values[0] = values[0]; + if (info) { + const auto &clip = *static_cast(info); + frame->clipFlags = clip.flags; + frame->cap = clip.cap; + if (clip.count) frame->clipKeys.assign(clip.keys, clip.keys + clip.count); + } } else if (type == 23) { frame->values[0] = values[0]; } else if (type == 18 || type == 22) { diff --git a/Sources/OpenRenderBoxCxx/include/module.modulemap b/Sources/OpenRenderBoxCxx/include/module.modulemap index fbf2dc5..8ab3d8d 100644 --- a/Sources/OpenRenderBoxCxx/include/module.modulemap +++ b/Sources/OpenRenderBoxCxx/include/module.modulemap @@ -43,7 +43,9 @@ module OpenRenderBoxCxx.Animation { module OpenRenderBoxCxx.Path { requires cplusplus + header "../Path/ClipStrokeKeyframes.hpp" header "../Path/NestedCallbacks.hpp" + header "../Path/PathClipStroke.hpp" header "../Path/PathGeometry.hpp" header "../Path/PathMapper.hpp" header "../Path/RoundedRect.hpp" diff --git a/Tests/OpenRenderBoxCompatibilityTests/PathStorageTests.swift b/Tests/OpenRenderBoxCompatibilityTests/PathStorageTests.swift index d99e7dd..7f66142 100644 --- a/Tests/OpenRenderBoxCompatibilityTests/PathStorageTests.swift +++ b/Tests/OpenRenderBoxCompatibilityTests/PathStorageTests.swift @@ -170,6 +170,137 @@ struct PathStorageTests { } } + @Test(arguments: [CGFloat(0), 0.25, 1]) + func clippedStrokeUsesProgressAndRoundCaps(progress: CGFloat) { + let keys = [ + ClipKeyframe(tag: 0, value: 0, start: 0, end: 0.5), + ClipKeyframe(tag: 0, value: 1, start: 0.5, end: 1), + ] + withPathStorage { storage in + keys.withUnsafeBufferPointer { keys in + var metadata = ClipInfo(flags: 0, cap: 1, count: keys.count, keys: keys.baseAddress) + withUnsafePointer(to: &metadata) { metadata in + storage.append(element: .clipStroke, points: [progress], userInfo: metadata) + } + } + storage.append(element: .rect, points: [0, 0, 10, 10], userInfo: nil) + storage.append(element: .end, points: [], userInfo: nil) + withStoragePath(storage) { path in + let elements = cgPathElements(path.cgPath) + if progress == 0 { + #expect(elements.isEmpty) + } else if progress == 1 { + #expect(elements == [ + PathElementRecord(element: 0, points: [0, 0]), + PathElementRecord(element: 1, points: [10, 0]), + PathElementRecord(element: 1, points: [10, 10]), + PathElementRecord(element: 1, points: [0, 10]), + PathElementRecord(element: 4, points: []), + ]) + } else { + #expect(elements == [ + PathElementRecord(element: 0, points: [0, 0]), + PathElementRecord(element: 1, points: [5, 0]), + PathElementRecord(element: 3, points: [5, -1.3807119131088257, 6.1192879676818848, -2.5, 7.5, -2.5]), + PathElementRecord(element: 3, points: [8.880711555480957, -2.5, 10, -1.3807119131088257, 10, 0]), + PathElementRecord(element: 1, points: [10, 5]), + PathElementRecord(element: 1, points: [10, 10]), + PathElementRecord(element: 1, points: [5, 10]), + PathElementRecord(element: 3, points: [5, 11.380711555480957, 3.8807117938995361, 12.5, 2.5, 12.5]), + PathElementRecord(element: 3, points: [1.1192880868911743, 12.5, 0, 11.380711555480957, 0, 10]), + PathElementRecord(element: 1, points: [0, 5]), + PathElementRecord(element: 1, points: [0, 0]), + PathElementRecord(element: 4, points: []), + ]) + } + } + } + } + + @Test + func clippedStrokeUsesCustomKeyframeCap() { + let keys = [ + ClipKeyframe(tag: 2, value: 0, start: 0, end: 0.25), + ClipKeyframe(tag: 0, value: 1, start: 0.5, end: 0.75), + ] + expectClippedRectangle(keys: keys, progress: 0.25, expected: [ + PathElementRecord(element: 0, points: [0, 0]), + PathElementRecord(element: 1, points: [10, 0]), + PathElementRecord(element: 1, points: [10, 2.5]), + PathElementRecord(element: 1, points: [0, 2.5]), + PathElementRecord(element: 4, points: []), + ]) + } + + @Test + func clippedStrokeWrapsTaggedKeyframe() { + let keys = [ + ClipKeyframe(tag: 0, value: 0, start: 0, end: 0.1), + ClipKeyframe(tag: 1, value: 0.5, start: 0.8, end: 0.7), + ClipKeyframe(tag: 0, value: 1, start: 0.5, end: 0.6), + ] + expectClippedRectangle(keys: keys, progress: 0.75, expected: [ + PathElementRecord(element: 0, points: [4, 10]), + PathElementRecord(element: 1, points: [0, 10]), + PathElementRecord(element: 1, points: [0, 8]), + PathElementRecord(element: 1, points: [5, 9]), + PathElementRecord(element: 3, points: [5.3186259269714355, 9.0637245178222656, 5.525263786315918, 9.3736820220947266, 5.4615383148193359, 9.6923074722290039]), + PathElementRecord(element: 3, points: [5.3978133201599121, 10.010933876037598, 5.0878567695617676, 10.217571258544922, 4.769230842590332, 10.15384578704834]), + PathElementRecord(element: 4, points: []), + PathElementRecord(element: 0, points: [0, 0]), + PathElementRecord(element: 1, points: [4, 0]), + PathElementRecord(element: 1, points: [2, 10]), + PathElementRecord(element: 1, points: [0, 10]), + PathElementRecord(element: 4, points: []), + ]) + } + + @Test(.enabled(if: referencePathGeometryEnabled)) + func clippedStrokeExtendsBelowProgressRange() { + let keys = [ + ClipKeyframe(tag: 0, value: 0, start: 0, end: 0.5), + ClipKeyframe(tag: 0, value: 1, start: 0.5, end: 1), + ] + expectClippedRectangle(keys: keys, flags: 4, progress: -0.25, expected: [ + PathElementRecord(element: 0, points: [8.821488380432129, 11.178511619567871]), + PathElementRecord(element: 1, points: [-1.178511381149292, 11.178511619567871]), + PathElementRecord(element: 1, points: [-1.178511381149292, 1.178511381149292]), + PathElementRecord(element: 1, points: [8.821488380432129, 1.178511381149292]), + PathElementRecord(element: 1, points: [8.821488380432129, 11.178511619567871]), + PathElementRecord(element: 1, points: [-1.178511381149292, 11.178511619567871]), + PathElementRecord(element: 1, points: [-1.178511381149292, 1.178511381149292]), + PathElementRecord(element: 1, points: [8.821488380432129, 1.178511381149292]), + PathElementRecord(element: 3, points: [11.582912445068359, 1.178511381149292, 13.821488380432129, 3.4170875549316406, 13.821488380432129, 6.178511142730713]), + PathElementRecord(element: 3, points: [13.821488380432129, 8.939934730529785, 11.582912445068359, 11.178511619567871, 8.821488380432129, 11.178511619567871]), + PathElementRecord(element: 4, points: []), + ]) + } + + private func expectClippedRectangle( + keys: [ClipKeyframe], flags: Int32 = 0, progress: CGFloat, expected: [PathElementRecord] + ) { + withPathStorage { storage in + keys.withUnsafeBufferPointer { keys in + var metadata = ClipInfo(flags: flags, cap: 1, count: keys.count, keys: keys.baseAddress) + withUnsafePointer(to: &metadata) { metadata in + storage.append(element: .clipStroke, points: [progress], userInfo: metadata) + } + } + storage.append(element: .rect, points: [0, 0, 10, 10], userInfo: nil) + storage.append(element: .end, points: [], userInfo: nil) + withStoragePath(storage) { path in + let actual = normalizedClipElements(cgPathElements(path.cgPath)) + #expect(actual.map(\.element) == expected.map(\.element)) + for (actual, expected) in zip(actual, expected) { + #expect(actual.points.count == expected.points.count) + for (actual, expected) in zip(actual.points, expected.points) { + #expect(abs(actual - expected) < 1e-6) + } + } + } + } + } + @Test func glyphDilationPreservesClosedGeometry() { withPathStorage { storage in diff --git a/Tests/OpenRenderBoxCompatibilityTests/PathTestSupport.swift b/Tests/OpenRenderBoxCompatibilityTests/PathTestSupport.swift index 761fa69..6d0640e 100644 --- a/Tests/OpenRenderBoxCompatibilityTests/PathTestSupport.swift +++ b/Tests/OpenRenderBoxCompatibilityTests/PathTestSupport.swift @@ -114,4 +114,47 @@ func cgPathElements(_ path: CGPath) -> [PathElementRecord] { } // Ignore commands that do not change the geometry of a closed clip result. +func normalizedClipElements(_ elements: [PathElementRecord]) -> [PathElementRecord] { + func near(_ lhs: CGPoint, _ rhs: CGPoint) -> Bool { + abs(lhs.x - rhs.x) < 1e-8 && abs(lhs.y - rhs.y) < 1e-8 + } + func endpoint(_ element: PathElementRecord) -> CGPoint { + CGPoint(x: element.points[element.points.count - 2], y: element.points[element.points.count - 1]) + } + var result: [PathElementRecord] = [] + var current = CGPoint.zero + var start = CGPoint.zero + for element in elements { + if element.element == 0 { + start = endpoint(element) + } else if element.element == 1 { + let next = endpoint(element) + if near(current, next) { continue } + if result.count >= 2, result.last?.element == 1, + !result[result.count - 2].points.isEmpty { + let previous = endpoint(result[result.count - 2]) + let a = CGPoint(x: current.x - previous.x, y: current.y - previous.y) + let b = CGPoint(x: next.x - current.x, y: next.y - current.y) + if abs(a.x * b.y - a.y * b.x) < 1e-8 && a.x * b.x + a.y * b.y >= 0 { + result.removeLast() + } + } + } else if element.element == 2 || element.element == 3 { + let isDegenerate = stride(from: 0, to: element.points.count, by: 2).allSatisfy { + near(current, CGPoint(x: element.points[$0], y: element.points[$0 + 1])) + } + if isDegenerate { continue } + } else if element.element == 4 { + if result.last?.element == 1, near(current, start) { + result.removeLast() + } + current = start + result.append(element) + continue + } + current = endpoint(element) + result.append(element) + } + return result +} #endif diff --git a/Tests/OpenRenderBoxCompatibilityTests/README.md b/Tests/OpenRenderBoxCompatibilityTests/README.md index 7992395..e551fe5 100644 --- a/Tests/OpenRenderBoxCompatibilityTests/README.md +++ b/Tests/OpenRenderBoxCompatibilityTests/README.md @@ -19,11 +19,11 @@ The local dependency options require sibling OpenCoreGraphics and DarwinPrivateFrameworks checkouts. Native Path suites run without enabling other unfinished compatibility suites. -The exact curve-trimming fixtures +The exact curve-trimming and clip-stroke undershoot fixtures use RenderBox from iOS 18.5 (22F77). They run against native OpenRenderBox and Apple RenderBox on iOS 18.5. -Other Apple releases skip those fixtures because their curve subdivision -can differ. The suites also cover callback cancellation, Storage lifetime, +Other Apple releases skip those fixtures because their curve subdivision and +clip-stroke behavior can differ. The suites also cover callback cancellation, Storage lifetime, primitive queries, arcs, and wrapper geometry. To test the reference runtime, open `Package.swift` in Xcode with the Apple compatibility environment above, select the From 0d78fe39952b208e9c43479d9537ebec642c6d6c Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 7 Nov 2026 19:02:35 +0800 Subject: [PATCH 09/12] feat(path): add enumeration length and interpolation --- Sources/OpenRenderBoxCxx/Path/ORBPath.h | 29 +++ .../OpenRenderBoxCxx/Path/PathEnumeration.cpp | 241 ++++++++++++++++++ .../Path/PathInterpolation.cpp | 204 +++++++++++++++ .../Path/PathInterpolation.hpp | 18 ++ Sources/OpenRenderBoxCxx/Path/PathMapper.cpp | 82 +++++- Sources/OpenRenderBoxCxx/Path/PathMapper.hpp | 1 + .../Path/PathStorageObject.cpp | 68 +++++ .../Path/PathStorageObject.hpp | 18 ++ .../OpenRenderBoxCxx/include/module.modulemap | 2 + Sources/OpenRenderBoxShims/ORBShims.swift | 3 + .../OpenRenderBoxShims/OpenRenderBoxShims.h | 11 + .../ORBShims.swift | 3 + .../PathOperationsTests.swift | 203 +++++++++++++++ .../OpenRenderBoxCompatibilityTests/README.md | 5 +- 14 files changed, 884 insertions(+), 4 deletions(-) create mode 100644 Sources/OpenRenderBoxCxx/Path/PathEnumeration.cpp create mode 100644 Sources/OpenRenderBoxCxx/Path/PathInterpolation.cpp create mode 100644 Sources/OpenRenderBoxCxx/Path/PathInterpolation.hpp create mode 100644 Sources/OpenRenderBoxCxx/Path/PathStorageObject.cpp create mode 100644 Sources/OpenRenderBoxCxx/Path/PathStorageObject.hpp create mode 100644 Tests/OpenRenderBoxCompatibilityTests/PathOperationsTests.swift diff --git a/Sources/OpenRenderBoxCxx/Path/ORBPath.h b/Sources/OpenRenderBoxCxx/Path/ORBPath.h index a32d5f8..4ee794a 100644 --- a/Sources/OpenRenderBoxCxx/Path/ORBPath.h +++ b/Sources/OpenRenderBoxCxx/Path/ORBPath.h @@ -60,6 +60,20 @@ typedef ORB_ENUM(int32_t, ORBPathRoundedCornerStyle) { /// Returns true to continue enumeration, false to stop typedef bool (*ORBPathApplyCallback)(void * info, ORBPathElement element, const CGFloat *points, const void * _Nullable userInfo); +#if ORB_TARGET_OS_DARWIN +/// Callback for mapped Core Graphics elements. Return false to stop callbacks. +typedef bool (*ORBPathApplierCallback)(void *info, const CGPathElement *element); + +typedef ORB_ENUM(int32_t, ORBPathLineElement) { + ORBPathLineElementMoveToPoint = 0, + ORBPathLineElementAddLineToPoint = 1, + ORBPathLineElementCloseSubpath = 2, +}; + +/// The point is unused for a close-subpath callback. +typedef bool (*ORBPathLineApplyCallback)(void *info, ORBPathLineElement element, const CGPoint *point); +#endif + typedef struct ORBPathCallbacks ORBPathCallbacks ORB_SWIFT_NAME(ORBPath.ApplyCallback); typedef struct ORBPathStorage * ORBPathStorageRef ORB_SWIFT_STRUCT ORB_SWIFT_NAME(ORBPath.Storage); @@ -226,6 +240,21 @@ bool ORBPathEqualToPath(ORBPath lhs, ORBPath rhs) ORB_SWIFT_NAME(ORBPath.isEqual // MARK: - CGPath Interoperability #if ORB_TARGET_OS_DARWIN +ORB_EXPORT +bool ORBPathApply(ORBPath path, const CGAffineTransform * _Nullable transform, void *info, ORBPathApplierCallback callback) ORB_SWIFT_NAME(ORBPath.applyCGPathElements(self:transform:info:callback:)); + +ORB_EXPORT +bool ORBPathApplyLines(ORBPath path, double tolerance, void *info, ORBPathLineApplyCallback callback) ORB_SWIFT_NAME(ORBPath.applyLines(self:tolerance:info:callback:)); + +ORB_EXPORT +double ORBPathGetLength(ORBPath path, double tolerance, const CGAffineTransform * _Nullable transform) ORB_SWIFT_NAME(ORBPath.length(self:tolerance:transform:)); + +ORB_EXPORT +bool ORBPathCanInterpolate(ORBPath path, ORBPath other) ORB_SWIFT_NAME(ORBPath.canInterpolate(self:to:)); + +ORB_EXPORT +ORBPath ORBPathCopyInterpolatedPath(ORBPath path, ORBPath other, float fraction) ORB_SWIFT_NAME(ORBPath.interpolated(self:to:fraction:)); + ORB_EXPORT CGPathRef ORBPathCopyCGPath(ORBPath path) ORB_SWIFT_NAME(getter:ORBPath.cgPath(self:)); diff --git a/Sources/OpenRenderBoxCxx/Path/PathEnumeration.cpp b/Sources/OpenRenderBoxCxx/Path/PathEnumeration.cpp new file mode 100644 index 0000000..30752e5 --- /dev/null +++ b/Sources/OpenRenderBoxCxx/Path/PathEnumeration.cpp @@ -0,0 +1,241 @@ +// +// PathEnumeration.cpp +// OpenRenderBox + +#include + +#if OPENRENDERBOX_CF_CGTYPES && ORB_TARGET_OS_DARWIN + +#include +#include "PathMapper.hpp" +#include +#include +#if defined(__aarch64__) +#include +#endif + +namespace { + +template struct Point { + T x, y; +}; + +template struct CubicIterator { + Point point, first, second, third; + + CubicIterator(const Point (&p)[4], int count) : point(p[0]) { + float step = 1.0f / count; + float square = step * step, cube = square * step; + auto initialize = [=](T p0, T p1, T p2, T p3, T &d1, T &d2, T &d3) { + T a = (p1 - p0) * T(3); + T b = std::fma(T(3), p2 - p1, -a); + T c = p3 - (std::fma(T(3), p1 - p0, p0) + b); + d3 = (c * T(6)) * T(cube); + b *= T(square); + d2 = std::fma(T(2), b, d3); + d1 = std::fma(c, T(cube), std::fma(a, T(step), b)); + }; + initialize(p[0].x, p[1].x, p[2].x, p[3].x, first.x, second.x, third.x); + initialize(p[0].y, p[1].y, p[2].y, p[3].y, first.y, second.y, third.y); + } + + void advance() { + point.x += first.x; + point.y += first.y; + first.x += second.x; + first.y += second.y; + second.x += third.x; + second.y += third.y; + } +}; + +CGPoint quadratic_control(CGPoint end, CGPoint control) { + return {std::fma(control.x, 2.0, end.x) * (1.0 / 3.0), + std::fma(control.y, 2.0, end.y) * (1.0 / 3.0)}; +} + +double distance(CGPoint first, CGPoint second) { + double x = second.x - first.x, y = second.y - first.y; + return std::sqrt(x * x + y * y); +} + +float reciprocal(float value) { +#if defined(__aarch64__) + float32x2_t input = vset_lane_f32(value, vdup_n_f32(0), 0); + float32x2_t result = vrecpe_f32(input); + result = vmul_f32(vrecps_f32(input, result), result); + result = vmul_f32(result, vrecps_f32(input, result)); + return vget_lane_f32(result, 0); +#else + return 1.0f / value; +#endif +} + +struct ApplyContext { + const CGAffineTransform *transform; + void *info; + ORBPathApplierCallback callback; + bool active = true; + + void apply(const CGPathElement &element) { + if (!active) { return; } + CGPoint points[3]; + static constexpr size_t counts[] = {1, 1, 2, 3, 0}; + size_t count = counts[element.type]; + for (size_t i = 0; i < count; ++i) { + CGPoint point = element.points[i]; + if (transform) { + points[i] = { + std::fma(transform->c, point.y, std::fma(transform->a, point.x, transform->tx)), + std::fma(transform->d, point.y, std::fma(transform->b, point.x, transform->ty)) + }; + } else { + points[i] = point; + } + } + CGPathElement mapped{element.type, count ? points : nullptr}; + active = callback(info, &mapped); + } +}; + +struct Flattener { + void *info; + ORBPathLineApplyCallback callback; + float factor; + bool active = true; + CGPoint start = CGPointZero, current = CGPointZero; + + void emit(ORBPathLineElement element, CGPoint point) { + if (active) { active = callback(info, element, &point); } + current = point; + } + + void cubic(CGPoint first, CGPoint second, CGPoint end) { + if (!active) { current = end; return; } + Point p[] = {{float(current.x), float(current.y)}, {float(first.x), float(first.y)}, + {float(second.x), float(second.y)}, {float(end.x), float(end.y)}}; + auto curvature = [](float p0, float p1, float p2, float p3) { + return std::fmax(std::abs((p2 - (p1 + p1)) + p0), std::abs((p1 - (p2 + p2)) + p3)); + }; + float x = curvature(p[0].x, p[1].x, p[2].x, p[3].x); + float y = curvature(p[0].y, p[1].y, p[2].y, p[3].y); + float estimate = std::ceil(std::sqrt(std::sqrt(x * x + y * y)) * factor); + if (!std::isfinite(estimate)) { current = end; return; } + if (estimate < 1 && p[0].x == p[3].x && p[0].y == p[3].y) { return; } + int count = int(std::fmin(std::fmax(estimate, 1), 1024)); + if (count >= 2) { + CubicIterator iterator(p, count); + for (int i = 1; i < count && active; ++i) { + iterator.advance(); + emit(ORBPathLineElementAddLineToPoint, {iterator.point.x, iterator.point.y}); + } + } + if (active) { emit(ORBPathLineElementAddLineToPoint, end); } + } + + void apply(const CGPathElement &element) { + switch (element.type) { + case kCGPathElementMoveToPoint: + start = element.points[0]; + emit(ORBPathLineElementMoveToPoint, start); + break; + case kCGPathElementAddLineToPoint: + emit(ORBPathLineElementAddLineToPoint, element.points[0]); + break; + case kCGPathElementAddQuadCurveToPoint: + cubic(quadratic_control(current, element.points[0]), + quadratic_control(element.points[1], element.points[0]), element.points[1]); + break; + case kCGPathElementAddCurveToPoint: + cubic(element.points[0], element.points[1], element.points[2]); + break; + case kCGPathElementCloseSubpath: + emit(ORBPathLineElementCloseSubpath, CGPointZero); + current = start; + break; + } + } +}; + +struct LengthAccumulator { + double tolerance; + double length = 0; + CGPoint start = CGPointZero, current = CGPointZero; + + void cubic(CGPoint first, CGPoint second, CGPoint end) { + Point p[] = {{current.x, current.y}, {first.x, first.y}, + {second.x, second.y}, {end.x, end.y}}; + auto curvature = [](double p0, double p1, double p2, double p3) { + return std::fmax(std::abs((p0 - (p1 + p1)) + p2), std::abs((p1 - (p2 + p2)) + p3)); + }; + double x = curvature(p[0].x, p[1].x, p[2].x, p[3].x); + double y = curvature(p[0].y, p[1].y, p[2].y, p[3].y); + double estimate = std::ceil(std::sqrt((std::sqrt(x * x + y * y) * 0.75) / tolerance)); + if (std::isfinite(estimate)) { + int count = estimate < 1 ? (CGPointEqualToPoint(current, end) ? 0 : 1) : int(std::fmin(estimate, 1024)); + if (count) { + CubicIterator iterator(p, count); + for (int i = 0; i < count; ++i) { + length += std::sqrt(iterator.first.x * iterator.first.x + iterator.first.y * iterator.first.y); + iterator.advance(); + } + } + } + current = end; + } + + void apply(const CGPathElement &element) { + switch (element.type) { + case kCGPathElementMoveToPoint: + current = start = element.points[0]; + break; + case kCGPathElementAddLineToPoint: + length += distance(current, element.points[0]); + current = element.points[0]; + break; + case kCGPathElementAddQuadCurveToPoint: + cubic(quadratic_control(current, element.points[0]), + quadratic_control(element.points[1], element.points[0]), element.points[1]); + break; + case kCGPathElementAddCurveToPoint: + cubic(element.points[0], element.points[1], element.points[2]); + break; + case kCGPathElementCloseSubpath: + length += distance(current, start); + current = start; + break; + } + } +}; + +} // namespace + +bool ORBPathApply(ORBPath path, const CGAffineTransform *transform, void *info, ORBPathApplierCallback callback) { + ApplyContext context{transform, info, callback}; + ORB::Path::apply_mapped_path(path, &context, +[](void *info, const CGPathElement *element) { + auto &context = *static_cast(info); + context.apply(*element); + return context.active; + }); + return context.active; +} + +bool ORBPathApplyLines(ORBPath path, double tolerance, void *info, ORBPathLineApplyCallback callback) { + Flattener flattener{info, callback, 0.75f * reciprocal(float(tolerance))}; + ORBPathApply(path, nullptr, &flattener, +[](void *info, const CGPathElement *element) { + static_cast(info)->apply(*element); + return true; + }); + return flattener.active; +} + +double ORBPathGetLength(ORBPath path, double tolerance, const CGAffineTransform *transform) { + LengthAccumulator accumulator{tolerance}; + ORBPathApply(path, transform, &accumulator, +[](void *info, const CGPathElement *element) { + static_cast(info)->apply(*element); + return true; + }); + return accumulator.length; +} + +#endif diff --git a/Sources/OpenRenderBoxCxx/Path/PathInterpolation.cpp b/Sources/OpenRenderBoxCxx/Path/PathInterpolation.cpp new file mode 100644 index 0000000..33a050f --- /dev/null +++ b/Sources/OpenRenderBoxCxx/Path/PathInterpolation.cpp @@ -0,0 +1,204 @@ +// +// PathInterpolation.cpp +// OpenRenderBox + +#include "PathInterpolation.hpp" + +#if OPENRENDERBOX_CF_CGTYPES && ORB_TARGET_OS_DARWIN + +#include "NestedCallbacks.hpp" +#include "PathStorageObject.hpp" +#include "Storage.hpp" +#include +#include +#include +#include + +namespace ORB::Path { +namespace { + +bool has_affine_info(ORBPathElement element) { + auto type = static_cast(element); + return type <= 24 && ((1u << type) & 0x1dd0010u) == 0; +} + +bool identity(const CGAffineTransform &t) { + return t.a == 1 && t.b == 0 && t.c == 0 && t.d == 1 && t.tx == 0 && t.ty == 0; +} + +struct Record { + ORBPathElement type; + CGFloat values[8] = {}; + CGAffineTransform transform{1, 0, 0, 1, 0, 0}; + bool hasTransform = false; + uint8_t boolean[2] = {}; + int32_t flags = 0; + int32_t cap = 1; +}; + +std::vector collect(ORBPath path) { + std::vector records; + ORBPathApplyElements(path, &records, +[](void *context, ORBPathElement type, const CGFloat *values, const void *info) { + Record record{type}; + std::copy_n(values, Storage::argument_count(type), record.values); + if (info) { + if (type == ORBPathElementBoolean) std::memcpy(record.boolean, info, sizeof(record.boolean)); + else if (type == ORBPathElementClipStroke) { + const auto &properties = *static_cast(info); + record.flags = properties.cap; + record.cap = properties.join; + } else if (has_affine_info(type)) { + record.transform = *static_cast(info); + record.hasTransform = !identity(record.transform); + } + } + static_cast *>(context)->push_back(record); + return true; + }); + return records; +} + +bool compatible_types(unsigned from, unsigned to, bool trimmed) { + if (from == to) return true; + bool fromRect = from == 6 || (from == 5 && !trimmed); + bool toRect = to == 6 || (to == 5 && !trimmed); + bool fromRounded = from >= 8 && from <= 11; + bool toRounded = to >= 8 && to <= 11; + return (fromRect && (toRect || toRounded)) || (fromRounded && toRect); +} + +double interpolate(double from, double to, double amount) { + double result = std::fma(to - from, amount, from); + return std::isnan(result) ? from : result; +} + +CGAffineTransform interpolate_transform(CGAffineTransform from, CGAffineTransform to, float fraction) { + double amount = fraction; + auto mix = [amount](double a, double b) { return std::fma(b - a, amount, a); }; + if (from.a == to.a && from.b == to.b && from.c == to.c && from.d == to.d) + return {from.a, from.b, from.c, from.d, mix(from.tx, to.tx), mix(from.ty, to.ty)}; + if ((from.b == 0 && from.c == 0 && to.b == 0 && to.c == 0) || + (from.a == 0 && from.d == 0 && to.a == 0 && to.d == 0)) + return {mix(from.a, to.a), mix(from.b, to.b), mix(from.c, to.c), mix(from.d, to.d), + mix(from.tx, to.tx), mix(from.ty, to.ty)}; + + auto a = CGAffineTransformDecompose(from), b = CGAffineTransformDecompose(to); + if ((a.scale.width < 0 && b.scale.height < 0) || (a.scale.height < 0 && b.scale.width < 0)) { + a.scale.width = -a.scale.width; + a.scale.height = -a.scale.height; + a.rotation += a.rotation < 0 ? M_PI : -M_PI; + } + if (a.rotation == 0) a.rotation = 2 * M_PI; + if (b.rotation == 0) b.rotation = 2 * M_PI; + if (std::abs(a.rotation - b.rotation) > M_PI) { + if (a.rotation > b.rotation) a.rotation -= 2 * M_PI; + else b.rotation -= 2 * M_PI; + } + CGAffineTransformComponents result{ + {mix(a.scale.width, b.scale.width), mix(a.scale.height, b.scale.height)}, + mix(a.horizontalShear, b.horizontalShear), mix(a.rotation, b.rotation), + {mix(a.translation.dx, b.translation.dx), mix(a.translation.dy, b.translation.dy)}, + }; + return CGAffineTransformMakeWithComponents(result); +} + +} // namespace + +bool can_interpolate(ORBPath from, ORBPath to) { + if (ORBPathEqualToPath(from, to)) return true; + auto records = collect(from); + struct Comparison { + const std::vector &records; + size_t position = 0; + std::vector trimmed{false}; + } comparison{records}; + bool result = ORBPathApplyElements(to, &comparison, +[](void *context, ORBPathElement type, const CGFloat *values, const void *info) { + auto &state = *static_cast(context); + if (NestedCallbacks::is_nesting_element(type)) + state.trimmed.push_back(state.trimmed.back() || type == ORBPathElementTrimmed); + else if (type == ORBPathElementEnd && state.trimmed.size() > 1) state.trimmed.pop_back(); + if (state.position == state.records.size()) return false; + const auto &record = state.records[state.position++]; + if (!compatible_types(record.type, type, state.trimmed.back())) return false; + if (type == ORBPathElementBoolean) { + uint8_t properties[2] = {}; + if (info) std::memcpy(properties, info, sizeof(properties)); + return std::equal(std::begin(record.boolean), std::end(record.boolean), properties); + } + if (type == ORBPathElementClipStroke) { + Storage::StrokeInfo defaults{0, 1, 0, nullptr}; + const auto &properties = info ? *static_cast(info) : defaults; + int32_t a = record.flags, b = properties.cap; + if (((a ^ b) & 1) && (record.values[0] < 0.0001 || record.values[0] > 0.9999) && + std::abs(record.values[0] + (values[0] - 1)) < 0.0001) { + a &= ~1; + b &= ~1; + } + return a == b && record.cap == properties.join; + } + return true; + }); + return result && comparison.position == records.size(); +} + +ORBPath copy_interpolated_path(ORBPath from, ORBPath to, float amount) { + if (amount == 0 || (from.storage == to.storage && from.callbacks == to.callbacks)) return ORBPathRetain(from); + if (amount == 1) return ORBPathRetain(to); + // The result takes discrete metadata from the second path, including + // metadata for which the compatibility query returns false. + auto records = collect(from); + Storage storage(sizeof(Storage)); + struct Mixing { + const std::vector &records; + Storage &storage; + float amount; + size_t position = 0; + } mixing{records, storage, amount}; + bool result = ORBPathApplyElements(to, &mixing, +[](void *context, ORBPathElement type, const CGFloat *values, const void *info) { + auto &state = *static_cast(context); + // The callback ABI permits arbitrary streams. Do not read beyond the + // first stream when a caller skips the compatibility query. + if (state.position == state.records.size()) return false; + const auto &record = state.records[state.position++]; + unsigned outputType = record.type; + size_t count = Storage::argument_count(record.type); + CGFloat left[8], right[8] = {}, output[8] = {}; + std::copy_n(record.values, count, left); + std::copy_n(values, Storage::argument_count(type), right); + if (record.type != type) { + bool fromRect = record.type == 5 || record.type == 6; + bool toRect = type == 5 || type == 6; + bool fromRounded = record.type >= 8 && record.type <= 11; + bool toRounded = type >= 8 && type <= 11; + if (fromRect && toRounded) { + outputType = type; + count = Storage::argument_count(type); + std::fill(left + 4, left + count, 0); + } else if (!(fromRounded && toRect) && !(fromRect && toRect)) return false; + } + for (size_t i = 0; i < count; ++i) output[i] = interpolate(left[i], right[i], state.amount); + + CGAffineTransform transform; + if (has_affine_info(type)) { + CGAffineTransform target = info ? *static_cast(info) : CGAffineTransformIdentity; + if (record.hasTransform || !identity(target)) { + transform = interpolate_transform(record.transform, target, state.amount); + info = &transform; + } + } + return state.storage.append_element(static_cast(outputType), output, info); + }); + return result ? copy_storage_path(storage) : ORBPathNull; +} + +} // namespace ORB::Path + +extern "C" bool ORBPathCanInterpolate(ORBPath from, ORBPath to) { + return ORB::Path::can_interpolate(from, to); +} + +extern "C" ORBPath ORBPathCopyInterpolatedPath(ORBPath from, ORBPath to, float amount) { + return ORB::Path::copy_interpolated_path(from, to, amount); +} + +#endif diff --git a/Sources/OpenRenderBoxCxx/Path/PathInterpolation.hpp b/Sources/OpenRenderBoxCxx/Path/PathInterpolation.hpp new file mode 100644 index 0000000..8006fd9 --- /dev/null +++ b/Sources/OpenRenderBoxCxx/Path/PathInterpolation.hpp @@ -0,0 +1,18 @@ +// +// PathInterpolation.hpp +// OpenRenderBox + +#pragma once + +#include + +#if OPENRENDERBOX_CF_CGTYPES && ORB_TARGET_OS_DARWIN + +#include + +namespace ORB::Path { +bool can_interpolate(ORBPath from, ORBPath to); +ORBPath copy_interpolated_path(ORBPath from, ORBPath to, float amount); +} + +#endif diff --git a/Sources/OpenRenderBoxCxx/Path/PathMapper.cpp b/Sources/OpenRenderBoxCxx/Path/PathMapper.cpp index bbfed34..4786f3a 100644 --- a/Sources/OpenRenderBoxCxx/Path/PathMapper.cpp +++ b/Sources/OpenRenderBoxCxx/Path/PathMapper.cpp @@ -761,6 +761,43 @@ void append_element(CGMutablePathRef path, const CGPathElement &element) { struct Mapper { std::vector> frames; + void *outputInfo = nullptr; + ORBPathApplierCallback output = nullptr; + bool active = true; + bool open = false; + + bool forwards(size_t index) const { + while (index) { + if (frames[index--]->type != 17) { return false; } + } + return output != nullptr; + } + + void emit(size_t index, const CGPathElement &element, const CGAffineTransform *transform = nullptr) { + if (!active || !forwards(index)) { return; } + if (element.type == kCGPathElementCloseSubpath) { + if (!open) { return; } + open = false; + } else { + open = true; + } + static constexpr size_t counts[] = {1, 1, 2, 3, 0}; + size_t count = counts[element.type]; + CGPoint points[3]; + std::copy_n(element.points, count, points); + auto applyTransform = [&](const CGAffineTransform &t) { + for (size_t i = 0; i < count; ++i) { + CGPoint p = points[i]; + points[i] = {std::fma(t.c, p.y, std::fma(t.a, p.x, t.tx)), + std::fma(t.d, p.y, std::fma(t.b, p.x, t.ty))}; + } + }; + if (transform) { applyTransform(*transform); } + while (index) { applyTransform(frames[index--]->transform); } + CGPathElement mapped{element.type, count ? points : nullptr}; + active = output(outputInfo, &mapped); + } + Mapper() { frames.push_back(std::make_unique(0)); } Frame ¤t() { return *frames.back(); } @@ -772,13 +809,15 @@ struct Mapper { struct AppendInfo { Mapper *mapper; size_t index; + bool emit; size_t skip; size_t count = 0; - } info{this, index - 1, frame.type == 23 ? frame.emittedElements : 0}; + } info{this, index - 1, frame.type != 17, frame.type == 23 ? frame.emittedElements : 0}; CGPathApply(path, &info, +[](void *context, const CGPathElement *element) { auto &info = *static_cast(context); if (info.count++ < info.skip) { return; } append_element(info.mapper->frames[info.index]->path, *element); + if (info.emit) { info.mapper->emit(info.index, *element); } }); frame.emittedElements = info.count; CGPathRelease(path); @@ -827,6 +866,23 @@ struct Mapper { auto *transform = static_cast(info); size_t index = frames.size() - 1; CGMutablePathRef path = current().path; + CGMutablePathRef primitive = nullptr; + bool skipMove = false; + if (type < 5) { + CGPoint points[3]; + size_t count = Storage::argument_count(element) / 2; + for (size_t i = 0; i < count; ++i) { points[i] = {values[2 * i], values[2 * i + 1]}; } + CGPathElement mapped{static_cast(type), count ? points : nullptr}; + emit(index, mapped, type == 4 ? nullptr : transform); + } else if (type <= 15 && forwards(index)) { + primitive = CGPathCreateMutable(); + if (type >= 12 && !CGPathIsEmpty(path)) { + CGPoint point = CGPathGetCurrentPoint(path); + CGPathMoveToPoint(primitive, nullptr, point.x, point.y); + skipMove = true; + } + path = primitive; + } switch (type) { case 0: CGPathMoveToPoint(path, transform, values[0], values[1]); @@ -918,6 +974,20 @@ struct Mapper { default: precondition_failure("unsupported path mapping element: %u", type); } + if (primitive) { + struct ReplayInfo { + Mapper *mapper; + size_t index; + bool skipMove; + } replay{this, index, skipMove}; + CGPathApply(primitive, &replay, +[](void *context, const CGPathElement *element) { + auto &replay = *static_cast(context); + if (replay.skipMove) { replay.skipMove = false; return; } + append_element(replay.mapper->frames[replay.index]->path, *element); + replay.mapper->emit(replay.index, *element); + }); + CGPathRelease(primitive); + } return true; } }; @@ -977,6 +1047,16 @@ bool apply_mapped_storage(const Storage &storage, void *context, size_t depth) { return mapped->apply_elements(context, apply_mapping_element); } +bool apply_mapped_path(ORBPath path, void *info, ORBPathApplierCallback callback) { + Mapper mapper; + mapper.outputInfo = info; + mapper.output = callback; + bool complete = ORBPathApplyElements(path, &mapper, apply_mapping_element); + if (!complete) { precondition_failure("path enumeration stopped during conversion"); } + while (mapper.frames.size() > 1) { mapper.pop(); } + return mapper.active; +} + CGPathRef copy_cg_path(ORBPath path) { Mapper mapper; bool complete = ORBPathApplyElements(path, &mapper, apply_mapping_element); diff --git a/Sources/OpenRenderBoxCxx/Path/PathMapper.hpp b/Sources/OpenRenderBoxCxx/Path/PathMapper.hpp index 6361d10..152cf78 100644 --- a/Sources/OpenRenderBoxCxx/Path/PathMapper.hpp +++ b/Sources/OpenRenderBoxCxx/Path/PathMapper.hpp @@ -14,6 +14,7 @@ namespace ORB { namespace Path { class Storage; CGPathRef copy_cg_path(ORBPath path); +bool apply_mapped_path(ORBPath path, void *info, ORBPathApplierCallback callback); bool apply_mapping_element(void *context, ORBPathElement element, const CGFloat *values, const void *info); bool apply_mapped_storage(const Storage &storage, void *context, size_t depth); void invalidate_mapped_storage(uint32_t identifier); diff --git a/Sources/OpenRenderBoxCxx/Path/PathStorageObject.cpp b/Sources/OpenRenderBoxCxx/Path/PathStorageObject.cpp new file mode 100644 index 0000000..409f2dc --- /dev/null +++ b/Sources/OpenRenderBoxCxx/Path/PathStorageObject.cpp @@ -0,0 +1,68 @@ +// +// PathStorageObject.cpp +// OpenRenderBox + +#include "PathStorageObject.hpp" + +#if OPENRENDERBOX_CF_CGTYPES + +#include "NestedCallbacks.hpp" +#include "Storage.hpp" +#include + +namespace ORB::Path { +namespace { + +struct StorageObject { + mutable std::atomic_uint references{1}; + Storage storage; + + explicit StorageObject(const Storage &source) : storage(sizeof(Storage), source) {} +}; + +const ORBPathCallbacks callbacks = { + .flags = {}, + .retain = +[](const void *object) -> const void * { + static_cast(object)->references.fetch_add(1, std::memory_order_relaxed); + return object; + }, + .release = +[](const void *object) { + auto *value = static_cast(object); + if (value->references.fetch_sub(1, std::memory_order_acq_rel) == 1) delete value; + }, + .apply = +[](const void *object, void *info, ORBPathApplyCallback callback) { + return static_cast(object)->storage.apply_elements(info, callback); + }, + .isEqual = +[](const void *a, const void *b) { + return static_cast(a)->storage == static_cast(b)->storage; + }, + .isEmpty = +[](const void *object) { return static_cast(object)->storage.isEmpty(); }, + .isSingleElement = +[](const void *object) { + return static_cast(object)->storage.flags().isSingleElement(); + }, + .bezierOrder = +[](const void *object) -> size_t { + return static_cast(object)->storage.flags().bezierOrder(); + }, + #if ORB_TARGET_OS_DARWIN + .boundingRect = +[](const void *object) { return static_cast(object)->storage.bounding_rect(); }, + .cgPath = +[](const void *object) { return static_cast(object)->storage.cgpath(); }, + #else + .boundingRect = nullptr, + .cgPath = nullptr, + #endif + .next = +[](const void *object) -> ORBPath { + auto *nested = NestedCallbacks::get(&callbacks, 1); + return {reinterpret_cast(const_cast(object)), + reinterpret_cast(&nested->extended)}; + }, +}; + +} // namespace + +ORBPath copy_storage_path(const Storage &storage) { + return {reinterpret_cast(new StorageObject(storage)), &callbacks}; +} + +} // namespace ORB::Path + +#endif diff --git a/Sources/OpenRenderBoxCxx/Path/PathStorageObject.hpp b/Sources/OpenRenderBoxCxx/Path/PathStorageObject.hpp new file mode 100644 index 0000000..5e61de3 --- /dev/null +++ b/Sources/OpenRenderBoxCxx/Path/PathStorageObject.hpp @@ -0,0 +1,18 @@ +// +// PathStorageObject.hpp +// OpenRenderBox + +#pragma once + +#include + +#if OPENRENDERBOX_CF_CGTYPES + +#include + +namespace ORB::Path { +class Storage; +ORBPath copy_storage_path(const Storage &storage); +} + +#endif diff --git a/Sources/OpenRenderBoxCxx/include/module.modulemap b/Sources/OpenRenderBoxCxx/include/module.modulemap index 8ab3d8d..8582676 100644 --- a/Sources/OpenRenderBoxCxx/include/module.modulemap +++ b/Sources/OpenRenderBoxCxx/include/module.modulemap @@ -46,8 +46,10 @@ module OpenRenderBoxCxx.Path { header "../Path/ClipStrokeKeyframes.hpp" header "../Path/NestedCallbacks.hpp" header "../Path/PathClipStroke.hpp" + header "../Path/PathInterpolation.hpp" header "../Path/PathGeometry.hpp" header "../Path/PathMapper.hpp" + header "../Path/PathStorageObject.hpp" header "../Path/RoundedRect.hpp" header "../Path/Storage.hpp" export * diff --git a/Sources/OpenRenderBoxShims/ORBShims.swift b/Sources/OpenRenderBoxShims/ORBShims.swift index 9f3b42c..a9c9dfb 100644 --- a/Sources/OpenRenderBoxShims/ORBShims.swift +++ b/Sources/OpenRenderBoxShims/ORBShims.swift @@ -40,6 +40,9 @@ public typealias ORBLayerDelegate = RBLayerDelegate public let __ORBPathElementArgumentCount = __RBPathElementArgumentCount public typealias ORBPath = RBPath public typealias ORBPathApplyCallback = RBPathApplyCallback +public typealias ORBPathApplierCallback = RBPathApplierCallback +public typealias ORBPathLineApplyCallback = RBPathLineApplyCallback +public typealias ORBPathLineElement = RBPathLineElement public typealias ORBPathApplyShapeCallback = RBPathApplyShapeCallback public typealias ORBPathFixedRoundedRectShape = RBPathFixedRoundedRectShape public typealias ORBPathLineShape = RBPathLineShape diff --git a/Sources/OpenRenderBoxShimsCxx/include/RenderBox/OpenRenderBoxShims/OpenRenderBoxShims.h b/Sources/OpenRenderBoxShimsCxx/include/RenderBox/OpenRenderBoxShims/OpenRenderBoxShims.h index d3c2287..64b2702 100644 --- a/Sources/OpenRenderBoxShimsCxx/include/RenderBox/OpenRenderBoxShims/OpenRenderBoxShims.h +++ b/Sources/OpenRenderBoxShimsCxx/include/RenderBox/OpenRenderBoxShims/OpenRenderBoxShims.h @@ -14,6 +14,9 @@ typedef RBPath ORBPath; typedef RBPathElement ORBPathElement; typedef RBPathRoundedCornerStyle ORBPathRoundedCornerStyle; typedef RBPathApplyCallback ORBPathApplyCallback; +typedef RBPathApplierCallback ORBPathApplierCallback; +typedef RBPathLineApplyCallback ORBPathLineApplyCallback; +typedef RBPathLineElement ORBPathLineElement; typedef RBPathCallbacks ORBPathCallbacks; typedef RBPathCallbacksFlags ORBPathCallbacksFlags; typedef RBPathCallbacksExtended ORBPathCallbacksExtended; @@ -151,5 +154,13 @@ typedef RBPathApplyShapeCallback ORBPathApplyShapeCallback; #define ORBPathStorageGetBoundingRect RBPathStorageGetBoundingRect #define ORBPathStorageGetCGPath RBPathStorageGetCGPath +#define ORBPathApply RBPathApply +#define ORBPathApplyLines RBPathApplyLines +#define ORBPathGetLength RBPathGetLength +#define ORBPathCanInterpolate RBPathCanInterpolate +#define ORBPathCopyInterpolatedPath RBPathCopyInterpolatedPath +#define ORBPathLineElementMoveToPoint RBPathLineElementMoveToPoint +#define ORBPathLineElementAddLineToPoint RBPathLineElementAddLineToPoint +#define ORBPathLineElementCloseSubpath RBPathLineElementCloseSubpath #define __ORBPathElementArgumentCount __RBPathElementArgumentCount diff --git a/Tests/OpenRenderBoxCompatibilityTests/ORBShims.swift b/Tests/OpenRenderBoxCompatibilityTests/ORBShims.swift index 4408cac..8926e20 100644 --- a/Tests/OpenRenderBoxCompatibilityTests/ORBShims.swift +++ b/Tests/OpenRenderBoxCompatibilityTests/ORBShims.swift @@ -16,6 +16,9 @@ public typealias ORBLayerDelegate = RBLayerDelegate public let __ORBPathElementArgumentCount = __RBPathElementArgumentCount public typealias ORBPath = RBPath public typealias ORBPathApplyCallback = RBPathApplyCallback +public typealias ORBPathApplierCallback = RBPathApplierCallback +public typealias ORBPathLineApplyCallback = RBPathLineApplyCallback +public typealias ORBPathLineElement = RBPathLineElement public typealias ORBPathApplyShapeCallback = RBPathApplyShapeCallback public typealias ORBPathFixedRoundedRectShape = RBPathFixedRoundedRectShape public typealias ORBPathLineShape = RBPathLineShape diff --git a/Tests/OpenRenderBoxCompatibilityTests/PathOperationsTests.swift b/Tests/OpenRenderBoxCompatibilityTests/PathOperationsTests.swift new file mode 100644 index 0000000..1c79a3e --- /dev/null +++ b/Tests/OpenRenderBoxCompatibilityTests/PathOperationsTests.swift @@ -0,0 +1,203 @@ +// +// PathOperationsTests.swift +// OpenRenderBoxCompatibilityTests + +import Testing + +#if canImport(CoreGraphics) && (OPENRENDERBOX_CF_CGTYPES || OPENRENDERBOX_COMPATIBILITY_TEST) +import CoreGraphics +import Foundation + +@Suite +struct PathOperationsTests { + private func curve(quadratic: Bool) -> ORBPath { + let path = CGMutablePath() + path.move(to: CGPoint(x: 0.1, y: 0.2)) + if quadratic { + path.addQuadCurve(to: CGPoint(x: 10, y: 0), control: CGPoint(x: 5, y: 10)) + } else { + path.addCurve(to: CGPoint(x: 10, y: 0), control1: CGPoint(x: 0, y: 10), control2: CGPoint(x: 10, y: 10)) + } + path.closeSubpath() + return ORBPath(cgPath: path) + } + + @Test + func applyTransformsElementsAndStopsOnCancellation() { + let path = curve(quadratic: false) + defer { path.release() } + var transform = CGAffineTransform(a: 2, b: 0, c: 0, d: 3, tx: 7, ty: 11) + var elements: [PathElementRecord] = [] + let completed = withUnsafeMutablePointer(to: &elements) { + path.applyCGPathElements(transform: &transform, info: $0) { info, element in + let element = element.pointee + let count = element.type == .addCurveToPoint ? 3 : element.type == .closeSubpath ? 0 : 1 + let points = UnsafeBufferPointer(start: element.points, count: count).flatMap { [$0.x, $0.y] } + info.assumingMemoryBound(to: [PathElementRecord].self).pointee.append( + PathElementRecord(element: element.type.rawValue, points: points) + ) + return true + } + } + #expect(completed) + #expect(elements == [ + PathElementRecord(element: 0, points: [7.2, 11.6]), + PathElementRecord(element: 3, points: [7, 41, 27, 41, 27, 11]), + PathElementRecord(element: 4, points: []), + ]) + var count = 0 + #expect(!path.applyCGPathElements(transform: nil, info: &count) { info, _ in + let count = info.assumingMemoryBound(to: Int.self) + count.pointee += 1 + return count.pointee < 2 + }) + #expect(count == 2) + count = 0 + #expect(!path.applyLines(tolerance: 0.5, info: &count) { info, _, _ in + let count = info.assumingMemoryBound(to: Int.self) + count.pointee += 1 + return count.pointee < 2 + }) + #expect(count == 2) + } + + @Test(.enabled(if: referencePathGeometryEnabled), arguments: [false, true]) + func flattenedCurvesPreserveReferenceStepsAndLength(quadratic: Bool) { + let path = curve(quadratic: quadratic) + defer { path.release() } + var elements: [PathElementRecord] = [] + let completed = withUnsafeMutablePointer(to: &elements) { + path.applyLines(tolerance: 0.5, info: $0) { info, element, point in + let points: [CGFloat] = element.rawValue == 2 ? [] : [point.pointee.x, point.pointee.y] + info.assumingMemoryBound(to: [PathElementRecord].self).pointee.append( + PathElementRecord(element: element.rawValue, points: points) + ) + return true + } + } + #expect(completed) + let expected: [CGFloat] = quadratic + ? [0.1, 0.2, 2.5562498569488525, 3.8625001907348633, 5.0249996185302734, 5.0500001907348633, + 7.5062494277954102, 3.762500524520874, 10, 0] + : [0.1, 0.2, 0.79861116409301758, 4.282407283782959, 2.6222224235534668, 6.7259254455566406, + 5.0125002861022949, 7.5249991416931152, 7.4111113548278809, 6.6740727424621582, + 9.2597227096557617, 4.1675910949707031, 10, 0] + #expect(elements.map(\.element) == [0] + Array(repeating: 1, count: quadratic ? 4 : 6) + [2]) + #expect(elements.flatMap(\.points) == expected) + let expectedLength = quadratic ? 24.360695925004599 : 29.409184003419121 + #expect(abs(path.length(tolerance: 0.5, transform: nil) - expectedLength) < 1e-10) + var transform = CGAffineTransform(a: 2, b: 0, c: 0, d: 3, tx: 7, ty: 11) + let transformedLength = quadratic ? 56.859833567655286 : 72.313997446559654 + #expect(abs(path.length(tolerance: 0.5, transform: &transform) - transformedLength) < 1e-10) + } + + @Test(arguments: [false, true]) + func applyPreservesRawMoveAndLineCommands(hasInitialMove: Bool) { + withPathStorage { storage in + if hasInitialMove { + storage.append(element: .moveToPoint, points: [1, 2], userInfo: nil) + storage.append(element: .moveToPoint, points: [3, 4], userInfo: nil) + storage.append(element: .addLineToPoint, points: [3, 4], userInfo: nil) + storage.append(element: .closeSubpath, points: [], userInfo: nil) + storage.append(element: .closeSubpath, points: [], userInfo: nil) + } else { + storage.append(element: .addLineToPoint, points: [3, 4], userInfo: nil) + storage.append(element: .addLineToPoint, points: [5, 6], userInfo: nil) + } + withStoragePath(storage) { path in + var kinds: [Int32] = [] + let completed = withUnsafeMutablePointer(to: &kinds) { + path.applyCGPathElements(transform: nil, info: $0) { info, element in + info.assumingMemoryBound(to: [Int32].self).pointee.append(element.pointee.type.rawValue) + return true + } + } + #expect(completed) + #expect(kinds == (hasInitialMove ? [0, 0, 1, 4] : [1, 1])) + kinds.removeAll() + let linesCompleted = withUnsafeMutablePointer(to: &kinds) { + path.applyLines(tolerance: 0.5, info: $0) { info, element, _ in + info.assumingMemoryBound(to: [Int32].self).pointee.append(element.rawValue) + return true + } + } + #expect(linesCompleted) + #expect(kinds == (hasInitialMove ? [0, 0, 1, 2] : [1, 1])) + } + } + } + + @Test + func closedLineLengthIncludesClosingEdge() { + let cgPath = CGMutablePath() + cgPath.move(to: .zero) + cgPath.addLine(to: CGPoint(x: 3, y: 4)) + cgPath.closeSubpath() + let path = ORBPath(cgPath: cgPath) + defer { path.release() } + #expect(path.length(tolerance: 0.5, transform: nil) == 10) + var transform = CGAffineTransform(scaleX: 2, y: 3) + #expect(abs(path.length(tolerance: 0.5, transform: &transform) - 26.83281572999748) < 1e-12) + } + + @Test + func interpolationPromotesRoundedRectAndPreservesEndpoints() { + let lhs = ORBPath(rect: CGRect(x: 1, y: 2, width: 10, height: 20), transform: nil) + let rhs = ORBPath(roundedRect: CGRect(x: 3, y: 4, width: 30, height: 40), cornerWidth: 5, cornerHeight: 6, style: .continuous, transform: nil) + defer { lhs.release(); rhs.release() } + #expect(lhs.canInterpolate(to: rhs)) + let mixed = lhs.interpolated(to: rhs, fraction: 0.25) + defer { mixed.release() } + #expect(pathElements(mixed) == [PathElementRecord(element: 9, points: [1.5, 2.5, 15, 25, 1.25, 1.5])]) + for fraction: Float in [0, 1] { + let endpoint = lhs.interpolated(to: rhs, fraction: fraction) + defer { endpoint.release() } + let expected = fraction == 0 ? lhs : rhs + #expect(endpoint.storage == expected.storage) + #expect(endpoint.callbacks == expected.callbacks) + } + let ellipse = ORBPath(ellipseIn: CGRect(x: 0, y: 0, width: 10, height: 10), transform: nil) + defer { ellipse.release() } + #expect(!lhs.canInterpolate(to: ellipse)) + let incompatible = lhs.interpolated(to: ellipse, fraction: 0.25) + defer { incompatible.release() } + #expect(unsafeBitCast(incompatible.storage, to: UInt.self) == 1) + } + + @Test + func interpolatedStrokeOwnsMetadataAndChild() { + func makeStroke(rect: CGRect, width: CGFloat, cap: Int32, dash: [CGFloat]) -> ORBPath { + let source = ORBPath(rect: rect, transform: nil) + defer { source.release() } + return dash.withUnsafeBufferPointer { + source.stroked(lineWidth: width, lineCap: cap, lineJoin: 1, miterLimit: 10, dashPhase: 0, dash: $0.baseAddress, dashCount: $0.count) + } + } + let lhs = makeStroke(rect: CGRect(x: 1, y: 2, width: 10, height: 20), width: 1, cap: 0, dash: [2, 4]) + let rhs = makeStroke(rect: CGRect(x: 3, y: 4, width: 30, height: 40), width: 3, cap: 2, dash: [3, 5, 7]) + #expect(lhs.canInterpolate(to: rhs)) + let path = lhs.interpolated(to: rhs, fraction: 0.25) + lhs.release() + rhs.release() + struct Result { var width: CGFloat = 0; var cap: Int32 = -1; var dash: [CGFloat] = []; var child = ORBPath.empty } + var result = Result() + #expect(path.applyShape(allowLine: true, info: &result) { info, type, shape in + if type == .stroked { + let stroke = shape.pointee.stroked + let result = info.assumingMemoryBound(to: Result.self) + result.pointee.width = stroke.style.lineWidth + result.pointee.cap = stroke.style.lineCap + result.pointee.dash = Array(UnsafeBufferPointer(start: stroke.style.dash, count: Int(stroke.style.dashCount))) + result.pointee.child = stroke.path.retain() + } + }) + path.release() + defer { result.child.release() } + #expect(result.width == 1.5) + #expect(result.cap == 2) + #expect(result.dash == [3, 5, 7]) + #expect(result.child.boundingRect == CGRect(x: 1.5, y: 2.5, width: 15, height: 25)) + } + +} +#endif diff --git a/Tests/OpenRenderBoxCompatibilityTests/README.md b/Tests/OpenRenderBoxCompatibilityTests/README.md index e551fe5..03bac61 100644 --- a/Tests/OpenRenderBoxCompatibilityTests/README.md +++ b/Tests/OpenRenderBoxCompatibilityTests/README.md @@ -19,12 +19,11 @@ The local dependency options require sibling OpenCoreGraphics and DarwinPrivateFrameworks checkouts. Native Path suites run without enabling other unfinished compatibility suites. -The exact curve-trimming and clip-stroke undershoot fixtures +The exact curve-trimming, line-flattening, and clip-stroke undershoot fixtures use RenderBox from iOS 18.5 (22F77). They run against native OpenRenderBox and Apple RenderBox on iOS 18.5. Other Apple releases skip those fixtures because their curve subdivision and -clip-stroke behavior can differ. The suites also cover callback cancellation, Storage lifetime, -primitive queries, arcs, and wrapper geometry. +clip-stroke behavior can differ. The suites also cover callback cancellation, interpolated storage lifetime, and wrapper geometry. To test the reference runtime, open `Package.swift` in Xcode with the Apple compatibility environment above, select the `OpenRenderBoxCompatibilityTests` package scheme, and run the Path suites on From 840be346aed29851f197b90f2f19a32ca2e57255 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 7 Nov 2026 19:04:06 +0800 Subject: [PATCH 10/12] feat(path): add path serialization and debug descriptions --- Sources/OpenRenderBoxCxx/Path/ORBPath.h | 9 + Sources/OpenRenderBoxCxx/Path/PathData.cpp | 433 ++++++++++++++++++ .../OpenRenderBoxShims/OpenRenderBoxShims.h | 3 + .../PathOperationsTests.swift | 30 ++ .../OpenRenderBoxCompatibilityTests/README.md | 3 +- 5 files changed, 477 insertions(+), 1 deletion(-) create mode 100644 Sources/OpenRenderBoxCxx/Path/PathData.cpp diff --git a/Sources/OpenRenderBoxCxx/Path/ORBPath.h b/Sources/OpenRenderBoxCxx/Path/ORBPath.h index 4ee794a..30d96f3 100644 --- a/Sources/OpenRenderBoxCxx/Path/ORBPath.h +++ b/Sources/OpenRenderBoxCxx/Path/ORBPath.h @@ -255,6 +255,15 @@ bool ORBPathCanInterpolate(ORBPath path, ORBPath other) ORB_SWIFT_NAME(ORBPath.c ORB_EXPORT ORBPath ORBPathCopyInterpolatedPath(ORBPath path, ORBPath other, float fraction) ORB_SWIFT_NAME(ORBPath.interpolated(self:to:fraction:)); +ORB_EXPORT +CFDataRef ORBPathCopyData(ORBPath path) ORB_SWIFT_NAME(getter:ORBPath.data(self:)); + +ORB_EXPORT +ORBPath ORBPathMakeWithData(CFDataRef data) ORB_SWIFT_NAME(ORBPath.init(data:)); + +ORB_EXPORT +CFStringRef ORBPathCopyDebugDescription(ORBPath path) ORB_SWIFT_NAME(getter:ORBPath.debugDescription(self:)); + ORB_EXPORT CGPathRef ORBPathCopyCGPath(ORBPath path) ORB_SWIFT_NAME(getter:ORBPath.cgPath(self:)); diff --git a/Sources/OpenRenderBoxCxx/Path/PathData.cpp b/Sources/OpenRenderBoxCxx/Path/PathData.cpp new file mode 100644 index 0000000..e23bbf9 --- /dev/null +++ b/Sources/OpenRenderBoxCxx/Path/PathData.cpp @@ -0,0 +1,433 @@ +// +// PathData.cpp +// OpenRenderBox + +#include + +#if OPENRENDERBOX_CF_CGTYPES && ORB_TARGET_OS_DARWIN + +#include +#include "PathClipStroke.hpp" +#include "PathStorageObject.hpp" +#include "Storage.hpp" +#include +#include +#include +#include +#include +#include + +namespace { + +using Bytes = std::vector; +using ORB::Path::Storage; +using ORB::Path::ClipStrokeInfo; +using ORB::Path::ClipStrokeKeyframe; + +struct Encoder { + Bytes bytes; + + void varint(uint64_t value) { + do { + uint8_t byte = value & 0x7f; + value >>= 7; + bytes.push_back(byte | (value ? 0x80 : 0)); + } while (value); + } + + void integer(unsigned field, uint64_t value) { + if (!value) return; + varint(uint64_t(field) << 3); + varint(value); + } + + template void scalar(T value) { + const auto *start = reinterpret_cast(&value); + bytes.insert(bytes.end(), start, start + sizeof(T)); + } + + void real(unsigned field, float value, float defaultValue = 0) { + if (value == defaultValue) return; + varint((uint64_t(field) << 3) | 5); + scalar(value); + } + + void data(unsigned field, const Bytes &value) { + varint((uint64_t(field) << 3) | 2); + varint(value.size()); + bytes.insert(bytes.end(), value.begin(), value.end()); + } +}; + +struct Field { + unsigned number = 0; + unsigned wire = 0; + uint64_t integer = 0; + const uint8_t *data = nullptr; + size_t size = 0; + + template T scalar() const { + T value; + std::memcpy(&value, data, sizeof(T)); + return value; + } +}; + +struct Decoder { + const uint8_t *bytes; + size_t size; + size_t offset = 0; + bool valid = true; + + bool varint(uint64_t &result) { + result = 0; + for (unsigned shift = 0; shift < 70; shift += 7) { + if (offset == size) return valid = false; + uint8_t byte = bytes[offset++]; + if (shift == 63 && byte > 1) return valid = false; + result |= uint64_t(byte & 0x7f) << shift; + if (!(byte & 0x80)) return true; + } + return valid = false; + } + + bool next(Field &field) { + if (!valid || offset == size) return false; + uint64_t key; + if (!varint(key) || key >> 3 == 0 || key >> 3 > 0x1fffffff) return valid = false; + field = {}; + field.number = unsigned(key >> 3); + field.wire = key & 7; + if (field.wire == 0) return varint(field.integer); + uint64_t count; + if (field.wire == 1) count = 8; + else if (field.wire == 5) count = 4; + else if (field.wire == 2) { + if (!varint(count)) return false; + } else return valid = false; + if (count > size - offset) return valid = false; + field.data = bytes + offset; + field.size = size_t(count); + offset += field.size; + return true; + } +}; + +struct Metadata { + unsigned kind = 0; + CGAffineTransform affine = CGAffineTransformIdentity; + Storage::StrokeInfo stroke = {}; + uint8_t boolean[2] = {}; + ClipStrokeInfo clip = {0, 1, 0, nullptr}; + std::vector dash; + std::vector keys; + + const void *pointer() { + switch (kind) { + case 1: return &affine; + case 2: + stroke.count = dash.size(); + stroke.values = dash.data(); + return &stroke; + case 3: return boolean; + case 4: + clip.count = keys.size(); + clip.keys = keys.data(); + return &clip; + default: return nullptr; + } + } +}; + +template bool read_reals(const Field &field, F append) { + if (field.wire != 2 && field.wire != (sizeof(T) == 4 ? 5u : 1u)) return false; + if (field.size % sizeof(T)) return false; + for (size_t i = 0; i < field.size; i += sizeof(T)) { + T value; + std::memcpy(&value, field.data + i, sizeof(T)); + append(value); + } + return true; +} + +bool decode_key(const Field &message, ClipStrokeKeyframe &key) { + if (message.wire != 2) return false; + Decoder decoder{message.data, message.size}; + Field field; + while (decoder.next(field)) { + if (field.number <= 3) { + float *value = field.number == 1 ? &key.value : field.number == 2 ? &key.start : &key.end; + if (!read_reals(field, [&](float v) { *value = v; })) return false; + } else if (field.number <= 5) { + if (field.wire != 0) return false; + if (field.integer) key.tag |= field.number == 4 ? 1 : 2; + } + } + return decoder.valid; +} + +bool decode_metadata(const Field &message, Metadata &metadata) { + if (message.wire != 2) return false; + Decoder outer{message.data, message.size}; + Field variant; + while (outer.next(variant)) { + if (variant.number > 4 || metadata.kind) continue; + if (variant.wire != 2) return false; + metadata.kind = variant.number; + Decoder decoder{variant.data, variant.size}; + Field field; + while (decoder.next(field)) { + if (metadata.kind == 1) { + CGFloat *values[] = {&metadata.affine.a, &metadata.affine.b, &metadata.affine.c, + &metadata.affine.d, &metadata.affine.tx, &metadata.affine.ty}; + if (field.number <= 6 && !read_reals(field, [&](float v) { *values[field.number - 1] = v; })) return false; + } else if (metadata.kind == 2) { + if (field.number <= 2) { + if (field.wire != 0) return false; + if (field.number == 1 && field.integer <= 6) metadata.stroke.cap = int(field.integer); + if (field.number == 2 && field.integer <= 2) metadata.stroke.join = int(field.integer); + } else if (field.number == 3 && !read_reals(field, [&](float v) { metadata.dash.push_back(v); })) return false; + } else if (metadata.kind == 3) { + if (field.number <= 2) { + if (field.wire != 0) return false; + if (field.number == 1 && field.integer <= 6) metadata.boolean[0] = uint8_t(field.integer); + if (field.number == 2) metadata.boolean[1] = field.integer != 0; + } + } else if (field.number == 5) { + ClipStrokeKeyframe key{}; + if (!decode_key(field, key)) return false; + metadata.keys.push_back(key); + } else if (field.number <= 6) { + if (field.wire != 0) return false; + if (field.number == 1) { + if (field.integer <= 6) metadata.clip.cap = int(field.integer); + } else if (field.integer) { + constexpr uint32_t flags[] = {0, 0, 4, 1, 2, 0, 8}; + metadata.clip.flags |= flags[field.number]; + } + } + } + if (!decoder.valid) return false; + } + return outer.valid; +} + +unsigned metadata_kind(unsigned element) { + switch (element) { + case 4: case 16: case 18: case 22: case 23: return 0; + case 19: return 2; + case 20: return 3; + case 24: return 4; + default: return 1; + } +} + +Encoder encode_metadata(unsigned element, const void *info) { + Encoder result, value; + if (!info || element == 4 || element == 16 || element == 18 || element == 22 || element == 23) return result; + if (element == 19) { + const auto &stroke = *static_cast(info); + if (stroke.cap == 0 && stroke.join == 0 && stroke.count == 0) return result; + value.integer(1, uint64_t(int64_t(stroke.cap))); + value.integer(2, uint64_t(int64_t(stroke.join))); + if (stroke.count && stroke.values) { + Encoder dash; + auto *values = static_cast(stroke.values); + for (size_t i = 0; i < stroke.count; ++i) dash.scalar(float(values[i])); + value.data(3, dash.bytes); + } + result.data(2, value.bytes); + } else if (element == 20) { + const auto *flags = static_cast(info); + if (flags[0] == 0 && flags[1] != 1) return result; + value.integer(1, flags[0]); + value.integer(2, flags[1] == 1); + result.data(3, value.bytes); + } else if (element == 24) { + const auto &clip = *static_cast(info); + if (clip.cap == 1 && clip.count == 0) return result; + if (clip.cap != 1) { + value.varint(8); + value.varint(uint64_t(int64_t(clip.cap))); + } + value.integer(2, (clip.flags & 4) != 0); + value.integer(3, (clip.flags & 1) != 0); + value.integer(4, (clip.flags & 2) != 0); + if (clip.keys) for (size_t i = 0; i < clip.count; ++i) { + const auto &key = clip.keys[i]; + Encoder item; + item.real(1, key.value); + item.real(2, key.start); + item.real(3, key.end); + item.integer(4, (key.tag & 1) != 0); + item.integer(5, (key.tag & 2) != 0); + value.data(5, item.bytes); + } + value.integer(6, (clip.flags & 8) != 0); + result.data(4, value.bytes); + } else { + const auto &affine = *static_cast(info); + if (CGAffineTransformIsIdentity(affine)) return result; + value.real(1, float(affine.a), 1); + value.real(2, float(affine.b)); + value.real(3, float(affine.c)); + value.real(4, float(affine.d), 1); + value.real(5, float(affine.tx)); + value.real(6, float(affine.ty)); + result.data(1, value.bytes); + } + return result; +} + +} // namespace + +CFDataRef ORBPathCopyData(ORBPath path) { + struct Encoding { + Encoder output, elements, floats, doubles; + } encoding; + ORBPathApplyElements(path, &encoding, +[](void *context, ORBPathElement element, const CGFloat *points, const void *info) { + auto &encoding = *static_cast(context); + unsigned type = unsigned(element); + Encoder metadata = encode_metadata(type, info); + uint8_t code = uint8_t(type); + if (!metadata.bytes.empty()) { + code |= 0x80; + encoding.output.data(4, metadata.bytes); + } + size_t count = Storage::argument_count(element); + for (size_t i = 0; i < count; ++i) if (!(std::abs(points[i]) <= 32767)) code |= 0x40; + encoding.elements.bytes.push_back(code); + for (size_t i = 0; i < count; ++i) { + if (code & 0x40) encoding.doubles.scalar(points[i]); + else encoding.floats.scalar(float(points[i])); + } + return true; + }); + if (!encoding.elements.bytes.empty()) encoding.output.data(3, encoding.elements.bytes); + if (!encoding.floats.bytes.empty()) encoding.output.data(2, encoding.floats.bytes); + if (!encoding.doubles.bytes.empty()) encoding.output.data(5, encoding.doubles.bytes); + return CFDataCreate(nullptr, encoding.output.bytes.data(), CFIndex(encoding.output.bytes.size())); +} + +ORBPath ORBPathMakeWithData(CFDataRef data) { + if (!data) return ORBPathNull; + Decoder decoder{CFDataGetBytePtr(data), size_t(CFDataGetLength(data))}; + Bytes elements; + std::vector floats, doubles; + std::vector metadata; + Field field; + while (decoder.next(field)) { + switch (field.number) { + case 1: case 3: + if (field.wire != 2) { decoder.valid = false; break; } + elements.assign(field.data, field.data + field.size); + if (field.number == 1) for (auto &element : elements) element &= 0x1f; + break; + case 2: + if (!read_reals(field, [&](float v) { floats.push_back(v); })) return ORBPathNull; + break; + case 4: + metadata.emplace_back(); + if (!decode_metadata(field, metadata.back())) return ORBPathNull; + break; + case 5: + if (!read_reals(field, [&](double v) { doubles.push_back(v); })) return ORBPathNull; + break; + default: break; + } + } + // The format accepts a valid decoded prefix after a truncated field. + size_t floatIndex = 0, doubleIndex = 0, metadataIndex = 0, depth = 0; + // Validate the complete stream before Storage reads any payload. + for (uint8_t code : elements) { + unsigned type = code & 0x3f; + if (type > 24) return ORBPathNull; + if (code & 0x80) { + if (metadataIndex == metadata.size() || metadata[metadataIndex++].kind != metadata_kind(type)) return ORBPathNull; + } + size_t count = Storage::argument_count(ORBPathElement(type)); + auto &index = code & 0x40 ? doubleIndex : floatIndex; + const auto &values = code & 0x40 ? doubles : floats; + if (count > values.size() - index) return ORBPathNull; + index += count; + if ((uint32_t(1) << type) & 0x1de0000) ++depth; + else if (type == 16) { + if (!depth) return ORBPathNull; + --depth; + } + } + if (depth) return ORBPathNull; + + alignas(Storage) uint8_t buffer[144]; + auto &storage = *new (buffer) Storage(sizeof(buffer)); + floatIndex = doubleIndex = metadataIndex = 0; + for (uint8_t code : elements) { + auto type = ORBPathElement(code & 0x3f); + const void *info = code & 0x80 ? metadata[metadataIndex++].pointer() : nullptr; + auto &index = code & 0x40 ? doubleIndex : floatIndex; + const auto &values = code & 0x40 ? doubles : floats; + size_t count = Storage::argument_count(type); + storage.append_element(type, count ? values.data() + index : nullptr, info); + index += count; + } + ORBPath result = ORB::Path::copy_storage_path(storage); + storage.~Storage(); + return result; +} + +CFStringRef ORBPathCopyDebugDescription(ORBPath path) { + auto result = CFStringCreateMutable(nullptr, 0); + CFStringAppend(result, CFSTR("RBPath:")); + ORBPathApplyElements(path, result, +[](void *context, ORBPathElement element, const CGFloat *points, const void *info) { + auto result = static_cast(context); + constexpr const char *names[] = { + "moveto", "lineto", "quadto", "cubeto", "close", "rect", "rect*", "ellipse", + "rounded-rect", "continuous-rect", "uneven-rounded-rect", "uneven-continuous-rect", + "relative-arc", "clockwise-arc", "counter-clockwise-arc", "arc-to-point", "pop", + "push affine", "push cut", "push stroke", "push bool", "end-path", "push-dilate-glyph", + "push offset", "push clip-stroke" + }; + constexpr const char *caps[] = {"butt", "round", "square", "outwards-triangle", "inwards-triangle", "forwards-triangle", "backwards-triangle"}; + constexpr const char *joins[] = {"miter", "round", "bevel"}; + constexpr const char *operations[] = {"normalize", "intersect", "union", "subtract", "symmetric-difference", "line-intersect", "line-subtract"}; + unsigned type = unsigned(element); + CFStringAppendFormat(result, nullptr, CFSTR("\n %s"), type < 25 ? names[type] : "unknown"); + for (size_t i = 0; i < Storage::argument_count(element); ++i) CFStringAppendFormat(result, nullptr, CFSTR(" %g"), points[i]); + if (!info) return true; + switch (metadata_kind(type)) { + case 1: { + const auto &t = *static_cast(info); + CFStringAppendFormat(result, nullptr, CFSTR(" affine(%g %g; %g %g; %g %g)"), t.a, t.b, t.c, t.d, t.tx, t.ty); + break; + } + case 2: { + const auto &stroke = *static_cast(info); + CFStringAppendFormat(result, nullptr, CFSTR(" %s %s ["), unsigned(stroke.cap) < 7 ? caps[stroke.cap] : "unknown", unsigned(stroke.join) < 3 ? joins[stroke.join] : "unknown"); + const auto *values = static_cast(stroke.values); + if (values) for (size_t i = 0; i < stroke.count; ++i) CFStringAppendFormat(result, nullptr, CFSTR("%s%g"), i ? ", " : "", values[i]); + CFStringAppend(result, CFSTR("]")); + break; + } + case 3: { + const auto *flags = static_cast(info); + CFStringAppendFormat(result, nullptr, CFSTR(" %s, %s"), flags[0] < 7 ? operations[flags[0]] : "unknown", flags[1] == 1 ? "even-odd" : "non-zero"); + break; + } + case 4: { + const auto &clip = *static_cast(info); + CFStringAppendFormat(result, nullptr, CFSTR(" %s ["), unsigned(clip.cap) < 7 ? caps[clip.cap] : "unknown"); + if (clip.keys) for (size_t i = 0; i < clip.count; ++i) { + const auto &key = clip.keys[i]; + CFStringAppendFormat(result, nullptr, CFSTR("%s%s%s%g:%g,%g"), i ? ", " : "", key.tag & 1 ? "^" : "", key.tag & 2 ? "*" : "", double(key.value), double(key.start), double(key.end)); + } + CFStringAppend(result, CFSTR("]")); + break; + } + } + return true; + }); + CFStringAppend(result, CFSTR("\n")); + return result; +} + +#endif diff --git a/Sources/OpenRenderBoxShimsCxx/include/RenderBox/OpenRenderBoxShims/OpenRenderBoxShims.h b/Sources/OpenRenderBoxShimsCxx/include/RenderBox/OpenRenderBoxShims/OpenRenderBoxShims.h index 64b2702..8152b08 100644 --- a/Sources/OpenRenderBoxShimsCxx/include/RenderBox/OpenRenderBoxShims/OpenRenderBoxShims.h +++ b/Sources/OpenRenderBoxShimsCxx/include/RenderBox/OpenRenderBoxShims/OpenRenderBoxShims.h @@ -159,6 +159,9 @@ typedef RBPathApplyShapeCallback ORBPathApplyShapeCallback; #define ORBPathGetLength RBPathGetLength #define ORBPathCanInterpolate RBPathCanInterpolate #define ORBPathCopyInterpolatedPath RBPathCopyInterpolatedPath +#define ORBPathCopyData RBPathCopyData +#define ORBPathMakeWithData RBPathMakeWithData +#define ORBPathCopyDebugDescription RBPathCopyDebugDescription #define ORBPathLineElementMoveToPoint RBPathLineElementMoveToPoint #define ORBPathLineElementAddLineToPoint RBPathLineElementAddLineToPoint #define ORBPathLineElementCloseSubpath RBPathLineElementCloseSubpath diff --git a/Tests/OpenRenderBoxCompatibilityTests/PathOperationsTests.swift b/Tests/OpenRenderBoxCompatibilityTests/PathOperationsTests.swift index 1c79a3e..43b2b85 100644 --- a/Tests/OpenRenderBoxCompatibilityTests/PathOperationsTests.swift +++ b/Tests/OpenRenderBoxCompatibilityTests/PathOperationsTests.swift @@ -199,5 +199,35 @@ struct PathOperationsTests { #expect(result.child.boundingRect == CGRect(x: 1.5, y: 2.5, width: 15, height: 25)) } + @Test + func serializedRectUsesCompatibleWireFormat() { + let path = ORBPath(rect: CGRect(x: 1, y: 2, width: 10, height: 20), transform: nil) + defer { path.release() } + let data = path.data as Data + #expect(data == Data([0x1a, 1, 5, 0x12, 0x10, 0, 0, 0x80, 0x3f, 0, 0, 0, 0x40, 0, 0, 0x20, 0x41, 0, 0, 0xa0, 0x41])) + let copy = ORBPath(data: data as CFData) + defer { copy.release() } + #expect(copy.isEqual(to: path)) + #expect(path.debugDescription as String == "RBPath:\n rect 1 2 10 20\n") + let fractional = ORBPath(rect: CGRect(x: 0.1, y: 0.2, width: 10.3, height: 20.4), transform: nil) + let decoded = ORBPath(data: fractional.data) + fractional.release() + defer { decoded.release() } + #expect(pathElements(decoded) == [PathElementRecord(element: 5, points: [CGFloat(Float(0.1)), CGFloat(Float(0.2)), CGFloat(Float(10.3)), CGFloat(Float(20.4))])]) + } + + @Test(arguments: [ + ([UInt8](), false), ([0x80], false), ([0x0f], false), + ([0x1a, 1, 0], true), ([0x1a, 1, 0x10], true), ([0x1a, 1, 0x91], true), + ]) + func dataDecoderDistinguishesEmptyMessagesFromInvalidPaths(bytes: [UInt8], isNull: Bool) { + let path = ORBPath(data: Data(bytes) as CFData) + defer { path.release() } + #expect(path.isEmpty) + #expect((unsafeBitCast(path.storage, to: UInt.self) == 1) == isNull) + if !isNull { + #expect(unsafeBitCast(path.storage, to: UInt.self) > 1) + } + } } #endif diff --git a/Tests/OpenRenderBoxCompatibilityTests/README.md b/Tests/OpenRenderBoxCompatibilityTests/README.md index 03bac61..2584daf 100644 --- a/Tests/OpenRenderBoxCompatibilityTests/README.md +++ b/Tests/OpenRenderBoxCompatibilityTests/README.md @@ -23,7 +23,8 @@ The exact curve-trimming, line-flattening, and clip-stroke undershoot fixtures use RenderBox from iOS 18.5 (22F77). They run against native OpenRenderBox and Apple RenderBox on iOS 18.5. Other Apple releases skip those fixtures because their curve subdivision and -clip-stroke behavior can differ. The suites also cover callback cancellation, interpolated storage lifetime, and wrapper geometry. +clip-stroke behavior can differ. The suites also cover serialized data, +callback cancellation, interpolated storage lifetime, and wrapper geometry. To test the reference runtime, open `Package.swift` in Xcode with the Apple compatibility environment above, select the `OpenRenderBoxCompatibilityTests` package scheme, and run the Path suites on From d80597e78d37893b0d55ea5fece42c806de76cf3 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 7 Nov 2026 19:07:43 +0800 Subject: [PATCH 11/12] feat(path): add portable path storage and geometry --- Sources/OpenRenderBox/Path/ORBPath.swift | 696 +++++++++++++++-- .../OpenRenderBox/Path/ORBPathCallbacks.swift | 2 +- .../OpenRenderBox/Path/ORBPathGeometry.swift | 728 ++++++++++++++++++ .../OpenRenderBox/Path/ORBPathStorage.swift | 75 +- .../OpenRenderBoxCompatibilityTests/README.md | 12 + .../Path/PortablePathTests.swift | 252 ++++++ 6 files changed, 1686 insertions(+), 79 deletions(-) create mode 100644 Sources/OpenRenderBox/Path/ORBPathGeometry.swift create mode 100644 Tests/OpenRenderBoxTests/Path/PortablePathTests.swift diff --git a/Sources/OpenRenderBox/Path/ORBPath.swift b/Sources/OpenRenderBox/Path/ORBPath.swift index cb05a80..713cd26 100644 --- a/Sources/OpenRenderBox/Path/ORBPath.swift +++ b/Sources/OpenRenderBox/Path/ORBPath.swift @@ -6,6 +6,162 @@ public import OpenCoreGraphicsShims public typealias ORBPathApplyCallback = (UnsafeMutableRawPointer, ORBPath.Element, UnsafePointer, UnsafeRawPointer?) -> Bool +public typealias ORBPathApplyShapeCallback = (UnsafeMutableRawPointer, ORBPathShapeType, UnsafePointer) -> Void + +public enum ORBPathShapeType: Int32, @unchecked Sendable { + case rect = 0 + case ellipse = 1 + case line = 2 + case fixedRoundedRect = 3 + case unevenRoundedRect = 4 + case nested = 5 + case stroked = 6 + case trimmed = 7 + case offset = 8 +} + +public struct ORBPathRectShape: @unchecked Sendable { + public var transform: CGAffineTransform + public var rect: CGRect + + public init(transform: CGAffineTransform, rect: CGRect) { + self.transform = transform + self.rect = rect + } +} + +public struct ORBPathFixedRoundedRectShape: @unchecked Sendable { + public var transform: CGAffineTransform + public var rect: CGRect + public var cornerSize: CGSize + public var style: ORBPath.RoundedCornerStyle + + public init(transform: CGAffineTransform, rect: CGRect, cornerSize: CGSize, style: ORBPath.RoundedCornerStyle) { + self.transform = transform + self.rect = rect + self.cornerSize = cornerSize + self.style = style + } +} + +public struct ORBPathUnevenRoundedRectShape: @unchecked Sendable { + public var transform: CGAffineTransform + public var rect: CGRect + public var topLeftRadius: CGFloat + public var topRightRadius: CGFloat + public var bottomRightRadius: CGFloat + public var bottomLeftRadius: CGFloat + public var style: ORBPath.RoundedCornerStyle + + public init(transform: CGAffineTransform, rect: CGRect, topLeftRadius: CGFloat, topRightRadius: CGFloat, bottomRightRadius: CGFloat, bottomLeftRadius: CGFloat, style: ORBPath.RoundedCornerStyle) { + self.transform = transform + self.rect = rect + self.topLeftRadius = topLeftRadius + self.topRightRadius = topRightRadius + self.bottomRightRadius = bottomRightRadius + self.bottomLeftRadius = bottomLeftRadius + self.style = style + } +} + +public struct ORBPathLineShape: @unchecked Sendable { + public var start: CGPoint + public var end: CGPoint + + public init(start: CGPoint, end: CGPoint) { + self.start = start + self.end = end + } +} + +public struct ORBPathStrokeStyle: @unchecked Sendable { + public var lineWidth: CGFloat + public var lineCap: Int32 + public var lineJoin: Int32 + public var miterLimit: CGFloat + public var dashPhase: CGFloat + public var dashCount: UInt64 + public var dash: UnsafePointer? + + public init( + lineWidth: CGFloat, + lineCap: Int32, + lineJoin: Int32, + miterLimit: CGFloat, + dashPhase: CGFloat, + dashCount: UInt64, + dash: UnsafePointer? + ) { + self.lineWidth = lineWidth + self.lineCap = lineCap + self.lineJoin = lineJoin + self.miterLimit = miterLimit + self.dashPhase = dashPhase + self.dashCount = dashCount + self.dash = dash + } +} + +public struct ORBPathStrokedShape: @unchecked Sendable { + public var path: ORBPath + public var style: ORBPathStrokeStyle + + public init(path: ORBPath, style: ORBPathStrokeStyle) { + self.path = path + self.style = style + } +} + +public struct ORBPathNestedShape: @unchecked Sendable { + public var path: ORBPath + public var transform: CGAffineTransform + + public init(path: ORBPath, transform: CGAffineTransform) { + self.path = path + self.transform = transform + } +} + +public struct ORBPathTrimmedShape: @unchecked Sendable { + public var path: ORBPath + public var from: CGFloat + public var to: CGFloat + + public init(path: ORBPath, from: CGFloat, to: CGFloat) { + self.path = path + self.from = from + self.to = to + } +} + +public struct ORBPathShape: @unchecked Sendable { + public var rect: ORBPathRectShape + public var ellipse: ORBPathRectShape + public var line: ORBPathLineShape + public var fixedRoundedRect: ORBPathFixedRoundedRectShape + public var unevenRoundedRect: ORBPathUnevenRoundedRectShape + public var nested: ORBPathNestedShape + public var trimmed: ORBPathTrimmedShape + public var stroked: ORBPathStrokedShape + + public init( + rect: ORBPathRectShape, + ellipse: ORBPathRectShape, + line: ORBPathLineShape = .init(start: .zero, end: .zero), + fixedRoundedRect: ORBPathFixedRoundedRectShape, + stroked: ORBPathStrokedShape, + unevenRoundedRect: ORBPathUnevenRoundedRectShape = .init(transform: .identity, rect: .zero, topLeftRadius: 0, topRightRadius: 0, bottomRightRadius: 0, bottomLeftRadius: 0, style: .circular) + ) { + self.rect = rect + self.ellipse = ellipse + self.line = line + self.fixedRoundedRect = fixedRoundedRect + self.nested = .init(path: stroked.path, transform: .identity) + self.trimmed = .init(path: stroked.path, from: 0, to: 1) + self.stroked = stroked + self.unevenRoundedRect = unevenRoundedRect + } +} public struct ORBPath { public var storage: ORBPath.Storage @@ -29,8 +185,24 @@ extension ORBPath { case closeSubpath = 4 case rect = 5 case roundedRect = 6 + case ellipse = 7 case fixedRoundedRectCircular = 8 case fixedRoundedRectContinuous = 9 + case unevenRoundedRectCircular = 10 + case unevenRoundedRectContinuous = 11 + case relativeArc = 12 + case arc = 13 + case arcCounterclockwise = 14 + case arcTo = 15 + case end = 16 + case transformed = 17 + case trimmed = 18 + case stroked = 19 + case boolean = 20 + case booleanSeparator = 21 + case dilateGlyph = 22 + case offset = 23 + case clipStroke = 24 case invalid = 25 } } @@ -63,95 +235,527 @@ extension ORBPath { } } -// MARK: - Static Properties +// MARK: - Portable Storage -extension ORBPath { - /// Global empty path (storage = null, callbacks = &ORBPathEmptyCallbacks) - public static var empty: ORBPath { - _openRenderBoxUnimplementedFailure() +struct _ORBPathStrokeInfo: Equatable { + var lineCap: Int32 + var lineJoin: Int32 + var dash: [CGFloat] +} + +struct _ORBPathStrokePayload { + var lineCap: Int32 + var lineJoin: Int32 + var dashCount: Int + var dash: UnsafePointer? +} + +struct _ORBPathClipRecord: Equatable { + var type: Int32 + var x: Float + var y: Float + var z: Float +} + +struct _ORBPathClipInfo: Equatable { + var flags: Int32 + var cap: Int32 + var records: [_ORBPathClipRecord] +} + +struct _ORBPathClipPayload { + var flags: Int32 + var cap: Int32 + var count: Int + var records: UnsafePointer<_ORBPathClipRecord>? +} + +struct _ORBPathRecord: Equatable { + var element: ORBPath.Element + var values: [CGFloat] + var transform: CGAffineTransform? + var stroke: _ORBPathStrokeInfo? + var boolean: [UInt8]? + var clipStroke: _ORBPathClipInfo? + + init(element: ORBPath.Element, values: [CGFloat], transform: CGAffineTransform? = nil) { + self.element = element + self.values = values + self.transform = transform?.isIdentity == false ? transform : nil } - - /// Global null path (storage = 0x1, callbacks = &ORBPathEmptyCallbacks) - public static var null: ORBPath { - _openRenderBoxUnimplementedFailure() + + init?(element: ORBPath.Element, points: UnsafePointer, userInfo: UnsafeRawPointer?) { + let counts = [2, 2, 4, 6, 0, 4, 4, 4, 6, 6, 8, 8, 5, 5, 5, 5, 0, 0, 2, 3, 0, 0, 2, 1, 1] + let index = Int(element.rawValue) + guard counts.indices.contains(index) else { return nil } + self.init( + element: element, + values: Array(UnsafeBufferPointer(start: points, count: counts[index])), + transform: ((0...15).contains(index) && index != 4) || index == 17 || index == 21 ? userInfo?.load(as: CGAffineTransform.self) : nil + ) + if element == .stroked { + let payload = userInfo?.load(as: _ORBPathStrokePayload.self) + stroke = .init(lineCap: payload?.lineCap ?? 0, lineJoin: payload?.lineJoin ?? 0, + dash: payload?.dash.map { Array(UnsafeBufferPointer(start: $0, count: max(0, payload!.dashCount))) } ?? []) + } + if element == .boolean { + boolean = userInfo.map { Array(UnsafeRawBufferPointer(start: $0, count: 2)) } ?? [0, 0] + } + if element == .clipStroke, let userInfo { + let payload = userInfo.load(as: _ORBPathClipPayload.self) + clipStroke = .init(flags: payload.flags, cap: payload.cap, + records: payload.records.map { Array(UnsafeBufferPointer(start: $0, count: max(0, payload.count))) } ?? []) + } + } + + func apply(info: UnsafeMutableRawPointer, callback: ORBPathApplyCallback) -> Bool { + // The non-null callback pointer is valid even for zero-argument elements. + var scalars = values.isEmpty ? [CGFloat(0)] : values + return scalars.withUnsafeMutableBufferPointer { points in + if let boolean { + return boolean.withUnsafeBufferPointer { callback(info, element, points.baseAddress!, $0.baseAddress) } + } + if let clipStroke { + return clipStroke.records.withUnsafeBufferPointer { records in + var payload = _ORBPathClipPayload(flags: clipStroke.flags, cap: clipStroke.cap, count: records.count, records: records.baseAddress) + return withUnsafePointer(to: &payload) { callback(info, element, points.baseAddress!, UnsafeRawPointer($0)) } + } + } + if let stroke { + let values = stroke.dash.isEmpty ? [CGFloat(0)] : stroke.dash + return values.withUnsafeBufferPointer { dash in + var payload = _ORBPathStrokePayload(lineCap: stroke.lineCap, lineJoin: stroke.lineJoin, dashCount: stroke.dash.count, dash: dash.baseAddress) + return withUnsafePointer(to: &payload) { + callback(info, element, points.baseAddress!, UnsafeRawPointer($0)) + } + } + } + if var transform { + return withUnsafePointer(to: &transform) { + callback(info, element, points.baseAddress!, UnsafeRawPointer($0)) + } + } + return callback(info, element, points.baseAddress!, nil) + } + } + + static func == (lhs: Self, rhs: Self) -> Bool { + guard lhs.element == rhs.element, lhs.values == rhs.values, lhs.transform == rhs.transform, + lhs.stroke == rhs.stroke, lhs.boolean == rhs.boolean else { return false } + let defaultClip = _ORBPathClipInfo(flags: 0, cap: 1, records: []) + return (lhs.clipStroke ?? defaultClip) == (rhs.clipStroke ?? defaultClip) + } + + var order: Int { + switch element { + case .stroked: + return stroke?.lineJoin == 1 || stroke?.lineCap == 1 ? 3 : 1 + case .clipStroke: + return clipStroke?.cap == 1 ? 3 : 1 + case .offset: return 3 + case .addQuadCurveToPoint: return 2 + case .addCurveToPoint, .ellipse, .fixedRoundedRectCircular, + .fixedRoundedRectContinuous, .unevenRoundedRectCircular, + .unevenRoundedRectContinuous, .relativeArc, .arc, + .arcCounterclockwise, .arcTo: return 3 + default: return 1 + } } } -// MARK: - Memory Management +final class _ORBPathRecords { + var records: [_ORBPathRecord] -extension ORBPath { - public func retain() -> ORBPath { - _openRenderBoxUnimplementedFailure() + init(_ records: [_ORBPathRecord] = []) { + self.records = records } - - public func release() { - _openRenderBoxUnimplementedFailure() + + func apply(info: UnsafeMutableRawPointer, callback: ORBPathApplyCallback?) -> Bool { + guard let callback else { return true } + for record in records { + if !record.apply(info: info, callback: callback) { return false } + } + return true } } -// MARK: - Initializers +func _orbCallbackPointer(_ value: ORBPath.Callbacks) -> UnsafePointer { + let pointer = UnsafeMutablePointer.allocate(capacity: 1) + pointer.initialize(to: value) + return UnsafePointer(pointer) +} + +private let _orbEmptyCallbacks = _orbCallbackPointer(ORBPath.Callbacks()) +private let _orbRecordCallbacks: UnsafePointer = { + var callbacks = ORBPath.Callbacks() + callbacks.retain = { object in + _ = Unmanaged<_ORBPathRecords>.fromOpaque(object).retain() + return object + } + callbacks.release = { Unmanaged<_ORBPathRecords>.fromOpaque($0).release() } + callbacks.apply = { object, info, callback in + Unmanaged<_ORBPathRecords>.fromOpaque(object).takeUnretainedValue().apply(info: info, callback: callback) + } + callbacks.isEqual = { lhs, rhs in + Unmanaged<_ORBPathRecords>.fromOpaque(lhs).takeUnretainedValue().records + == Unmanaged<_ORBPathRecords>.fromOpaque(rhs).takeUnretainedValue().records + } + callbacks.isEmpty = { Unmanaged<_ORBPathRecords>.fromOpaque($0).takeUnretainedValue().records.isEmpty } + callbacks.isSingleElement = { _orbIsSingleElement(Unmanaged<_ORBPathRecords>.fromOpaque($0).takeUnretainedValue().records) } + callbacks.bezierOrder = { Unmanaged<_ORBPathRecords>.fromOpaque($0).takeUnretainedValue().records.map(\.order).max() ?? 1 } + return _orbCallbackPointer(callbacks) +}() + +let _orbCGPathCallbacks: UnsafePointer = { + var callbacks = ORBPath.Callbacks() + callbacks.retain = { object in + _ = Unmanaged.fromOpaque(object).retain() + return object + } + callbacks.release = { Unmanaged.fromOpaque($0).release() } + callbacks.apply = { object, info, callback in + let path = Unmanaged.fromOpaque(object).takeUnretainedValue() + return _ORBPathRecords(_orbRecords(path)).apply(info: info, callback: callback) + } + callbacks.isEqual = { lhs, rhs in + Unmanaged.fromOpaque(lhs).takeUnretainedValue() == Unmanaged.fromOpaque(rhs).takeUnretainedValue() + } + callbacks.isEmpty = { Unmanaged.fromOpaque($0).takeUnretainedValue().isEmpty } + callbacks.bezierOrder = { _orbRecords(Unmanaged.fromOpaque($0).takeUnretainedValue()).map(\.order).max() ?? 1 } + callbacks.cgPath = { Unmanaged.fromOpaque($0) } + return _orbCallbackPointer(callbacks) +}() extension ORBPath { - public init(cgPath: CGPath) { - _openRenderBoxUnimplementedFailure() + static func _fromRecords(_ records: [_ORBPathRecord]) -> ORBPath { + let object = Unmanaged.passRetained(_ORBPathRecords(records)).toOpaque() + return ORBPath(storage: Storage(OpaquePointer(object)), callbacks: _orbRecordCallbacks) + } + + var _object: UnsafeRawPointer { UnsafeRawPointer(storage.rawValue) } + + var _extended: UnsafePointer { + UnsafeRawPointer(callbacks).assumingMemoryBound(to: CallbacksExtended.self) + } + + func _records() -> [_ORBPathRecord]? { + var records: [_ORBPathRecord] = [] + let success = withUnsafeMutablePointer(to: &records) { records in + apply(info: UnsafeMutableRawPointer(records)) { info, element, points, userInfo in + guard let record = _ORBPathRecord(element: element, points: points, userInfo: userInfo) else { return false } + info.assumingMemoryBound(to: [_ORBPathRecord].self).pointee.append(record) + return true + } + } + return success ? records : nil + } + + public static var empty: ORBPath { + ORBPath(storage: Storage(unsafeBitCast(UInt(0), to: OpaquePointer.self)), callbacks: _orbEmptyCallbacks) + } + + public static var null: ORBPath { + ORBPath(storage: Storage(OpaquePointer(bitPattern: 1)!), callbacks: _orbEmptyCallbacks) + } + + @discardableResult + public func retain() -> ORBPath { + guard let retain = callbacks.pointee.retain else { return self } + return ORBPath(storage: Storage(OpaquePointer(retain(_object))), callbacks: callbacks) + } + + public func release() { + callbacks.pointee.release?(_object) + } + + public init(cgPath: CGPath?) { + guard let cgPath else { self = .null; return } + self.init(storage: Storage(OpaquePointer(Unmanaged.passRetained(cgPath).toOpaque())), callbacks: _orbCGPathCallbacks) } public init(rect: CGRect, transform: UnsafePointer?) { - _openRenderBoxUnimplementedFailure() + self = rect.isNull ? .empty : Self._fromRecords([_ORBPathRecord(element: .rect, values: _orbRectValues(rect), transform: transform?.pointee)]) } public init(ellipseIn rect: CGRect, transform: UnsafePointer?) { - _openRenderBoxUnimplementedFailure() + self = rect.isNull ? .empty : Self._fromRecords([_ORBPathRecord(element: .ellipse, values: _orbRectValues(rect), transform: transform?.pointee)]) } - public init(roundedRect rect: CGRect, cornerWidth: CGFloat, cornerHeight: CGFloat, style: ORBPath.RoundedCornerStyle, transform: UnsafePointer?) { - _openRenderBoxUnimplementedFailure() + public init(roundedRect rect: CGRect, cornerWidth: CGFloat, cornerHeight: CGFloat, style: RoundedCornerStyle, transform: UnsafePointer?) { + guard !rect.isNull else { self = .empty; return } + let zero = cornerWidth == 0 && cornerHeight == 0 + self = Self._fromRecords([_ORBPathRecord( + element: zero ? .roundedRect : (style == .circular ? .fixedRoundedRectCircular : .fixedRoundedRectContinuous), + values: _orbRectValues(rect) + (zero ? [] : [cornerWidth, cornerHeight]), + transform: transform?.pointee + )]) } - public init(roundedRect rect: CGRect, topLeftRadius: CGFloat, topRightRadius: CGFloat, bottomRightRadius: CGFloat, bottomLeftRadius: CGFloat, style: ORBPath.RoundedCornerStyle, transform: UnsafePointer?) { - _openRenderBoxUnimplementedFailure() + public init(roundedRect rect: CGRect, topLeftRadius: CGFloat, topRightRadius: CGFloat, bottomRightRadius: CGFloat, bottomLeftRadius: CGFloat, style: RoundedCornerStyle, transform: UnsafePointer?) { + guard !rect.isNull else { self = .empty; return } + let radii = [topLeftRadius, topRightRadius, bottomRightRadius, bottomLeftRadius] + let zero = radii.allSatisfy { $0 == 0 } + self = Self._fromRecords([_ORBPathRecord( + element: zero ? .roundedRect : (style == .circular ? .unevenRoundedRectCircular : .unevenRoundedRectContinuous), + values: _orbRectValues(rect) + (zero ? [] : radii), + transform: transform?.pointee + )]) } -} - -// MARK: - Properties -extension ORBPath { public var isNull: Bool { storage == Self.null.storage && callbacks == Self.null.callbacks } - public func copy(transform: UnsafePointer) -> ORBPath { - _openRenderBoxUnimplementedFailure() + public var isEmpty: Bool { + if callbacks.pointee.flags.isExtended { + if let isEmpty = _extended.pointee.isEmpty { return isEmpty(_object, _extended) } + } else if let isEmpty = callbacks.pointee.isEmpty { + return isEmpty(_object) + } + var empty = true + _ = apply(info: &empty) { info, _, _, _ in + info.assumingMemoryBound(to: Bool.self).pointee = false + return false + } + return empty } - public var isEmpty: Bool { - _openRenderBoxUnimplementedFailure() + public var isSingleElement: Bool { + if callbacks.pointee.flags.isExtended { + if let isSingleElement = _extended.pointee.isSingleElement { return isSingleElement(_object, _extended) } + } else if let isSingleElement = callbacks.pointee.isSingleElement { + return isSingleElement(_object) + } + return _records().map(_orbIsSingleElement) ?? false } - + + public var bezierOrder: Int { + if callbacks.pointee.flags.isExtended { + if let order = _extended.pointee.bezierOrder { return order(_object, _extended) } + } else if let order = callbacks.pointee.bezierOrder { + return order(_object) + } + return _records()?.map(\.order).max() ?? 1 + } + public var cgPath: CGPath { - _openRenderBoxUnimplementedFailure() + if callbacks.pointee.flags.isExtended { + if let path = _extended.pointee.cgPath?(_object, _extended) { return path.takeUnretainedValue() } + } else if let path = callbacks.pointee.cgPath?(_object) { + return path.takeUnretainedValue() + } + guard let records = _records() else { preconditionFailure("Unsupported path element") } + return _orbMakeCGPath(records) } -} -// MARK: - Methods + public var boundingRect: CGRect { + if callbacks.pointee.flags.isExtended { + if let bounds = _extended.pointee.boundingRect { return bounds(_object, _extended) } + } else if let bounds = callbacks.pointee.boundingRect { + return bounds(_object) + } + guard let records = _records() else { return .null } + if records.count == 1, let record = records.first, (5...11).contains(record.element.rawValue) { + return _orbRect(record.values).applying(record.transform ?? .identity) + } + if _orbIsSingleElement(records), let record = records.first, record.element == .transformed { + let child = Self._fromRecords(Array(records.dropFirst().dropLast())) + defer { child.release() } + return child.boundingRect.applying(record.transform ?? .identity) + } + return _orbBounds(_orbExpandedRecords(records)) + } -extension ORBPath { public func apply(info: UnsafeMutableRawPointer, callback: ORBPathApplyCallback?) -> Bool { - _openRenderBoxUnimplementedFailure() + if callbacks.pointee.flags.isExtended { + return _extended.pointee.apply?(_object, info, callback, _extended) ?? true + } + return callbacks.pointee.apply?(_object, info, callback) ?? true } - + public func isEqual(to rhs: ORBPath) -> Bool { - _openRenderBoxUnimplementedFailure() + if callbacks != rhs.callbacks { + if callbacks == _orbEmptyCallbacks { return storage == Self.null.storage ? false : rhs.isEmpty } + if rhs.callbacks == _orbEmptyCallbacks { return rhs.storage == Self.null.storage ? false : isEmpty } + } + if callbacks == rhs.callbacks { + if storage == rhs.storage { return true } + if callbacks.pointee.flags.isExtended { + if let equal = _extended.pointee.isEqual { return equal(_object, rhs._object, _extended) } + } else if let equal = callbacks.pointee.isEqual { + return equal(_object, rhs._object) + } + } + guard let lhsRecords = _records(), let rhsRecords = rhs._records() else { return false } + return lhsRecords == rhsRecords } - + public func contains(point: CGPoint, eoFill: Bool) -> Bool { - _openRenderBoxUnimplementedFailure() + var point = point + return containsPoints(count: 1, points: &point, eoFill: eoFill, transform: nil) & 1 != 0 } - + public func containsPoints(count: UInt64, points: UnsafePointer, eoFill: Bool, transform: UnsafePointer?) -> UInt64 { - _openRenderBoxUnimplementedFailure() + guard count > 0, let records = _records() else { return 0 } + let queries = (0.. Bool { + guard let records = _records() else { return false } + let zeroRect = ORBPathRectShape(transform: .identity, rect: .zero) + var shape = ORBPathShape( + rect: zeroRect, ellipse: zeroRect, + fixedRoundedRect: .init(transform: .identity, rect: .zero, cornerSize: .zero, style: .circular), + stroked: .init(path: self, style: .init(lineWidth: 0, lineCap: 0, lineJoin: 0, miterLimit: 0, dashPhase: 0, dashCount: 0, dash: nil)) + ) + let type: ORBPathShapeType + var ownedChild: ORBPath? + var strokeDash: [CGFloat]? + defer { ownedChild?.release() } + if let record = records.first, records.last?.element == .end, + [.transformed, .trimmed, .stroked].contains(record.element), _orbIsSingleElement(records) { + let child: ORBPath + if callbacks == _orbWrapperCallbacks { + child = Unmanaged<_ORBPathWrapper>.fromOpaque(_object).takeUnretainedValue().child + } else { + child = Self._fromRecords(Array(records.dropFirst().dropLast())) + ownedChild = child + } + switch record.element { + case .transformed: + type = .nested + shape.nested = .init(path: child, transform: record.transform ?? .identity) + case .trimmed: + type = .trimmed + shape.trimmed = .init(path: child, from: record.values[0], to: record.values[1]) + default: + type = .stroked + strokeDash = record.stroke?.dash ?? [] + shape.stroked = .init(path: child, style: .init(lineWidth: record.values[0], lineCap: record.stroke?.lineCap ?? 0, + lineJoin: record.stroke?.lineJoin ?? 0, miterLimit: record.values[1], dashPhase: record.values[2], + dashCount: UInt64(strokeDash!.count), dash: nil)) + } + } else if records.count == 1, let record = records.first { + switch record.element { + case .rect, .roundedRect, .ellipse, .fixedRoundedRectCircular, .fixedRoundedRectContinuous, .unevenRoundedRectCircular, .unevenRoundedRectContinuous: + let rect = _orbRect(record.values) + let transform = record.transform ?? .identity + shape.rect = .init(transform: transform, rect: rect) + shape.ellipse = shape.rect + switch record.element { + case .rect, .roundedRect: type = .rect + case .ellipse: type = .ellipse + case .fixedRoundedRectCircular, .fixedRoundedRectContinuous: + type = .fixedRoundedRect + shape.fixedRoundedRect = .init(transform: transform, rect: rect, cornerSize: CGSize(width: record.values[4], height: record.values[5]), style: record.element == .fixedRoundedRectContinuous ? .continuous : .circular) + default: + type = .unevenRoundedRect + shape.unevenRoundedRect = .init(transform: transform, rect: rect, topLeftRadius: record.values[4], topRightRadius: record.values[5], bottomRightRadius: record.values[6], bottomLeftRadius: record.values[7], style: record.element == .unevenRoundedRectContinuous ? .continuous : .circular) + } + default: return false + } + } else if !allowLine, records.count == 2, records[0].element == .moveToPoint, records[1].element == .addLineToPoint { + type = .line + shape.line = .init(start: _orbPoint(records[0].values, 0), end: _orbPoint(records[1].values, 0)) + } else if let rect = _orbRecognizeRect(records) { + type = .rect + shape.rect = .init(transform: .identity, rect: rect) + } else { + return false + } + if let strokeDash { + let values = strokeDash.isEmpty ? [CGFloat(0)] : strokeDash + values.withUnsafeBufferPointer { dash in + shape.stroked.style.dash = dash.baseAddress + withUnsafePointer(to: &shape) { callback(info, type, $0) } + } + } else { + withUnsafePointer(to: &shape) { callback(info, type, $0) } + } + return true } } -#endif +func _orbIsSingleElement(_ records: [_ORBPathRecord]) -> Bool { + var depth = 0 + var count = 0 + for record in records { + switch record.element { + case .end: + if depth == 0 { return false } + depth -= 1 + case .transformed, .trimmed, .stroked, .boolean, .dilateGlyph, .offset, .clipStroke: + if depth == 0 { count += 1 } + depth += 1 + default: + if depth == 0 { count += 1 } + } + if count > 1 { return false } + } + return count == 1 && depth == 0 +} +private final class _ORBPathWrapper { + let child: ORBPath + let record: _ORBPathRecord + + init(child: ORBPath, record: _ORBPathRecord) { + self.child = child.retain() + self.record = record + } + + deinit { child.release() } +} + +private let _orbWrapperCallbacks: UnsafePointer = { + var callbacks = ORBPath.Callbacks() + callbacks.retain = { object in + _ = Unmanaged<_ORBPathWrapper>.fromOpaque(object).retain() + return object + } + callbacks.release = { Unmanaged<_ORBPathWrapper>.fromOpaque($0).release() } + callbacks.apply = { object, info, callback in + guard let callback else { return true } + let wrapper = Unmanaged<_ORBPathWrapper>.fromOpaque(object).takeUnretainedValue() + guard wrapper.record.apply(info: info, callback: callback), + wrapper.child.apply(info: info, callback: callback) else { return false } + return _ORBPathRecord(element: .end, values: []).apply(info: info, callback: callback) + } + callbacks.isEqual = { lhs, rhs in + let a = Unmanaged<_ORBPathWrapper>.fromOpaque(lhs).takeUnretainedValue() + let b = Unmanaged<_ORBPathWrapper>.fromOpaque(rhs).takeUnretainedValue() + return a.record == b.record && a.child.isEqual(to: b.child) + } + callbacks.isEmpty = { _ in false } + callbacks.isSingleElement = { _ in true } + callbacks.next = { Unmanaged<_ORBPathWrapper>.fromOpaque($0).takeUnretainedValue().child } + return _orbCallbackPointer(callbacks) +}() + +extension ORBPath { + private func _wrapped(_ record: _ORBPathRecord) -> ORBPath { + let object = Unmanaged.passRetained(_ORBPathWrapper(child: self, record: record)).toOpaque() + return ORBPath(storage: Storage(OpaquePointer(object)), callbacks: _orbWrapperCallbacks) + } + + public func copy(transform: UnsafePointer?) -> ORBPath { + if callbacks == _orbEmptyCallbacks { return self } + return _wrapped(.init(element: .transformed, values: [], transform: transform?.pointee)) + } + + public func trimmed(from: CGFloat, to: CGFloat) -> ORBPath { + if callbacks == _orbEmptyCallbacks { return .empty } + return _wrapped(.init(element: .trimmed, values: [from, to])) + } + + public func stroked(lineWidth: CGFloat, lineCap: Int32, lineJoin: Int32, miterLimit: CGFloat, dashPhase: CGFloat, dash: UnsafePointer?, dashCount: Int) -> ORBPath { + if callbacks == _orbEmptyCallbacks { return .empty } + var record = _ORBPathRecord(element: .stroked, values: [lineWidth, miterLimit, dashPhase]) + record.stroke = .init(lineCap: lineCap, lineJoin: lineJoin, dash: dash.map { Array(UnsafeBufferPointer(start: $0, count: max(0, dashCount))) } ?? []) + return _wrapped(record) + } +} + + +#endif diff --git a/Sources/OpenRenderBox/Path/ORBPathCallbacks.swift b/Sources/OpenRenderBox/Path/ORBPathCallbacks.swift index 09ef7c3..aa3c7ea 100644 --- a/Sources/OpenRenderBox/Path/ORBPathCallbacks.swift +++ b/Sources/OpenRenderBox/Path/ORBPathCallbacks.swift @@ -96,7 +96,7 @@ extension ORBPath { extension ORBPath.Callbacks { /// Global callbacks for CGPath-backed paths public static var cgPath: ORBPath.Callbacks { - _openRenderBoxUnimplementedFailure() + _orbCGPathCallbacks.pointee } } diff --git a/Sources/OpenRenderBox/Path/ORBPathGeometry.swift b/Sources/OpenRenderBox/Path/ORBPathGeometry.swift new file mode 100644 index 0000000..bb149dc --- /dev/null +++ b/Sources/OpenRenderBox/Path/ORBPathGeometry.swift @@ -0,0 +1,728 @@ +// +// ORBPathGeometry.swift +// OpenRenderBox + +#if !OPENRENDERBOX_CF_CGTYPES +import Foundation +import OpenCoreGraphicsShims + +func _orbRectValues(_ rect: CGRect) -> [CGFloat] { + [rect.origin.x, rect.origin.y, rect.size.width, rect.size.height] +} + +func _orbRect(_ values: [CGFloat]) -> CGRect { + CGRect(x: values[0], y: values[1], width: values[2], height: values[3]) +} + +func _orbPoint(_ values: [CGFloat], _ index: Int) -> CGPoint { + CGPoint(x: values[index * 2], y: values[index * 2 + 1]) +} + +func _orbRecords(_ path: CGPath) -> [_ORBPathRecord] { + var records: [_ORBPathRecord] = [] + #if OPENRENDERBOX_COREGRAPHICS + path.applyWithBlock { pointer in + let value = pointer.pointee + let element: ORBPath.Element + let count: Int + switch value.type { + case .moveToPoint: element = .moveToPoint; count = 1 + case .addLineToPoint: element = .addLineToPoint; count = 1 + case .addQuadCurveToPoint: element = .addQuadCurveToPoint; count = 2 + case .addCurveToPoint: element = .addCurveToPoint; count = 3 + case .closeSubpath: element = .closeSubpath; count = 0 + @unknown default: return + } + records.append(_ORBPathRecord(element: element, values: (0.. CGPath { + let path = CGMutablePath() + for record in _orbExpandedRecords(records) { + switch record.element { + case .moveToPoint: path.move(to: _orbPoint(record.values, 0)) + case .addLineToPoint: path.addLine(to: _orbPoint(record.values, 0)) + case .addQuadCurveToPoint: path.addQuadCurve(to: _orbPoint(record.values, 1), control: _orbPoint(record.values, 0)) + case .addCurveToPoint: path.addCurve(to: _orbPoint(record.values, 2), control1: _orbPoint(record.values, 0), control2: _orbPoint(record.values, 1)) + case .closeSubpath: path.closeSubpath() + default: preconditionFailure("Unsupported path element") + } + } + return path +} + +struct _ORBBezierSegment { + var points: [CGPoint] + + func point(_ t: CGFloat) -> CGPoint { + var values = points + for level in stride(from: values.count - 1, through: 1, by: -1) { + for index in 0..(_ keyPath: KeyPath) -> [CGFloat] { + let p = points.map { CGFloat($0[keyPath: keyPath]) } + let roots: [CGFloat] + switch p.count { + case 3: + let a = p[0] - 2 * p[1] + p[2] + roots = a == 0 ? [] : [(p[0] - p[1]) / a] + case 4: + roots = _orbQuadraticRoots(-p[0] + 3 * p[1] - 3 * p[2] + p[3], 2 * (p[0] - 2 * p[1] + p[2]), p[1] - p[0]) + default: roots = [] + } + return roots.filter { $0 > 0 && $0 < 1 }.sorted() + } +} + +func _orbQuadraticRoots(_ a: CGFloat, _ b: CGFloat, _ c: CGFloat) -> [CGFloat] { + if a == 0 { return b == 0 ? [] : [-c / b] } + let discriminant = b * b - 4 * a * c + if discriminant < 0 { return [] } + if discriminant == 0 { return [-b / (2 * a)] } + let root = discriminant.squareRoot() + let q = -(b + (b >= 0 ? root : -root)) / 2 + return [q / a, c / q] +} + +func _orbSegments(_ records: [_ORBPathRecord], closeOpenSubpaths: Bool) -> [_ORBBezierSegment] { + var segments: [_ORBBezierSegment] = [] + var current: CGPoint? + var start: CGPoint? + func close() { + if let current, let start, current != start { + segments.append(.init(points: [current, start])) + } + } + for record in records { + switch record.element { + case .moveToPoint: + if closeOpenSubpaths { close() } + current = _orbPoint(record.values, 0) + start = current + case .addLineToPoint, .addQuadCurveToPoint, .addCurveToPoint: + let points = stride(from: 0, to: record.values.count, by: 2).map { CGPoint(x: record.values[$0], y: record.values[$0 + 1]) } + if let current { + segments.append(.init(points: [current] + points)) + } else { + start = points.last + } + current = points.last + case .closeSubpath: + close() + current = start + default: break + } + } + if closeOpenSubpaths { close() } + return segments +} + +func _orbBounds(_ records: [_ORBPathRecord]) -> CGRect { + var bounds = CGRect.null + func include(_ point: CGPoint) { + guard point.x.isFinite && point.y.isFinite else { return } + let rect = CGRect(origin: point, size: .zero) + bounds = bounds.isNull ? rect : bounds.union(rect) + } + for record in records where record.element == .moveToPoint { + include(_orbPoint(record.values, 0)) + } + for segment in _orbSegments(records, closeOpenSubpaths: false) { + include(segment.points[0]) + include(segment.points.last!) + for t in segment.extrema(\.x) + segment.extrema(\.y) { include(segment.point(t)) } + } + return bounds +} + +func _orbRecognizeRect(_ records: [_ORBPathRecord]) -> CGRect? { + guard records.count == 5, records[0].element == .moveToPoint, + records[1...3].allSatisfy({ $0.element == .addLineToPoint }), + records[4].element == .closeSubpath else { return nil } + let points = records.prefix(4).map { _orbPoint($0.values, 0) } + let bounds = _orbBounds(records) + guard !bounds.isNull else { return nil } + let corners = [CGPoint(x: bounds.minX, y: bounds.minY), CGPoint(x: bounds.maxX, y: bounds.minY), CGPoint(x: bounds.maxX, y: bounds.maxY), CGPoint(x: bounds.minX, y: bounds.maxY)] + guard points.allSatisfy({ corners.contains($0) }) else { return nil } + for index in 0..<4 { + let a = points[index] + let b = points[(index + 1) % 4] + if a == b || (a.x != b.x && a.y != b.y) { return nil } + } + return bounds +} + +func _orbClampRadii(_ input: [CGFloat], width: CGFloat, height: CGFloat) -> [CGFloat] { + let width = abs(width) + let height = abs(height) + let limit = max(width, height) + var radii = input.map { min(max($0, 0), limit) } + for (a, b, dimension) in [(0, 1, width), (3, 2, width), (0, 3, height), (1, 2, height)] { + var deficit = dimension - (radii[a] + radii[b]) + if deficit < 0, radii[a] != radii[b] { + let reduction = min(-deficit, abs(radii[a] - radii[b])) + if radii[a] > radii[b] { radii[a] -= reduction } else { radii[b] -= reduction } + deficit += reduction + } + if deficit < 0 { + let change = radii[a] * deficit / (radii[a] + radii[b]) + radii[a] += change + radii[b] += deficit - change + } + } + return radii +} + +func _orbExpandedRecords(_ records: [_ORBPathRecord]) -> [_ORBPathRecord] { + var result: [_ORBPathRecord] = [] + var frames: [(_ORBPathRecord, [_ORBPathRecord])] = [] + for record in records { + switch record.element { + case .transformed, .trimmed, .stroked: + frames.append((record, result)) + result = [] + continue + case .end: + if let (header, parent) = frames.popLast() { + result = parent + _orbFinishFrame(header, result) + } + continue + default: break + } + let transform = record.transform ?? .identity + var expanded: [_ORBPathRecord] + switch record.element { + case .moveToPoint, .addLineToPoint, .addQuadCurveToPoint, .addCurveToPoint, .closeSubpath: + expanded = [record] + case .rect: + let rect = _orbRect(record.values) + expanded = [ + .init(element: .moveToPoint, values: [rect.minX, rect.minY]), + .init(element: .addLineToPoint, values: [rect.maxX, rect.minY]), + .init(element: .addLineToPoint, values: [rect.maxX, rect.maxY]), + .init(element: .addLineToPoint, values: [rect.minX, rect.maxY]), + .init(element: .closeSubpath, values: []) + ] + case .ellipse: + let rect = _orbRect(record.values) + let rx = rect.width / 2 + let ry = rect.height / 2 + let cx = rect.midX + let cy = rect.midY + let k = CGFloat(0.5522847498) + expanded = [ + .init(element: .moveToPoint, values: [cx + rx, cy]), + .init(element: .addCurveToPoint, values: [cx + rx, cy + ry * k, cx + rx * k, cy + ry, cx, cy + ry]), + .init(element: .addCurveToPoint, values: [cx - rx * k, cy + ry, cx - rx, cy + ry * k, cx - rx, cy]), + .init(element: .addCurveToPoint, values: [cx - rx, cy - ry * k, cx - rx * k, cy - ry, cx, cy - ry]), + .init(element: .addCurveToPoint, values: [cx + rx * k, cy - ry, cx + rx, cy - ry * k, cx + rx, cy]), + .init(element: .closeSubpath, values: []) + ] + case .roundedRect, .fixedRoundedRectCircular, .fixedRoundedRectContinuous, .unevenRoundedRectCircular, .unevenRoundedRectContinuous: + expanded = _orbRoundedRecords(record) + case .relativeArc, .arc, .arcCounterclockwise, .arcTo: + let current = _orbCurrentPoint(result).map { $0.applying(transform.inverted()) } + expanded = _orbArcRecords(record, current: current) + default: + preconditionFailure("Unsupported path element") + } + if !transform.isIdentity { + expanded = expanded.map { entry in + var entry = entry + for index in stride(from: 0, to: entry.values.count, by: 2) { + let point = CGPoint(x: entry.values[index], y: entry.values[index + 1]).applying(transform) + entry.values[index] = point.x + entry.values[index + 1] = point.y + } + entry.transform = nil + return entry + } + } + result.append(contentsOf: expanded) + } + while let (header, parent) = frames.popLast() { + result = parent + _orbFinishFrame(header, result) + } + return result +} + +func _orbRoundedRecords(_ record: _ORBPathRecord) -> [_ORBPathRecord] { + let rect = _orbRect(record.values).standardized + let continuous = record.element == .fixedRoundedRectContinuous || record.element == .unevenRoundedRectContinuous + let rx: [CGFloat] + let ry: [CGFloat] + if record.values.count == 8 { + rx = _orbClampRadii(Array(record.values[4...7]), width: rect.width, height: rect.height) + ry = rx + } else if record.values.count == 6 { + let limit = min(rect.width, rect.height) / 2 + rx = Array(repeating: min(max(record.values[4], 0), limit), count: 4) + ry = Array(repeating: min(max(record.values[5], 0), limit), count: 4) + } else { + rx = Array(repeating: 0, count: 4) + ry = rx + } + var result: [_ORBPathRecord] = [.init(element: .moveToPoint, values: [rect.maxX, (rect.minY + ry[1] + rect.maxY - ry[2]) / 2])] + if rx.allSatisfy({ $0 == 0 }) && ry.allSatisfy({ $0 == 0 }) { + result += [ + .init(element: .addLineToPoint, values: [rect.maxX, rect.maxY]), + .init(element: .addLineToPoint, values: [rect.minX, rect.maxY]), + .init(element: .addLineToPoint, values: [rect.minX, rect.minY]), + .init(element: .addLineToPoint, values: [rect.maxX, rect.minY]), + .init(element: .closeSubpath, values: []) + ] + return result + } + // The sequence begins at the right edge, then visits BR, BL, TL, and TR. + let corners = [2, 3, 0, 1] + let vertices = [CGPoint(x: rect.maxX, y: rect.maxY), CGPoint(x: rect.minX, y: rect.maxY), CGPoint(x: rect.minX, y: rect.minY), CGPoint(x: rect.maxX, y: rect.minY)] + let incoming = [CGPoint(x: 0, y: -1), CGPoint(x: 1, y: 0), CGPoint(x: 0, y: 1), CGPoint(x: -1, y: 0)] + let outgoing = [CGPoint(x: -1, y: 0), CGPoint(x: 0, y: -1), CGPoint(x: 1, y: 0), CGPoint(x: 0, y: 1)] + let k = CGFloat(0.5522847498) + for index in 0..<4 { + let corner = corners[index] + let vertex = vertices[index] + let incomingRadius = index & 1 == 0 ? ry[corner] : rx[corner] + let outgoingRadius = index & 1 == 0 ? rx[corner] : ry[corner] + func point(_ a: CGFloat, _ b: CGFloat) -> [CGFloat] { + [vertex.x + incoming[index].x * incomingRadius * a + outgoing[index].x * outgoingRadius * b, + vertex.y + incoming[index].y * incomingRadius * a + outgoing[index].y * outgoingRadius * b] + } + if continuous { + let previous = corners[(index + 3) % 4] + let next = corners[(index + 1) % 4] + let incomingDimension = index & 1 == 0 ? rect.height : rect.width + let outgoingDimension = index & 1 == 0 ? rect.width : rect.height + let previousRadius = index & 1 == 0 ? ry[previous] : rx[previous] + let nextRadius = index & 1 == 0 ? rx[next] : ry[next] + let a = _orbContinuousCoefficients(incomingDimension, incomingRadius, previousRadius) + let b = _orbContinuousCoefficients(outgoingDimension, outgoingRadius, nextRadius) + let u = CGFloat(Float(0.0749114)) + let v = CGFloat(Float(0.631494)) + let c = CGFloat(Float(0.16906)) + let d = CGFloat(Float(0.372824)) + result.append(.init(element: .addLineToPoint, values: point(a.0, 0))) + result.append(.init(element: .addCurveToPoint, values: point(a.1, 0) + point(a.2, 0) + point(v, u))) + result.append(.init(element: .addCurveToPoint, values: point(d, c) + point(c, d) + point(u, v))) + result.append(.init(element: .addCurveToPoint, values: point(0, b.2) + point(0, b.1) + point(0, b.0))) + } else { + result.append(.init(element: .addLineToPoint, values: point(1, 0))) + result.append(.init(element: .addCurveToPoint, values: point(1 - k, 0) + point(0, 1 - k) + point(0, 1))) + } + } + result.append(.init(element: .closeSubpath, values: [])) + return result +} + +func _orbContinuousCoefficients(_ length: CGFloat, _ a: CGFloat, _ b: CGFloat) -> (CGFloat, CGFloat, CGFloat) { + let sum = Float(a) + Float(b) + let delta = Float(bitPattern: 0x3f075696) + let ratio = (Float(abs(length)) - sum) / (sum * delta) + if ratio < 1 { + let t = min(max(ratio, 0), 1) + return (CGFloat(Float(1).addingProduct(t, delta)), + CGFloat(Float(0.96).addingProduct(t, Float(bitPattern: 0x3e0392e4))), + CGFloat(Float(0.82).addingProduct(t, Float(bitPattern: 0x3d464670)))) + } + return (CGFloat(Float(1.528665)), CGFloat(Float(1.08849)), CGFloat(Float(0.868407))) +} + +func _orbMix(_ a: CGPoint, _ b: CGPoint, _ t: CGFloat) -> CGPoint { + CGPoint(x: a.x.addingProduct(b.x - a.x, t), y: a.y.addingProduct(b.y - a.y, t)) +} + +func _orbDistance(_ a: CGPoint, _ b: CGPoint) -> CGFloat { + let x = b.x - a.x, y = b.y - a.y + return (x * x + y * y).squareRoot() +} + +struct _ORBCubic { + var points: [CGPoint] + + func split(_ t: CGFloat) -> (_ORBCubic, _ORBCubic) { + let a = _orbMix(points[0], points[1], t) + let b = _orbMix(points[1], points[2], t) + let c = _orbMix(points[2], points[3], t) + let d = _orbMix(a, b, t), e = _orbMix(b, c, t), f = _orbMix(d, e, t) + return (.init(points: [points[0], a, d, f]), .init(points: [f, e, c, points[3]])) + } + + func measure(_ requested: CGFloat = 0, findIndex: Bool = false) -> CGFloat { + var curve = self + var edge0 = _orbDistance(curve.points[0], curve.points[1]) + var edge2 = _orbDistance(curve.points[2], curve.points[3]) + if !(edge0 + edge2).isFinite || (findIndex && requested == 0) { return 0 } + var pending: [(_ORBCubic, CGFloat)] = [] + var interval: CGFloat = 1, parameter: CGFloat = 0, total: CGFloat = 0 + for _ in 0..<1024 { + edge0 = _orbDistance(curve.points[0], curve.points[1]) + let edge1 = _orbDistance(curve.points[1], curve.points[2]) + edge2 = _orbDistance(curve.points[2], curve.points[3]) + let polygon = edge0 + edge2 + edge1 + let chord = _orbDistance(curve.points[0], curve.points[3]) + var flat = polygon - chord <= 0.5 * interval + if findIndex { flat = flat && abs(edge0 - edge1) + abs(edge1 - edge2) < 0.5 } + if flat || pending.count == 29 { + let length = (polygon + chord) * 0.5 + if findIndex && total + length >= requested { + return min(parameter + (requested - total) * interval / length, 1) + } + total += length + guard let next = pending.popLast() else { return findIndex ? 1 : total } + parameter += interval + curve = next.0 + interval = next.1 + } else { + let (left, right) = curve.split(0.5) + interval *= 0.5 + pending.append((right, interval)) + curve = left + } + } + return 1 + } +} + +func _orbTrimmedRecords(_ records: [_ORBPathRecord], from: Float, to: Float) -> [_ORBPathRecord] { + if from <= 0 && to >= 1 { return records } + guard from < to else { return [] } + struct Segment { + var element: ORBPath.Element + var curve: _ORBCubic + var length: CGFloat + } + var segments: [Segment] = [] + var current = CGPoint.zero, start = CGPoint.zero + var total: CGFloat = 0 + for record in records { + var curve = _ORBCubic(points: [current, .zero, .zero, .zero]) + var length: CGFloat = 0 + switch record.element { + case .moveToPoint: + current = _orbPoint(record.values, 0) + start = current + curve.points[0] = current + case .addLineToPoint: + curve.points[3] = _orbPoint(record.values, 0) + length = _orbDistance(current, curve.points[3]) + current = curve.points[3] + case .addQuadCurveToPoint: + let control = _orbPoint(record.values, 0), end = _orbPoint(record.values, 1) + curve.points[1] = CGPoint(x: (current.x + 2 * control.x) / 3, y: (current.y + 2 * control.y) / 3) + curve.points[2] = CGPoint(x: (end.x + 2 * control.x) / 3, y: (end.y + 2 * control.y) / 3) + curve.points[3] = end + length = curve.measure() + current = end + case .addCurveToPoint: + curve.points = [current, _orbPoint(record.values, 0), _orbPoint(record.values, 1), _orbPoint(record.values, 2)] + length = curve.measure() + current = curve.points[3] + case .closeSubpath: + curve.points[3] = start + length = _orbDistance(current, start) + current = start + default: continue + } + total += length + segments.append(.init(element: record.element, curve: curve, length: length)) + } + let fromDistance = total * CGFloat(from), toDistance = total * CGFloat(to) + var consumed: Float = 0 + var needsMove = true, includesStart = false + var result: [_ORBPathRecord] = [] + func move(_ point: CGPoint) { + result.append(.init(element: .moveToPoint, values: [point.x, point.y])) + } + for segment in segments { + if segment.element == .moveToPoint { + needsMove = true + includesStart = false + continue + } + let begin = CGFloat(consumed), end = begin + segment.length + consumed = Float(end) + if segment.length == 0 { + if segment.element == .closeSubpath && includesStart && begin <= toDistance { + result.append(.init(element: .closeSubpath, values: [])) + } + continue + } + var first = fromDistance >= begin ? max((fromDistance - begin) / segment.length, 0) : 0 + var last = toDistance < end ? min((toDistance - begin) / segment.length, 1) : 1 + guard first < last else { continue } + if needsMove { includesStart = first == 0 } + if segment.element == .addLineToPoint || segment.element == .closeSubpath { + let start = _orbMix(segment.curve.points[0], segment.curve.points[3], first) + let finish = _orbMix(segment.curve.points[0], segment.curve.points[3], last) + if needsMove { move(start); needsMove = false } + result.append(.init(element: .addLineToPoint, values: [finish.x, finish.y])) + if segment.element == .closeSubpath && last == 1 && includesStart { + result.append(.init(element: .closeSubpath, values: [])) + } + } else { + var curve = segment.curve + if first > 0 || last < 1 { + if first > 0.001 { first = curve.measure(first * segment.length, findIndex: true) } + if last < 0.999 { last = curve.measure(last * segment.length, findIndex: true) } + if first > 0.001 { + curve = curve.split(first).1 + last = (last - first) / (1 - first) + } + if last < 0.999 { curve = curve.split(last).0 } + } + if needsMove { move(curve.points[0]); needsMove = false } + result.append(.init(element: .addCurveToPoint, values: curve.points.dropFirst().flatMap { [$0.x, $0.y] })) + } + } + return result +} + +func _orbFinishFrame(_ header: _ORBPathRecord, _ records: [_ORBPathRecord]) -> [_ORBPathRecord] { + switch header.element { + case .transformed: + let transform = header.transform ?? .identity + return records.map { record in + var record = record + for index in stride(from: 0, to: record.values.count, by: 2) { + let point = CGPoint(x: record.values[index], y: record.values[index + 1]).applying(transform) + record.values[index] = point.x + record.values[index + 1] = point.y + } + return record + } + case .trimmed: + return _orbTrimmedRecords(records, from: Float(header.values[0]), to: Float(header.values[1])) + case .stroked: + #if OPENRENDERBOX_COREGRAPHICS + var path = _orbMakeCGPath(records) + if let stroke = header.stroke, !stroke.dash.isEmpty { + path = path.copy(dashingWithPhase: CGFloat(Float(header.values[2])), lengths: stroke.dash) + } + let cap = CGLineCap(rawValue: header.stroke?.lineCap ?? 0) ?? .butt + let join = CGLineJoin(rawValue: header.stroke?.lineJoin ?? 0) ?? .miter + return _orbRecords(path.copy(strokingWithWidth: CGFloat(Float(header.values[0])), lineCap: cap, lineJoin: join, miterLimit: CGFloat(Float(header.values[1])))) + #else + preconditionFailure("Stroke conversion requires CoreGraphics") + #endif + default: + preconditionFailure("Unsupported path wrapper") + } +} + +func _orbCurrentPoint(_ records: [_ORBPathRecord]) -> CGPoint? { + var current: CGPoint? + var start: CGPoint? + for record in records { + switch record.element { + case .moveToPoint: current = _orbPoint(record.values, 0); start = current + case .addLineToPoint, .addQuadCurveToPoint, .addCurveToPoint: + current = _orbPoint(record.values, record.values.count / 2 - 1) + case .closeSubpath: current = start + default: break + } + } + return current +} + +func _orbArcRecords(_ record: _ORBPathRecord, current: CGPoint?) -> [_ORBPathRecord] { + let v = record.values + #if OPENRENDERBOX_COREGRAPHICS + let path = CGMutablePath() + if let current { path.move(to: current) } + switch record.element { + case .relativeArc: path.addRelativeArc(center: CGPoint(x: v[0], y: v[1]), radius: v[2], startAngle: v[3], delta: v[4]) + case .arc, .arcCounterclockwise: path.addArc(center: CGPoint(x: v[0], y: v[1]), radius: v[2], startAngle: v[3], endAngle: v[4], clockwise: record.element == .arc) + case .arcTo: path.addArc(tangent1End: CGPoint(x: v[0], y: v[1]), tangent2End: CGPoint(x: v[2], y: v[3]), radius: v[4]) + default: return [] + } + let records = _orbRecords(path) + return current == nil ? records : Array(records.dropFirst()) + #else + if record.element == .arcTo { + let corner = CGPoint(x: v[0], y: v[1]), endpoint = CGPoint(x: v[2], y: v[3]) + guard let current else { return [.init(element: .moveToPoint, values: [corner.x, corner.y])] } + let a = CGPoint(x: current.x - corner.x, y: current.y - corner.y) + let b = CGPoint(x: endpoint.x - corner.x, y: endpoint.y - corner.y) + let al = (a.x * a.x + a.y * a.y).squareRoot(), bl = (b.x * b.x + b.y * b.y).squareRoot() + let cross = a.x * b.y - a.y * b.x + guard al > 0, bl > 0, cross != 0, v[4] > 0 else { + return [.init(element: .addLineToPoint, values: [corner.x, corner.y])] + } + let u = CGPoint(x: a.x / al, y: a.y / al), w = CGPoint(x: b.x / bl, y: b.y / bl) + let angle = acos(min(max(u.x * w.x + u.y * w.y, -1), 1)) + let distance = v[4] / tan(angle / 2) + let first = CGPoint(x: corner.x + u.x * distance, y: corner.y + u.y * distance) + let last = CGPoint(x: corner.x + w.x * distance, y: corner.y + w.y * distance) + let sign: CGFloat = cross < 0 ? 1 : -1 + let center = CGPoint(x: first.x + sign * u.y * v[4], y: first.y - sign * u.x * v[4]) + let start = atan2(first.y - center.y, first.x - center.x) + let finish = atan2(last.y - center.y, last.x - center.x) + var delta = finish - start + if sign > 0 { while delta < 0 { delta += 2 * .pi } } else { while delta > 0 { delta -= 2 * .pi } } + return _orbCircularArc(center: center, radius: v[4], start: start, delta: delta, current: current) + } + var delta = v[4] + if record.element != .relativeArc { + delta -= v[3] + if record.element == .arc, delta > 0 { + delta -= ceil(delta / (2 * .pi)) * (2 * .pi) + } else if record.element == .arcCounterclockwise, delta < 0 { + delta += ceil(-delta / (2 * .pi)) * (2 * .pi) + } + } + return _orbCircularArc(center: CGPoint(x: v[0], y: v[1]), radius: v[2], start: v[3], delta: delta, current: current) + #endif +} + +func _orbCircularArc(center: CGPoint, radius: CGFloat, start: CGFloat, delta: CGFloat, current: CGPoint?) -> [_ORBPathRecord] { + guard radius.isFinite, start.isFinite, delta.isFinite else { return [] } + let segmentCount = ceil(abs(delta) / (.pi / 2)) + guard segmentCount < CGFloat(Int.max) else { return [] } + let count = max(1, Int(segmentCount)) + let step = delta / CGFloat(count) + var angle = start + let first = CGPoint(x: center.x + cos(angle) * radius, y: center.y + sin(angle) * radius) + var result: [_ORBPathRecord] = [] + if current == nil || current! != first { + result.append(.init(element: current == nil ? .moveToPoint : .addLineToPoint, values: [first.x, first.y])) + } + if delta == 0 { return result } + for _ in 0..] + var winding: [Int] + var minimum: SIMD2 + var maximum: SIMD2 + var start = SIMD2.zero + var current = SIMD2.zero + + static func narrow(_ p: CGPoint) -> SIMD2 { SIMD2(Float(p.x), Float(p.y)) } + + mutating func line(_ end: SIMD2) { + let from = current + current = end + let delta = end - from + if abs(delta.y) < 0.0001 { return } + for index in queries.indices { + let t = (queries[index].y - from.y) / delta.y + if t >= 0 && t <= 1 && from.x.addingProduct(delta.x, t) > queries[index].x { + winding[index] += delta.y > 0 ? 1 : -1 + } + } + } + + mutating func close() { + if current != start { line(start) } + } + + mutating func cubic(_ cp1: CGPoint, _ cp2: CGPoint, _ end: CGPoint) { + var a = current + let b = Self.narrow(cp1), c = Self.narrow(cp2), d = Self.narrow(end) + let minX = min(a.x, b.x, c.x, d.x), minY = min(a.y, b.y, c.y, d.y) + let maxX = max(a.x, b.x, c.x, d.x), maxY = max(a.y, b.y, c.y, d.y) + if minY > maximum.y || maxX < minimum.x || maxY < minimum.y { current = d; return } + if minX > maximum.x { line(d); return } + let e = c - (b + b) + a, f = b - (c + c) + d + let x = max(abs(e.x), abs(f.x)), y = max(abs(e.y), abs(f.y)) + let estimate = ceil((x * x + y * y).squareRoot().squareRoot()) + guard estimate.isFinite else { current = d; return } + let segments = Int(min(estimate, 1024)) + if segments >= 2 { + let first = (b - a) * 3 + var second = SIMD2.zero, third = SIMD2.zero + for axis in 0..<2 { + second[axis] = (-first[axis]).addingProduct(3, c[axis] - b[axis]) + third[axis] = d[axis] - (a[axis].addingProduct(3, b[axis] - a[axis]) + second[axis]) + } + let dt = 1 / Float(segments), dt2 = dt * dt, dt3 = dt2 * dt + var delta1 = SIMD2.zero, delta2 = SIMD2.zero + let delta3 = (third * 6) * dt3 + second *= dt2 + for axis in 0..<2 { + delta2[axis] = delta3[axis].addingProduct(2, second[axis]) + delta1[axis] = second[axis].addingProduct(first[axis], dt).addingProduct(third[axis], dt3) + } + for _ in 1.. UInt64 { + var state = _ORBPathWinding(queries: queries.map(_ORBPathWinding.narrow), winding: Array(repeating: 0, count: queries.count), + minimum: SIMD2(repeating: .infinity), maximum: SIMD2(repeating: -.infinity)) + for query in state.queries { + for axis in 0..<2 { + state.minimum[axis] = fmin(state.minimum[axis], query[axis]) + state.maximum[axis] = fmax(state.maximum[axis], query[axis]) + } + } + if state.minimum == state.maximum { + if !bounds.contains(CGPoint(x: CGFloat(state.minimum.x), y: CGFloat(state.minimum.y))) { return 0 } + } else { + let queryBounds = CGRect(x: CGFloat(state.minimum.x), y: CGFloat(state.minimum.y), + width: CGFloat(state.maximum.x - state.minimum.x), height: CGFloat(state.maximum.y - state.minimum.y)) + if !bounds.intersects(queryBounds) { return 0 } + } + for record in records { + switch record.element { + case .moveToPoint: + state.close() + state.current = _ORBPathWinding.narrow(_orbPoint(record.values, 0)) + state.start = state.current + case .addLineToPoint: state.line(_ORBPathWinding.narrow(_orbPoint(record.values, 0))) + case .addQuadCurveToPoint: + let control = _orbPoint(record.values, 0), end = _orbPoint(record.values, 1) + let cp1 = CGPoint(x: CGFloat(state.current.x).addingProduct(2, control.x) / 3, + y: CGFloat(state.current.y).addingProduct(2, control.y) / 3) + let cp2 = CGPoint(x: end.x.addingProduct(2, control.x) / 3, y: end.y.addingProduct(2, control.y) / 3) + state.cubic(cp1, cp2, end) + case .addCurveToPoint: state.cubic(_orbPoint(record.values, 0), _orbPoint(record.values, 1), _orbPoint(record.values, 2)) + case .closeSubpath: state.close() + default: break + } + } + state.close() + var result: UInt64 = 0 + for index in queries.indices { + if eoFill ? state.winding[index] & 1 != 0 : state.winding[index] != 0 { result |= UInt64(1) << index } + } + return result +} + +#endif diff --git a/Sources/OpenRenderBox/Path/ORBPathStorage.swift b/Sources/OpenRenderBox/Path/ORBPathStorage.swift index 4e75f6a..a3ab93d 100644 --- a/Sources/OpenRenderBox/Path/ORBPathStorage.swift +++ b/Sources/OpenRenderBox/Path/ORBPathStorage.swift @@ -6,54 +6,65 @@ public import OpenCoreGraphicsShims extension ORBPath.Storage { + private var _slot: UnsafeMutablePointer { + UnsafeMutableRawPointer(rawValue).assumingMemoryBound(to: UnsafeMutableRawPointer?.self) + } + + private var _records: _ORBPathRecords { + Unmanaged<_ORBPathRecords>.fromOpaque(_slot.pointee!).takeUnretainedValue() + } + public func initialize(capacity: Int, source: ORBPath.Storage?) { - _openRenderBoxUnimplementedFailure() + precondition(capacity >= MemoryLayout.stride) + let records = source?._records.records ?? [] + _slot.initialize(to: Unmanaged.passRetained(_ORBPathRecords(records)).toOpaque()) } - + public func destroy() { - _openRenderBoxUnimplementedFailure() + if let object = _slot.pointee { + _slot.pointee = nil + Unmanaged<_ORBPathRecords>.fromOpaque(object).release() + } + _slot.deinitialize(count: 1) } - + public func clear() { - _openRenderBoxUnimplementedFailure() + _records.records.removeAll(keepingCapacity: true) } - + public func append(element: ORBPath.Element, points: UnsafePointer, userInfo: UnsafeRawPointer?) { - _openRenderBoxUnimplementedFailure() + guard let record = _ORBPathRecord(element: element, points: points, userInfo: userInfo) else { return } + _records.records.append(record) } - + + @discardableResult public func append(path: ORBPath) -> Bool { - _openRenderBoxUnimplementedFailure() + guard let records = path._records() else { return false } + _records.records.append(contentsOf: records) + return true } - + public func apply(info: UnsafeMutableRawPointer, callback: ORBPathApplyCallback?) -> Bool { - _openRenderBoxUnimplementedFailure() - } - - public var isEmpty: Bool { - _openRenderBoxUnimplementedFailure() + _records.apply(info: info, callback: callback) } - + + public var isEmpty: Bool { _records.records.isEmpty } + public func isEqual(to rhs: ORBPath.Storage) -> Bool { - _openRenderBoxUnimplementedFailure() - } - - public var isSingleElement: Bool { - _openRenderBoxUnimplementedFailure() + _records.records == rhs._records.records } - - public var bezierOrder: Int { - _openRenderBoxUnimplementedFailure() - } - + + public var isSingleElement: Bool { _orbIsSingleElement(_records.records) } + + public var bezierOrder: Int { _records.records.map(\.order).max() ?? 1 } + public var boundingRect: CGRect { - _openRenderBoxUnimplementedFailure() - } - - public var cgPath: CGPath? { - _openRenderBoxUnimplementedFailure() + let path = ORBPath._fromRecords(_records.records) + defer { path.release() } + return path.boundingRect } + + public var cgPath: CGPath? { _orbMakeCGPath(_records.records) } } #endif - diff --git a/Tests/OpenRenderBoxCompatibilityTests/README.md b/Tests/OpenRenderBoxCompatibilityTests/README.md index 2584daf..3aad5fa 100644 --- a/Tests/OpenRenderBoxCompatibilityTests/README.md +++ b/Tests/OpenRenderBoxCompatibilityTests/README.md @@ -19,6 +19,18 @@ The local dependency options require sibling OpenCoreGraphics and DarwinPrivateFrameworks checkouts. Native Path suites run without enabling other unfinished compatibility suites. +To test Swift Path with OpenCoreGraphics, use: + +```sh +OPENSWIFTUI_COREGRAPHICS=0 OPENRENDERBOX_CF_CGTYPES=0 \ +OPENRENDERBOX_USE_LOCAL_DEPS=1 OPENRENDERBOX_RENDERBOX=0 \ +OPENRENDERBOX_LIBRARY_EVOLUTION=0 swift test --filter PortablePathTests +``` + +Set `OPENSWIFTUI_COREGRAPHICS=1` to test Swift Path with system CoreGraphics. +This shared setting selects the same backend in ORB and OpenCoreGraphicsShims. +`OPENRENDERBOX_CF_CGTYPES` selects C++ Path (`1`) or Swift Path (`0`). + The exact curve-trimming, line-flattening, and clip-stroke undershoot fixtures use RenderBox from iOS 18.5 (22F77). They run against native OpenRenderBox and Apple RenderBox on iOS 18.5. diff --git a/Tests/OpenRenderBoxTests/Path/PortablePathTests.swift b/Tests/OpenRenderBoxTests/Path/PortablePathTests.swift new file mode 100644 index 0000000..9339939 --- /dev/null +++ b/Tests/OpenRenderBoxTests/Path/PortablePathTests.swift @@ -0,0 +1,252 @@ +// +// PortablePathTests.swift +// OpenRenderBoxTests + +#if !OPENRENDERBOX_CF_CGTYPES +import OpenCoreGraphicsShims +import OpenRenderBox +import Testing + +@Suite +struct PortablePathTests { + private func withStorage(capacity: Int = 96, source: ORBPath.Storage? = nil, _ body: (ORBPath.Storage) -> Result) -> Result { + let bytes = UnsafeMutableRawPointer.allocate(byteCount: capacity, alignment: 16) + let storage = ORBPath.Storage(OpaquePointer(bytes)) + storage.initialize(capacity: capacity, source: source) + defer { + storage.destroy() + bytes.deallocate() + } + return body(storage) + } + + private func elementCount(_ storage: ORBPath.Storage) -> Int { + var count = 0 + _ = storage.apply(info: &count) { info, _, _, _ in + info.assumingMemoryBound(to: Int.self).pointee += 1 + return true + } + return count + } + + @Test + func primitiveQueries() { + let rect = CGRect(x: 1, y: 2, width: 100, height: 80) + let path = ORBPath(rect: rect, transform: nil) + defer { path.release() } + #expect(path.boundingRect == rect) + #expect(!path.isEmpty) + #expect(path.isSingleElement) + #expect(path.contains(point: CGPoint(x: 50, y: 40), eoFill: false)) + #expect(!path.contains(point: CGPoint(x: -1, y: 40), eoFill: false)) + var type = ORBPathShapeType.line + #expect(path.applyShape(allowLine: true, info: &type) { info, type, _ in + info.assumingMemoryBound(to: ORBPathShapeType.self).pointee = type + }) + #expect(type == .rect) + } + + @Test + func retainedPathUsesReturnedStorage() { + var destination: UInt64 = 0 + withUnsafePointer(to: &destination) { destination in + var source = UnsafeRawPointer(destination) + var callbacks = ORBPath.Callbacks() + callbacks.retain = { object in + object.assumingMemoryBound(to: UnsafeRawPointer.self).pointee + } + withUnsafePointer(to: &source) { source in + withUnsafePointer(to: &callbacks) { callbacks in + let path = ORBPath(storage: ORBPath.Storage(OpaquePointer(source)), callbacks: callbacks) + let retained = path.retain() + #expect(retained.storage.rawValue == OpaquePointer(destination)) + } + } + } + } + + @Test(arguments: [ + (CGPoint(x: 5, y: 0), false), + (CGPoint(x: 10, y: 5), false), + (CGPoint(x: 5, y: 10), false), + (CGPoint(x: 0, y: 5), true), + (CGPoint(x: 5, y: 5), true), + ]) + func ellipseBoundaryContainment(point: CGPoint, expected: Bool) { + let path = ORBPath(ellipseIn: CGRect(x: 0, y: 0, width: 10, height: 10), transform: nil) + defer { path.release() } + #expect(path.contains(point: point, eoFill: false) == expected) + } + + @Test + func containmentPreservesMaskBits() { + let path = ORBPath(rect: CGRect(x: 0, y: 0, width: 10, height: 10), transform: nil) + defer { path.release() } + var points = Array(repeating: CGPoint(x: 20, y: 20), count: 65) + points[0] = CGPoint(x: 5, y: 5) + points[63] = CGPoint(x: 5, y: 5) + points[64] = CGPoint(x: 5, y: 5) + #expect(path.containsPoints(count: 65, points: points, eoFill: false, transform: nil) == (UInt64(1) | (UInt64(1) << 63))) + } + + @Test(arguments: [64, 256]) + func storageCopiesAndClearsIndependently(capacity: Int) { + withStorage(capacity: capacity) { source in + source.append(element: .moveToPoint, points: [0, 0], userInfo: nil) + for index in 1...40 { + source.append(element: .addLineToPoint, points: [CGFloat(index), CGFloat(index * 2)], userInfo: nil) + } + #expect(elementCount(source) == 41) + withStorage(capacity: 64, source: source) { copy in + #expect(copy.isEqual(to: source)) + source.clear() + #expect(source.isEmpty) + #expect(elementCount(copy) == 41) + #expect(copy.boundingRect == CGRect(x: 0, y: 0, width: 40, height: 80)) + } + } + } + + @Test + func storageUsesBezierOrderAndStopsEnumeration() { + withStorage { storage in + storage.append(element: .moveToPoint, points: [0, 0], userInfo: nil) + storage.append(element: .addQuadCurveToPoint, points: [1, 2, 3, 4], userInfo: nil) + #expect(storage.bezierOrder == 2) + storage.append(element: .addCurveToPoint, points: [5, 6, 7, 8, 9, 10], userInfo: nil) + #expect(storage.bezierOrder == 3) + var count = 0 + #expect(!storage.apply(info: &count) { info, _, _, _ in + info.assumingMemoryBound(to: Int.self).pointee += 1 + return false + }) + #expect(count == 1) + } + } + + @Test + func roundedStylesProduceDifferentElements() { + let rect = CGRect(x: 0, y: 0, width: 100, height: 80) + for style in [ORBPath.RoundedCornerStyle.circular, .continuous] { + let path = ORBPath(roundedRect: rect, topLeftRadius: 5, topRightRadius: 10, bottomRightRadius: 15, bottomLeftRadius: 20, style: style, transform: nil) + defer { path.release() } + var element = ORBPath.Element.invalid + #expect(path.apply(info: &element) { info, element, _, _ in + info.assumingMemoryBound(to: ORBPath.Element.self).pointee = element + return true + }) + #expect(element == (style == .circular ? .unevenRoundedRectCircular : .unevenRoundedRectContinuous)) + #expect(path.contains(point: CGPoint(x: 50, y: 40), eoFill: false)) + var actualStyle = ORBPath.RoundedCornerStyle.circular + #expect(path.applyShape(allowLine: true, info: &actualStyle) { info, type, shape in + if type == .unevenRoundedRect { + info.assumingMemoryBound(to: ORBPath.RoundedCornerStyle.self).pointee = shape.pointee.unevenRoundedRect.style + } + }) + #expect(actualStyle == style) + } + } + + #if !OPENRENDERBOX_COREGRAPHICS + @Test + func trimmedCubicGeometry() { + let cgPath = CGMutablePath() + cgPath.move(to: .zero) + cgPath.addCurve(to: CGPoint(x: 10, y: 0), control1: CGPoint(x: 0, y: 10), control2: CGPoint(x: 10, y: 10)) + let source = ORBPath(cgPath: cgPath) + let path = source.trimmed(from: 0.25, to: 0.75) + source.release() + defer { path.release() } + let elements = path.cgPath.elements + #expect(elements.count == 2) + var actual: [CGFloat] = [] + for element in elements { + switch element { + case let .moveToPoint(point): actual += [point.x, point.y] + case let .addCurveToPoint(first, second, end): actual += [first.x, first.y, second.x, second.y, end.x, end.y] + default: Issue.record("Unexpected trimmed curve element") + } + } + let expected: [CGFloat] = [1.0906342200107548, 4.8932050683032262, + 3.0138364775468443, 8.3689316438989252, + 6.9861635224531566, 8.3689316438989252, + 8.9093657799892458, 4.8932050683032253] + #expect(actual.count == expected.count) + for (actual, expected) in zip(actual, expected) { + #expect(abs(actual - expected) < 1e-10) + } + } + + @Test + func relativeArcGeometry() { + withStorage { storage in + storage.append(element: .relativeArc, points: [0, 0, 10, 0, .pi / 2], userInfo: nil) + guard let path = storage.cgPath else { + Issue.record("Missing arc path") + return + } + #expect(path.elements.count == 2) + #expect(path.elements.first == .moveToPoint(CGPoint(x: 10, y: 0))) + if case let .addCurveToPoint(_, _, end) = path.elements.last { + #expect(abs(end.x) < 1e-6) + #expect(abs(end.y - 10) < 1e-6) + } else { + Issue.record("Missing arc curve") + } + } + } + + @Test(arguments: [ORBPath.RoundedCornerStyle.circular, .continuous]) + func roundedRectangleClampsBothAxes(style: ORBPath.RoundedCornerStyle) { + let rect = CGRect(x: 0, y: 0, width: 100, height: 20) + let oversized = ORBPath(roundedRect: rect, cornerWidth: 50, cornerHeight: 50, style: style, transform: nil) + let clamped = ORBPath(roundedRect: rect, cornerWidth: 10, cornerHeight: 10, style: style, transform: nil) + let singleAxis = ORBPath(roundedRect: rect, cornerWidth: 0, cornerHeight: 10, style: style, transform: nil) + let square = ORBPath(roundedRect: rect, cornerWidth: 0, cornerHeight: 0, style: style, transform: nil) + defer { + oversized.release() + clamped.release() + singleAxis.release() + square.release() + } + #expect(oversized.cgPath == clamped.cgPath) + #expect(singleAxis.cgPath.elements.count == (style == .circular ? 10 : 18)) + #expect(square.cgPath.elements.count == 6) + } + + @Test + func continuousRoundedRectangleGeometry() { + let path = ORBPath(roundedRect: CGRect(x: 0, y: 0, width: 100, height: 80), cornerWidth: 10, cornerHeight: 20, style: .continuous, transform: nil) + defer { path.release() } + let elements = path.cgPath.elements + #expect(elements.count == 18) + #expect(elements[0] == .moveToPoint(CGPoint(x: 100, y: 40))) + #expect(elements[1] == .addLineToPoint(CGPoint(x: 100, y: 49.426701068878174))) + #expect(elements[2] == .addCurveToPoint( + CGPoint(x: 100, y: 58.230199813842773), + CGPoint(x: 100, y: 62.63185977935791), + CGPoint(x: 99.250885993242264, y: 67.370120286941528) + )) + } + #endif + + @Test + func nestedShapeSurvivesSourceRelease() { + let source = ORBPath(rect: CGRect(x: 1, y: 2, width: 10, height: 20), transform: nil) + var transform = CGAffineTransform(translationX: 20, y: 30) + let path = source.copy(transform: &transform) + source.release() + var nested = ORBPath.empty + #expect(path.isSingleElement) + #expect(path.boundingRect == CGRect(x: 21, y: 32, width: 10, height: 20)) + #expect(path.applyShape(allowLine: true, info: &nested) { info, type, shape in + if type == .nested { + info.assumingMemoryBound(to: ORBPath.self).pointee = shape.pointee.nested.path.retain() + } + }) + path.release() + defer { nested.release() } + #expect(nested.boundingRect == CGRect(x: 1, y: 2, width: 10, height: 20)) + } +} +#endif From db2dc2e289834cdcb84c7b24f15d37d94465af2d Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 7 Nov 2026 19:17:22 +0800 Subject: [PATCH 12/12] build(deps): update DarwinPrivateFrameworks --- Package.resolved | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.resolved b/Package.resolved index d9ee8ff..9d71347 100644 --- a/Package.resolved +++ b/Package.resolved @@ -7,7 +7,7 @@ "location" : "https://github.com/OpenSwiftUIProject/DarwinPrivateFrameworks.git", "state" : { "branch" : "main", - "revision" : "bc86728a45d2d6dc04bea9ad248971b24c36f019" + "revision" : "511cc728141f57dc325753f23898568ccd49c339" } }, {