diff --git a/Sources/PoieticCore/Design/Design.swift b/Sources/PoieticCore/Design/Design.swift index 57544b5f..1771e59c 100644 --- a/Sources/PoieticCore/Design/Design.swift +++ b/Sources/PoieticCore/Design/Design.swift @@ -347,9 +347,7 @@ public class Design { /// Discards the mutable frame that is associated with the design. /// public func discard(_ frame: TransientFrame) { - precondition(frame.design === self) - precondition(frame.state == .transient) - precondition(_transientFrames[frame.id] != nil) + precondition(isPending(frame)) identityManager.freeReservation(frame.id) identityManager.freeReservations(Array(frame._reservations)) @@ -357,6 +355,14 @@ public class Design { frame.discard() } + /// Return `true` if the transient frame is owned by the design and is in transient state. + /// + public func isPending(_ trans: TransientFrame) -> Bool { + return trans.design === self + && trans.state == .transient + && _transientFrames[trans.id] != nil + } + /// Remove a frame from the design. /// /// The frame will also be removed from named frames, undoable frame list and redo-able frame diff --git a/Sources/PoieticCore/Design/DesignFrame.swift b/Sources/PoieticCore/Design/DesignFrame.swift index dac088ab..4bc87ae8 100644 --- a/Sources/PoieticCore/Design/DesignFrame.swift +++ b/Sources/PoieticCore/Design/DesignFrame.swift @@ -19,8 +19,6 @@ /// - SeeAlso: ``TransientFrame`` /// public final class DesignFrame: Frame, Identifiable { - public typealias Snapshot = ObjectSnapshot - /// Design to which the frame belongs. public unowned let design: Design diff --git a/Sources/PoieticCore/Design/Frame.swift b/Sources/PoieticCore/Design/Frame.swift index f674ebaa..9bb7c2b2 100644 --- a/Sources/PoieticCore/Design/Frame.swift +++ b/Sources/PoieticCore/Design/Frame.swift @@ -46,13 +46,13 @@ public protocol Frame: func filter(type: ObjectType) -> [ObjectSnapshot] /// Get distinct values of an attribute. - func distinctAttribute(_ attributeName: String, ids: [ObjectID]) -> Set + func distinctAttribute(_ attributeName: String, ids: some Collection) -> Set /// Get distinct object types of a list of objects. - func distinctTypes(_ ids: [ObjectID]) -> [ObjectType] + func distinctTypes(_ ids: some Collection) -> [ObjectType] /// Get shared traits of a list of objects. - func sharedTraits(_ ids: [ObjectID]) -> [Trait] + func sharedTraits(_ ids: some Collection) -> [Trait] /// Filter IDs and keep only those that are contained in the frame. /// @@ -223,7 +223,7 @@ extension Frame { // MARK: Distinct queries extension Frame { - public func distinctAttribute(_ attributeName: String, ids: [ObjectID]) -> Set { + public func distinctAttribute(_ attributeName: String, ids: some Collection) -> Set { // TODO: Use ordered set here var values: Set = Set() for id in ids { @@ -239,7 +239,7 @@ extension Frame { /// /// IDs that do not have corresponding objects in the frame are ignored. /// - public func distinctTypes(_ ids: [ObjectID]) -> [ObjectType] { + public func distinctTypes(_ ids: some Collection) -> [ObjectType] { var types: [ObjectType] = [] for id in ids { guard let object = self[id] else { continue } @@ -254,14 +254,14 @@ extension Frame { } /// Get shared traits of a list of objects. - public func sharedTraits(_ ids: [ObjectID]) -> [Trait] { + public func sharedTraits(_ ids: some Collection) -> [Trait] { // TODO: Move this method to metamodel as sharedTraits(_ types: [ObjectType]) - guard ids.count > 0 else { - return [] - } + guard ids.count > 0 else { return [] } let types = self.distinctTypes(ids) + guard !types.isEmpty else { return [] } + var traits = types.first!.traits for type in types.suffix(from: 1) { diff --git a/Sources/PoieticCore/Design/Selection.swift b/Sources/PoieticCore/Design/Selection.swift index 23915a85..1b6f25da 100644 --- a/Sources/PoieticCore/Design/Selection.swift +++ b/Sources/PoieticCore/Design/Selection.swift @@ -5,6 +5,26 @@ // Created by Stefan Urbanek on 18/01/2025. // +import Collections + +/// Component denoting a selection change. +/// +/// Example use-cases of this component: +/// +/// - Selection tool in an application on a mouse interaction. +/// - Search feature in an application +/// +/// - SeeAlso: ``Selection/apply(_:)`` +/// +public enum SelectionChange: Component { + case appendOne(ObjectID) + case append([ObjectID]) + case replaceAllWithOne(ObjectID) + case replaceAll([ObjectID]) + case removeAll + case toggle(ObjectID) +} + /// Collection of selected objects. /// /// An ordered set of object identifiers with convenience methods to support typical user @@ -26,13 +46,14 @@ public final class Selection: Collection, Component { /// When a selection is preserved between changes, it is recommended to sanitise the objects /// in the selection using the ``Frame/existing(from:)`` function. /// - public private(set) var ids: [ObjectID] = [] + public private(set) var ids: OrderedSet = [] public var startIndex: Index { ids.startIndex } public var endIndex: Index { ids.endIndex } public func index(after i: Index) -> Index { ids.index(after: i) } public subscript(i: Index) -> ObjectID { return ids[i] } - + + /// Create an empty selection. public init() { self.ids = [] } @@ -40,12 +61,15 @@ public final class Selection: Collection, Component { /// Create a selection of given IDs /// public init(_ ids:[ObjectID]) { + self.ids = OrderedSet(ids) + } + public init(_ ids:OrderedSet) { self.ids = ids } - + + /// Returns `true` if the selection contains a given ID. public func contains(_ id: ObjectID) -> Bool { - guard !ids.isEmpty else { return false } return ids.contains(id) } @@ -53,30 +77,48 @@ public final class Selection: Collection, Component { return ids.isEmpty } + /// Returns an object ID if it is the only object in the selection, otherwise `nil`. + public func selectionOfOne() -> ObjectID? { + if ids.count == 1 { + return ids.first! + } + else { + return nil + } + } + + /// Apply a selection change. + /// + /// Use this function in a selection system that is typically triggered by an user interaction + /// such as tool use. + /// + public func apply(_ change: SelectionChange) { + switch change { + case .appendOne(let id): self.append([id]) + case .append(let ids): self.append(ids) + case .replaceAllWithOne(let id): self.replaceAll([id]) + case .replaceAll(let ids): self.replaceAll(ids) + case .removeAll: self.removeAll() + case .toggle(let id): self.toggle(id) + } + } + /// Append the ID if it is not already present in the selection. public func append(_ id: ObjectID) { - guard !contains(id) else { - return - } ids.append(id) } /// Append IDs to the selection, if they are not already present in the selection. /// public func append(_ ids: [ObjectID]) { - for id in ids { - guard !contains(id) else { - return - } - self.ids.append(id) - } + self.ids.append(contentsOf: ids) } /// Replace all objects in the selection. /// public func replaceAll(_ ids: [ObjectID]) { self.ids.removeAll() - self.ids += ids + self.ids.append(contentsOf: ids) } @@ -90,8 +132,8 @@ public final class Selection: Collection, Component { /// selection. /// public func toggle(_ id: ObjectID) { - if let index = ids.firstIndex(of: id) { - ids.remove(at: index) + if ids.contains(id) { + ids.remove(id) } else { ids.append(id) @@ -99,67 +141,29 @@ public final class Selection: Collection, Component { } } -extension Selection: SetAlgebra { +extension Selection /* : SetAlgebra */ { public func union(_ other: __owned Selection) -> Self { - var result: [ObjectID] = [] - for item in other { - if !result.contains(item) { - result.append(item) - } - } - return Self(result) + return Self(self.ids.union(other.ids)) } public func intersection(_ other: Selection) -> Self { - var result: [ObjectID] = [] - for item in other { - if ids.contains(item) { - result.append(item) - } - } - return Self(result) + return Self(self.ids.intersection(other.ids)) } public func symmetricDifference(_ other: __owned Selection) -> Self { - fatalError("NOT IMPLEMENTED") - } - - public func insert(_ newMember: __owned ObjectID) -> (inserted: Bool, memberAfterInsert: ObjectID) { - if !ids.contains(newMember) { - ids.append(newMember) - return (true, newMember) - } - else { - return (false, newMember) - } - } - - public func remove(_ member: ObjectID) -> ObjectID? { - if let index = ids.firstIndex(of: member) { - let obj = ids[index] - ids.remove(at: index) - return obj - } - else { - return nil - } - } - - public func update(with newMember: __owned ObjectID) -> ObjectID? { - // do nothing - return newMember + return Self(self.ids.symmetricDifference(other.ids)) } public func formUnion(_ other: __owned Selection) { - fatalError("NOT IMPLEMENTED") + self.ids.formUnion(other.ids) } public func formIntersection(_ other: Selection) { - fatalError("NOT IMPLEMENTED") + self.ids.formIntersection(other.ids) } public func formSymmetricDifference(_ other: __owned Selection) { - fatalError("NOT IMPLEMENTED") + self.ids.formSymmetricDifference(other.ids) } public static func == (lhs: Selection, rhs: Selection) -> Bool { diff --git a/Sources/PoieticCore/Design/TransientObject.swift b/Sources/PoieticCore/Design/TransientObject.swift index 31194cbe..eca93090 100644 --- a/Sources/PoieticCore/Design/TransientObject.swift +++ b/Sources/PoieticCore/Design/TransientObject.swift @@ -7,6 +7,7 @@ import Collections +// FIXME: Remove id and Identifiable (historical remnant that causes confusion) @usableFromInline class _TransientSnapshotBox: Identifiable { // IMPORTANT: Make sure that the self.id is _always_ object ID, not a snapshot ID here. @@ -120,7 +121,6 @@ public class TransientObject: ObjectProtocol { /// Flag to denote whether the object's parent-child hierarchy has been modified, public private(set) var hierarchyChanged: Bool - public private(set) var componentsChanged: Bool /// Set of changed attributes. /// @@ -129,7 +129,7 @@ public class TransientObject: ObjectProtocol { /// public private(set) var changedAttributes: Set - var hasChanges: Bool { !changedAttributes.isEmpty || hierarchyChanged || componentsChanged } + var hasChanges: Bool { !changedAttributes.isEmpty || hierarchyChanged } public init(type: ObjectType, snapshotID: ObjectSnapshotID, @@ -148,7 +148,6 @@ public class TransientObject: ObjectProtocol { attributes: attributes) self.changedAttributes = Set() self.hierarchyChanged = false - self.componentsChanged = false } init(original: ObjectSnapshot, snapshotID: ObjectSnapshotID) { @@ -156,7 +155,6 @@ public class TransientObject: ObjectProtocol { self._body = original._body self.changedAttributes = Set() self.hierarchyChanged = false - self.componentsChanged = false } @inlinable public var objectID: ObjectID { _body.id } diff --git a/Sources/PoieticCore/Expression/Function.swift b/Sources/PoieticCore/Expression/Function.swift index cb226618..10a54e04 100644 --- a/Sources/PoieticCore/Expression/Function.swift +++ b/Sources/PoieticCore/Expression/Function.swift @@ -19,10 +19,6 @@ public struct FunctionArgument: Sendable { /// public let type: VariableType - /// Flag whether the argument is a constant. - /// - public let isConstant: Bool - /// Create a new function argument. /// /// - Parameters: @@ -30,10 +26,9 @@ public struct FunctionArgument: Sendable { /// - type: Argument type. Default is ``VariableType/any``. /// - isConstant: Flag whether the function argument is a constant. /// - public init(_ name: String, type: VariableType = .any, isConstant: Bool = false) { + public init(_ name: String, type: VariableType = .any) { self.name = name self.type = type - self.isConstant = isConstant } } @@ -112,6 +107,35 @@ public final class Signature: CustomStringConvertible, Sendable { ], returns: .double ) + public static let NumericBinaryOperator = Signature( + [ + FunctionArgument("left", type: .union([.int, .double])), + FunctionArgument("right", type: .union([.int, .double])) + ], + returns: .double + ) + public static let EquatableOperator = Signature( + [ + FunctionArgument("left", type: .any), + FunctionArgument("right", type: .any) + ], + returns: .bool + ) + public static let ComparisonOperator = Signature( + [ + FunctionArgument("left", type: .union([.int, .double])), + FunctionArgument("right", type: .union([.int, .double])) + ], + returns: .bool + ) + public static let LogicalBinaryOperator = Signature( + [ + FunctionArgument("left", type: .union([.bool])), + FunctionArgument("right", type: .union([.bool])) + ], + returns: .bool + ) + /// Convenience signature representing a numeric function with many /// numeric arguments. /// diff --git a/Sources/PoieticCore/System/ExpressionParserSystem.swift b/Sources/PoieticCore/System/ExpressionParserSystem.swift index b00c562f..b7d09017 100644 --- a/Sources/PoieticCore/System/ExpressionParserSystem.swift +++ b/Sources/PoieticCore/System/ExpressionParserSystem.swift @@ -23,22 +23,19 @@ public struct ExpressionParserSystem: System { guard let frame = world.frame else { return } for object in frame.filter(trait: .Formula) { - guard let formula: String = object["formula"] else { continue } - parseExpression(formula, object: object, in: world) + guard let formula: String = object["formula"], + let entity = world.entity(object.objectID) + else { continue } + parseExpression(formula, object: object, entity: entity) } } - func parseExpression(_ formula: String, object: ObjectSnapshot, in world: World) { + func parseExpression(_ formula: String, object: ObjectSnapshot, entity: RuntimeEntity) { + let expr: UnboundExpression + let component: ParsedExpressionComponent + let parser = ExpressionParser(string: formula) do { - let expr: UnboundExpression - let component: ParsedExpressionComponent - let parser = ExpressionParser(string: formula) expr = try parser.parse() - component = ParsedExpressionComponent( - expression: expr, - variables: Set(expr.allVariables) - ) - world.setComponent(component, for: object.objectID) } catch { let issue = Issue( @@ -52,8 +49,11 @@ public struct ExpressionParserSystem: System { ] ) - world.appendIssue(issue, for: object.objectID) + entity.appendIssue(issue) + return } - + let vars = Set(expr.allVariables) + component = ParsedExpressionComponent(expression: expr, variables: vars) + entity.setComponent(component) } } diff --git a/Sources/PoieticCore/Variant/Variant+Comparison.swift b/Sources/PoieticCore/Variant/Variant+Comparison.swift index c31fccce..ac4e326e 100644 --- a/Sources/PoieticCore/Variant/Variant+Comparison.swift +++ b/Sources/PoieticCore/Variant/Variant+Comparison.swift @@ -8,6 +8,15 @@ // extension VariantAtom { + /// Compares two variant atoms. + /// + /// Rules: + /// - Two ints are compared as they are + /// - Two doubles are compared as they are + /// - When comparing int and double, int is casted to double and compared as doubles. + /// - Two strings are compared lexicographically. + /// - Other type combinations are not considered comparable (string is not cast to a number, + /// neither int to a bool or vice versa) public static func <(lhs: VariantAtom, rhs: VariantAtom) -> Bool { switch (lhs, rhs) { case let (.int(lvalue), .int(rvalue)): lvalue < rvalue @@ -18,6 +27,15 @@ extension VariantAtom { default: false } } + /// Compares two variant atoms. + /// + /// Rules: + /// - Two ints are compared as they are + /// - Two doubles are compared as they are + /// - When comparing int and double, int is casted to double and compared as doubles. + /// - Two strings are compared lexicographically. + /// - Other type combinations are not considered comparable (string is not cast to a number, + /// neither int to a bool or vice versa) public static func <=(lhs: VariantAtom, rhs: VariantAtom) -> Bool { switch (lhs, rhs) { case let (.int(lvalue), .int(rvalue)): lvalue <= rvalue @@ -29,7 +47,16 @@ extension VariantAtom { default: false } } - + + /// Compares two variant atoms. + /// + /// Rules: + /// - Two ints are compared as they are + /// - Two doubles are compared as they are + /// - When comparing int and double, int is casted to double and compared as doubles. + /// - Two strings are compared lexicographically. + /// - Other type combinations are not considered comparable (string is not cast to a number, + /// neither int to a bool or vice versa) public static func >(lhs: VariantAtom, rhs: VariantAtom) -> Bool { switch (lhs, rhs) { case let (.int(lvalue), .int(rvalue)): lvalue > rvalue @@ -40,6 +67,15 @@ extension VariantAtom { default: false } } + /// Compares two variant atoms. + /// + /// Rules: + /// - Two ints are compared as they are + /// - Two doubles are compared as they are + /// - When comparing int and double, int is casted to double and compared as doubles. + /// - Two strings are compared lexicographically. + /// - Other type combinations are not considered comparable (string is not cast to a number, + /// neither int to a bool or vice versa) public static func >=(lhs: VariantAtom, rhs: VariantAtom) -> Bool { switch (lhs, rhs) { case let (.int(lvalue), .int(rvalue)): lvalue >= rvalue @@ -51,7 +87,16 @@ extension VariantAtom { default: false } } +} +extension VariantAtom { + /// Compares two variant atoms for equality. + /// + /// Rules: + /// - If both are of the same, they are compared as they are. + /// - An int and an double are compared by casting the int to double and then comparing doubles. + /// - Other type combinations are not considered comparable (string is not cast to a number, + /// neither int to a bool or vice versa) public static func ==(lhs: VariantAtom, rhs: VariantAtom) -> Bool { switch (lhs, rhs) { case let (.int(lvalue), .int(rvalue)): lvalue == rvalue @@ -65,6 +110,13 @@ extension VariantAtom { } } + /// Compares two variant atoms for equality. + /// + /// Rules: + /// - If both are of the same, they are compared as they are. + /// - An int and an double are compared by casting the int to double and then comparing doubles. + /// - Other type combinations are not considered comparable (string is not cast to a number, + /// neither int to a bool or vice versa) public static func !=(lhs: VariantAtom, rhs: VariantAtom) -> Bool { switch (lhs, rhs) { case let (.int(lvalue), .int(rvalue)): lvalue != rvalue @@ -79,19 +131,14 @@ extension VariantAtom { } } extension VariantArray { - public static func <(lhs: VariantArray, rhs: VariantArray) throws -> Bool { - false - } - public static func <=(lhs: VariantArray, rhs: VariantArray) throws -> Bool { - false - } - - public static func >(lhs: VariantArray, rhs: VariantArray) throws -> Bool { - false - } - public static func >=(lhs: VariantArray, rhs: VariantArray) throws -> Bool { - false - } + /// Compares two variant arrays for equality. + /// + /// Rules: + /// - If both are of the same type, they are compared as they are. + /// - If one is array of ints and other of double, the ints are cast to doubles and the arrays + /// are compared as arrays of doubles. + /// - Other type combinations are not considered comparable (string is not cast to a number, + /// neither int to a bool or vice versa) public static func ==(lhs: VariantArray, rhs: VariantArray) -> Bool { switch (lhs, rhs) { case let (.int(lvalue), .int(rvalue)): lvalue == rvalue @@ -105,6 +152,14 @@ extension VariantArray { } } + /// Compares two variant arrays for equality. + /// + /// Rules: + /// - If both are of the same type, they are compared as they are. + /// - If one is array of ints and other of double, the ints are cast to doubles and the arrays + /// are compared as arrays of doubles. + /// - Other type combinations are not considered comparable (string is not cast to a number, + /// neither int to a bool or vice versa) public static func !=(lhs: VariantArray, rhs: VariantArray) -> Bool { switch (lhs, rhs) { case let (.int(lvalue), .int(rvalue)): lvalue != rvalue diff --git a/Sources/PoieticCore/World/Component.swift b/Sources/PoieticCore/World/Component.swift index 72d76fdb..3c235649 100644 --- a/Sources/PoieticCore/World/Component.swift +++ b/Sources/PoieticCore/World/Component.swift @@ -22,209 +22,4 @@ public protocol Component { // Empty, just an annotation. } -/// Component where some or all of its attributes can be inspected as ``Variant``s. -/// -public protocol InspectableComponent: Component { - static var attributeKeys: [String] { get } - func attribute(forKey key: String) -> Variant? - - static var toOneEntityReferenceKeys: [String] { get } - static var toOneDesignReferenceKeys: [String] { get } - func entityReference(forKey key: String) -> RuntimeID? - func designReference(forKey key: String) -> DesignEntityID? - - static var toManyEntityReferenceKeys: [String] { get } - static var toManyDesignReferenceKeys: [String] { get } - func entityReferences(forKey key: String) -> [RuntimeID] - func designReferences(forKey key: String) -> [DesignEntityID] -} - -extension InspectableComponent { - public static var toOneEntityReferenceKeys: [String] { [] } - public static var toOneDesignReferenceKeys: [String] { [] } - public static var toManyEntityReferenceKeys: [String] { [] } - public static var toManyDesignReferenceKeys: [String] { [] } - - public func attributeDictionary() -> [String:Variant] { - var result: [String:Variant] = [:] - for key in Self.attributeKeys { - guard let value = attribute(forKey: key) else { continue } - result[key] = value - } - return result - } - public func entityReference(forKey key: String) -> RuntimeID? { - return nil - } - public func designReference(forKey key: String) -> DesignEntityID? { - return nil - } - public func entityReferences(forKey key: String) -> [RuntimeID] { - return [] - } - public func designReferences(forKey key: String) -> [DesignEntityID] { - return [] - } -} - -/// Collection of components of an object. -/// -/// - Note: This is a naive implementation. Purpose is rather semantic, -/// definitely not optimised for any performance. -/// -public struct ComponentSet { - var components: [Component] = [] - - /// Create a component set from a list of components. - /// - /// If the list of components contains multiple components of the same - /// type, then the later component in the list will be considered and the - /// previous one discarded. - /// - public init(_ components: [Component]) { - self.set(components) - } - - /// Sets a component in the component set. - /// - /// If a component of the same type as `component` exists, then it is - /// replaced by the new instance. - /// - public mutating func set(_ component: Component) { - let componentType = type(of: component) - let index: Int? = components.firstIndex { - type(of: $0) == componentType - } - if let index { - components[index] = component - } - else { - components.append(component) - } - } - - /// Set all components in the provided list. - /// - /// If the list of components contains multiple components of the same - /// type, then the later component in the list will be considered and the - /// previous one discarded. - /// - /// Existing components of the same type will be replaced by the instances - /// in the list. - /// - public mutating func set(_ components: [Component]) { - for component in components { - set(component) - } - } - - /// Removes all components from the set. - /// - public mutating func removeAll() { - components.removeAll() - } - - /// Remove a component of the given type. - /// - /// If the component set does not contain a component of the given type - /// nothing happens. - /// - public mutating func remove(_ componentType: Component.Type) { - components.removeAll { - type(of: $0) == componentType - } - } - - public subscript(componentType: Component.Type) -> (Component)? { - get { - let first: Component? = components.first { - type(of: $0) == componentType - } - return first - } - set(component) { - let index: Int? = components.firstIndex { - type(of: $0) == componentType - } - if let index { - if let component { - components[index] = component - } - else { - components.remove(at: index) - } - } - else { - if let component { - components.append(component) - } - } - - } - } - - public subscript(componentType: T.Type) -> T? where T : Component { - get { - for component in components { - if let component = component as? T { - return component - } - } - return nil - } - set(component) { - let index: Int? = components.firstIndex { - type(of: $0) == componentType - } - if let index { - if let component { - components[index] = component - } - else { - components.remove(at: index) - } - } - else { - if let component { - components.append(component) - } - } - } - } - - /// Get a count of the components in the set. - /// - public var count: Int { components.count } - - /// Checks wether the component set contains a component of given type. - /// - /// - Returns: `true` if the component set contains a component of given - /// type. - /// - public func has(_ componentType: Component.Type) -> Bool{ - return components.contains { - type(of: $0) == componentType - } - } -} - -extension ComponentSet: ExpressibleByArrayLiteral { - public init(arrayLiteral elements: ArrayLiteralElement...) { - self.init(elements) - } - - public typealias ArrayLiteralElement = Component -} - -extension ComponentSet: Collection { - public typealias Index = Array.Index - public var startIndex: Index { return components.startIndex } - public var endIndex: Index { return components.endIndex } - public func index(after index: Index) -> Index { - return components.index(after: index) - } - public subscript(index: Index) -> any Component { - return components[index] - } -} diff --git a/Sources/PoieticCore/World/ComponentSet.swift b/Sources/PoieticCore/World/ComponentSet.swift new file mode 100644 index 00000000..aabca9d3 --- /dev/null +++ b/Sources/PoieticCore/World/ComponentSet.swift @@ -0,0 +1,133 @@ +// +// ComponentSet.swift +// poietic-core +// +// Created by Stefan Urbanek on 01/03/2026. +// + + +/// Collection of components. +/// +/// Used primarily to store singletons. +/// +public struct ComponentSet { + private var components: [ObjectIdentifier: Component] = [:] + + /// Create a component set from a list of components. + /// + /// If the list of components contains multiple components of the same + /// type, then the later component in the list will be considered and the + /// previous one discarded. + /// + public init(_ components: [Component]) { + self.set(components) + } + + /// Sets a component in the component set. + /// + /// If a component of the same type as `component` exists, then it is + /// replaced by the new instance. + /// + public mutating func set(_ component: Component) { + let typeID = ObjectIdentifier(type(of: component)) + components[typeID] = component + } + + /// Set all components in the provided list. + /// + /// If the list of components contains multiple components of the same + /// type, then the later component in the list will be considered and the + /// previous one discarded. + /// + /// Existing components of the same type will be replaced by the instances + /// in the list. + /// + public mutating func set(_ components: [Component]) { + for component in components { + set(component) + } + } + + /// Removes all components from the set. + /// + public mutating func removeAll() { + components.removeAll() + } + + /// Remove a component of the given type. + /// + /// If the component set does not contain a component of the given type + /// nothing happens. + /// + public mutating func remove(_ componentType: Component.Type) { + let typeID = ObjectIdentifier(componentType) + components[typeID] = nil + } + public mutating func remove(_ oid: ObjectIdentifier) { + components[oid] = nil + } + + public subscript(componentType: Component.Type) -> (Component)? { + get { + let typeID = ObjectIdentifier(componentType) + return components[typeID] + } + set(component) { + if let component { + self.set(component) + } + else { + self.remove(componentType) + } + } + } + + public subscript(componentType: T.Type) -> T? where T : Component { + get { + let typeID = ObjectIdentifier(componentType) + return components[typeID] as? T + } + set(component) { + if let component { + self.set(component) + } + else { + self.remove(componentType) + } + } + } + + /// Get a count of the components in the set. + /// + public var count: Int { components.count } + + /// Checks wether the component set contains a component of given type. + /// + /// - Returns: `true` if the component set contains a component of given + /// type. + /// + public func has(_ componentType: Component.Type) -> Bool{ + let typeID = ObjectIdentifier(componentType) + return components[typeID] != nil + } +} + +extension ComponentSet: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: ArrayLiteralElement...) { + self.init(elements) + } + + public typealias ArrayLiteralElement = Component +} + +extension ComponentSet: Collection { + public typealias Index = Dictionary.Index + public var startIndex: Index { return components.startIndex } + public var endIndex: Index { return components.endIndex } + public func index(after index: Index) -> Index { + return components.index(after: index) + } + public subscript(index: Index) -> any Component { + return components[index].value + } +} diff --git a/Sources/PoieticCore/World/ComponentStorage.swift b/Sources/PoieticCore/World/ComponentStorage.swift new file mode 100644 index 00000000..b0a221a7 --- /dev/null +++ b/Sources/PoieticCore/World/ComponentStorage.swift @@ -0,0 +1,58 @@ +// +// ComponentStorage.swift +// poietic-core +// +// Created by Stefan Urbanek on 28/02/2026. +// + +protocol ComponentStorageProtocol { + associatedtype ComponentType: Component + func removeComponent(for entity: RuntimeID) + func hasComponent(for entity: RuntimeID) -> Bool + func removeAll() + func component(for runtimeID: RuntimeID) -> ComponentType? + func relationship(for runtimeID: RuntimeID) -> (any Relationship)? +} + +extension ComponentStorageProtocol { + func relationship(for runtimeID: RuntimeID) -> (any Relationship)? { + return nil + } +} + +extension ComponentStorageProtocol where ComponentType: Relationship { + func relationship(for runtimeID: RuntimeID) -> (any Relationship)? { + return component(for: runtimeID) as? Relationship + } +} + +final class ComponentStorage: ComponentStorageProtocol { + typealias ComponentType = C + private var components: [RuntimeID: ComponentType] = [:] + + func setComponent(_ component: ComponentType, for runtimeID: RuntimeID) + { + components[runtimeID] = component + } + + func component(for runtimeID: RuntimeID) -> ComponentType? { + return components[runtimeID] + } + + func removeComponent(for runtimeID: RuntimeID) { + components.removeValue(forKey: runtimeID) + } + + func removeAll() { + components.removeAll() + } + + func hasComponent(for runtimeID: RuntimeID) -> Bool { + return components[runtimeID] != nil + } + + // For iteration over all entities with this component +// var allEntities: Dictionary.Keys { +// return components.keys +// } +} diff --git a/Sources/PoieticCore/World/Query.swift b/Sources/PoieticCore/World/Query.swift index e59279a2..23734b33 100644 --- a/Sources/PoieticCore/World/Query.swift +++ b/Sources/PoieticCore/World/Query.swift @@ -5,69 +5,35 @@ // Created by Stefan Urbanek on 19/12/2025. // -public class QueryResult where T: Component { - public typealias ComponentType = T - public typealias Element = (RuntimeID, ComponentType) - public typealias Iterator = [Element].Iterator - - let items: [Element] - - init(_ items: [Element]) { - self.items = items - } +/// Query result. +/// +/// _Development note:_ It is a wrapped compact map over all world entities. +/// +/// - Complexity: O(n). For now. +/// - Note: This is a makeshift world query structure and it is not cheap. +/// +public struct QueryResult: Sequence, IteratorProtocol { + public typealias Element = T - /// Get a single element from the result if the result contains only one element. If the result - /// contains more than one element then `nil` is returned. - /// - public func single() -> Element? { - guard items.count == 1 else { return nil } - return items.first + typealias WrappedIterator = [RuntimeID].Iterator + var wrapped: WrappedIterator + let predicate: ((RuntimeEntity) -> T?) + let world: World + + init(world: World, iterator: WrappedIterator? = nil, predicate: @escaping ((RuntimeEntity) -> T?)) { + self.world = world + self.wrapped = iterator ?? world.entities.makeIterator() + self.predicate = predicate } - public var count: Int { items.count } - public var first: Element? { items.first } - public var isEmpty: Bool { items.isEmpty } - - /// - Complexity: O(n) - public func contains(_ id: RuntimeID) -> Bool { - // TODO: Make this O(1) - return items.contains {$0.0 == id} + public mutating func next() -> Element? { + while let value = wrapped.next() { + let entity = RuntimeEntity(runtimeID: value, world: world) + if let result = predicate(entity) { + return result + } + } + return nil } } -extension QueryResult: Sequence { - public func makeIterator() -> Array.Iterator { - return items.makeIterator() - } -} - -#if false -public class MultiQueryResult { - public typealias Element = (EphemeralID, repeat each T) - public typealias Iterator = [Element].Iterator - - let items: [Element] - - init(_ items: [Element]) { - self.items = items - } - - /// Get a single element from the result if the result contains only one element. If the result - /// contains more than one element then `nil` is returned. - /// - public func single() -> Element? { - guard items.count == 1 else { return nil } - return items.first - } - - public var count: Int { items.count } - public var first: Element? { items.first } - public var isEmpty: Bool { items.isEmpty } - - /// - Complexity: O(n) - public func contains(_ id: EphemeralID) -> Bool { - // TODO: Make this O(1) - return items.contains {$0.0 == id} - } -} -#endif diff --git a/Sources/PoieticCore/World/Relationship.swift b/Sources/PoieticCore/World/Relationship.swift new file mode 100644 index 00000000..29d24b00 --- /dev/null +++ b/Sources/PoieticCore/World/Relationship.swift @@ -0,0 +1,60 @@ +// +// Relationship.swift +// poietic-core +// +// Created by Stefan Urbanek on 27/02/2026. +// + +/// Defines cleanup behaviour when the target of a relationship is removed +public enum RemovalPolicy: Sendable, Equatable { + /// Remove the entity holding this relationship component + case removeSelf + + /// Remove just this relationship component from the source + case removeRelationship + + // Remove the target entity + // case removeTarget + + /// Do nothing automatically (manual cleanup required) + case none +} + + +/// A component that represents a relationship between two entities +public protocol Relationship: Component { + + /// The target entity this relationship points to + var target: RuntimeID { get } + + /// Defines what happens when the target entity is removed + static var removalPolicy: RemovalPolicy { get } + + // TODO: insert/removal hooks +} + +// MARK: - Relationship Components + +/// Indicates that an entity is a child of another entity +public struct ChildOf: Relationship { + public let target: RuntimeID + + /// When parent is removed, remove the child + public static let removalPolicy: RemovalPolicy = .removeSelf + + public init(_ parent: RuntimeID) { + self.target = parent + } +} + +/// Indicates ownership - when owner is removed, remove the owned entity +public struct OwnedBy: Relationship { + public let target: RuntimeID + + /// When owner is removed, remove the owned entity + public static let removalPolicy: RemovalPolicy = .removeSelf + + public init(_ owner: RuntimeID) { + self.target = owner + } +} diff --git a/Sources/PoieticCore/World/RuntimeEntity.swift b/Sources/PoieticCore/World/RuntimeEntity.swift index dd20faa0..bb1b7dd7 100644 --- a/Sources/PoieticCore/World/RuntimeEntity.swift +++ b/Sources/PoieticCore/World/RuntimeEntity.swift @@ -43,3 +43,148 @@ public struct RuntimeID: public var description: String { String(value) } } + +/// Structure representing a runtime, in-memory non-persistent entity that lives in a ``World``. +/// +/// Entities are identified by ``RuntimeID``. +/// +public struct RuntimeEntity { + public let runtimeID: RuntimeID + public unowned let world: World + + /// Get + public var objectID: ObjectID? { world.entityToObjectMap[runtimeID] } + + /// Get corresponding design object that is being represented by the runtime entity, if it + /// exists in the world's current frame. + /// + public var designObject: ObjectSnapshot? { + guard let objectID = world.entityToObjectMap[runtimeID] else { return nil } + return world.frame?[objectID] + } + + internal init(runtimeID: RuntimeID, world: World) { + self.runtimeID = runtimeID + self.world = world + } + + /// Check if an object has a specific component type + /// + /// - Parameters: + /// - type: The component type to check + /// - runtimeID: The object ID + /// - Returns: True if the object has the component, otherwise false + /// + public func contains(_ type: T.Type) -> Bool { + return world._containsComponent(type, for: self.runtimeID) + } + + /// Get a component for a runtime object + /// + /// - Parameters: + /// - runtimeID: Runtime ID of an object or an ephemeral entity. + /// - Returns: The component if it exists, otherwise nil + /// + public func component() -> T? { + return world._getComponent(T.self, for: self.runtimeID) + } + + /// Set a component for an entity. + /// + /// If a component of the same type already exists for this object, + /// it will be replaced. + /// + /// - Parameters: + /// - component: The component to set + /// - runtimeID: The object ID + /// + /// - Precondition: Entity must exist in the world. + /// + public func setComponent(_ component: T) { + precondition(world.entities.contains(runtimeID)) + world._setComponent(component, for: self.runtimeID) + } + + /// Remove a component from an object + /// + /// - Parameters: + /// - type: The component type to remove + /// - runtimeID: The object ID + /// + public func removeComponent(_ type: T.Type) { + world._removeComponent(type, for: runtimeID) + } + + public func modify( + _ modification: (inout T) -> Result + ) -> Result? { + guard var component: T = component() else { + return nil + } + let result = modification(&component) + setComponent(component) + return result + } + + /// Append a user-facing issue for the entity representing a design object. + /// + /// Issues are non-fatal problems with user data. Systems should append + /// issues here rather than throwing errors, allowing processing to continue + /// and collect multiple issues. + /// + /// - Parameters: + /// - issue: The error/issue to append + /// - objectID: The object ID associated with the issue + /// + ///- Returns: `true` if the entity represents a design object, otherwise false. + /// + @discardableResult + public func appendIssue(_ issue: Issue) -> Bool { + guard let objectID = self.objectID else { return false } + world.issues[objectID, default: []].append(issue) + return false + } + + public var issues: [Issue]? { + guard let objectID = self.objectID else { return nil } + return world.issues[objectID] + } + + public var hasIssues: Bool { + guard let objectID = self.objectID else { return false } + if let issues = world.issues[objectID] { return !issues.isEmpty } + else { return false } + } + + /// Access components via subscript syntax. + /// + /// ```swift + /// // Get a component + /// if let position = entity[Position.self] { + /// print(position.x, position.y) + /// } + /// + /// // Set a component + /// entity[Position.self] = Position(x: 10, y: 20) + /// + /// // Remove a component (by setting to nil) + /// entity[Position.self] = nil + /// + /// // Mutate in place (for value types, creates copy, mutates, sets back) + /// entity[Position.self]?.x += 10 // Works but creates copy! + /// ``` + /// + public subscript(_ type: T.Type) -> T? { + get { + return component() + } + set { + if let newValue { + setComponent(newValue) + } else { + removeComponent(type) + } + } + } + +} diff --git a/Sources/PoieticCore/World/World.swift b/Sources/PoieticCore/World/World.swift index 1d836b80..1b6d01e7 100644 --- a/Sources/PoieticCore/World/World.swift +++ b/Sources/PoieticCore/World/World.swift @@ -14,8 +14,9 @@ /// - design issue management /// public class World { - let design: Design + public let design: Design // FIXME: Rename to currentFrame + public private(set) var frame: DesignFrame? // Identity @@ -43,27 +44,38 @@ public class World { /// created by the users, therefore associating issues with them is not only unhelpful but /// also meaningless. Users can act only on objects they created. /// - public private(set) var issues: [ObjectID: [Issue]] + public internal(set) var issues: [ObjectID: [Issue]] internal var objectToEntityMap: [ObjectID:RuntimeID] internal var entityToObjectMap: [RuntimeID:ObjectID] /// Entity ID representing current frame. /// internal var entities: [RuntimeID] - private var components: [RuntimeID: ComponentSet] /// Components without an entity. /// /// Only one component of given type might exist in the world as a singleton. /// public private(set) var singletons: ComponentSet + private var storages: [ObjectIdentifier: any ComponentStorageProtocol] = [:] - /// Existential dependencies between entities. + struct Dependant: Hashable { + /// Who is pointing at the target? + let sourceID: RuntimeID + /// Component that is pointing to the source + let componentTypeID: ObjectIdentifier + let removalPolicy: RemovalPolicy + } + + /// Dependencies between entities based on relationships. /// /// Keys are entities that other entities depend on, values are sets of dependants. /// When an entity is de-spawned from the world all its dependants are de-spawned cascadingly. /// - var dependencies: [RuntimeID:Set] + /// - SeeAlso: ``Relationship``, ``ChildOf``, ``OwnedBy`` + /// + var dependencies: [RuntimeID:Set] +// var dependencies: [RuntimeID:[RuntimeID:(ObjectIdentifier, RemovalPolicy)]] public init(design: Design) { self.design = design @@ -71,7 +83,6 @@ public class World { self.schedules = [:] self.scheduleLabels = [:] self.dependencies = [:] - self.components = [:] self.issues = [:] self.entities = [] @@ -92,6 +103,7 @@ public class World { /// Objects in the ``frame`` are always guaranteed to have an entity that represents them. /// public func entityToObject(_ ephemeralID: RuntimeID) -> ObjectID? { + // TODO: [REFACTORING] Rename to runtimeToObject entityToObjectMap[ephemeralID] } /// Get an entity that represents an object with given ID, if such entity exists. @@ -99,6 +111,7 @@ public class World { /// Objects in the ``frame`` are always guaranteed to have an entity that represents them. /// public func objectToEntity(_ objectID: ObjectID) -> RuntimeID? { + // TODO: [REFACTORING] Rename to objectToRuntime objectToEntityMap[objectID] } @@ -107,7 +120,23 @@ public class World { public func contains(_ id: RuntimeID) -> Bool { self.entities.contains(id) } + /// Test whether the world contains an entity. + /// + public func contains(_ entity: RuntimeEntity) -> Bool { + self.entities.contains(entity.runtimeID) + } + + public func entity(_ runtimeID: RuntimeID) -> RuntimeEntity? { + guard self.entities.contains(runtimeID) else { return nil } + return RuntimeEntity(runtimeID: runtimeID, world: self) + } + + public func entity(_ objectID: ObjectID) -> RuntimeEntity? { + guard let runtimeID = objectToEntityMap[objectID] else { return nil } + return RuntimeEntity(runtimeID: runtimeID, world: self) + } + public func addSchedule(_ schedule: Schedule) { let id = ObjectIdentifier(schedule.label) self.schedules[id] = schedule @@ -157,7 +186,7 @@ public class World { else { return } for objectID in frame.objectIDs { - let runtimeID = spawn() + let runtimeID: RuntimeID = spawn() objectToEntityMap[objectID] = runtimeID entityToObjectMap[runtimeID] = objectID } @@ -167,15 +196,26 @@ public class World { /// /// - Returns: Entity ID of the spawned entity. /// - public func spawn(_ components: any Component...) -> RuntimeID { - // TODO: Use lock once we are multi-thread ready (we are not) + public func spawn(_ components: [any Component]) -> RuntimeID { let value = entitySequence entitySequence += 1 let id = RuntimeID(intValue: value) - self.components[id] = ComponentSet(components) self.entities.append(id) + for component in components { + self._setComponent(component, for: id) + } return id } + + public func spawn(_ components: any Component...) -> RuntimeID { + // TODO: Use lock once we are multi-thread ready (we are not) + return self.spawn(components) + } + + public func spawn(_ components: any Component...) -> RuntimeEntity { + let id = self.spawn(components) + return RuntimeEntity(runtimeID: id, world: self) + } /// Removes the entity from the world and all entities that depend on it. /// @@ -185,61 +225,43 @@ public class World { public func despawn(_ id: RuntimeID) { self.despawn([id]) } + public func despawn(_ entity: RuntimeEntity) { + self.despawn([entity.runtimeID]) + } + public func despawn(_ ids: some Sequence) { var trash: Set = Set(ids) - var removed: Set = Set() + guard !trash.isEmpty else { return } + + var removed: Set = [] while !trash.isEmpty { let id = trash.removeFirst() - - if let objectID = entityToObjectMap.removeValue(forKey: id) { - objectToEntityMap[objectID] = nil + removed.insert(id) + defer { + _removeAllComponents(for: id) } - removed.insert(id) - if let dependants = dependencies.removeValue(forKey: id) { - let remaining = dependants.subtracting(removed) - trash.formUnion(remaining) + guard let dependants = self.dependencies[id] else { continue } + + for dependant in dependants { + guard !removed.contains(dependant.sourceID) && !trash.contains(dependant.sourceID) + else { continue } + + switch dependant.removalPolicy { + case .removeSelf: + trash.insert(dependant.sourceID) + case .removeRelationship: + self._removeComponent(dependant.componentTypeID, for: dependant.sourceID) + case .none: + break + } } - components[id] = nil - entities.removeAll { $0 == id } } - } - // MARK: - Dependencies - /// Make existence of an entity `dependant` dependent on entity `master`. - /// - /// When entity `master` is removed from the world, all its dependants are removed as well. - /// - /// - Precondition: `dependant` and `master` must exist as world entities. - /// - public func setDependency(of dependant: RuntimeID, on master: RuntimeID) { - precondition(entities.contains(dependant)) - precondition(entities.contains(master)) - dependencies[master, default: Set()].insert(dependant) + entities.removeAll { removed.contains($0) } } // MARK: - Components - /// Get a component for a runtime object - /// - /// - Parameters: - /// - runtimeID: Runtime ID of an object or an ephemeral entity. - /// - Returns: The component if it exists, otherwise nil - /// - public func component(for runtimeID: RuntimeID) -> T? { - components[runtimeID]?[T.self] - } - - /// Get a component for a runtime object - /// - /// - Parameters: - /// - objectID: The object ID - /// - Returns: The component if it exists, otherwise nil - /// - public func component(for objectID: ObjectID) -> T? { - guard let runtimeID = objectToEntityMap[objectID] else { return nil } - return components[runtimeID]?[T.self] - } - /// Set a component for an entity. /// /// If a component of the same type already exists for this object, @@ -251,21 +273,88 @@ public class World { /// /// - Precondition: Entity must exist in the world. /// - public func setComponent(_ component: T, for runtimeID: RuntimeID) { + internal func _setComponent(_ component: T, for runtimeID: RuntimeID) { precondition(entities.contains(runtimeID)) - // TODO: Check whether the object exists - components[runtimeID, default: ComponentSet()].set(component) + + let storage = componentStorage(for: T.self) + storage.setComponent(component, for: runtimeID) + + if let rship = component as? Relationship { + let type = type(of: rship) + let dep = Dependant(sourceID: runtimeID, + componentTypeID: ObjectIdentifier(type), + removalPolicy: type.removalPolicy) + dependencies[rship.target, default: Set()].insert(dep) + } + } + + internal func _containsComponent(_ type: T.Type, for runtimeID: RuntimeID) -> Bool { + let storage = componentStorage(for: T.self) + return storage.hasComponent(for: runtimeID) + } + internal func _getComponent(_ type: T.Type, for runtimeID: RuntimeID) -> T? { + let storage = componentStorage(for: T.self) + return storage.component(for: runtimeID) + } + + private func componentStorage(for type: T.Type) -> ComponentStorage { + let id = ObjectIdentifier(T.self) + + if let existing = storages[id] as? ComponentStorage { + return existing + } + + let newStorage = ComponentStorage() + storages[id] = newStorage + return newStorage + } + + /// Remove a component from an object + /// + /// - Parameters: + /// - type: The component type to remove + /// - runtimeID: The object ID + /// + public func _removeComponent(_ type: T.Type, for runtimeID: RuntimeID) { + let componentTypeID = ObjectIdentifier(T.self) + _removeComponent(componentTypeID, for: runtimeID) } + public func _removeComponent(_ componentTypeID: ObjectIdentifier, for runtimeID: RuntimeID) { + guard let storage = storages[componentTypeID] else { return } + + if let relationship = storage.relationship(for: runtimeID) + { + let removalPolicy = type(of: relationship).removalPolicy + let item = Dependant(sourceID: runtimeID, + componentTypeID: componentTypeID, + removalPolicy: removalPolicy) + dependencies[relationship.target, default: Set()].remove(item) + } + + storage.removeComponent(for: runtimeID) + } + + public func removeComponentForAll(_ type: T.Type) { + let storageTypeID = ObjectIdentifier(type) + guard let storage = storages[storageTypeID] else { return } + storage.removeAll() + } + + /// Remove all components from an entity. + func _removeAllComponents(for runtimeID: RuntimeID) { + for storage in storages.values { + storage.removeComponent(for: runtimeID) + } + } + /// Set singleton component – a component without an entity. /// public func setSingleton(_ component: T) { - // TODO: Check whether the object exists singletons.set(component) } public func removeSingleton(_ component: T.Type) { - // TODO: Check whether the object exists singletons.remove(component) } @@ -280,76 +369,73 @@ public class World { singletons.has(component) } - /// Set a component for an entity representing an object. - /// - /// This is a convenience method. + // MARK: - Query + /// Get a list of entities which represent objects from the list. /// - /// - SeeAlso: ``setComponent(_:for:)-(_,RuntimeID)`` - /// - Precondition: Entity representing the object must exist. + /// - Complexity: O(n). For now. See ``QueryResult`` for developer comments. /// - public func setComponent(_ component: T, for objectID: ObjectID) { - guard let runtimeID = objectToEntityMap[objectID] else { - preconditionFailure("Object without entity") + public func query(_ ids: some Sequence) -> QueryResult { + let runtimeIDs = ids.compactMap { objectToEntityMap[$0] } + return QueryResult(world: self, iterator: runtimeIDs.makeIterator()) { entity in + guard entity.objectID != nil else { return nil } + return entity } - setComponent(component, for: runtimeID) } - /// Check if an object has a specific component type - /// - /// - Parameters: - /// - type: The component type to check - /// - runtimeID: The object ID - /// - Returns: True if the object has the component, otherwise false - /// - public func hasComponent(_ type: T.Type, for runtimeID: RuntimeID) -> Bool { - components[runtimeID]?.has(type) ?? false - } - - /// Remove a component from an object + // FIXME: Make the query(...) methods use the ComponentStorage. Current implementation is a historical remnant. + /// Get a list of objects with given component. /// - /// - Parameters: - /// - type: The component type to remove - /// - runtimeID: The object ID + /// - Complexity: O(n). For now. See ``QueryResult`` for developer comments. /// - public func removeComponent(_ type: T.Type, for runtimeID: RuntimeID) { - // TODO: Check whether the object exists - components[runtimeID]?.remove(type) - } - public func removeComponent(_ type: T.Type, for objectID: ObjectID) { - guard let runtimeID = objectToEntityMap[objectID] else { return } - removeComponent(type, for: runtimeID) + public func query(_ componentType: T.Type) -> QueryResult { + return QueryResult(world: self) { entity in + guard entity.contains(T.self) else { return nil } + return entity + } } - public func removeComponentForAll(_ type: T.Type) { - for id in components.keys { - components[id]?.remove(type) + /// - Complexity: O(n). For now. See ``QueryResult`` for developer comments. + /// + public func query(_ componentType: T.Type) -> QueryResult { + return QueryResult(world: self) { entity in + return entity[T.self] } } - // MARK: - Filter - - /// Get a list of objects with given component. + /// - Complexity: O(n). For now. See ``QueryResult`` for developer comments. /// - public func query(_ componentType: T.Type) -> QueryResult { - let result: [(RuntimeID, T)] = components.compactMap { id, components in - guard let comp: T = components[T.self] else { + public func query(_ componentType: T.Type) -> QueryResult<(RuntimeEntity, T)> { + return QueryResult(world: self) { entity in + guard let comp: T = entity[T.self] else { return nil } - return (id, comp) + return (entity, comp) } - return QueryResult(result) } - + + /// - Complexity: O(n). For now. See ``QueryResult`` for developer comments. + /// + public func query(_ componentType1: C1.Type, _ componentType2: C2.Type) -> QueryResult<(RuntimeEntity, C1, C2)> { + return QueryResult(world: self) { entity in + guard let comp1: C1 = entity[C1.self], + let comp2: C2 = entity[C2.self] + else { return nil } + return (entity, comp1, comp2) + } + } + // MARK: - Issues /// Flag indicating whether any issues were collected public var hasIssues: Bool { !issues.isEmpty } + @available(*, deprecated, message: "Use entity") public func objectHasIssues(_ objectID: ObjectID) -> Bool { guard let issues = self.issues[objectID] else { return false } return issues.isEmpty } + @available(*, deprecated, message: "Use entity") public func objectIssues(_ objectID: ObjectID) -> [Issue]? { guard let issues = self.issues[objectID], !issues.isEmpty else { return nil } return issues @@ -366,6 +452,7 @@ public class World { /// - issue: The error/issue to append /// - objectID: The object ID associated with the issue /// + @available(*, deprecated, message: "Use entity") public func appendIssue(_ issue: Issue, for objectID: ObjectID) { issues[objectID, default: []].append(issue) } diff --git a/Tests/PoieticCoreTests/Runtime/WorldTests.swift b/Tests/PoieticCoreTests/Runtime/WorldTests.swift index 8874d750..9380f7f2 100644 --- a/Tests/PoieticCoreTests/Runtime/WorldTests.swift +++ b/Tests/PoieticCoreTests/Runtime/WorldTests.swift @@ -8,7 +8,7 @@ import Testing @testable import PoieticCore -struct TestFrameComponent: Component, Equatable { +struct TestSingletonComponent: Component, Equatable { var orderedIDs: [ObjectID] init(orderedIDs: [ObjectID] = []) { @@ -16,21 +16,27 @@ struct TestFrameComponent: Component, Equatable { } } +struct WeakRelationship: Relationship, Sendable { + var target: RuntimeID + + static let removalPolicy: RemovalPolicy = .removeRelationship +} + @Suite struct WorldTests { let design: Design let emptyFrame: DesignFrame let testFrame: DesignFrame let objectIDs: [ObjectID] // IDs of created objects for easy reference - + init() throws { // Create a test design with a few objects self.design = Design(metamodel: TestMetamodel) let trans1 = design.createFrame() - + self.emptyFrame = try design.accept(trans1) let trans2 = design.createFrame() - + // Create some test objects with proper structure let obj1 = trans2.create(.Stock, structure: .node) let obj2 = trans2.create(.FlowRate, structure: .node) @@ -38,187 +44,184 @@ struct TestFrameComponent: Component, Equatable { self.objectIDs = [obj1.objectID, obj2.objectID, obj3.objectID] self.testFrame = try design.accept(trans2) } - + // MARK: - Basics - + @Test func createWorld() throws { let world = World(frame: emptyFrame) - + #expect(world.entities.count == 0) #expect(!world.hasIssues) } -// @Test func setFrame() throws { -// let world = World(frame: self.frame) -// let trans = design.createFrame() -// for id in self.frame.objectIDs { -// trans.removeCascading(id) -// } -// let obj = trans.create(.Stock, structure: .node) -// let newFrame = try self.design.accept(trans) -// world.setFrame(newFrame.id) -// -// #expect(world.entities.count == 1) -// let ent = try #require(world.entities.first) -// #expect(ent == world.objectToEntity(obj.objectID)) -// } -// + // @Test func setFrame() throws { + // let world = World(frame: self.frame) + // let trans = design.createFrame() + // for id in self.frame.objectIDs { + // trans.removeCascading(id) + // } + // let obj = trans.create(.Stock, structure: .node) + // let newFrame = try self.design.accept(trans) + // world.setFrame(newFrame.id) + // + // #expect(world.entities.count == 1) + // let ent = try #require(world.entities.first) + // #expect(ent == world.objectToEntity(obj.objectID)) + // } + // // MARK: - Spawn/Despawn @Test func spawn() throws { let world = World(frame: self.emptyFrame) - let ent = world.spawn(TestComponent(text: "test")) + let ent: RuntimeEntity = world.spawn(TestComponent(text: "test")) #expect(world.entities.count == 1) #expect(world.contains(ent)) - - let component: TestComponent = try #require(world.component(for: ent)) + + let component: TestComponent = try #require(ent.component()) #expect(component.text == "test") } @Test func despawn() throws { let world = World(frame: self.emptyFrame) - let ent = world.spawn(TestComponent(text: "test")) + let ent: RuntimeEntity = world.spawn(TestComponent(text: "test")) world.despawn(ent) #expect(world.entities.count == 0) #expect(!world.contains(ent)) - let component: TestComponent? = world.component(for: ent) + let component: TestComponent? = ent.component() #expect(component == nil) } - - // MARK: - Dependencies - @Test func setDependency() throws { - let world = World(frame: self.emptyFrame) - let parent = world.spawn() - let child = world.spawn() - world.setDependency(of: child, on: parent) - world.despawn(parent) - #expect(!world.contains(parent)) - #expect(!world.contains(child)) - } - @Test func setIndirectDependency() throws { - let world = World(frame: self.emptyFrame) - let a = world.spawn() - let b = world.spawn() - let c = world.spawn() - world.setDependency(of: b, on: a) - world.setDependency(of: c, on: b) - world.despawn(a) - #expect(!world.contains(a)) - #expect(!world.contains(b)) - #expect(!world.contains(c)) - } - + // MARK: - Components @Test func setAndGetComponent() throws { let world = World(frame: self.emptyFrame) - let ent = world.spawn() - - #expect(!world.hasComponent(TestComponent.self, for: ent)) - let empty: TestComponent? = world.component(for: ent) + let ent: RuntimeEntity = world.spawn() + + #expect(!ent.contains(TestComponent.self)) + let empty: TestComponent? = ent.component() #expect(empty == nil) - - world.setComponent(TestComponent(text: "test"), for: ent) - #expect(world.hasComponent(TestComponent.self, for: ent)) - - let retrieved: TestComponent = try #require(world.component(for: ent)) + + ent.setComponent(TestComponent(text: "test")) + #expect(ent.contains(TestComponent.self)) + + let retrieved: TestComponent = try #require(ent.component()) #expect(retrieved.text == "test") - + } @Test func replaceComponent() throws { let world = World(frame: self.emptyFrame) - let ent = world.spawn() - - world.setComponent(TestComponent(text: "first"), for: ent) - world.setComponent(TestComponent(text: "second"), for: ent) - - let retrieved: TestComponent = try #require(world.component(for: ent)) + let ent: RuntimeEntity = world.spawn() + + ent.setComponent(TestComponent(text: "first")) + ent.setComponent(TestComponent(text: "second")) + + let retrieved: TestComponent = try #require(ent.component()) #expect(retrieved.text == "second") } - + @Test func removeComponent() throws { let world = World(frame: self.emptyFrame) - let ent = world.spawn() - - world.setComponent(TestComponent(text: "test"), for: ent) - #expect(world.hasComponent(TestComponent.self, for: ent)) - world.removeComponent(TestComponent.self, for: ent) - #expect(!world.hasComponent(TestComponent.self, for: ent)) - - let empty: TestComponent? = world.component(for: ent) + let ent: RuntimeEntity = world.spawn() + + ent.setComponent(TestComponent(text: "test")) + #expect(ent.contains(TestComponent.self)) + ent.removeComponent(TestComponent.self) + #expect(!ent.contains(TestComponent.self)) + + let empty: TestComponent? = ent.component() #expect(empty == nil) } - + @Test func multipleComponentsPerEntity() throws { let world = World(frame: self.emptyFrame) - let ent = world.spawn() - - world.setComponent(TestComponent(text: "test"), for: ent) - world.setComponent(IntegerComponent(value: 1024), for: ent) - - let testComp: TestComponent = try #require(world.component(for: ent)) - let intComp: IntegerComponent = try #require(world.component(for: ent)) - + let ent: RuntimeEntity = world.spawn() + + ent.setComponent(TestComponent(text: "test")) + ent.setComponent(IntegerComponent(value: 1024)) + + let testComp: TestComponent = try #require(ent.component()) + let intComp: IntegerComponent = try #require(ent.component()) + #expect(testComp.text == "test") #expect(intComp.value == 1024) } - + @Test func componentsIsolatedPerEntity() throws { let world = World(frame: self.emptyFrame) - let ent1 = world.spawn() - let ent2 = world.spawn() - - world.setComponent(TestComponent(text: "obj1"), for: ent1) - world.setComponent(TestComponent(text: "obj2"), for: ent2) - - let comp1: TestComponent = try #require(world.component(for: ent1)) - let comp2: TestComponent = try #require(world.component(for: ent2)) - + let ent1: RuntimeEntity = world.spawn() + let ent2: RuntimeEntity = world.spawn() + + ent1.setComponent(TestComponent(text: "obj1")) + ent2.setComponent(TestComponent(text: "obj2")) + + let comp1: TestComponent = try #require(ent1.component()) + let comp2: TestComponent = try #require(ent2.component()) + #expect(comp1.text == "obj1") #expect(comp2.text == "obj2") } - + // MARK: - Query - + @Test func queryComponent() throws { let world = World(frame: self.emptyFrame) - let ent1 = world.spawn() - let ent2 = world.spawn() - let ent3 = world.spawn() - - let empty = world.query(TestComponent.self) - #expect(empty.isEmpty) - - world.setComponent(TestComponent(text: "test1"), for: ent1) - world.setComponent(TestComponent(text: "test2"), for: ent2) - - let some = world.query(TestComponent.self) - let ids = some.map { $0.0 } - #expect(some.count == 2) - #expect(ids.contains(ent1)) - #expect(ids.contains(ent2)) - #expect(!ids.contains(ent3)) + let ent1: RuntimeEntity = world.spawn() + let ent2: RuntimeEntity = world.spawn() + let ent3: RuntimeEntity = world.spawn() + + var empty: QueryResult = world.query(TestComponent.self) + #expect(empty.next() == nil) + + ent1.setComponent(TestComponent(text: "test1")) + ent2.setComponent(TestComponent(text: "test2")) + + let some: QueryResult = world.query(TestComponent.self) + let ids: [RuntimeID] = some.map { $0.runtimeID } + #expect(ids.count == 2) + #expect(ids.contains(ent1.runtimeID)) + #expect(ids.contains(ent2.runtimeID)) + #expect(!ids.contains(ent3.runtimeID)) } - + + @Test func querySkipsNonMatchingEntities() throws { + let world = World(frame: self.emptyFrame) + + let ent1: RuntimeEntity = world.spawn(TestComponent(text: "first")) + let ent2: RuntimeEntity = world.spawn(IntegerComponent(value: 10)) + let ent3: RuntimeEntity = world.spawn(IntegerComponent(value: 20)) + let ent4: RuntimeEntity = world.spawn(TestComponent(text: "second")) + let ent5: RuntimeEntity = world.spawn(IntegerComponent(value: 30)) + let ent6: RuntimeEntity = world.spawn(TestComponent(text: "third")) + + let results: Array = Array(world.query(TestComponent.self)) + + #expect(results.count == 3) + // Matches + #expect(results.contains(where: { $0.runtimeID == ent1.runtimeID })) + #expect(results.contains(where: { $0.runtimeID == ent4.runtimeID })) + #expect(results.contains(where: { $0.runtimeID == ent6.runtimeID })) + + // Non-matches + #expect(!results.contains(where: { $0.runtimeID == ent2.runtimeID })) + #expect(!results.contains(where: { $0.runtimeID == ent3.runtimeID })) + #expect(!results.contains(where: { $0.runtimeID == ent5.runtimeID })) + } + @Test func queryDifferentComponents() throws { let world = World(frame: self.emptyFrame) - let ent1 = world.spawn() - let ent2 = world.spawn() - let ent3 = world.spawn() - - world.setComponent(TestComponent(text: "test"), for: ent1) - world.setComponent(IntegerComponent(value: 42), for: ent2) - world.setComponent(TestComponent(text: "test2"), for: ent3) - - let withText = world.query(TestComponent.self) - let withInt = world.query(IntegerComponent.self) - + let entT1: RuntimeEntity = world.spawn(TestComponent(text: "test")) + let entT2: RuntimeEntity = world.spawn(TestComponent(text: "test2")) + let entI: RuntimeEntity = world.spawn(IntegerComponent(value: 42)) + + let withText: Array = Array(world.query(TestComponent.self)) #expect(withText.count == 2) + #expect(withText.contains(where: {$0.runtimeID == entT1.runtimeID})) + #expect(withText.contains(where: {$0.runtimeID == entT2.runtimeID})) + + let withInt: Array = Array(world.query(IntegerComponent.self)) #expect(withInt.count == 1) - #expect(withText.contains(ent1)) - #expect(withText.contains(ent3)) - #expect(withInt.contains(ent2)) + #expect(withInt.contains(where: {$0.runtimeID == entI.runtimeID})) } // MARK: - Frame @@ -256,66 +259,156 @@ struct TestFrameComponent: Component, Equatable { // MARK: - Singleton @Test func setAndGetSingletonComponent() throws { let world = World(frame: self.emptyFrame) - - let component = TestFrameComponent(orderedIDs: objectIDs) + + let component = TestSingletonComponent(orderedIDs: objectIDs) world.setSingleton(component) - - let retrieved: TestFrameComponent = try #require(world.singleton()) + + let retrieved: TestSingletonComponent = try #require(world.singleton()) #expect(retrieved.orderedIDs == objectIDs) } - - @Test func getFrameComponentReturnsNilWhenNotSet() throws { + + @Test func getSingletonReturnsNilWhenNotSet() throws { let world = World(frame: self.emptyFrame) - - let component: TestFrameComponent? = world.singleton() + + let component: TestSingletonComponent? = world.singleton() #expect(component == nil) } - - @Test func replaceFrameComponent() throws { + + @Test func replaceSingleton() throws { let world = World(frame: self.emptyFrame) - - world.setSingleton(TestFrameComponent(orderedIDs: [objectIDs[0]])) - world.setSingleton(TestFrameComponent(orderedIDs: objectIDs)) - - let retrieved: TestFrameComponent = try #require(world.singleton()) + + world.setSingleton(TestSingletonComponent(orderedIDs: [objectIDs[0]])) + world.setSingleton(TestSingletonComponent(orderedIDs: objectIDs)) + + let retrieved: TestSingletonComponent = try #require(world.singleton()) #expect(retrieved.orderedIDs.count == 3) } - - @Test func hasFrameComponent() throws { + + @Test func hasSingleton() throws { let world = World(frame: emptyFrame) - - #expect(!world.hasSingleton(TestFrameComponent.self)) - - world.setSingleton(TestFrameComponent(orderedIDs: objectIDs)) - - #expect(world.hasSingleton(TestFrameComponent.self)) + + #expect(!world.hasSingleton(TestSingletonComponent.self)) + + world.setSingleton(TestSingletonComponent(orderedIDs: objectIDs)) + + #expect(world.hasSingleton(TestSingletonComponent.self)) } - - @Test func removeFrameComponent() throws { + + @Test func removeSingleton() throws { let world = World(frame: emptyFrame) - + // Set frame component - world.setSingleton(TestFrameComponent(orderedIDs: objectIDs)) - #expect(world.hasSingleton(TestFrameComponent.self)) - + world.setSingleton(TestSingletonComponent(orderedIDs: objectIDs)) + #expect(world.hasSingleton(TestSingletonComponent.self)) + // Remove it - world.removeSingleton(TestFrameComponent.self) - - #expect(!world.hasSingleton(TestFrameComponent.self)) - let retrieved: TestFrameComponent? = world.singleton() + world.removeSingleton(TestSingletonComponent.self) + + #expect(!world.hasSingleton(TestSingletonComponent.self)) + let retrieved: TestSingletonComponent? = world.singleton() #expect(retrieved == nil) } - - @Test func multipleFrameComponents() throws { + + @Test func multipleSingletons() throws { let world = World(frame: emptyFrame) - - world.setSingleton(TestFrameComponent(orderedIDs: objectIDs)) + + world.setSingleton(TestSingletonComponent(orderedIDs: objectIDs)) world.setSingleton(IntegerComponent(value: 100)) - - let orderComp: TestFrameComponent = try #require(world.singleton()) + + let orderComp: TestSingletonComponent = try #require(world.singleton()) let intComp: IntegerComponent = try #require(world.singleton()) - + #expect(orderComp.orderedIDs.count == 3) #expect(intComp.value == 100) } + + // MARK: - Relationships and Dependencies + + @Test func removeDependency() throws { + let world = World(frame: self.emptyFrame) + + let parent: RuntimeEntity = world.spawn() + let child: RuntimeEntity = world.spawn() + let unrelated: RuntimeEntity = world.spawn() + + child.setComponent(ChildOf(parent.runtimeID)) + + world.despawn(parent) + + #expect(!world.contains(parent)) + #expect(!world.contains(child)) + #expect(world.contains(unrelated)) + } + + @Test func removeCycledDependency() throws { + let world = World(frame: self.emptyFrame) + + let left: RuntimeEntity = world.spawn() + let right: RuntimeEntity = world.spawn() + + left.setComponent(ChildOf(right.runtimeID)) + right.setComponent(ChildOf(left.runtimeID)) + + world.despawn(left) + + #expect(!world.contains(left)) + #expect(!world.contains(right)) + } + + @Test func cascadingEntityRelationshipRemoval() throws { + let world = World(frame: self.emptyFrame) + + let grandparent: RuntimeEntity = world.spawn() + let parent: RuntimeEntity = world.spawn() + let child: RuntimeEntity = world.spawn() + + parent.setComponent(ChildOf(grandparent.runtimeID)) + child.setComponent(ChildOf(parent.runtimeID)) + + #expect(world.contains(grandparent)) + #expect(world.contains(parent)) + #expect(world.contains(child)) + + world.despawn(grandparent) + + #expect(!world.contains(grandparent)) + #expect(!world.contains(parent)) + #expect(!world.contains(child)) + } + + @Test func removeWeakRelationshipComponent() throws { + let world = World(frame: self.emptyFrame) + + let target: RuntimeEntity = world.spawn() + let source: RuntimeEntity = world.spawn() + + source.setComponent(WeakRelationship(target: target.runtimeID)) + #expect(source.contains(WeakRelationship.self)) + + world.despawn(target) + + #expect(!world.contains(target)) + #expect(world.contains(source)) + #expect(!source.contains(WeakRelationship.self)) + } + + @Test func keepUnrelatedWeakRelationshipComponent() throws { + let world = World(frame: self.emptyFrame) + + let target: RuntimeEntity = world.spawn() + let source: RuntimeEntity = world.spawn() + let unrelated: RuntimeEntity = world.spawn() + + source.setComponent(WeakRelationship(target: target.runtimeID)) + unrelated.setComponent(WeakRelationship(target: source.runtimeID)) + #expect(source.contains(WeakRelationship.self)) + #expect(unrelated.contains(WeakRelationship.self)) + + world.despawn(target) + + #expect(!world.contains(target)) + #expect(world.contains(source)) + #expect(!source.contains(WeakRelationship.self)) + #expect(unrelated.contains(WeakRelationship.self)) + } } diff --git a/Tests/PoieticCoreTests/World+extensions.swift b/Tests/PoieticCoreTests/World+extensions.swift index 28117a17..aa0b1058 100644 --- a/Tests/PoieticCoreTests/World+extensions.swift +++ b/Tests/PoieticCoreTests/World+extensions.swift @@ -10,7 +10,9 @@ // Testing convenience methods extension World { func objectHasError(_ objectID: ObjectID, error: T) -> Bool { - guard let issues = objectIssues(objectID) else { return false } + guard let entity = self.entity(objectID), + let issues = entity.issues + else { return false } for issue in issues { if let objectError = issue.error as? T, objectError == error {