diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..e004e0a --- /dev/null +++ b/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "AttachmentsEverywhere", + platforms: [.macOS(.v15), .iOS(.v18)], + products: [ + .library( + name: "AttachmentsEverywhere", + targets: ["AttachmentsEverywhere"]), + ], + targets: [ + .target( + name: "AttachmentsEverywhere", + resources: [.process("Resources/GlassMaterial.usda")] + ), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..5df5de0 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# AttachmentsEverywhere +Backports `RealityView`'s `init(make:update:attachments)` to non-visionOS platforms. + +## Basic Usage +- Use as a standard SPM dependency +- Import `AttachmentsEverywhere` in your code +- Replace any `RealityView` that uses `init(make:update:attachments:)` with `AttachableRealityView` +- Your code will probably (hopefully_ compile, though it may not function properly without tweaking + +## Advanced Usage +In order to approach the usability of attachments on visionOS, `AttachmentsEverywhere` provides some +extensions on top of the `RealityKit` API. These extensions are not available on visionOS, and +therefore must be wrapped inside `#if !os(visionOS)`. + +### Glass Background Customization +By default, `AttachmentsEverywhere` adds a glass background (rather, a crude facsimile of it) to +attachments. Use the `.glassBackgroundStyle(_:)` modifier on `Attachment` with a +`GlassBackgroundType` in order to customize this behavior: +- `.capsule`: The view's glass background will be a capsule + (corner radius equal to half the height of the attachment). +- `.roundedRectangle(cornerRadius:)`: The view's glass background will be a rounded rectangle, + with a corner radius defined in points. +- `.none`: The view will not have a glass background. + +### Gestures +Use `.onTapGesture(_:)` on `Attachment` in order to respond to tap gestures. At this time, this is +the only supported gesture. Your closure will be passed a `CGPoint` which is the location of the tap +in your view's coordinate space. + +### Attachment Scale +By default, attachment view entities are scaled such that 300 points is equivalent to one +(RealityView) meter. In order to customize this scaling, apply the +`.attachmentScale(pointsPerMeter:)` view modifier outside your `AttachableRealityView`. + +## Important Considerations / Quirks +`AttachmentsEverywhere` relies on `SwiftUI`'s current behavior regarding `RealityView` view updates. +This behavior could change in future OS releases (i.e., past iOS 18.0 and macOS 15.0) and some +features of this library may cease functioning. This library is intended as a stopgap solution until +Apple (hopefully) adds attachments to non-visionOS platforms. As such, this library has some quirks +regarding its functionality that should be taken into account. + +### Automatic Re-Rendering of Attachment Views +`Attachment`s will be re-rendered when `ImageRenderer` determines their state to have changed. +Crucially, this only seems to take into account `State`s, `Binding`s, etc. held *inside* the +attachment views. State held outside of `AttachableRealityView` *will not* automatically re-render +attachment views. Such state *must* be passed into `Attachment`s with `Binding` (and similar). + +### Automatic Insertion / Deletion / Modification of Attachments +Every time `RealityView` would call the `update` closure, `AttachmentsEverywhere` reevaluates the +`attachments` closure. This means that `Attachment`s can be dynamically added or removed (with +result builder control flow) in response to state changes, though your surrounding view must depend +on these state variables. Just like with `RealityView`, any attachments must be added to the scene +manually. Any attachments that are removed due to a state change will be automatically removed from +the scene. + +**At this time (until I find a clean way to make it function), `ForEach` is not supported +in the `attachments` closure.** Instead, use a for-in loop, as it is supported. + +## Library To-Do +- [ ] Support `ForEach` (if possible) in order to bolster code compatibility +- [ ] Support other gestures +- [ ] Create better glass material diff --git a/Sources/AttachmentsEverywhere/API/AttachableRealityView.swift b/Sources/AttachmentsEverywhere/API/AttachableRealityView.swift new file mode 100644 index 0000000..3e68718 --- /dev/null +++ b/Sources/AttachmentsEverywhere/API/AttachableRealityView.swift @@ -0,0 +1,172 @@ +// +// AttachableRealityView.swift +// AttachmentsEverywhere +// +// Created by Elijah Santos on 8/7/24. +// + +#if !os(visionOS) + +import SwiftUI +import RealityKit + +/// Replicates (to the extent possible) the attachments functionality of `RealityView` on visionOS. +/// +/// Use as you would a `RealityView`. On visionOS, `AttachableRealityView` is a typealias for `RealityView`. +@MainActor +public struct AttachableRealityView: View { + private enum AttachmentUpdateState { + case notInProgress, preUpdate, postUpdate + } + private let make: @MainActor (inout RealityViewCameraContent, RealityViewAttachments) async -> Void + private let update: (@MainActor (inout RealityViewCameraContent, RealityViewAttachments) -> Void)? + private let placeholder: Placeholder? + private let attachments: () -> AttachmentContent + + @Environment(\.attachmentPointsPerMeter) private var pointsPerMeter + @StateObject private var updator = Updator() + @State private var n = 0 + @State private var attachmentUpdateState = AttachmentUpdateState.notInProgress + @State private var cachedAttachments: RealityViewAttachmentsBacking? = nil + + public init( + make: @escaping @MainActor (inout RealityViewCameraContent, RealityViewAttachments) async -> Void, + update: (@MainActor (inout RealityViewCameraContent, RealityViewAttachments) -> Void)? = nil, + @AttachmentContentBuilder attachments: @escaping () -> AttachmentContent + ) where Placeholder == Never { + self.make = make + self.update = update + self.placeholder = nil + self.attachments = attachments + } + + public init( + make: @escaping @MainActor (inout RealityViewCameraContent, RealityViewAttachments) async -> Void, + update: (@MainActor (inout RealityViewCameraContent, RealityViewAttachments) -> Void)? = nil, + @ViewBuilder placeholder: () -> Placeholder, + @AttachmentContentBuilder attachments: @escaping () -> AttachmentContent + ) { + self.make = make + self.update = update + self.placeholder = placeholder() + self.attachments = attachments + } + + // TODO: reimplement non-attachment initializers, for convenience + + private func createAttachments() async { + self.cachedAttachments = await RealityViewAttachmentsBacking( + metersPerRenderedPx: 1 / (pointsPerMeter * ImageGenerator_renderScale), + views: attachments()) { + Task { @MainActor in + do { + var attachments = cachedAttachments + try await attachments?.regenerateAttachments() + self.cachedAttachments = attachments + self.updator.forceViewUpdate() + } catch { + print("[AttachmentsEverywhere] warning: failed to update attachments: \(error.localizedDescription)") + } + } + } + } + + private func updateNewAndRemovedAttachments( + new: AttachmentContent, + cached: RealityViewAttachmentsBacking, + content: inout RealityViewCameraContent + ) -> RealityViewAttachments? { + if attachmentUpdateState == .postUpdate { + // attachments now loaded; continue update with new attachments + DispatchQueue.main.async { + attachmentUpdateState = .notInProgress + } + return cached.collection() + } + let newPairs = new.filter { !cached.keys.contains($0.key) } + let removedKeys = cached.keys.filter { !new.keys.contains($0) } + if !removedKeys.isEmpty { + self.cachedAttachments?.removeAttachments(forKeys: removedKeys, from: &content) + } + + if !newPairs.isEmpty { + Task { @MainActor [newPairs] in + attachmentUpdateState = .preUpdate + var c = self.cachedAttachments + await c?.insertAttachments(newPairs) + self.cachedAttachments = c + updator.forceViewUpdate() + attachmentUpdateState = .postUpdate + } + // defer update until attachments loaded + return nil + } + // no attachments to load; proceed with update + return cached.collection(excluding: removedKeys) + } + + private func doUpdate(_ content: inout RealityViewCameraContent) { + _ = updator.updateCount + guard let cachedAttachments else { return } + let newAttachments = attachments() + if let pushedAttachments = updateNewAndRemovedAttachments( + new: newAttachments, + cached: cachedAttachments, + content: &content + ) { + update?(&content, pushedAttachments) + } + } + + public var body: some View { + Group { + if let placeholder { + RealityView { content in + _ = updator.updateCount + await createAttachments() + await make(&content, cachedAttachments!.collection()) + } update: { content in + doUpdate(&content) + } placeholder: { + placeholder + } + } else { + RealityView { content in + _ = updator.updateCount + await createAttachments() + await make(&content, cachedAttachments!.collection()) + } update: { content in + doUpdate(&content) + } + + } + } + .gesture( + SpatialTapGesture() + .targetedToEntity(where: .has(ViewAttachmentComponent.self)) + .onEnded { value in + guard let entity = value.entity as? ViewAttachmentEntity, + let tapPoint3D = value.hitTest( + point: value.location, + in: .local).first?.position + else { + return + } + let attachmentOrientation = entity.orientation(relativeTo: nil) + let attachmentPosition = entity.position(relativeTo: nil) + let xyAlignedPoint = simd_act(simd_negate(attachmentOrientation), tapPoint3D - attachmentPosition) + let resultantPoint = CGPoint( + x: Double(((xyAlignedPoint.x + (entity.attachment.bounds.extents.x / 2)) * pointsPerMeter)) - Attachment_padding, + y: Double((entity.attachment.bounds.extents.y - xyAlignedPoint.y - (entity.attachment.bounds.extents.y / 2)) * pointsPerMeter) - Attachment_padding) + entity.attachment.tapResponder?(resultantPoint) + } + ) + .overlay { + // force swiftui to do a view update when updator changes + Text(updator.updateCount.description) + .hidden() + } + } +} + +#endif diff --git a/Sources/AttachmentsEverywhere/API/AttachableRealityView_visionOS.swift b/Sources/AttachmentsEverywhere/API/AttachableRealityView_visionOS.swift new file mode 100644 index 0000000..2f78e2c --- /dev/null +++ b/Sources/AttachmentsEverywhere/API/AttachableRealityView_visionOS.swift @@ -0,0 +1,14 @@ +// +// AttachableRealityView_visionOS.swift +// AttachmentsEverywhere +// +// Created by Elijah Santos on 8/7/24. +// + +#if os(visionOS) +import SwiftUI +import RealityKit + +// since visionOS has attachments, no custom implementation is needed; only a typealias +public typealias AttachableRealityView = RealityView +#endif diff --git a/Sources/AttachmentsEverywhere/API/Attachments/.swift b/Sources/AttachmentsEverywhere/API/Attachments/.swift new file mode 100644 index 0000000..c23de1f --- /dev/null +++ b/Sources/AttachmentsEverywhere/API/Attachments/.swift @@ -0,0 +1,8 @@ +// +// File.swift +// AttachmentsEverywhere +// +// Created by Elijah Santos on 8/11/24. +// + +import Foundation diff --git a/Sources/AttachmentsEverywhere/API/Attachments/Attachment Customization/Attachment+modifiers.swift b/Sources/AttachmentsEverywhere/API/Attachments/Attachment Customization/Attachment+modifiers.swift new file mode 100644 index 0000000..273e673 --- /dev/null +++ b/Sources/AttachmentsEverywhere/API/Attachments/Attachment Customization/Attachment+modifiers.swift @@ -0,0 +1,43 @@ +// +// Attachment+modifiers.swift +// AttachmentsEverywhere +// +// Created by Elijah Santos on 8/11/24. +// + +import Foundation +import SwiftUI + +#if !os(visionOS) + +extension Attachment { + internal func withConfigurationValue( + _ kp: WritableKeyPath, + setTo newValue: T + ) -> Attachment { + var copy = self + copy.configuration[keyPath: kp] = newValue + return copy + } + + public func glassBackgroundStyle(_ style: GlassBackgroundType) -> Self { + self.withConfigurationValue(\.backgroundType, setTo: style) + } + + public func onTapGesture( + _ action: @MainActor @escaping (_ location: CGPoint) -> Void + ) -> Self { + let new: @MainActor (CGPoint) -> Void + if let existing = configuration.tapResponder { + new = { + existing($0) + action($0) + } + } else { + new = action + } + return self.withConfigurationValue(\.tapResponder, setTo: new) + } +} + +#endif diff --git a/Sources/AttachmentsEverywhere/API/Attachments/Attachment Customization/AttachmentCustomization.swift b/Sources/AttachmentsEverywhere/API/Attachments/Attachment Customization/AttachmentCustomization.swift new file mode 100644 index 0000000..1b3bcb1 --- /dev/null +++ b/Sources/AttachmentsEverywhere/API/Attachments/Attachment Customization/AttachmentCustomization.swift @@ -0,0 +1,18 @@ +// +// AttachmentCustomization.swift +// AttachmentsEverywhere +// +// Created by Elijah Santos on 8/11/24. +// + +import Foundation +import CoreGraphics + +public struct AttachmentCustomization: Sendable { + var backgroundType: GlassBackgroundType + var tapResponder: (@MainActor (CGPoint) -> Void)? + + static let `default`: AttachmentCustomization = .init( + backgroundType: .roundedRectangle(cornerRadius: 20), + tapResponder: nil) +} diff --git a/Sources/AttachmentsEverywhere/API/Attachments/Attachment Customization/GlassBackgroundType.swift b/Sources/AttachmentsEverywhere/API/Attachments/Attachment Customization/GlassBackgroundType.swift new file mode 100644 index 0000000..9a90679 --- /dev/null +++ b/Sources/AttachmentsEverywhere/API/Attachments/Attachment Customization/GlassBackgroundType.swift @@ -0,0 +1,24 @@ +// +// GlassBackgroundType.swift +// AttachmentsEverywhere +// +// Created by Elijah Santos on 8/11/24. +// + +import Foundation + +/// Describes the type of glass background placed behind an attachment. +/// +/// - SeeAlso: `Attachment.glassBackgroundStyle(_:)` +public enum GlassBackgroundType: Hashable, Sendable { + /// A capsule (flat top and bottom edges, circular left and right). + case capsule + + /// A rounded rectangle. + /// + /// `cornerRadius` is defined in points. + case roundedRectangle(cornerRadius: Double) + + /// No glass background. + case none +} diff --git a/Sources/AttachmentsEverywhere/API/Attachments/Attachment.swift b/Sources/AttachmentsEverywhere/API/Attachments/Attachment.swift new file mode 100644 index 0000000..016f0a8 --- /dev/null +++ b/Sources/AttachmentsEverywhere/API/Attachments/Attachment.swift @@ -0,0 +1,33 @@ +// +// Attachment.swift +// AttachmentsEverywhere +// +// Created by Elijah Santos on 8/7/24. +// + +import Foundation +import SwiftUI + +#if !os(visionOS) + +internal let Attachment_padding: CGFloat = 20 + +public struct Attachment: Identifiable { + public init(id: AnyHashable, @ViewBuilder _ content: () -> Content) { + self.id = id + self.content = content() + self.configuration = .default + } + public let id: AnyHashable + public let content: Content + internal var configuration: AttachmentCustomization + + // the content that is actually rendered in AttachableRealityView + internal var adaptedContent: some View { + content + .foregroundStyle(.white, .white.opacity(0.6), .white.opacity(0.475)) + .padding(Attachment_padding) + } +} + +#endif diff --git a/Sources/AttachmentsEverywhere/API/Attachments/AttachmentContentBuilder.swift b/Sources/AttachmentsEverywhere/API/Attachments/AttachmentContentBuilder.swift new file mode 100644 index 0000000..ec6978c --- /dev/null +++ b/Sources/AttachmentsEverywhere/API/Attachments/AttachmentContentBuilder.swift @@ -0,0 +1,70 @@ +// +// AttachmentContentBuilder.swift +// AttachmentsEverywhere +// +// Created by Elijah Santos on 8/7/24. +// + +import Foundation +import SwiftUI + +#if !os(visionOS) + +public typealias AttachmentContent = [AnyHashable: (AnyView, AttachmentCustomization)] + +@resultBuilder +public enum AttachmentContentBuilder { + public static func buildExpression(_ expression: Attachment) -> AttachmentContent { + return [expression.id: (AnyView(expression.adaptedContent), expression.configuration)] + } + + public static func buildExpression(_ expression: AttachmentContent) -> AttachmentContent { + expression + } + + public static func buildBlock() -> AttachmentContent { + // allows empty attachment blocks + return [:] + } + + public static func buildPartialBlock(first: AttachmentContent) -> AttachmentContent { + first + } + + public static func buildPartialBlock(accumulated: AttachmentContent, next: AttachmentContent) -> AttachmentContent { + var res = accumulated + for (key, value) in next { + if next.contains(where: { $0.key == key }) { + print("[AttachmentsEverywhere] warning: duplicate key in attachment content: \(String(reflecting: key))") + } + res[key] = value + } + return res + } + + public static func buildEither(first component: AttachmentContent) -> AttachmentContent { + component + } + + public static func buildEither(second component: AttachmentContent) -> AttachmentContent { + component + } + + public static func buildOptional(_ component: AttachmentContent?) -> AttachmentContent { + component ?? [:] + } + + public static func buildLimitedAvailability(_ component: AttachmentContent) -> AttachmentContent { + component + } + + public static func buildArray(_ components: [AttachmentContent]) -> AttachmentContent { + var res = [:] as AttachmentContent + for component in components { + res = buildPartialBlock(accumulated: res, next: component) + } + return res + } +} + +#endif diff --git a/Sources/AttachmentsEverywhere/API/Attachments/Entity/GlassEntity.swift b/Sources/AttachmentsEverywhere/API/Attachments/Entity/GlassEntity.swift new file mode 100644 index 0000000..ebf11ff --- /dev/null +++ b/Sources/AttachmentsEverywhere/API/Attachments/Entity/GlassEntity.swift @@ -0,0 +1,39 @@ +// +// GlassEntity.swift +// AttachmentsEverywhere +// +// Created by Elijah Santos on 8/10/24. +// + +import Foundation +import RealityKit +import Synchronization + +internal final class GlassEntity: Entity { + private actor MaterialLoader { + private var material: ShaderGraphMaterial? = nil + + func load() async throws -> ShaderGraphMaterial { + // TODO: make a better material than this + if material == nil { + // framework error if not in bundle; force unwrap ok + let sceneURL = Bundle.module.url(forResource: "GlassMaterial", withExtension: "usda")! + material = try await ShaderGraphMaterial(named: "/Root/Glass", from: sceneURL) + material!.faceCulling = .none + } + return material! + } + } + + private static let glassLoader = MaterialLoader() + + internal var cornerRadius: Float = 0.0 + required init() {} + convenience init(width: Float, height: Float, cornerRadius: Float) async throws { + self.init() + self.components[ModelComponent.self] = .init( + mesh: .generatePlane(width: width, height: height, cornerRadius: cornerRadius), + materials: [try await Self.glassLoader.load()]) + self.cornerRadius = cornerRadius + } +} diff --git a/Sources/AttachmentsEverywhere/API/Attachments/Entity/ViewAttachmentComponent.swift b/Sources/AttachmentsEverywhere/API/Attachments/Entity/ViewAttachmentComponent.swift new file mode 100644 index 0000000..2bb6256 --- /dev/null +++ b/Sources/AttachmentsEverywhere/API/Attachments/Entity/ViewAttachmentComponent.swift @@ -0,0 +1,36 @@ +// +// ViewAttachmentComponent.swift +// AttachmentsEverywhere +// +// Created by Elijah Santos on 8/9/24. +// + +import Foundation +import SwiftUI +import RealityKit + +#if !os(visionOS) + +public struct ViewAttachmentComponent: TransientComponent, Identifiable { + internal init( + id: AnyHashable, + bounds: BoundingBox, + tapResponder: (@MainActor (CGPoint) -> Void)? + ) { + ViewAttachmentComponent.registration + + self.id = id + self.bounds = bounds + self.tapResponder = tapResponder + } + + public internal(set) var id: AnyHashable + public internal(set) var bounds: BoundingBox + internal var tapResponder: (@MainActor (CGPoint) -> Void)? + + private static let registration: Void = { + Self.registerComponent() + }() +} + +#endif diff --git a/Sources/AttachmentsEverywhere/API/Attachments/Entity/ViewAttachmentEntity.swift b/Sources/AttachmentsEverywhere/API/Attachments/Entity/ViewAttachmentEntity.swift new file mode 100644 index 0000000..452f7cc --- /dev/null +++ b/Sources/AttachmentsEverywhere/API/Attachments/Entity/ViewAttachmentEntity.swift @@ -0,0 +1,113 @@ +// +// ViewAttachmentEntity.swift +// AttachmentsEverywhere +// +// Created by Elijah Santos on 8/9/24. +// + +import Foundation +import SwiftUI +import RealityKit + +#if !os(visionOS) + +public final class ViewAttachmentEntity: Entity { + private enum CreateError: Int, Error { + case missingCGImage + } + + public required init() { + super.init() + attachment = .init(id: 0, bounds: .empty, tapResponder: nil) + } + + private var glass: GlassEntity? = nil + + internal init(id: AnyHashable, image: CGImage?, bounds: BoundingBox, customization: AttachmentCustomization) async throws { + guard let image else { + throw CreateError.missingCGImage + } + + super.init() + + attachment = ViewAttachmentComponent( + id: id, + bounds: bounds, + tapResponder: customization.tapResponder) + + let plane = MeshResource.generatePlane(width: bounds.extents.x, height: bounds.extents.y) + let tex = try await TextureResource( + image: image, + options: .init(semantic: .color)) + var mat = RealityKit.UnlitMaterial(texture: tex) + + // workaround to make transparency work in rendered view image + // only works with tintColor, not color.tintColor for some reason + mat.tintColor = .white.withAlphaComponent(0.999) + components.set(ModelComponent(mesh: plane, materials: [mat])) + + components.set(InputTargetComponent()) + components.set(CollisionComponent( + shapes: [.generateBox( + width: bounds.extents.x, + height: bounds.extents.y, + depth: 0.01)], + mode: .trigger, + filter: .default)) + + switch customization.backgroundType { + case .none: + // do not create glass background + break + case .capsule: + let glass = try await GlassEntity( + width: bounds.extents.x, + height: bounds.extents.y, + cornerRadius: bounds.extents.y) + self.addChild(glass) + glass.setPosition(.init(x: 0, y: 0, z: -0.01), relativeTo: self) + self.glass = glass + case .roundedRectangle(let cornerRadius): + let glass = try await GlassEntity( + width: bounds.extents.x, + height: bounds.extents.y, + cornerRadius: Float(cornerRadius) * (bounds.extents.y / (Float(image.height) / ImageGenerator_renderScale))) + self.addChild(glass) + glass.setPosition(.init(x: 0, y: 0, z: -0.01), relativeTo: self) + self.glass = glass + } + } + + public private(set) var attachment: ViewAttachmentComponent { + get { + self.components[ViewAttachmentComponent.self]! + } + set { + self.components[ViewAttachmentComponent.self] = newValue + } + } + + internal func updateAttachment(image: CGImage, bounds: BoundingBox) async throws { + let plane = MeshResource.generatePlane(width: bounds.extents.x, height: bounds.extents.y) + let tex = try await TextureResource( + image: image, + options: .init(semantic: .color)) + var mat = RealityKit.UnlitMaterial(texture: tex) + if bounds != attachment.bounds, let glass { + // size changed; replace glass + let newglass = try await GlassEntity( + width: bounds.extents.x, + height: bounds.extents.y, + cornerRadius: glass.cornerRadius) + self.addChild(newglass) + self.removeChild(glass) + newglass.setPosition(.init(x: 0, y: 0, z: -0.01), relativeTo: self) + self.glass = newglass + } + mat.tintColor = .white.withAlphaComponent(0.999) + components[ModelComponent.self] = .init(mesh: plane, materials: [mat]) + self.attachment.bounds = bounds + } +} + +#endif diff --git a/Sources/AttachmentsEverywhere/API/Attachments/Image Texture Handling/ImageGenerator.swift b/Sources/AttachmentsEverywhere/API/Attachments/Image Texture Handling/ImageGenerator.swift new file mode 100644 index 0000000..20e7444 --- /dev/null +++ b/Sources/AttachmentsEverywhere/API/Attachments/Image Texture Handling/ImageGenerator.swift @@ -0,0 +1,37 @@ +// +// ImageGenerator.swift +// IGS-RisingSeaLevels +// +// Created by Elijah Santos on 8/5/24. +// + +import Foundation +import SwiftUI +import Combine + +let ImageGenerator_renderScale = 3 as Float + +@MainActor +internal class ImageGenerator { + private let renderer: ImageRenderer + private var subscription: AnyCancellable? = nil + private var onUpdate: (() -> Void)? = nil + + init(@ViewBuilder content: () -> Content) { + self.renderer = ImageRenderer(content: content()) + renderer.scale = CGFloat(ImageGenerator_renderScale) + renderer.isOpaque = false + renderer.isObservationEnabled = true + } + + func subscribe(onUpdate: @escaping () -> Void) { + subscription = renderer.objectWillChange.sink { + onUpdate() + } + } + + func cgImage() -> CGImage? { + // TODO: subscribe to updates + return renderer.cgImage + } +} diff --git a/Sources/AttachmentsEverywhere/API/Attachments/RealityViewAttachments.swift b/Sources/AttachmentsEverywhere/API/Attachments/RealityViewAttachments.swift new file mode 100644 index 0000000..cd991c8 --- /dev/null +++ b/Sources/AttachmentsEverywhere/API/Attachments/RealityViewAttachments.swift @@ -0,0 +1,22 @@ +// +// RealityViewAttachments.swift +// AttachmentsEverywhere +// +// Created by Elijah Santos on 8/13/24. +// + +import Foundation +import RealityKit + +#if !os(visionOS) + +@MainActor +public struct RealityViewAttachments { + let entities: [AnyHashable: ViewAttachmentEntity] + + public func entity(for id: some Hashable) -> ViewAttachmentEntity? { + return entities[id] + } +} + +#endif diff --git a/Sources/AttachmentsEverywhere/API/Attachments/RealityViewAttachmentsBacking.swift b/Sources/AttachmentsEverywhere/API/Attachments/RealityViewAttachmentsBacking.swift new file mode 100644 index 0000000..5ad07cb --- /dev/null +++ b/Sources/AttachmentsEverywhere/API/Attachments/RealityViewAttachmentsBacking.swift @@ -0,0 +1,124 @@ +// +// RealityViewAttachments.swift +// AttachmentsEverywhere +// +// Created by Elijah Santos on 8/9/24. +// + +import Foundation +import SwiftUI +import RealityKit + +#if !os(visionOS) + +@MainActor +internal struct RealityViewAttachmentsBacking { + internal init() { + self.metersPerRenderedPx = 0 + self.entities = [:] + self.imageGenerators = [:] + self.onChange = {} + } + + internal init( + metersPerRenderedPx: Float, + views: [AnyHashable : (AnyView, AttachmentCustomization)], + onChange: @escaping () -> Void + ) async { + self.metersPerRenderedPx = metersPerRenderedPx + self.entities = [:] + self.imageGenerators = [:] + self.onChange = onChange + + await createAttachments(views) + } + + private let onChange: () -> Void + private let metersPerRenderedPx: Float + private var entities: [AnyHashable: ViewAttachmentEntity] + private var imageGenerators: [AnyHashable: ImageGenerator] + + public func entity(for id: some Hashable) -> ViewAttachmentEntity? { + return entities[id] + } + + private mutating func createAttachments(_ views: AttachmentContent) async { + for (id, (view, customization)) in views { + do { + let imgGen = ImageGenerator { view } + guard let img = imgGen.cgImage() else { + print("[AttachmentsEverywhere] warning: failed to create image for view with id \(id)") + continue + } + imageGenerators[id] = imgGen + entities[id] = try await createEntity(id: id, img: img, customization: customization) + imgGen.subscribe { [onChange] in + onChange() + } + } catch { + print("[AttachmentsEverywhere] warning: failed to create entity for attachment " + + "with ID \(id): \(error.localizedDescription)") + } + } + } + + private func bounds(for image: CGImage) -> BoundingBox { + BoundingBox( + min: .zero, + max: .init( + x: Float(image.width) * metersPerRenderedPx, + y: Float(image.height) * metersPerRenderedPx, + z: 0)) + } + + private func createEntity(id: AnyHashable, img: CGImage, customization: AttachmentCustomization) async throws -> ViewAttachmentEntity { + return try await ViewAttachmentEntity( + id: id, + image: img, + bounds: bounds(for: img), + customization: customization + ) + } + + internal var keys: some Collection { + entities.keys + } + + internal mutating func regenerateAttachments() async throws { + for (id, generator) in imageGenerators { + do { + guard let img = generator.cgImage() else { + print("[AttachmentsEverywhere] warning: failed to recreate image for view with id \(id)") + continue + } + try await entities[id]?.updateAttachment(image: img, bounds: bounds(for: img)) + } catch { + print("[AttachmentsEverywhere] warning: failed to update entity for attachment " + + "with ID \(id): \(error.localizedDescription)") + } + } + } + + internal mutating func insertAttachments(_ new: AttachmentContent) async { + await createAttachments(new) + } + + internal mutating func removeAttachments( + forKeys keys: some Collection, + from content: inout RealityViewCameraContent + ) { + for key in keys { + if let entity = entities[key] { + content.remove(entity) + } + entities.removeValue(forKey: key) + imageGenerators.removeValue(forKey: key) + } + } + + internal func collection(excluding keys: [AnyHashable] = []) -> RealityViewAttachments { + return .init(entities: self.entities.filter { !keys.contains($0.key) }) + } +} + +#endif diff --git a/Sources/AttachmentsEverywhere/API/Environment+.swift b/Sources/AttachmentsEverywhere/API/Environment+.swift new file mode 100644 index 0000000..fa78c7a --- /dev/null +++ b/Sources/AttachmentsEverywhere/API/Environment+.swift @@ -0,0 +1,20 @@ +// +// Environment+.swift +// AttachmentsEverywhere +// +// Created by Elijah Santos on 8/9/24. +// + +import Foundation +import SwiftUI + +extension EnvironmentValues { + @Entry internal var attachmentPointsPerMeter: Float = 300 +} + +extension View { + /// Sets the number of points (in view scaling) per meter for all attachments in an ``AttachableRealityView-swift.struct``. + public func attachmentScale(pointsPerMeter: Float) -> some View { + self.environment(\.attachmentPointsPerMeter, pointsPerMeter) + } +} diff --git a/Sources/AttachmentsEverywhere/API/Util/Updator.swift b/Sources/AttachmentsEverywhere/API/Util/Updator.swift new file mode 100644 index 0000000..f9a4ad9 --- /dev/null +++ b/Sources/AttachmentsEverywhere/API/Util/Updator.swift @@ -0,0 +1,18 @@ +// +// Updator.swift +// AttachmentsEverywhere +// +// Created by Elijah Santos on 8/13/24. +// + +import Foundation +import Combine + +internal class Updator: ObservableObject { + private(set) var updateCount = 0 + + internal func forceViewUpdate() { + self.objectWillChange.send() + updateCount += 1 + } +} diff --git a/Sources/AttachmentsEverywhere/Resources/GlassMaterial.usda b/Sources/AttachmentsEverywhere/Resources/GlassMaterial.usda new file mode 100644 index 0000000..18ab112 --- /dev/null +++ b/Sources/AttachmentsEverywhere/Resources/GlassMaterial.usda @@ -0,0 +1,61 @@ +#usda 1.0 +( + customLayerData = { + string creator = "Reality Composer Pro Version 2.0 (448.0.10.0.2)" + } + defaultPrim = "Root" + metersPerUnit = 1 + upAxis = "Y" +) + +def Xform "Root" +{ + reorder nameChildren = ["Glass", "Video_Dock"] + def Material "Glass" + { + token cullMode = "unspecified" ( + allowedTokens = ["unspecified", "none", "front", "back"] + ) + token outputs:mtlx:surface.connect = + token outputs:realitykit:vertex + float2 ui:nodegraph:realitykit:subgraphOutputs:pos = (596.25, 154.5) + int ui:nodegraph:realitykit:subgraphOutputs:stackingOrder = 161 + + def Shader "PreviewSurface" + { + uniform token info:id = "ND_UsdPreviewSurface_surfaceshader" + float inputs:clearcoat = 0.1 + float inputs:clearcoatRoughness = 1 + color3f inputs:diffuseColor = (0.5, 0.5, 0.5) ( + colorSpace = "srgb_displayp3" + ) + color3f inputs:diffuseColor.connect = None + color3f inputs:emissiveColor + float inputs:ior = 1.5 + float inputs:metallic = 0 + float3 inputs:normal.connect = + float inputs:occlusion = 1 + float inputs:opacity = 0.65 + float inputs:opacity.connect = None + float inputs:roughness = 1 + token outputs:out + float2 ui:nodegraph:node:pos = (354.5, 154.5) + int ui:nodegraph:node:stackingOrder = 241 + string[] ui:nodegraph:realitykit:node:attributesShowingChildren = ["inputs:diffuseColor", "Advanced"] + } + + def Shader "Fractal3D" + { + uniform token info:id = "ND_fractal3d_vector3" + float3 inputs:amplitude = (1, 1, 1) + float inputs:diminish = 2 + float inputs:lacunarity + int inputs:octaves = 13 + float3 inputs:position + float3 outputs:out + float2 ui:nodegraph:node:pos = (68.0625, 89.94531) + int ui:nodegraph:node:stackingOrder = 227 + } + } +} +