diff --git a/Demos/Navigation/NavigationDemoMain.swift b/Demos/Navigation/NavigationDemoMain.swift index d9cb8dd36..e8d1fccf3 100644 --- a/Demos/Navigation/NavigationDemoMain.swift +++ b/Demos/Navigation/NavigationDemoMain.swift @@ -100,7 +100,7 @@ struct NavigationDemoMain { let session = world.getResource(NavigationDemoSession.self), let robotID = session.robot, let robot = world.getEntityByID(robotID) else { throw DemoFailure.sceneUnavailable } let probe = Vector3(7.5, 0, 4.5) - let clip = camera.projection.makeClipView() * transform.matrix.inverse * Vector4(probe, 1) + let clip = camera.projection.makeClipView() * (transform.matrix.inverse * Vector4(probe, 1)) guard clip.w.isFinite, clip.w > 0 else { throw DemoFailure.sceneUnavailable } let projected = clip.xyz / clip.w guard projected.x.isFinite, projected.y.isFinite, projected.z >= 0, projected.z <= 1 else { diff --git a/Editor/Sources/AdaEditor/Assets/AgentSkills/ada-procedural-models/SKILL.md b/Editor/Sources/AdaEditor/Assets/AgentSkills/ada-procedural-models/SKILL.md index 6f65f2a79..3adcc52cc 100644 --- a/Editor/Sources/AdaEditor/Assets/AgentSkills/ada-procedural-models/SKILL.md +++ b/Editor/Sources/AdaEditor/Assets/AgentSkills/ada-procedural-models/SKILL.md @@ -34,7 +34,7 @@ Define `func build(model)`. Axes follow the engine: X right, Y up, Z forward. Ev | `model.group()` | Empty part, used as a pivot or parent. | | `model.place(id, x, y, z)` | Position relative to the parent. | | `model.rotate(id, x, y, z)` | Degrees; roll (Z) is applied first, then pitch (X), then yaw (Y). | -| `model.scale(id, x, y, z)` | Positive factors only; mirroring is not supported. | +| `model.scale(id, x, y, z)` | Positive factors only; mirroring is not supported. The scale of a part without children is baked into its geometry, so squashed spheres (clouds, bushes) shade correctly; a scaled group scales its children and can distort their shading when the factors differ. | | `model.paint(id, i)` | Material for the part. Material 0 is neutral gray unless you define it. | | `model.parent(id, parentId)` | Attach to a part created earlier; transforms are inherited. | | `model.name(id, "text")` | Name shown in the scene hierarchy and `editor.model.inspect`. | diff --git a/Sources/AdaAssets/Procedural/GLBWriter.swift b/Sources/AdaAssets/Procedural/GLBWriter.swift index 2744052c5..19918f28d 100644 --- a/Sources/AdaAssets/Procedural/GLBWriter.swift +++ b/Sources/AdaAssets/Procedural/GLBWriter.swift @@ -49,6 +49,34 @@ public struct ProceduralModel: Sendable, Equatable { } } +extension ProceduralModel { + /// Applies positive, finite scales of parts without children to their vertices and normals, leaving those nodes unscaled. + /// + /// A non-uniform node scale distorts normals unless the renderer uses the inverse transpose, which produces dark + /// patches on squashed spheres such as clouds. Baking the scale makes the model shade correctly everywhere. + /// Parts with children or nonpositive or nonfinite scale components keep their original mesh and node scale. + public func bakingLeafScales() -> ProceduralModel { + var result = self + let parents = Set(nodes.compactMap(\.parent)) + for index in result.nodes.indices where !parents.contains(index) { + let scale = result.nodes[index].scale + guard scale != .one, + scale.x > 0, scale.y > 0, scale.z > 0, + scale.x.isFinite, scale.y.isFinite, scale.z.isFinite, + var mesh = result.nodes[index].mesh, mesh.positions.count == mesh.normals.count + else { continue } + let inverse = SIMD3(1 / scale.x, 1 / scale.y, 1 / scale.z) + for vertex in mesh.positions.indices { + mesh.positions[vertex] *= scale + mesh.normals[vertex] = vecNormalize(mesh.normals[vertex] * inverse) + } + result.nodes[index].mesh = mesh + result.nodes[index].scale = .one + } + return result + } +} + public enum GLBWritingError: Error, Equatable, Sendable { case noGeometry case invalidMaterial(node: Int) @@ -60,7 +88,8 @@ public enum GLBWritingError: Error, Equatable, Sendable { public struct GLBWriter: Sendable { public init() {} - public func encode(_ model: ProceduralModel) throws -> Data { + public func encode(_ source: ProceduralModel) throws -> Data { + let model = source.bakingLeafScales() guard model.nodes.contains(where: { $0.mesh?.isEmpty == false }) else { throw GLBWritingError.noGeometry } var binary = Data() diff --git a/Tests/AdaAssetsTests/ProceduralModelTests.swift b/Tests/AdaAssetsTests/ProceduralModelTests.swift index 2cac8353c..926ecb4df 100644 --- a/Tests/AdaAssetsTests/ProceduralModelTests.swift +++ b/Tests/AdaAssetsTests/ProceduralModelTests.swift @@ -1,3 +1,4 @@ +import Math import Testing @testable import AdaAssets @@ -107,6 +108,85 @@ struct ProceduralModelTests { #expect(gold.emissiveFactor.x == 0.1) } + @Test("Scale of a part without children is baked into its geometry so normals stay correct") + func bakesLeafScale() throws { + var group = ProceduralNode(name: "Group") + group.scale = [2, 2, 2] + var cloud = ProceduralNode(name: "Cloud", mesh: ProceduralGeometry.sphere(radius: 1, segments: 16)) + cloud.scale = [1.7, 0.55, 0.6] + cloud.parent = 0 + var puff = ProceduralNode(name: "Puff", mesh: ProceduralGeometry.sphere(radius: 1, segments: 16)) + puff.scale = [3, 1, 1] + let model = ProceduralModel(materials: [ProceduralMaterial(name: "Default")], nodes: [group, cloud, puff]) + + let baked = model.bakingLeafScales() + #expect(baked.nodes[1].scale == .one && baked.nodes[2].scale == .one) + #expect(baked.nodes[0].scale == SIMD3(2, 2, 2), "a parent keeps its scale") + let mesh = try #require(baked.nodes[2].mesh) + #expect(abs((mesh.positions.map(\.x).max() ?? 0) - 3) < 1e-4) + #expect(abs((mesh.positions.map(\.y).max() ?? 0) - 1) < 1e-4) + #expect(mesh.normals.allSatisfy { abs(vecLength($0) - 1) < 1e-4 }) + // The pole of a stretched sphere still faces straight up, and the stretched equator faces along the long axis. + let top = try #require(mesh.positions.indices.max { mesh.positions[$0].y < mesh.positions[$1].y }) + #expect(mesh.normals[top].y > 0.99) + // Every triangle still faces outward after scaling. + for start in stride(from: 0, to: mesh.indices.count, by: 3) { + let corners = (0..<3).map { Int(mesh.indices[start + $0]) } + let face = vecCross(mesh.positions[corners[1]] - mesh.positions[corners[0]], mesh.positions[corners[2]] - mesh.positions[corners[0]]) + let centroid = corners.map { mesh.positions[$0] }.reduce(SIMD3.zero, +) + #expect((face * centroid).sum() > 0) + } + + let imported = try NativeGLTFLoader().load(data: GLBWriter().encode(model)) + let meshIndex = try #require(imported.nodes[2].meshIndex) + let primitive = try #require(imported.meshes[meshIndex].primitives.first) + let positions = try #require(primitive.attributes[.position]?.vector3Values()) + #expect(abs((positions.map(\.x).max() ?? 0) - 3) < 1e-4, "the exported mesh carries the baked scale") + } + + @Test("Mirrored, singular and nonfinite leaf scales preserve the original mesh and transform", arguments: [ + SIMD3(-2, 1, 3), SIMD3(2, -1, 3), SIMD3(2, 1, -3), + SIMD3(-2, -1, 3), SIMD3(-2, -1, -3), + SIMD3(0, 1, 3), SIMD3(2, 0, 3), SIMD3(2, 1, 0), + SIMD3(.infinity, 1, 3), SIMD3(2, -.infinity, 3), SIMD3(2, 1, .nan), + ]) + func preservesUnsupportedLeafScale(scale: SIMD3) { + var node = ProceduralNode(name: "Leaf", mesh: ProceduralGeometry.box(width: 1, height: 2, depth: 3)) + node.scale = scale + let model = ProceduralModel(materials: [ProceduralMaterial(name: "Default")], nodes: [node]) + + let baked = model.bakingLeafScales() + #expect(baked.nodes[0].mesh == node.mesh) + for axis in 0..<3 { + if scale[axis].isNaN { + #expect(baked.nodes[0].scale[axis].isNaN) + } else { + #expect(baked.nodes[0].scale[axis] == scale[axis]) + } + } + } + + @Test("GLB output preserves mirrored and singular leaf transforms and geometry", arguments: [ + SIMD3(-2, 1, 3), SIMD3(-2, -1, 3), SIMD3(-2, -1, -3), + SIMD3(0, 1, 3), SIMD3(2, 0, 3), SIMD3(2, 1, 0), + ]) + func unsupportedScaleRoundTrip(scale: SIMD3) throws { + let mesh = ProceduralGeometry.box(width: 1, height: 2, depth: 3) + var node = ProceduralNode(name: "Leaf", mesh: mesh) + node.scale = scale + let model = ProceduralModel(materials: [ProceduralMaterial(name: "Default")], nodes: [node]) + + let imported = try NativeGLTFLoader().load(data: GLBWriter().encode(model)) + let importedNode = try #require(imported.nodes.first) + let restPose = try #require(importedNode.restPose) + #expect(restPose.scale.x == scale.x && restPose.scale.y == scale.y && restPose.scale.z == scale.z) + let meshIndex = try #require(importedNode.meshIndex) + let primitive = try #require(imported.meshes[meshIndex].primitives.first) + #expect(primitive.attributes[.position]?.values == mesh.positions.flatMap { [$0.x, $0.y, $0.z] }) + #expect(primitive.attributes[.normal]?.values == mesh.normals.flatMap { [$0.x, $0.y, $0.z] }) + #expect(primitive.indices == mesh.indices) + } + @Test("Models without geometry, with bad parents or bad materials are rejected") func invalidModels() { let material = ProceduralMaterial(name: "Default")