From 6d7482add442b11f35ed18aa4f629ab5129bb664 Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Sat, 17 Oct 2026 17:03:27 +0300 Subject: [PATCH 1/2] Bake leaf part scale into generated GLB geometry A non-uniform node scale distorted normals in the renderer, which gave a clouds model made from squashed spheres black patches in the Flappy3D trial. GLBWriter now applies the scale of parts without children to vertices and normals (inverse transpose), leaving the node unscaled. Co-Authored-By: Claude Sonnet 5.5 --- .../ada-procedural-models/SKILL.md | 2 +- Sources/AdaAssets/Procedural/GLBWriter.swift | 27 +++++++++++++- .../AdaAssetsTests/ProceduralModelTests.swift | 36 +++++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) 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..cb9b857ef 100644 --- a/Sources/AdaAssets/Procedural/GLBWriter.swift +++ b/Sources/AdaAssets/Procedural/GLBWriter.swift @@ -49,6 +49,30 @@ public struct ProceduralModel: Sendable, Equatable { } } +extension ProceduralModel { + /// Applies the scale of every part that has no children to its vertices and normals, leaving the node 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. + /// Scales are positive, so triangle winding is unchanged. A scaled part with children keeps its 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, 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 +84,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..036d1cc21 100644 --- a/Tests/AdaAssetsTests/ProceduralModelTests.swift +++ b/Tests/AdaAssetsTests/ProceduralModelTests.swift @@ -107,6 +107,42 @@ 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("Models without geometry, with bad parents or bad materials are rejected") func invalidModels() { let material = ProceduralMaterial(name: "Default") From 807bac28225cf4e50752b0b73be68952a290a2a7 Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Sat, 17 Oct 2026 17:03:27 +0300 Subject: [PATCH 2/2] Fix main build errors seen with the Xcode 26.6 toolchain Match C enum raw values against Int32 results with an explicit conversion (navigation), parenthesize a matrix-vector product the type checker resolved as matrix-matrix, and remove a doubled L10n.text call in the Studio inspector. Co-Authored-By: Claude Sonnet 5.5 --- Demos/Navigation/NavigationDemoMain.swift | 2 +- ...EditorInspectorSidebar+PropertyControls.swift | 2 +- Sources/AdaNavigation/NavigationMesh.swift | 16 ++++++++-------- .../NavigationMeshBaker.swift | 6 +++--- 4 files changed, 13 insertions(+), 13 deletions(-) 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/UI/Editor/EditorInspectorSidebar+PropertyControls.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorInspectorSidebar+PropertyControls.swift index dc1aaa76b..558425802 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorInspectorSidebar+PropertyControls.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorInspectorSidebar+PropertyControls.swift @@ -226,7 +226,7 @@ extension EditorInspectorSidebar { Text("\u{F720}") .font(AdaEditorMaterialSymbolFont.font(size: 17)) .foregroundColor(theme.editorColors.blue) - Text(value.isEmpty ? L10n.text(L10n.text("Choose scene prefab…")) : value) + Text(value.isEmpty ? L10n.text("Choose scene prefab…") : value) .font(.system(size: 10, weight: .bold)) .foregroundColor(value.isEmpty ? theme.editorColors.muted : theme.editorColors.text) .lineLimit(1) diff --git a/Sources/AdaNavigation/NavigationMesh.swift b/Sources/AdaNavigation/NavigationMesh.swift index 66887105b..98fa1dfe5 100644 --- a/Sources/AdaNavigation/NavigationMesh.swift +++ b/Sources/AdaNavigation/NavigationMesh.swift @@ -62,19 +62,19 @@ public actor NavigationMesh { } let status: NavigationPathStatus switch result { - case AE_NAV_SUCCESS.rawValue: + case Int32(truncatingIfNeeded: AE_NAV_SUCCESS.rawValue): status = .complete - case AE_NAV_PARTIAL.rawValue: + case Int32(truncatingIfNeeded: AE_NAV_PARTIAL.rawValue): guard options.allowsPartialPath else { throw .unreachable } status = .partial - case AE_NAV_SEARCH_LIMIT.rawValue: + case Int32(truncatingIfNeeded: AE_NAV_SEARCH_LIMIT.rawValue): guard options.allowsPartialPath else { throw .searchLimitReached } status = .searchLimitReached - case AE_NAV_START_OUTSIDE.rawValue: throw .startOutsideMesh - case AE_NAV_TARGET_OUTSIDE.rawValue: throw .targetOutsideMesh - case AE_NAV_OUT_OF_MEMORY.rawValue: throw .outOfMemory - case AE_NAV_BUFFER_LIMIT.rawValue: throw .pathCapacityExceeded - case AE_NAV_INVALID_INPUT.rawValue: throw .invalidQuery + case Int32(truncatingIfNeeded: AE_NAV_START_OUTSIDE.rawValue): throw .startOutsideMesh + case Int32(truncatingIfNeeded: AE_NAV_TARGET_OUTSIDE.rawValue): throw .targetOutsideMesh + case Int32(truncatingIfNeeded: AE_NAV_OUT_OF_MEMORY.rawValue): throw .outOfMemory + case Int32(truncatingIfNeeded: AE_NAV_BUFFER_LIMIT.rawValue): throw .pathCapacityExceeded + case Int32(truncatingIfNeeded: AE_NAV_INVALID_INPUT.rawValue): throw .invalidQuery default: throw .queryFailed } let corners = (0..