Skip to content

Commit 6d4abd5

Browse files
committed
Make transforms chained on a tag reference move it in the local frame
Direct chains on a tag (e.g. tag.translated(x: 10)) used to be silently cancelled by the world-anchor logic, which was a common source of confusion. Tag now overrides transformed(_:) to return a TagReference that composes successive transforms and applies them to the world-anchored geometry in the local coordinate frame at the call site — matching how transforms behave on regular geometry. Outer wrappers (transforms applied to a parent containing a reference) still flow through the environment and are cancelled, preserving the world anchor for that case. Expanded the doc comments to explain the two cases with examples.
1 parent cd2faa4 commit 6d4abd5

2 files changed

Lines changed: 203 additions & 5 deletions

File tree

Sources/Cadova/Abstract Layer/Geometry/References/Tag.swift

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,45 @@ import Foundation
1616
/// “used” in the current tree so it can be resolved in a later pass. If a tag remains unresolved at the top level,
1717
/// a warning is printed.
1818
///
19-
/// - Coordinate systems:
20-
/// - Referencing a tag reproduces the tagged geometry at the same world-space location and orientation it had at the
21-
/// time of tagging. This means the geometry appears where you would expect, relative to the current transform context.
19+
/// ## World-anchored references
20+
///
21+
/// A tag reference reproduces its geometry at the world-space position it had at the time of tagging,
22+
/// **regardless of where the reference appears in the tree**. This is the central property of tags:
23+
/// they are anchored to a captured world position, so transforms applied to ancestor geometry around
24+
/// a reference do not move it.
25+
///
26+
/// ```swift
27+
/// let part = Tag()
28+
/// let model = Box(1)
29+
/// .translated(x: 5) // tagged box is at world (5...6)
30+
/// .tagged(part)
31+
/// .adding {
32+
/// // The Union below would normally translate everything inside it by +100,
33+
/// // but the reference stays anchored at its captured world position (5...6).
34+
/// Union { part }.translated(x: 100)
35+
/// }
36+
/// ```
37+
///
38+
/// ## Transforms applied directly to a reference
39+
///
40+
/// Transforms chained directly onto a tag reference *do* move it. They are applied in the
41+
/// **local coordinate frame** at the call site — the same way transforms on regular geometry behave:
42+
///
43+
/// ```swift
44+
/// part.translated(x: 10) // reference moves +10 along local X
45+
/// part.translated(x: 10).rotated(...) // chained transforms compose in local frame
46+
/// ```
47+
///
48+
/// If the reference sits inside a rotated parent, the direct transforms see that rotation as the
49+
/// local frame, so `.translated(x: 10)` moves along the parent's local X — not world X.
50+
///
51+
/// The distinction is:
52+
/// - **Direct chains** on a tag/reference (via `translated`, `rotated`, etc.) compose into a single
53+
/// transform and move the reference, in the local frame.
54+
/// - **Outer wrappers** (transforms applied to a parent containing a reference) flow through the
55+
/// environment and are cancelled, preserving the world anchor.
56+
///
57+
/// This means `tag.translated(x: 10)` translates; but `Group { tag }.translated(x: 10)` does not.
2258
///
2359
public struct Tag: Hashable, Sendable {
2460
internal let id = UUID()
@@ -43,8 +79,16 @@ public struct Tag: Hashable, Sendable {
4379
public extension Geometry3D {
4480
/// Attaches a tag to this geometry, allowing it to be referenced elsewhere in the same model.
4581
///
46-
/// The tagged geometry is recorded in its current coordinate system so that later references to the tag reproduce
47-
/// the same geometry at the same world-space location and orientation.
82+
/// The tagged geometry is recorded in its **current** coordinate system, meaning the world-space position
83+
/// it has at the point where `tagged(_:)` is called — including any transforms applied to it earlier in
84+
/// the chain. References to this tag later reproduce the geometry at that same world position.
85+
///
86+
/// ```swift
87+
/// // The tagged box is captured at world (5...6) — the translation is part of the anchor.
88+
/// Box(1).translated(x: 5).tagged(myTag)
89+
/// ```
90+
///
91+
/// See ``Tag`` for how references behave when transforms are applied to them.
4892
///
4993
/// - Multiple definitions:
5094
/// - You can tag multiple geometries with the same `Tag`. When that tag is referenced, all tagged geometries are
@@ -64,6 +108,10 @@ public extension Geometry3D {
64108
/// location and orientation it had at the time of tagging. If the tag was applied to multiple geometries,
65109
/// the referenced result is the merged (unioned) combination of all of them.
66110
///
111+
/// Transforms applied to ancestor geometry around the reference are cancelled by the world-anchor logic,
112+
/// while transforms chained directly onto the reference (via `translated`, `rotated`, etc.) move it
113+
/// relative to its anchor. See ``Tag`` for a full discussion of these two cases.
114+
///
67115
/// - Undefined tags:
68116
/// - If the tag has not yet been defined in the model, this produces an empty geometry placeholder and marks the
69117
/// tag as “used” so it can be resolved in a later pass. If the tag is still undefined at the top level, a
@@ -78,6 +126,48 @@ extension Tag: Geometry {
78126
return try await context.buildResult(for: output, in: environment)
79127
.modifyingElement(ReferenceState.self) { $0.read(tag: self) }
80128
}
129+
130+
/// Applies a transform to this tag reference.
131+
///
132+
/// Transforms chained directly onto a tag reference (e.g. `tag.translated(x: 10)`) are applied
133+
/// to the world-anchored geometry, so they move it as expected. Transforms applied to a parent
134+
/// geometry that contains a tag reference still flow through the environment and are cancelled
135+
/// by the world-anchor logic, preserving the captured position.
136+
///
137+
public func transformed(_ transform: Transform3D) -> any Geometry3D {
138+
if transform.isIdentity {
139+
return self
140+
}
141+
return TagReference(tag: self, transform: transform)
142+
}
143+
}
144+
145+
/// A tag reference with applied modifications.
146+
///
147+
/// Currently this carries an explicit transform; additional kinds of per-reference modifications
148+
/// (e.g. material or color overrides) can be added here without affecting `Tag`'s identity semantics.
149+
///
150+
internal struct TagReference: Geometry {
151+
let tag: Tag
152+
let transform: Transform3D
153+
154+
func transformed(_ additional: Transform3D) -> any Geometry3D {
155+
if additional.isIdentity {
156+
return self
157+
}
158+
return TagReference(tag: tag, transform: transform.transformed(additional))
159+
}
160+
161+
func build(in environment: EnvironmentValues, context: EvaluationContext) async throws -> D3.BuildResult {
162+
let output = Union {
163+
environment.buildResults(for: tag)
164+
}
165+
.transformed(environment.transform.inverse)
166+
.transformed(transform)
167+
168+
return try await context.buildResult(for: output, in: environment)
169+
.modifyingElement(ReferenceState.self) { $0.read(tag: tag) }
170+
}
81171
}
82172

83173
public extension Tag {

Tests/Tests/Tags.swift

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,114 @@ struct TagTests {
9292
#expect(try await geometry.measurements.volume 21)
9393
}
9494

95+
@Test func `direct transform on tag reference translates it`() async throws {
96+
let t = Tag()
97+
let geometry = Box(1)
98+
.tagged(t)
99+
.adding {
100+
t.translated(x: 10)
101+
}
102+
103+
let bounds = try await geometry.bounds
104+
#expect(bounds?.minimum [0, 0, 0])
105+
#expect(bounds?.maximum [11, 1, 1])
106+
}
107+
108+
@Test func `chained transforms on tag reference compose`() async throws {
109+
let t = Tag()
110+
let geometry = Box(2)
111+
.tagged(t)
112+
.adding {
113+
t.translated(x: 10).translated(y: 5)
114+
}
115+
116+
let bounds = try await geometry.bounds
117+
#expect(bounds?.minimum [0, 0, 0])
118+
#expect(bounds?.maximum [12, 7, 2])
119+
}
120+
121+
@Test func `rotation chained with translation on tag reference applies in order`() async throws {
122+
// Box(2,1,1) rotated 90° around z: (x,y,z) → (-y, x, z), so corners (0..2, 0..1, 0..1)
123+
// become (-1..0, 0..2, 0..1). Then translated by x:5 → (4..5, 0..2, 0..1).
124+
let t = Tag()
125+
let geometry = Box([2, 1, 1])
126+
.tagged(t)
127+
.adding {
128+
t.rotated(z: 90°).translated(x: 5)
129+
}
130+
131+
let bounds = try await geometry.bounds
132+
#expect(bounds?.minimum.x 0)
133+
#expect(bounds?.maximum.x 5)
134+
#expect(bounds?.minimum.y 0)
135+
#expect(bounds?.maximum.y 2)
136+
}
137+
138+
@Test func `tag world anchor reflects tagged geometry's own transform`() async throws {
139+
// Tagged geometry was already translated by x:5 when tagged, so its world position is x:5..6.
140+
// The reference then translates that by an additional x:10 → x:15..16.
141+
let t = Tag()
142+
let geometry = Box(1)
143+
.translated(x: 5)
144+
.tagged(t)
145+
.adding {
146+
t.translated(x: 10)
147+
}
148+
149+
let bounds = try await geometry.bounds
150+
#expect(bounds?.minimum [5, 0, 0])
151+
#expect(bounds?.maximum [16, 1, 1])
152+
}
153+
154+
@Test func `outer transform around a tag reference is cancelled by world anchor`() async throws {
155+
// Wrapping the tag reference inside a group and translating the group should NOT move
156+
// the reference — the world anchor is preserved. Only direct chains on the tag/modified
157+
// reference move it.
158+
let t = Tag()
159+
let geometry = Box(1)
160+
.tagged(t)
161+
.adding {
162+
Union { t }.translated(x: 100)
163+
}
164+
165+
let bounds = try await geometry.bounds
166+
#expect(bounds?.minimum [0, 0, 0])
167+
#expect(bounds?.maximum [1, 1, 1])
168+
}
169+
170+
@Test func `tag translation applies in local frame of containing rotation`() async throws {
171+
// Tag captured at world (0..1, 0..1, 0..1). Inside a parent rotated by z:90°, the local +X
172+
// axis points along world +Y. So `.translated(x: 10)` on the reference should move it by
173+
// +10 in world Y, not in world X.
174+
let t = Tag()
175+
let geometry = Box(1)
176+
.tagged(t)
177+
.adding {
178+
Union { t.translated(x: 10) }.rotated(z: 90°)
179+
}
180+
181+
let bounds = try await geometry.bounds
182+
#expect(bounds?.minimum.x 0)
183+
#expect(bounds?.maximum.x 1)
184+
#expect(bounds?.minimum.y 0)
185+
#expect(bounds?.maximum.y 11)
186+
}
187+
188+
@Test func `outer transform around a modified tag reference is also cancelled`() async throws {
189+
// Same as above, but with a direct chain on the tag first. The direct chain should still
190+
// take effect; the outer group translate is cancelled.
191+
let t = Tag()
192+
let geometry = Box(1)
193+
.tagged(t)
194+
.adding {
195+
Union { t.translated(x: 10) }.translated(x: 100)
196+
}
197+
198+
let bounds = try await geometry.bounds
199+
#expect(bounds?.minimum [0, 0, 0])
200+
#expect(bounds?.maximum [11, 1, 1])
201+
}
202+
95203
@Test func `tag can map each member separately`() async throws {
96204
let sharedTag = Tag("shared tag")
97205

0 commit comments

Comments
 (0)