openusd 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +47 -0
  3. data/.yardopts +5 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +163 -0
  6. data/Rakefile +91 -0
  7. data/benchmark/RESULTS.md +16 -0
  8. data/benchmark/parser.rb +53 -0
  9. data/docs/CLI_SMOKE_TEST.md +29 -0
  10. data/exe/openusd +6 -0
  11. data/lib/openusd/asset_resolver.rb +70 -0
  12. data/lib/openusd/attribute.rb +103 -0
  13. data/lib/openusd/attribute_spec.rb +120 -0
  14. data/lib/openusd/cli.rb +115 -0
  15. data/lib/openusd/composition.rb +190 -0
  16. data/lib/openusd/errors.rb +41 -0
  17. data/lib/openusd/format/registry.rb +52 -0
  18. data/lib/openusd/format/usda/lexer.rb +258 -0
  19. data/lib/openusd/format/usda/parser.rb +338 -0
  20. data/lib/openusd/format/usda/property_merger.rb +47 -0
  21. data/lib/openusd/format/usda/reference_metadata.rb +37 -0
  22. data/lib/openusd/format/usda/writer.rb +296 -0
  23. data/lib/openusd/format/usdz/reader.rb +195 -0
  24. data/lib/openusd/format/usdz/writer.rb +145 -0
  25. data/lib/openusd/layer.rb +126 -0
  26. data/lib/openusd/metadata_view.rb +26 -0
  27. data/lib/openusd/path.rb +158 -0
  28. data/lib/openusd/prim.rb +151 -0
  29. data/lib/openusd/prim_spec.rb +179 -0
  30. data/lib/openusd/relationship.rb +56 -0
  31. data/lib/openusd/relationship_spec.rb +57 -0
  32. data/lib/openusd/schema/base.rb +61 -0
  33. data/lib/openusd/schema/camera.rb +30 -0
  34. data/lib/openusd/schema/material.rb +17 -0
  35. data/lib/openusd/schema/mesh.rb +31 -0
  36. data/lib/openusd/schema/scope.rb +10 -0
  37. data/lib/openusd/schema/xform.rb +49 -0
  38. data/lib/openusd/stage.rb +262 -0
  39. data/lib/openusd/types.rb +168 -0
  40. data/lib/openusd/value.rb +100 -0
  41. data/lib/openusd/version.rb +6 -0
  42. data/lib/openusd.rb +40 -0
  43. metadata +85 -0
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUSD
4
+ # A single authored USD layer.
5
+ class Layer
6
+ attr_reader :identifier, :root_prims, :metadata
7
+
8
+ class << self
9
+ # Create an empty layer.
10
+ # @return [Layer]
11
+ def create(identifier)
12
+ new(identifier)
13
+ end
14
+
15
+ # Open a USDA, USD, or USDZ layer.
16
+ # @return [Layer]
17
+ def open(path)
18
+ if path.to_s.match?(/\.usdz\[[^\]]+\]\z/i)
19
+ require_relative "format/usdz/reader"
20
+ return Format::Usdz::Reader.read_uri(path.to_s)
21
+ end
22
+
23
+ expanded = File.expand_path(path)
24
+ Format::Registry.reader_for(expanded).read(expanded)
25
+ rescue Errno::ENOENT
26
+ raise CompositionError, "layer not found: #{expanded}"
27
+ end
28
+ end
29
+
30
+ def initialize(identifier = nil, metadata: {}, root_prims: [])
31
+ @identifier = identifier&.to_s
32
+ @metadata = metadata.dup
33
+ @root_prims = []
34
+ @root_prim_index = {}
35
+ root_prims.each { |prim| add_root_prim(prim) }
36
+ end
37
+
38
+ def identifier=(value)
39
+ @identifier = value&.to_s
40
+ end
41
+
42
+ def add_root_prim(prim)
43
+ raise OpenUSD::TypeError, "root prim must be a PrimSpec" unless prim.is_a?(PrimSpec)
44
+ raise PathError, "duplicate root prim: #{prim.name}" if @root_prim_index.key?(prim.name)
45
+
46
+ prim.parent = nil
47
+ root_prims << prim
48
+ @root_prim_index[prim.name] = prim
49
+ prim
50
+ end
51
+
52
+ # Remove and return a root prim by name.
53
+ # @return [PrimSpec, nil]
54
+ def remove_root_prim(name)
55
+ prim = root_prim_named(name)
56
+ root_prims.delete(prim)
57
+ @root_prim_index.delete(name.to_s)
58
+ prim
59
+ end
60
+
61
+ # @return [PrimSpec, nil] root prim with the requested name
62
+ def root_prim_named(name)
63
+ @root_prim_index[name.to_s]
64
+ end
65
+
66
+ def prim_at(path)
67
+ parsed = Path.parse(path)
68
+ raise PathError, "prim path required" if parsed.property?
69
+ return nil unless parsed.absolute?
70
+ return nil if parsed.to_s == "/"
71
+
72
+ names = parsed.to_s.delete_prefix("/").split("/")
73
+ names.drop(1).reduce(root_prim_named(names.first)) { |prim, name| prim&.child_named(name) }
74
+ end
75
+
76
+ # Traverse authored specs depth-first.
77
+ # @return [Enumerator, Layer]
78
+ def each_prim(&block)
79
+ return enum_for(__method__) unless block
80
+
81
+ root_prims.each do |prim|
82
+ yield prim
83
+ prim.each_descendant(&block)
84
+ end
85
+ end
86
+
87
+ # @return [Array<String>] authored sublayer asset paths
88
+ def sub_layer_paths
89
+ value = metadata["subLayers"]
90
+ value = value.value if value.is_a?(ListOp)
91
+ Array(value).map { |path| path.is_a?(AssetPath) ? path.path : path.to_s }
92
+ end
93
+
94
+ # @return [PrimSpec, nil] authored default prim
95
+ def default_prim
96
+ name = metadata["defaultPrim"]
97
+ name = name.to_s if name
98
+ root_prim_named(name)
99
+ end
100
+
101
+ def save
102
+ raise Error, "in-memory layer has no identifier" unless identifier
103
+
104
+ export(identifier)
105
+ end
106
+
107
+ # Export using the format selected by the destination extension.
108
+ # @return [Layer]
109
+ def export(path)
110
+ Format::Registry.writer_for(path).write(self, path)
111
+ self
112
+ end
113
+
114
+ # @return [String] deterministic USDA representation
115
+ def to_usda
116
+ require_relative "format/usda/writer"
117
+ Format::Usda::Writer.new.write_to_string(self)
118
+ end
119
+
120
+ # Compare authored semantic content, ignoring identifiers.
121
+ def ==(other)
122
+ other.is_a?(self.class) && metadata == other.metadata &&
123
+ root_prims.map(&:to_h) == other.root_prims.map(&:to_h)
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUSD
4
+ # Hash-compatible composed metadata that authors mutations through a callback.
5
+ class MetadataView < Hash
6
+ def initialize(values, writer:)
7
+ super()
8
+ values.each { |key, value| store(key.to_s, value) }
9
+ @writer = writer
10
+ end
11
+
12
+ # Author a metadata value through the owning view.
13
+ def []=(key, value)
14
+ normalized = key.to_s
15
+ @writer.call(:set, normalized, value)
16
+ store(normalized, value)
17
+ end
18
+
19
+ # Delete an authored metadata value.
20
+ def delete(key)
21
+ normalized = key.to_s
22
+ @writer.call(:delete, normalized, nil)
23
+ super(normalized)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUSD
4
+ # Immutable SdfPath-like value object for prim and property paths.
5
+ class Path
6
+ include Comparable
7
+
8
+ # Reusable USD identifier fragment.
9
+ IDENTIFIER = /[\p{L}_][\p{L}\p{N}_]*/u
10
+ # Validation expression for prim names.
11
+ PRIM_NAME = /\A#{IDENTIFIER}\z/u
12
+ # Validation expression for namespaced property names.
13
+ PROPERTY_NAME = /\A#{IDENTIFIER}(?::#{IDENTIFIER})*\z/u
14
+
15
+ attr_reader :property_name
16
+
17
+ class << self
18
+ # Parse a path string.
19
+ # @param value [String, Path]
20
+ # @return [Path]
21
+ # @raise [PathError] if the path is malformed
22
+ def parse(value)
23
+ return value if value.is_a?(self)
24
+
25
+ new(String(value))
26
+ rescue ArgumentError, TypeError
27
+ raise PathError, "path must be a string or Path"
28
+ end
29
+ end
30
+
31
+ def initialize(value)
32
+ @value = value.dup.freeze
33
+ @property_name = split_property
34
+ validate!
35
+ freeze
36
+ end
37
+
38
+ # Whether this path begins at the pseudo-root.
39
+ def absolute?
40
+ @value.start_with?("/")
41
+ end
42
+
43
+ # Whether this identifies a property rather than a prim.
44
+ def property?
45
+ !property_name.nil?
46
+ end
47
+
48
+ # Return the prim portion of this path.
49
+ # @return [Path]
50
+ def prim_path
51
+ return self unless property?
52
+
53
+ self.class.parse(@value.delete_suffix(".#{property_name}"))
54
+ end
55
+
56
+ # Return the namespace parent, or nil when no parent exists.
57
+ # @return [Path, nil]
58
+ def parent
59
+ return prim_path if property?
60
+ return nil if @value == "/"
61
+
62
+ separator = @value.rindex("/")
63
+ return nil unless separator
64
+ return self.class.parse("/") if separator.zero?
65
+
66
+ self.class.parse(@value[0...separator])
67
+ end
68
+
69
+ # Append a child prim name.
70
+ # @param name [String]
71
+ # @return [Path]
72
+ def child(name)
73
+ raise PathError, "a property path cannot have prim children" if property?
74
+
75
+ child_name = String(name)
76
+ raise PathError, "invalid prim name: #{child_name.inspect}" unless PRIM_NAME.match?(child_name)
77
+
78
+ separator = @value == "/" ? "" : "/"
79
+ self.class.parse("#{@value}#{separator}#{child_name}")
80
+ rescue ArgumentError, TypeError
81
+ raise PathError, "child name must be a string"
82
+ end
83
+
84
+ # Append a property name.
85
+ # @param name [String]
86
+ # @return [Path]
87
+ def property(name)
88
+ raise PathError, "a property path cannot have another property" if property?
89
+
90
+ property = String(name)
91
+ raise PathError, "invalid property name: #{property.inspect}" unless PROPERTY_NAME.match?(property)
92
+
93
+ self.class.parse("#{@value}.#{property}")
94
+ rescue ArgumentError, TypeError
95
+ raise PathError, "property name must be a string"
96
+ end
97
+
98
+ # Compare paths by their canonical string form.
99
+ def <=>(other)
100
+ @value <=> self.class.parse(other).to_s
101
+ rescue PathError
102
+ nil
103
+ end
104
+
105
+ def eql?(other)
106
+ other.is_a?(self.class) && @value == other.to_s
107
+ end
108
+ alias == eql?
109
+
110
+ # @return [Integer] value hash compatible with {#eql?}
111
+ def hash
112
+ @value.hash
113
+ end
114
+
115
+ # @return [String] canonical path text
116
+ def to_s
117
+ @value
118
+ end
119
+
120
+ # @return [String] developer representation
121
+ def inspect
122
+ "#<#{self.class} #{@value.inspect}>"
123
+ end
124
+
125
+ private
126
+
127
+ def split_property
128
+ index = @value.index(".")
129
+ return unless index
130
+
131
+ @value[(index + 1)..]
132
+ end
133
+
134
+ def validate!
135
+ raise PathError, "path cannot be empty" if @value.empty?
136
+ return if @value == "/"
137
+
138
+ validate_prim_path!
139
+ return unless property?
140
+ raise PathError, "property path requires a prim" if raw_prim_path.empty?
141
+ raise PathError, "invalid property name: #{property_name.inspect}" unless PROPERTY_NAME.match?(property_name)
142
+ end
143
+
144
+ def validate_prim_path!
145
+ prim = raw_prim_path
146
+ raise PathError, "path cannot end with /" if prim.end_with?("/")
147
+ raise PathError, "path cannot contain an empty component" if prim.include?("//")
148
+
149
+ components = prim.delete_prefix("/").split("/")
150
+ invalid = components.find { |part| !PRIM_NAME.match?(part) }
151
+ raise PathError, "invalid prim name: #{invalid.inspect}" if invalid
152
+ end
153
+
154
+ def raw_prim_path
155
+ property? ? @value[0...@value.index(".")] : @value
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUSD
4
+ # Composed prim view on a Stage.
5
+ class Prim
6
+ attr_reader :stage, :path
7
+
8
+ def initialize(stage, path)
9
+ @stage = stage
10
+ @path = Path.parse(path)
11
+ end
12
+
13
+ # @return [String] final path component
14
+ def name
15
+ path.to_s.split("/").last
16
+ end
17
+
18
+ # @return [String, nil] strongest authored type name
19
+ def type_name
20
+ opinions.filter_map(&:type_name).find { |value| !value.empty? }
21
+ end
22
+
23
+ # Author a type name in the edit target.
24
+ def type_name=(value)
25
+ stage.set_prim_type(path, value)
26
+ end
27
+
28
+ # @return [Prim, PseudoRoot, nil] composed namespace parent
29
+ def parent
30
+ parent_path = path.parent
31
+ return stage.pseudo_root if parent_path&.to_s == "/"
32
+ return unless parent_path
33
+
34
+ stage.prim_at(parent_path)
35
+ end
36
+
37
+ # @return [Array<Prim>] active direct children
38
+ def children
39
+ stage.child_paths(path).filter_map { |child_path| stage.prim_at(child_path) }
40
+ end
41
+
42
+ # @return [Attribute, nil] composed attribute by name
43
+ def attribute(name)
44
+ properties = property_opinions(name)
45
+ return unless properties.first.is_a?(AttributeSpec)
46
+
47
+ Attribute.new(self, name)
48
+ end
49
+
50
+ # Create or return an attribute in the edit target.
51
+ # @return [Attribute]
52
+ def create_attribute(name, type_name)
53
+ stage.author_attribute(path, name, type_name)
54
+ Attribute.new(self, name)
55
+ end
56
+
57
+ # @return [Relationship, nil] composed relationship by name
58
+ def relationship(name)
59
+ properties = property_opinions(name)
60
+ return unless properties.first.is_a?(RelationshipSpec)
61
+
62
+ Relationship.new(self, name)
63
+ end
64
+
65
+ # Create or return a relationship in the edit target.
66
+ # @return [Relationship]
67
+ def create_relationship(name)
68
+ stage.author_relationship(path, name)
69
+ Relationship.new(self, name)
70
+ end
71
+
72
+ def active?
73
+ authored = opinions.find { |opinion| opinion.metadata.key?("active") }
74
+ authored ? authored.metadata["active"] != false : true
75
+ end
76
+
77
+ # @return [MetadataView] composed, writable metadata
78
+ def metadata
79
+ values = opinions.reverse_each.with_object({}) { |opinion, result| result.merge!(opinion.metadata) }
80
+ MetadataView.new(values, writer: method(:write_metadata))
81
+ end
82
+
83
+ # @return [Hash] composed variant-set definitions
84
+ def variant_sets
85
+ opinions.reverse_each.with_object({}) do |opinion, result|
86
+ result.merge!(opinion.variant_sets) if opinion.respond_to?(:variant_sets)
87
+ end
88
+ end
89
+
90
+ # Author a variant selection.
91
+ # @return [Prim]
92
+ def set_variant_selection(set_name, choice)
93
+ stage.set_variant_selection(path, set_name, choice)
94
+ self
95
+ end
96
+
97
+ # @api private
98
+ # @return [Array<AttributeSpec, RelationshipSpec>]
99
+ def property_opinions(name)
100
+ opinions.filter_map { |opinion| opinion.property_named(name) }
101
+ end
102
+
103
+ protected
104
+
105
+ # Return all composed opinions for this prim.
106
+ # @api private
107
+ def opinions
108
+ stage.opinions_for(path)
109
+ end
110
+
111
+ private
112
+
113
+ def write_metadata(operation, key, value)
114
+ stage.author_prim_metadata(path, operation, key, value)
115
+ end
116
+ end
117
+
118
+ # Synthetic root for a Stage's root prims.
119
+ class PseudoRoot
120
+ attr_reader :stage
121
+
122
+ def initialize(stage)
123
+ @stage = stage
124
+ end
125
+
126
+ # @return [Path] pseudo-root path
127
+ def path
128
+ Path.parse("/")
129
+ end
130
+
131
+ # @return [String] empty pseudo-root name
132
+ def name
133
+ ""
134
+ end
135
+
136
+ # @return [nil]
137
+ def type_name
138
+ nil
139
+ end
140
+
141
+ # @return [nil]
142
+ def parent
143
+ nil
144
+ end
145
+
146
+ # @return [Array<Prim>] active root prims
147
+ def children
148
+ stage.root_paths.filter_map { |root_path| stage.prim_at(root_path) }
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUSD
4
+ # An authored prim specification in a Layer.
5
+ class PrimSpec
6
+ # Valid authored specifier values.
7
+ SPECIFIERS = %i[def over class].freeze
8
+ # Sentinel distinguishing no reference opinion from an authored empty list.
9
+ REFERENCES_UNAUTHORED = Object.new.freeze
10
+ # Supported reference list-edit operators.
11
+ REFERENCE_LIST_OPERATIONS = %i[prepend append delete reorder add explicit].freeze
12
+
13
+ attr_reader :name, :children, :properties, :metadata, :references, :variant_sets, :specifier,
14
+ :reference_list_op
15
+ attr_accessor :type_name, :parent
16
+
17
+ def initialize(name, type_name: nil, specifier: :def, metadata: {}, references: REFERENCES_UNAUTHORED,
18
+ reference_list_op: :prepend)
19
+ @name = validate_name(name)
20
+ @type_name = type_name&.to_s
21
+ self.specifier = specifier
22
+ @metadata = metadata.dup
23
+ @references = []
24
+ @references_authored = false
25
+ set_references(references, operation: reference_list_op) unless references.equal?(REFERENCES_UNAUTHORED)
26
+ @variant_sets = {}
27
+ @children = []
28
+ @child_index = {}
29
+ @properties = []
30
+ @property_index = {}
31
+ @parent = nil
32
+ end
33
+
34
+ def specifier=(value)
35
+ normalized = value.to_sym
36
+ raise OpenUSD::TypeError, "invalid specifier: #{value.inspect}" unless SPECIFIERS.include?(normalized)
37
+
38
+ @specifier = normalized
39
+ end
40
+
41
+ # Absolute path within the containing layer.
42
+ def path
43
+ return Path.parse("/#{name}") unless parent
44
+
45
+ parent.path.child(name)
46
+ end
47
+
48
+ def add_child(child)
49
+ raise OpenUSD::TypeError, "child must be a PrimSpec" unless child.is_a?(PrimSpec)
50
+ raise PathError, "duplicate child prim: #{child.name}" if @child_index.key?(child.name)
51
+
52
+ child.parent = self
53
+ children << child
54
+ @child_index[child.name] = child
55
+ child
56
+ end
57
+
58
+ # Remove a direct child by name.
59
+ # @return [PrimSpec, nil]
60
+ def remove_child(name)
61
+ child = child_named(name)
62
+ return unless child
63
+
64
+ children.delete(child)
65
+ @child_index.delete(name.to_s)
66
+ child.parent = nil
67
+ child
68
+ end
69
+
70
+ # @return [PrimSpec, nil] direct child by name
71
+ def child_named(name)
72
+ @child_index[name.to_s]
73
+ end
74
+
75
+ def add_property(property)
76
+ valid = property.is_a?(AttributeSpec) || property.is_a?(RelationshipSpec)
77
+ raise OpenUSD::TypeError, "property must be an AttributeSpec or RelationshipSpec" unless valid
78
+ raise PathError, "duplicate property: #{property.name}" if @property_index.key?(property.name)
79
+
80
+ properties << property
81
+ @property_index[property.name] = property
82
+ property
83
+ end
84
+
85
+ # @return [AttributeSpec, RelationshipSpec, nil] authored property by name
86
+ def property_named(name)
87
+ @property_index[name.to_s]
88
+ end
89
+
90
+ # Remove an authored property by name.
91
+ # @return [AttributeSpec, RelationshipSpec, nil]
92
+ def remove_property(name)
93
+ property = property_named(name)
94
+ properties.delete(property)
95
+ @property_index.delete(name.to_s)
96
+ property
97
+ end
98
+
99
+ # Add a reference composition arc.
100
+ # @return [Reference]
101
+ def add_reference(asset_path = nil, prim_path = nil)
102
+ reference = Reference.new(asset_path, prim_path)
103
+ references << reference
104
+ @references_authored = true
105
+ @reference_list_op = :prepend if @reference_list_op.nil?
106
+ reference
107
+ end
108
+
109
+ # Replace the authored references and list-edit operation.
110
+ # @return [Array<Reference>]
111
+ def set_references(values, operation: nil)
112
+ normalized_operation = operation&.to_sym
113
+ unless normalized_operation.nil? || REFERENCE_LIST_OPERATIONS.include?(normalized_operation)
114
+ raise OpenUSD::TypeError, "invalid reference list operation: #{operation.inspect}"
115
+ end
116
+
117
+ @references = Array(values).map { |reference| normalize_reference(reference) }
118
+ @reference_list_op = normalized_operation
119
+ @references_authored = true
120
+ references
121
+ end
122
+
123
+ # Whether a reference opinion, including an empty list, is authored.
124
+ def references_authored?
125
+ @references_authored
126
+ end
127
+
128
+ # Traverse authored descendants depth-first.
129
+ # @return [Enumerator, nil]
130
+ def each_descendant(&block)
131
+ return enum_for(__method__) unless block
132
+
133
+ children.each do |child|
134
+ yield child
135
+ child.each_descendant(&block)
136
+ end
137
+ end
138
+
139
+ # @return [Hash] semantic representation used for equality
140
+ def to_h
141
+ {
142
+ name: name, type_name: type_name, specifier: specifier,
143
+ metadata: metadata, references: references, references_authored: references_authored?,
144
+ reference_list_op: reference_list_op, variant_sets: variant_sets_to_h,
145
+ properties: semantic_properties(properties), children: children.map(&:to_h)
146
+ }
147
+ end
148
+
149
+ private
150
+
151
+ def validate_name(value)
152
+ name = String(value)
153
+ return name.dup.freeze if Path::PRIM_NAME.match?(name)
154
+
155
+ raise PathError, "invalid prim name: #{name.inspect}"
156
+ end
157
+
158
+ def normalize_reference(value)
159
+ return value if value.is_a?(Reference)
160
+ return Reference.new(*value) if value.is_a?(Array)
161
+
162
+ Reference.new(value)
163
+ end
164
+
165
+ def variant_sets_to_h
166
+ variant_sets.transform_values do |choices|
167
+ choices.transform_values do |variant|
168
+ next variant unless variant.respond_to?(:properties)
169
+
170
+ { properties: semantic_properties(variant.properties), children: variant.children.map(&:to_h) }
171
+ end
172
+ end
173
+ end
174
+
175
+ def semantic_properties(values)
176
+ values.sort_by(&:name).map(&:to_h)
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUSD
4
+ # Composed relationship view on a Stage.
5
+ class Relationship
6
+ attr_reader :prim, :name
7
+
8
+ def initialize(prim, name)
9
+ @prim = prim
10
+ @name = name.to_s
11
+ end
12
+
13
+ # @return [Array<Path>] strongest authored targets
14
+ def targets
15
+ opinions.find(&:targets_authored?)&.targets || []
16
+ end
17
+
18
+ # Add one target in the edit target.
19
+ # @return [Relationship]
20
+ def add_target(path)
21
+ authored_spec.add_target(path)
22
+ prim.stage.invalidate!
23
+ self
24
+ end
25
+
26
+ # Replace targets in the edit target.
27
+ # @return [Relationship]
28
+ def set_targets(paths)
29
+ authored_spec.targets = paths
30
+ prim.stage.invalidate!
31
+ self
32
+ end
33
+
34
+ # @return [MetadataView] composed, writable metadata
35
+ def metadata
36
+ values = opinions.reverse_each.with_object({}) { |opinion, result| result.merge!(opinion.metadata) }
37
+ MetadataView.new(values, writer: method(:write_metadata))
38
+ end
39
+
40
+ private
41
+
42
+ def opinions
43
+ prim.property_opinions(name).grep(RelationshipSpec)
44
+ end
45
+
46
+ def authored_spec
47
+ prim.stage.author_relationship(prim.path, name)
48
+ end
49
+
50
+ def write_metadata(operation, key, value)
51
+ authored = authored_spec.metadata
52
+ operation == :set ? authored[key] = value : authored.delete(key)
53
+ prim.stage.invalidate!
54
+ end
55
+ end
56
+ end