From 82c91bb37c82dd2e722adbfa4bafa0aea148a419 Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Sun, 8 Nov 2026 01:11:29 +0300 Subject: [PATCH 01/11] Add missing terrain noise and auto palette editor sources EditorTerrainNoise and EditorTerrainAutoPalette were referenced by the terrain authoring tools but omitted from the Mesh Terrain MVP change, so the branch did not compile. --- .../AdaEditor/EditorTerrainAutoPalette.swift | 52 ++++++++++++++++ .../AdaEditor/EditorTerrainNoise.swift | 62 +++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 Editor/Sources/AdaEditor/EditorTerrainAutoPalette.swift create mode 100644 Editor/Sources/AdaEditor/EditorTerrainNoise.swift diff --git a/Editor/Sources/AdaEditor/EditorTerrainAutoPalette.swift b/Editor/Sources/AdaEditor/EditorTerrainAutoPalette.swift new file mode 100644 index 000000000..686be8ddd --- /dev/null +++ b/Editor/Sources/AdaEditor/EditorTerrainAutoPalette.swift @@ -0,0 +1,52 @@ +@_spi(AdaEngine) import AdaEngine + +/// Adds automatic roles without renumbering or removing imported layers. +enum EditorTerrainAutoPalette { + static func make(existing: TerrainMaterialPalette?, variation: TerrainTextureVariation, addRules: Bool) -> TerrainMaterialPalette { + var layers = (existing?.layers ?? []).map { layer in + TerrainLayerReference( + id: layer.id, + name: layer.name, + albedoPath: layer.albedoPath, + normalPath: layer.normalPath, + ormPath: layer.ormPath, + blendHeightPath: layer.blendHeightPath, + scale: layer.scale, + tint: layer.tint, + roughness: layer.roughness, + metallic: layer.metallic, + projection: layer.projection, + variation: variation + ) + } + guard addRules || existing == nil else { + return .init( + layers: layers, + fallbackLayerID: existing?.fallbackLayerID ?? 0, + interiorLayerID: existing?.interiorLayerID, + rules: existing?.rules ?? [] + ) + } + func role(_ name: String, tint: Vector4) -> UInt16 { + if let layer = layers.first(where: { $0.name == name }) { + return layer.id + } + let used = Set(layers.map(\.id)) + guard layers.count < 32, let id = (UInt16(0)...UInt16(31)).first(where: { !used.contains($0) }) else { + return layers.first?.id ?? 0 + } + layers.append(.init(id: id, name: name, scale: 4, tint: tint, projection: .triplanar, variation: variation)) + return id + } + let grass = role("Grass", tint: [0.22, 0.42, 0.13, 1]) + let rock = role("Rock", tint: [0.45, 0.43, 0.4, 1]) + let snow = role("Snow", tint: [0.92, 0.95, 1, 1]) + let automatic = Set([grass, rock, snow]) + let rules = (existing?.rules ?? []).filter { !automatic.contains($0.layerID) } + [ + TerrainMaterialRule(layerID: grass, height: .init(minimum: -10_000, maximum: 8, transition: 4), slope: .init(minimum: 0, maximum: 35, transition: 15)), + .init(layerID: rock, slope: .init(minimum: 35, maximum: 90, transition: 15)), + .init(layerID: snow, height: .init(minimum: 12, maximum: 10_000, transition: 4)), + ] + return .init(layers: layers, fallbackLayerID: grass, interiorLayerID: rock, rules: rules) + } +} diff --git a/Editor/Sources/AdaEditor/EditorTerrainNoise.swift b/Editor/Sources/AdaEditor/EditorTerrainNoise.swift new file mode 100644 index 000000000..897bfd977 --- /dev/null +++ b/Editor/Sources/AdaEditor/EditorTerrainNoise.swift @@ -0,0 +1,62 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation + +struct EditorTerrainNoise: Sendable { + var seed: UInt64 = 42 + var amplitude: Float = 12 + var frequency: Float = 0.035 + + func height(x: Float, z: Float) -> Float { + var result: Float = 0, weight: Float = 1, scale = frequency, total: Float = 0 + for octave in 0..<5 { + result += value(x * scale, z * scale, seed: seed &+ UInt64(octave) &* 7919) * weight + total += weight + weight *= 0.5 + scale *= 2 + } + return result / total * amplitude + } + + private func value(_ x: Float, _ z: Float, seed: UInt64) -> Float { + let ix = Int64(floor(Double(x))), iz = Int64(floor(Double(z))) + var u = x - Float(ix), v = z - Float(iz) + u = u * u * (3 - 2 * u); v = v * v * (3 - 2 * v) + func hash(_ x: Int64, _ z: Int64) -> Float { + var h = UInt64(bitPattern: x) &* 0x9e3779b97f4a7c15 ^ UInt64(bitPattern: z) &* 0xbf58476d1ce4e5b9 ^ seed + h = (h ^ (h >> 30)) &* 0xbf58476d1ce4e5b9 + h = (h ^ (h >> 27)) &* 0x94d049bb133111eb + h ^= h >> 31 + return Float(h & 0xffffff) / Float(0xffffff) * 2 - 1 + } + let a = hash(ix, iz), b = hash(ix + 1, iz), c = hash(ix, iz + 1), d = hash(ix + 1, iz + 1) + return (a + (b - a) * u) * (1 - v) + (c + (d - c) * u) * v + } + + @concurrent + static func generate(_ settings: Self, at file: URL, manifest: TerrainManifestAsset) async throws { + let cache = try await TerrainRegionCache.open(manifestAt: file.path) + var regions: [TerrainRegionAsset] = [] + for reference in manifest.heightRegions { + try Task.checkCancellation() + if let region = try await cache.loadRegion(at: .init(x: Int(reference.x), z: Int(reference.z))) { regions.append(region) } + } + let storage = try TerrainHeightStorage(regions: regions, spacing: manifest.spacing, origin: [manifest.originX, manifest.originZ]) + guard storage.tileCoordinates.count <= 240 else { throw EditorTerrainResourceWriter.Failure.modelTooLarge } + for coordinate in storage.tileCoordinates { + guard let snapshot = storage.snapshot(of: coordinate) else { continue } + for z in 0...snapshot.cellCountZ { + try Task.checkCancellation() + for x in 0...snapshot.cellCountX { + let point = TerrainGridPoint(x: coordinate.x * 64 + x, z: coordinate.z * 64 + z) + let height = settings.height(x: manifest.originX + Float(point.x) * manifest.spacing, z: manifest.originZ + Float(point.z) * manifest.spacing) + _ = try storage.setHeight(height, at: point) + } + } + } + regions = [] + for coordinate in storage.resourceCoordinates { + if let region = try storage.regionAsset(at: coordinate) { regions.append(region) } + } + _ = try await AssetsManager.saveTerrainRegions(regions, manifest: manifest, at: file.path, mode: .update(removing: [])) + } +} From 333a30be489800c40885210601eecbd5ae6da445 Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Sun, 8 Nov 2026 01:56:48 +0300 Subject: [PATCH 02/11] Align Windows Swift CI and restore terrain regression coverage --- .github/workflows/swift-windows.yml | 8 +-- .../EditorTerrainToolsTests.swift | 67 +++++++++++++++++++ .../AdaScripting/AdaScriptAsyncBridge.swift | 6 ++ .../AdaEngineTests/Render3DMeshletTests.swift | 43 +++++++++--- .../Render3DPathTracingTests.swift | 51 +++++++------- .../Render3DTerrainVolumeMaterialTests.swift | 56 ++++++++++++++++ .../AdaScriptSaveReadTests.swift | 9 +++ 7 files changed, 201 insertions(+), 39 deletions(-) create mode 100644 Editor/Tests/AdaEditorTests/EditorTerrainToolsTests.swift create mode 100644 Tests/AdaEngineTests/Render3DTerrainVolumeMaterialTests.swift diff --git a/.github/workflows/swift-windows.yml b/.github/workflows/swift-windows.yml index aabb5835e..4065930a9 100644 --- a/.github/workflows/swift-windows.yml +++ b/.github/workflows/swift-windows.yml @@ -14,8 +14,6 @@ defaults: jobs: Windows: name: Windows with SwiftPM - # Swift 6.2.3 ships Clang 19, while the Visual Studio 2026 STL on - # windows-latest requires Clang 20 or newer. runs-on: windows-2022 env: ADAENGINE_HEADLESS: "1" @@ -24,8 +22,8 @@ jobs: - name: Setup uses: compnerd/gha-setup-swift@v0.2.3 with: - branch: swift-6.2.3-release - tag: 6.2.3-RELEASE + branch: swift-6.3.1-release + tag: 6.3.1-RELEASE - name: Checkout uses: actions/checkout@v4 @@ -33,7 +31,7 @@ jobs: run: swift -version - name: Test - # Swift Testing 6.2.3 on Windows becomes unstable when all tests are + # Swift Testing on Windows becomes unstable when all tests are # scheduled at once. Keep bounded parallelism so the suite still # makes progress without exhausting the runner. # Build the test product directly: SwiftPM otherwise compiles the diff --git a/Editor/Tests/AdaEditorTests/EditorTerrainToolsTests.swift b/Editor/Tests/AdaEditorTests/EditorTerrainToolsTests.swift new file mode 100644 index 000000000..049cc08ef --- /dev/null +++ b/Editor/Tests/AdaEditorTests/EditorTerrainToolsTests.swift @@ -0,0 +1,67 @@ +@_spi(AdaEngine) import AdaEngine +@_spi(Internal) import AdaRender +@testable import AdaScene +import Foundation +import Testing + +@testable import AdaEditor + +@MainActor +@Suite("Terrain editor tools", .serialized) +struct EditorTerrainToolsTests { + @Test("Five-octave height generation is deterministic, seeded and spatially varied") + func noise() { + let first = EditorTerrainNoise(seed: 42, amplitude: 12, frequency: 0.035) + let second = EditorTerrainNoise(seed: 43, amplitude: 12, frequency: 0.035) + let values = (0..<32).map { first.height(x: Float($0), z: 4) } + #expect(values == (0..<32).map { first.height(x: Float($0), z: 4) }) + #expect(Set(values).count > 20) + #expect(values != (0..<32).map { second.height(x: Float($0), z: 4) }) + #expect(values.allSatisfy { $0.isFinite && abs($0) <= 12 }) + #expect(EditorTerrainNoise(seed: 42, amplitude: 0, frequency: 0.035).height(x: 3, z: 7) == 0) + } + + @Test("All 24 brush presets use real operation or shape settings") + func presets() { + #expect(EditorTerrainBrushPreset.all.count == 24) + #expect(Set(EditorTerrainBrushPreset.all.map(\.name)).count == 24) + #expect(Set(EditorTerrainBrushPreset.all.map(\.operation)).count == TerrainHeightBrushOperation.allCases.count) + } + + @Test("Automatic rules preserve custom layers and variation toggles preserve existing rule roles") + func automaticMaterials() { + let own = TerrainMaterialPalette(layers: [.init(id: 17, name: "Custom texture", albedoPath: "custom.png")], fallbackLayerID: 17) + let automatic = EditorTerrainAutoPalette.make(existing: own, variation: .init(isEnabled: true), addRules: true) + #expect(automatic.layers.first?.id == 17) + #expect(automatic.layers.first?.albedoPath == "custom.png") + #expect(automatic.layers.count == 4) + #expect(automatic.rules.count == 3) + let toggled = EditorTerrainAutoPalette.make(existing: automatic, variation: .init(isEnabled: false), addRules: false) + #expect(toggled.layers.map(\.id) == automatic.layers.map(\.id)) + #expect(toggled.rules == automatic.rules) + #expect(toggled.layers.allSatisfy { !$0.variation.isEnabled }) + } + + @Test("Volume material rows use actual vertex height and independent triangle field coordinates") + func volumeMaterials() throws { + let storage = try TerrainHeightStorage(importing: Terrain3D(resolution: 65, size: 64)) + let palette = TerrainMaterialPalette( + layers: [.init(id: 0, name: "Low"), .init(id: 1, name: "High")], + fallbackLayerID: 0, + rules: [.init(layerID: 1, height: .init(minimum: 5, maximum: 30))] + ) + var descriptor = MeshDescriptor(name: "Stacked strata") + descriptor.positions = MeshBuffer([Vector3(0, 0, 0), [1, 0, 0], [0, 0, 1], [0, 10, 0], [1, 10, 0], [0, 10, 1]]) + descriptor.normals = MeshBuffer([Vector3](repeating: .up, count: 6)) + descriptor.indicies = [0, 2, 1, 3, 5, 4] + let field = try TerrainSourceMaterials.prepareVolume(descriptor, storage: storage, resolver: TerrainMaterialResolver(palette)) + let ids = try #require(field.descriptor[TerrainSourceMaterials.layerIDs]).elements + let uv = try #require(field.descriptor[MeshDescriptor.textureCoordinates1]).elements + #expect(field.evaluations == 6) + #expect(uv.count == 6) + #expect(ids[0].x == 0) + #expect(ids[2].x == 1) + #expect(field.descriptor.positions.elements[0].y == 0) + #expect(field.descriptor.positions.elements[3].y == 10) + } +} diff --git a/Sources/AdaScripting/AdaScriptAsyncBridge.swift b/Sources/AdaScripting/AdaScriptAsyncBridge.swift index 3c6457e33..25284e049 100644 --- a/Sources/AdaScripting/AdaScriptAsyncBridge.swift +++ b/Sources/AdaScripting/AdaScriptAsyncBridge.swift @@ -318,7 +318,13 @@ final class AdaScriptAsyncHost: @unchecked Sendable { } let virtualRoot = path.hasPrefix("@user://") ? "@user://" : "@cache://" let root = AssetsManager.resolveAssetURL(at: virtualRoot).resolvingSymlinksInPath().standardizedFileURL + #if os(Windows) + // Windows leaves short path aliases unresolved when the file does not exist. + let resolved = url.deletingLastPathComponent().resolvingSymlinksInPath() + .appendingPathComponent(url.lastPathComponent).resolvingSymlinksInPath().standardizedFileURL + #else let resolved = url.resolvingSymlinksInPath().standardizedFileURL + #endif // Unlike replacement writes, reads follow the final file link as well as directory links. guard resolved.path.hasPrefix(root.path + "/") else { return nil diff --git a/Tests/AdaEngineTests/Render3DMeshletTests.swift b/Tests/AdaEngineTests/Render3DMeshletTests.swift index 70c402f4c..57d0e0c26 100644 --- a/Tests/AdaEngineTests/Render3DMeshletTests.swift +++ b/Tests/AdaEngineTests/Render3DMeshletTests.swift @@ -41,6 +41,7 @@ struct Render3DMeshletSettingsTests { #expect(MemoryLayout.offset(of: \.firstInstance) == 16) } + #if canImport(WebGPU) && !WASM && !os(Android) @Test(.enabled(if: ProcessInfo.processInfo.environment["ADAENGINE_VALIDATE_TINT"] == "1"), arguments: [0, 1, 2, 3]) func mapShadersCompileFreshForEveryAttachmentLayout(variant: Int) async throws { let root = ProcessInfo.processInfo.environment["ADAENGINE_SHADER_SOURCE_ROOT"].map { URL(fileURLWithPath: $0) } @@ -56,6 +57,7 @@ struct Render3DMeshletSettingsTests { #expect(converted.language == .wgsl && converted.source.contains("fn meshlet_map_validation(")) } } + #endif } extension Render3DGPUTests { @@ -343,9 +345,11 @@ extension Render3DGPUTests { let cluster = try #require(geometry.clusters.first) #expect(expected.starts(with: [0, 1, 2])) for corner in 0..<8 { - let p = Vector3(corner & 1 == 0 ? cluster.minimum.x : cluster.maximum.x, + let p = Vector3( + corner & 1 == 0 ? cluster.minimum.x : cluster.maximum.x, corner & 2 == 0 ? cluster.minimum.y : cluster.maximum.y, - corner & 4 == 0 ? cluster.minimum.z : cluster.maximum.z) + corner & 4 == 0 ? cluster.minimum.z : cluster.maximum.z + ) let clip = projection * transform * Vector4(p, 1) #expect(clip.x > clip.w + 0.05, "The fixture must expose the old tighter OBB rejection.") } @@ -414,8 +418,10 @@ extension Render3DGPUTests { "scenes": [["nodes": [0]]], "nodes": [["mesh": 0]], "buffers": [["uri": "geometry.bin", "byteLength": 72]], "bufferViews": [["buffer": 0, "byteOffset": 0, "byteLength": 48], ["buffer": 0, "byteOffset": 48, "byteLength": 24]], - "accessors": [["bufferView": 0, "componentType": 5126, "count": 4, "type": "VEC3"], - ["bufferView": 1, "componentType": 5125, "count": 6, "type": "SCALAR"]], + "accessors": [ + ["bufferView": 0, "componentType": 5126, "count": 4, "type": "VEC3"], + ["bufferView": 1, "componentType": 5125, "count": 6, "type": "SCALAR"], + ], "materials": [["pbrMetallicRoughness": ["baseColorFactor": [1, 1, 1, 1], "metallicFactor": 0, "roughnessFactor": 1]]], "meshes": [["primitives": [["attributes": ["POSITION": 0], "indices": 1, "material": 0]]]], ] @@ -480,10 +486,21 @@ extension Render3DGPUTests.Meshlets { let lists = try #require(render.getResource(VisibleMesh3DLists.self)) let original = try #require(lists.items(view: scene.camera.id, pass: -1).first) #expect(Meshlet3DEligibility.fallbackReason(original) == nil, "The actual extracted singleton must otherwise be eligible.") - let custom = Opaque3DRenderItem(entity: original.entity, drawPass: UnmarkedModelPass(), sortKey: original.sortKey, - modelIndex: original.modelIndex, partIndex: original.partIndex, mesh: original.mesh, material: original.material, - worldTransform: original.worldTransform, castShadows: original.castShadows, receiveShadows: original.receiveShadows, - batchRange: original.batchRange, skinningBuffer: original.skinningBuffer, morphBuffer: original.morphBuffer) + let custom = Opaque3DRenderItem( + entity: original.entity, + drawPass: UnmarkedModelPass(), + sortKey: original.sortKey, + modelIndex: original.modelIndex, + partIndex: original.partIndex, + mesh: original.mesh, + material: original.material, + worldTransform: original.worldTransform, + castShadows: original.castShadows, + receiveShadows: original.receiveShadows, + batchRange: original.batchRange, + skinningBuffer: original.skinningBuffer, + morphBuffer: original.morphBuffer + ) #expect(Meshlet3DEligibility.fallbackReason(custom) == "drawPass") let uniform = try #require(scene.camera.components[GlobalViewUniform.self]) for mode: MeshletVisibilityMode3D in [.cpuReference, .gpu] { @@ -491,8 +508,14 @@ extension Render3DGPUTests.Meshlets { var settings = try #require(scene.camera.components[Environment3D.self]).meshVisibility settings.meshlets = mode let command = scene.device.createCommandQueue().makeCommandBuffer() - let count = state.prepare(items: &items, view: scene.camera.id, projection: uniform.viewProjectionMatrix, - settings: settings, command: command, device: scene.device) + let count = state.prepare( + items: &items, + view: scene.camera.id, + projection: uniform.viewProjectionMatrix, + settings: settings, + command: command, + device: scene.device + ) #expect(count.eligibleItems == 0 && count.gpuDraws == 0 && count.fallbackItems == 1) #expect(count.fallbackReasons["drawPass"] == 1 && state.output(for: scene.camera.id) == nil) #expect(items[0].meshletOverride == nil) diff --git a/Tests/AdaEngineTests/Render3DPathTracingTests.swift b/Tests/AdaEngineTests/Render3DPathTracingTests.swift index ec45fbb0f..b0ee26513 100644 --- a/Tests/AdaEngineTests/Render3DPathTracingTests.swift +++ b/Tests/AdaEngineTests/Render3DPathTracingTests.swift @@ -12,6 +12,7 @@ import Testing @Suite("Path tracing settings and BVH") struct Render3DPathTracingSettingsTests { + #if canImport(WebGPU) && !WASM && !os(Android) @Test(.enabled(if:ProcessInfo.processInfo.environment["ADAENGINE_VALIDATE_TINT"] == "1")) func pathShadersCompileFreshWithoutAGPU() async throws { let root = URL(fileURLWithPath:#filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() @@ -24,6 +25,8 @@ struct Render3DPathTracingSettingsTests { } } } + #endif + @Test func oldScenesStayOptedOutAndSettingsRoundTrip() throws { #expect(try JSONDecoder().decode(Environment3D.self,from:Data("{}".utf8)).pathTracing.isEnabled == false) var environment = Environment3D() @@ -44,14 +47,14 @@ struct Render3DPathTracingSettingsTests { @Test func stacklessBVHCoversEveryTriangleAndHasValidEscapes() { let input: [PathTracing3DBVH.Triangle] = (0..<39).map { index in - let x = Float(index%7)-4, z = Float(index/7)-3 - return .init(vertices:[Vector4(x,0,z,Float(index)),Vector4(x+0.3,0,z,0),Vector4(x,0.3,z,0)]+Array(repeating:.zero,count:9)) + let x = Float(index % 7) - 4, z = Float(index / 7) - 3 + return .init(vertices:[Vector4(x,0,z,Float(index)),Vector4(x + 0.3,0,z,0),Vector4(x,0.3,z,0)] + Array(repeating:.zero,count:9)) } let bvh = PathTracing3DBVH(input) #expect(bvh.nodes.first?.escape == bvh.nodes.count) #expect(bvh.triangles.count == input.count) #expect(Set(bvh.triangles.map { $0.vertices[0].w }) == Set((0..<39).map(Float.init))) - #expect(bvh.nodes.filter { $0.count > 0 }.reduce(0) { $0+$1.count } == input.count) + #expect(bvh.nodes.filter { !$0.isEmpty }.reduce(0) { $0 + $1.count } == input.count) for (index,node) in bvh.nodes.enumerated() { #expect(node.escape > index && node.escape <= bvh.nodes.count) #expect(node.count <= 4) @@ -62,11 +65,11 @@ struct Render3DPathTracingSettingsTests { } #expect(PathTracing3DBVH([]).nodes.isEmpty) var refitted = bvh - var moved = input.reversed().map { $0 } - for t in moved.indices { for v in 0..<3 { moved[t].vertices[v] += Vector4(20,3,-10,0) } } + var moved = Array(input.reversed()) + for t in moved.indices { for v in 0..<3 { moved[t].vertices[v] += Vector4(20, 3, -10, 0) } } refitted.refit(moved) - #expect(refitted.nodes[0].minimum == bvh.nodes[0].minimum+Vector3(20,3,-10)) - #expect(refitted.nodes[0].maximum == bvh.nodes[0].maximum+Vector3(20,3,-10)) + #expect(refitted.nodes[0].minimum == bvh.nodes[0].minimum + Vector3(20, 3, -10)) + #expect(refitted.nodes[0].maximum == bvh.nodes[0].maximum + Vector3(20, 3, -10)) #expect(Set(refitted.triangles.map { $0.vertices[0].w }) == Set(bvh.triangles.map { $0.vertices[0].w })) refitted.refit(Array(moved.prefix(5))) #expect(refitted.triangles.count == 5) @@ -96,7 +99,7 @@ extension Render3DGPUTests { static func red(_ image: Image) -> Float { var sum: Float = 0 for y in 225..<255 { for x in 305..<335 { sum += image.getPixel(x:x,y:y).red } } - return sum/900 + return sum / 900 } @Test(.enabled(if: GoldenFrame.activeBackend != nil)) @@ -170,15 +173,15 @@ extension Render3DGPUTests { Transform(position:[-4,0,-4]) } let blocked = try await s.render(.none,frames:2) - #expect(Self.red(lit) > Self.red(blocked)+0.1,"Actual offscreen triangles must block a light without a shadow atlas") + #expect(Self.red(lit) > Self.red(blocked) + 0.1,"Actual offscreen triangles must block a light without a shadow atlas") var mesh = try #require(blocker.components[Mesh3DComponent.self]); mesh.castShadows = false; blocker.components[Mesh3DComponent.self] = mesh let restored = try await s.render(.none,frames:2) - #expect(Self.red(restored) > Self.red(blocked)+0.1) + #expect(Self.red(restored) > Self.red(blocked) + 0.1) let stats = try Self.stats(s) #expect(stats.snapshots.values.allSatisfy { $0.failure == nil && $0.encodedFrames >= 7 && $0.historyResets >= 3 && $0.triangles >= 14 }) lamp.components[PointLightComponent.self] = PointLightComponent(intensity:0,range:20) let off = try await s.render(.none,frames:1) - #expect(Self.red(off)<0.01,"Turning the lamp off must discard the previous lit history immediately") + #expect(Self.red(off) < 0.01,"Turning the lamp off must discard the previous lit history immediately") try s.configure { $0.pathTracing.isEnabled = false } _ = try await s.render(.none,frames:1) #expect(stats.snapshots.isEmpty) @@ -197,13 +200,13 @@ extension Render3DGPUTests { let direct = try await s.render(.none,frames:3) try s.configure { $0.pathTracing.maximumBounces = 3; $0.pathTracing.samplesPerPixel = 8 } let bounce = try await s.render(.none,frames:16) - #expect(Self.red(bounce)>Self.red(direct)+0.02,"A receiver outside the light range must see bounce from a wall behind the camera") + #expect(Self.red(bounce) > Self.red(direct) + 0.02,"A receiver outside the light range must see bounce from a wall behind the camera") red.emissiveFactor = [1,0.005,0.001]; red.emissiveStrength = 3 lamp.components[PointLightComponent.self] = PointLightComponent(intensity:0,range:2) receiver.metallicFactor = 1; receiver.roughnessFactor = 0.04 let reflection = try await s.render(.none,frames:4) let pixel = reflection.getPixel(x:320,y:240) - #expect(pixel.red>pixel.green+0.1 && pixel.red>0.2,"A mirror must reflect offscreen emissive triangles") + #expect(pixel.red > pixel.green + 0.1 && pixel.red > 0.2,"A mirror must reflect offscreen emissive triangles") try bounce.writePNG(to:GoldenFrame.outputDirectory.appendingPathComponent("path-hidden-bounce.png")) try reflection.writePNG(to:GoldenFrame.outputDirectory.appendingPathComponent("path-hidden-reflection.png")) } @@ -212,12 +215,12 @@ extension Render3DGPUTests { func atlasQualityChangeRetainsFineTextureDetailAndResetsHistory() async throws { let s = try await Self.scene() let pbr = Self.receiver(s) - var pixels = [UInt8](repeating: 255, count: 512*512*4) + var pixels = [UInt8](repeating: 255, count: 512 * 512 * 4) for y in 0..<512 { for x in 0..<512 { - let i = (y*512+x)*4 - pixels[i] = x%4<2 ? 255 : 0 - pixels[i+1] = 0 - pixels[i+2] = x%4<2 ? 0 : 255 + let i = (y * 512 + x) * 4 + pixels[i] = x % 4 < 2 ? 255 : 0 + pixels[i + 1] = 0 + pixels[i + 2] = x % 4 < 2 ? 0 : 255 } } pbr.baseColorTexture = Texture2D(image: Image(width:512,height:512,data:Data(pixels))) _ = try await s.render(.none,frames:3) @@ -240,15 +243,15 @@ extension Render3DGPUTests { pbr.baseColorTexture = Texture2D(image:Image(width:2,height:2,color:Color(0.9,0.01,0.005,1))) let lamp = s.app.main.spawn("Spot behind camera") { SpotLightComponent(intensity:400,range:20,innerConeAngle:15,outerConeAngle:25) - Transform(rotation:Quat(axis:.up,angle:Float.pi/4),position:[-6,0,-6]) + Transform(rotation:Quat(axis:.up,angle:Float.pi / 4),position:[-6,0,-6]) } let on = try await s.render(.none,frames:3) #expect(try Self.stats(s).snapshots.values.allSatisfy { $0.failure == nil && $0.encodedFrames > 0 }) let pixel = on.getPixel(x:320,y:240) - #expect(pixel.red>pixel.green+0.1) - lamp.components[Transform.self] = Transform(rotation:Quat(axis:.up,angle:Float.pi*1.25),position:[-6,0,-6]) + #expect(pixel.red > pixel.green + 0.1) + lamp.components[Transform.self] = Transform(rotation:Quat(axis:.up,angle:Float.pi * 1.25),position:[-6,0,-6]) let off = try await s.render(.none,frames:1) - #expect(Self.red(off)<0.01) + #expect(Self.red(off) < 0.01) try s.configure { $0.pathTracing.resolutionScale = 0.25 } _ = try await s.render(.none,frames:1) #expect(try Self.stats(s).snapshots.values.allSatisfy { $0.width == 160 && $0.height == 120 && $0.accumulatedFrames == 1 && $0.failure == nil }) @@ -272,12 +275,12 @@ extension Render3DGPUTests { } let front = try await s.render(.none,frames:6) let pixel = front.getPixel(x:320,y:240) - #expect(pixel.green>pixel.red+0.3 && pixel.green>0.5) + #expect(pixel.green > pixel.red + 0.3 && pixel.green > 0.5) #expect(try Self.stats(s).snapshots.values.allSatisfy { $0.failure == nil && $0.encodedFrames >= 6 && $0.triangles == 2 }) #expect(instance.compilationError == nil && instance.hasPreparedPipeline) card.components[Transform.self] = Transform(position:[0,0,0.5]) let behind = try await s.render(.none,frames:2) - #expect(behind.getPixel(x:320,y:240).green<0.01,"The graph pass must load opaque depth and never clear traced HDR") + #expect(behind.getPixel(x:320,y:240).green < 0.01,"The graph pass must load opaque depth and never clear traced HDR") } } } diff --git a/Tests/AdaEngineTests/Render3DTerrainVolumeMaterialTests.swift b/Tests/AdaEngineTests/Render3DTerrainVolumeMaterialTests.swift new file mode 100644 index 000000000..a5952084b --- /dev/null +++ b/Tests/AdaEngineTests/Render3DTerrainVolumeMaterialTests.swift @@ -0,0 +1,56 @@ +#if os(macOS) || os(Windows) || os(Linux) + @_spi(Internal) @testable import AdaApp + import AdaEngine + @_spi(Internal) @testable import AdaRender + @testable import AdaScene + import Foundation + import Testing + + extension Render3DGPUTests.TerrainMaterials { + @Test("Volume triangle atlas shades separate height strata through the real terrain GPU pipeline") + func terrainVolumeMaterialAtlasPixels() async throws { + let directory = FileManager.default.temporaryDirectory.resolvingSymlinksInPath() + .appendingPathComponent("TerrainVolumeMaterial-" + UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let scene = try await makeScene(in: directory, textured: false) + let state = try #require(scene.owner.components[TerrainSourceState.self]) + let storage = try #require(state.storage) + let maps = try #require(state.materialArrays) + let cache = try #require(state.residency?.cache) + let palette = try #require(await cache.manifest.materials) + var descriptor = MeshDescriptor(name: "Two material strata") + descriptor.positions = MeshBuffer([ + Vector3(-2, 0, -1), [0, 0, -1], [-2, 0, 1], [0, 0, 1], + [0, 10, -1], [2, 10, -1], [0, 10, 1], [2, 10, 1], + ]) + descriptor.normals = MeshBuffer([Vector3](repeating: .up, count: 8)) + descriptor.indicies = [0, 2, 1, 1, 2, 3, 4, 6, 5, 5, 6, 7] + let prepared = try TerrainSourceMaterials.prepareVolume(descriptor, storage: storage, resolver: TerrainMaterialResolver(palette)) + let material = try TerrainSourceMaterialUpload.make( + descriptor: prepared.descriptor, + coordinate: .init(x: 0, z: 0), + storage: storage, + palette: palette, + maps: maps, + color: .one, + device: scene.engine.renderDevice + ) + scene.owner.components.remove(Terrain3DSource.self) + scene.app.main.spawn("Volume material proof") { + Transform(rotation: Quat(axis: .right, angle: -Float.pi / 2)) + Mesh3DComponent(mesh: Mesh.generate(from: [TerrainSourceMaterials.displayGeometry(prepared.descriptor)], renderDevice: scene.engine.renderDevice), materials: [material]) + } + scene.camera.components[Transform.self] = Transform(position: [0, 0, -20]) + for _ in 0..<4 { try await scene.app.update() } + let image = try #require(try await scene.engine.renderDevice.readImage(from: scene.target)) + try FileManager.default.createDirectory(at: GoldenFrame.outputDirectory, withIntermediateDirectories: true) + let artifact = GoldenFrame.outputDirectory.appendingPathComponent("terrain-volume-material-atlas.png") + try image.writePNG(to: artifact) + print("TERRAIN-VOLUME-MATERIAL-GPU-IMAGE", artifact.path) + let low = image.getPixel(x: 80, y: 64), high = image.getPixel(x: 176, y: 64) + #expect(low.red > 0.8 && low.green < 0.1 && low.blue < 0.1) + #expect(high.blue > 0.8 && high.red < 0.1 && high.green < 0.1) + } + } +#endif diff --git a/Tests/AdaScriptingTests/AdaScriptSaveReadTests.swift b/Tests/AdaScriptingTests/AdaScriptSaveReadTests.swift index 7694e535c..fec0765d3 100644 --- a/Tests/AdaScriptingTests/AdaScriptSaveReadTests.swift +++ b/Tests/AdaScriptingTests/AdaScriptSaveReadTests.swift @@ -9,6 +9,15 @@ import Testing @MainActor @Suite(.serialized) struct AdaScriptSaveReadTests { + @Test func missingSaveSucceedsWithoutAFile() async throws { + try await withRoots { _ in + let result = try await waitForOperation(AdaScriptAsyncHost().readText("@user://missing.json")) + #expect(result.isSuccess(), "\(result.errorCode()): \(result.message())") + #expect(!result.exists()) + #expect(result.value().isEmpty) + } + } + @Test func scriptReadsWrittenMissingAndEmptySaves() async throws { try await withRoots { root in AsyncPosition.registerComponent() From cb9ad20ec3a4d81f41015f0d27fbaa63628355f7 Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Sun, 8 Nov 2026 02:04:36 +0300 Subject: [PATCH 03/11] Keep terrain resident after load errors and stabilize platform tests --- .../3D/Terrain/TerrainSourceJob.swift | 18 ++++++++----- .../3D/Terrain/TerrainSourceResidency.swift | 24 ++++++++++++------ .../mesh-terrain-shaded-wireframe.png} | Bin Tests/AdaEngineTests/Physics3DTests.swift | 10 ++++---- .../Render3DPathTracingTests.swift | 2 +- .../AdaScriptAsyncTests.swift | 4 +-- .../InputResourceBindingTests.swift | 11 +++++--- 7 files changed, 43 insertions(+), 26 deletions(-) rename Tests/AdaEngineTests/GoldenFrames/{webgpu/pbr-spheres-shadedWireframe.png => webgpu-evidence/mesh-terrain-shaded-wireframe.png} (100%) diff --git a/Sources/AdaScene/3D/Terrain/TerrainSourceJob.swift b/Sources/AdaScene/3D/Terrain/TerrainSourceJob.swift index 9e40a7da5..02bc085aa 100644 --- a/Sources/AdaScene/3D/Terrain/TerrainSourceJob.swift +++ b/Sources/AdaScene/3D/Terrain/TerrainSourceJob.swift @@ -204,22 +204,28 @@ struct TerrainSourceRequest: Sendable { @concurrent func build() async -> TerrainSourceOutcome { - if let meshEdit { return await buildMeshEdit(meshEdit) } + if let meshEdit { + return await buildMeshEdit(meshEdit) + } if let edit { return await buildHeightEdit(edit) } do { try Task.checkCancellation() let requested = TerrainSourceKey(source) - if loadedKey != requested || residency == nil { - let cache = try await TerrainRegionCache.open(manifestAt: source.source) + let failure = failedLoad.flatMap { $0.key == requested ? $0 : nil } + if failure == nil, loadedKey != requested || residency == nil { + let cache = try await TerrainSourceResidency.openManifest(at: source.source) if cache.manifest.meshDocumentFileName != nil { - let build = try await MeshTerrainSourcePreparation.prepare(cache: cache, source: source, request: self, - assetOverride: loadedKey == requested ? meshAsset : nil) + let build = try await MeshTerrainSourcePreparation.prepare( + cache: cache, + source: source, + request: self, + assetOverride: loadedKey == requested ? meshAsset : nil + ) return .prepared(.init(build: build, key: requested, failure: nil)) } } - let failure = failedLoad.flatMap { $0.key == requested ? $0 : nil } let retained = failure != nil && loadedKey != requested && storage != nil let sameData = loadedKey == requested || retained let input: TerrainResidencyInput diff --git a/Sources/AdaScene/3D/Terrain/TerrainSourceResidency.swift b/Sources/AdaScene/3D/Terrain/TerrainSourceResidency.swift index 58af24370..ae273bac0 100644 --- a/Sources/AdaScene/3D/Terrain/TerrainSourceResidency.swift +++ b/Sources/AdaScene/3D/Terrain/TerrainSourceResidency.swift @@ -51,6 +51,16 @@ struct TerrainManifestLoadFailure: LocalizedError { } extension TerrainSourceResidency { + static func openManifest(at path: String, limits: TerrainRegionCache.Limits = .init()) async throws -> TerrainRegionCache { + do { + return try await TerrainRegionCache.open(manifestAt: path, limits: limits) + } catch is CancellationError { + throw CancellationError() + } catch { + throw TerrainManifestLoadFailure(message: error.localizedDescription) + } + } + func pinning(_ coordinates: Set) -> Self { var value = self value.transactionPins = coordinates @@ -93,15 +103,13 @@ extension TerrainSourceResidency { if let previous { baseCache = previous.cache } else { - do { - baseCache = try await TerrainRegionCache.open( - manifestAt: source.source, - limits: .init( - maximumResidentHeightBytes: source.maximumResidentHeightBytes, - maximumResidentRegions: source.maximumResidentRegions - ) + baseCache = try await openManifest( + at: source.source, + limits: .init( + maximumResidentHeightBytes: source.maximumResidentHeightBytes, + maximumResidentRegions: source.maximumResidentRegions ) - } catch is CancellationError { throw CancellationError() } catch { throw TerrainManifestLoadFailure(message: error.localizedDescription) } + ) } let manifest = baseCache.manifest let declared = Set(manifest.heightRegions.map { TerrainRegionCoordinate(x: Int($0.x), z: Int($0.z)) }) diff --git a/Tests/AdaEngineTests/GoldenFrames/webgpu/pbr-spheres-shadedWireframe.png b/Tests/AdaEngineTests/GoldenFrames/webgpu-evidence/mesh-terrain-shaded-wireframe.png similarity index 100% rename from Tests/AdaEngineTests/GoldenFrames/webgpu/pbr-spheres-shadedWireframe.png rename to Tests/AdaEngineTests/GoldenFrames/webgpu-evidence/mesh-terrain-shaded-wireframe.png diff --git a/Tests/AdaEngineTests/Physics3DTests.swift b/Tests/AdaEngineTests/Physics3DTests.swift index 90ddc185b..2133c4518 100644 --- a/Tests/AdaEngineTests/Physics3DTests.swift +++ b/Tests/AdaEngineTests/Physics3DTests.swift @@ -5,8 +5,8 @@ // Created by Codex on 7/6/26. // -import AdaECS @_spi(Internal) @testable import AdaApp +import AdaECS @testable import AdaPhysics import AdaTransform import Testing @@ -14,7 +14,6 @@ import Testing @MainActor @Suite(.serialized) struct Physics3DTests { - let world: AppWorlds init() async throws { @@ -104,9 +103,10 @@ struct Physics3DTests { } let startY = try #require(box.components[Transform.self]?.position.y) - try await world.update() - try await Task.sleep(for: .milliseconds(20)) - try await world.update() + try await SimulationClock.$fixedDeltaTime.withValue(1 / 60) { + try await world.update() + try await world.update() + } let runtimeBody = try #require(box.components[PhysicsBody3DComponent.self]?.runtimeBody) let endY = try #require(box.components[Transform.self]?.position.y) diff --git a/Tests/AdaEngineTests/Render3DPathTracingTests.swift b/Tests/AdaEngineTests/Render3DPathTracingTests.swift index b0ee26513..1213feee8 100644 --- a/Tests/AdaEngineTests/Render3DPathTracingTests.swift +++ b/Tests/AdaEngineTests/Render3DPathTracingTests.swift @@ -54,7 +54,7 @@ struct Render3DPathTracingSettingsTests { #expect(bvh.nodes.first?.escape == bvh.nodes.count) #expect(bvh.triangles.count == input.count) #expect(Set(bvh.triangles.map { $0.vertices[0].w }) == Set((0..<39).map(Float.init))) - #expect(bvh.nodes.filter { !$0.isEmpty }.reduce(0) { $0 + $1.count } == input.count) + #expect(bvh.nodes.reduce(0) { $0 + $1.count } == input.count) for (index,node) in bvh.nodes.enumerated() { #expect(node.escape > index && node.escape <= bvh.nodes.count) #expect(node.count <= 4) diff --git a/Tests/AdaScriptingTests/AdaScriptAsyncTests.swift b/Tests/AdaScriptingTests/AdaScriptAsyncTests.swift index 2c953d858..96cb737b1 100644 --- a/Tests/AdaScriptingTests/AdaScriptAsyncTests.swift +++ b/Tests/AdaScriptingTests/AdaScriptAsyncTests.swift @@ -36,8 +36,8 @@ struct AdaScriptAsyncTests { @Test("Real-time timers complete without a world update") func realTimeTimerCompletes() async throws { let timer = AdaScriptAsyncHost().sleepRealTime(0.01) - try await Task.sleep(for: .milliseconds(30)) - #expect(timer.result().isSuccess()) + let result = try await waitForOperation(timer) + #expect(result.isSuccess()) } @Test("A background save rejects paths outside writable roots") diff --git a/Tests/AdaScriptingTests/InputResourceBindingTests.swift b/Tests/AdaScriptingTests/InputResourceBindingTests.swift index 455b64a17..ea4952fed 100644 --- a/Tests/AdaScriptingTests/InputResourceBindingTests.swift +++ b/Tests/AdaScriptingTests/InputResourceBindingTests.swift @@ -38,7 +38,7 @@ struct InputResourceBindingTests { #expect(plugin.diagnostics.isEmpty) } - @Test func declaresOnlyRequestedReadAccess() throws { + @Test func declaresInputReadAlongsideSharedVMAccess() throws { let plugin = try AdaScriptPlugin(source: """ @system(id: "with-input") class WithInput { @@ -56,12 +56,15 @@ struct InputResourceBindingTests { let scheduler = try #require(world.schedulers.getScheduler(.update)) let withInput = try #require(scheduler.systemGraph.nodes.first { $0.name.hasSuffix("with-input") }) let withoutInput = try #require(scheduler.systemGraph.nodes.first { $0.name.hasSuffix("without-input") }) - var expected = SystemAccessSet() - expected.addResourceRead(DeltaTime.self) + let taskPump = try #require(scheduler.systemGraph.nodes.first { $0.name.hasPrefix("AdaScripting.TaskPump.") }) + var expected = taskPump.queries.access #expect(withoutInput.queries.access == expected) expected.addResourceRead(Input.self) #expect(withInput.queries.access == expected) - #expect(withInput.queries.access.isCompatible(with: expected)) + var reader = SystemAccessSet() + reader.addResourceRead(Input.self) + #expect(withInput.queries.access.isCompatible(with: reader)) + #expect(!withInput.queries.access.isCompatible(with: withoutInput.queries.access)) var writer = SystemAccessSet() writer.addResourceWrite(Input.self) #expect(!withInput.queries.access.isCompatible(with: writer)) From 1e9379d5bbba246b1468c9fb68e7a3e15755d92e Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Sun, 8 Nov 2026 02:09:56 +0300 Subject: [PATCH 04/11] Bound Windows contributor validation parallelism --- script/contribution/profiles.py | 6 ++++-- script/test_contribution.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/script/contribution/profiles.py b/script/contribution/profiles.py index d2c0a1295..623579586 100644 --- a/script/contribution/profiles.py +++ b/script/contribution/profiles.py @@ -104,12 +104,14 @@ def module(name, category, module_name, arguments): prefix = "editor" if package == "Editor" else "swift" scratch = config["swift_scratch"] + ("-editor" if package == "Editor" else "") common = ["--package-path", package, "--scratch-path", scratch] - checks.append(Check(prefix + "-build", "analysis", ["swift", "build", "--build-tests", "-v", *common], timeout=timeout)) + jobs = ["--jobs", "2"] if sys.platform == "win32" else [] + workers = ["--num-workers", "2"] if sys.platform == "win32" else [] + checks.append(Check(prefix + "-build", "analysis", ["swift", "build", "--build-tests", "-v", *jobs, *common], timeout=timeout)) selected = [p for p in swift_paths if p.startswith("Editor/") == (package == "Editor")] checks.append(adapter(prefix + "-analysis", "analysis") if selected else Check(prefix + "-analysis", "analysis", status="not_applicable", reason="No existing Swift source changed in " + package)) - checks.append(Check(prefix + "-tests", "tests", ["swift", "test", "--parallel", *common], tests=True, timeout=timeout)) + checks.append(Check(prefix + "-tests", "tests", ["swift", "test", "--parallel", *jobs, *workers, *common], tests=True, timeout=timeout)) if "web" in profiles: selected = [str(root / p) for p in paths if p.startswith("Editor/WebUI/") and Path(p).suffix in (".js", ".jsx", ".mjs", ".ts", ".tsx", ".css") and (root / p).is_file()] diff --git a/script/test_contribution.py b/script/test_contribution.py index 672fea519..3fcf6e644 100644 --- a/script/test_contribution.py +++ b/script/test_contribution.py @@ -11,6 +11,7 @@ import sys import tempfile import unittest +from unittest.mock import patch from contribution import adapters, hooks, workflow from contribution.cli import main @@ -65,6 +66,15 @@ def test_editor_and_root_tests_both_selected_for_shared_change(self): checks = build_checks(REPO, ["Sources/AdaUI/View.swift", "Editor/Package.swift"], CONFIG, REPO / "files.json", REPO) self.assertTrue({"swift-tests", "editor-tests"}.issubset({check.name for check in checks})) + def test_windows_swift_checks_limit_compiler_jobs_and_test_workers(self): + with patch.object(sys, "platform", "win32"): + checks = build_checks(REPO, ["Sources/AdaUI/View.swift"], CONFIG, REPO / "files.json", REPO) + commands = {check.name: check.argv for check in checks} + for prefix in ("swift", "editor"): + self.assertIn("--jobs", commands[prefix + "-build"]) + self.assertIn("--jobs", commands[prefix + "-tests"]) + self.assertIn("--num-workers", commands[prefix + "-tests"]) + def test_document_change_does_not_build_engine(self): checks = build_checks(REPO, ["Documentation/Designs/example.md"], CONFIG, REPO / "files.json", REPO) self.assertFalse(any("swift" in check.argv for check in checks)) From 2ef6e27642f3cc9c69628bef28bd173f6a7f5fe4 Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Sun, 8 Nov 2026 02:14:34 +0300 Subject: [PATCH 05/11] Isolate Git hook environment from validation subprocesses --- script/contribution/execution.py | 10 +++++++++- script/test_contribution.py | 9 +++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/script/contribution/execution.py b/script/contribution/execution.py index 5adef3b17..1b6bf9302 100644 --- a/script/contribution/execution.py +++ b/script/contribution/execution.py @@ -92,10 +92,18 @@ def execute(check, root, logs): log.parent.mkdir(parents=True, exist_ok=True) result["log"] = str(log) try: + environment = os.environ.copy() + # Git hooks export repository paths that would override a check's working directory. + if any(name.startswith("GIT_") for name in environment): + local_git_vars = subprocess.check_output( + ["git", "rev-parse", "--local-env-vars"], cwd=root, text=True, timeout=10 + ) + for name in local_git_vars.splitlines(): + environment.pop(name, None) with log.open("wb") as output: process = subprocess.Popen( check.argv, cwd=Path(root) / check.cwd, stdout=output, stderr=subprocess.STDOUT, - env={**os.environ, **check.env, "PYTHONUTF8": "1"}, + env={**environment, **check.env, "PYTHONUTF8": "1"}, start_new_session=os.name != "nt", ) try: diff --git a/script/test_contribution.py b/script/test_contribution.py index 3fcf6e644..7af1a59a1 100644 --- a/script/test_contribution.py +++ b/script/test_contribution.py @@ -4,6 +4,7 @@ import importlib.util import contextlib import io +import os import shlex import shutil from pathlib import Path @@ -176,6 +177,14 @@ def test_safe_path_rejects_escape_and_git_internals(self): class ExecutionTests(RepositoryFixture): + def test_checks_discover_their_own_git_repository_when_run_from_a_hook(self): + foreign_git_dir = str(self.root / "foreign.git") + check = Check("git-root", "tests", ["git", "rev-parse", "--show-toplevel"]) + with patch.dict(os.environ, {"GIT_DIR": foreign_git_dir}): + result = execute(check, self.root, self.root / "logs") + self.assertEqual(result["status"], "passed") + self.assertEqual(Path(Path(result["log"]).read_text().strip()).resolve(), self.root.resolve()) + def test_deferred_check_does_not_pass_complete_validation(self): report = {"purpose": "publication", "checks": [{"status": "deferred"}]} self.assertEqual(status_code(report), 2) From 61699d06caf53190a1c99bfbb1487b5fdd271100 Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Sun, 8 Nov 2026 02:21:18 +0300 Subject: [PATCH 06/11] Refresh portable player fixtures and stabilize parallel UI tests --- .../AdaWebPlayerTests.swift | 32 ++++++++++++------- Tests/AdaSpriteTests/SpritePickingTests.swift | 1 + Tests/AdaUITests/ViewVisibilityTest.swift | 18 +++++++---- 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/Tests/AdaScriptCompilerCoreTests/AdaWebPlayerTests.swift b/Tests/AdaScriptCompilerCoreTests/AdaWebPlayerTests.swift index 6bbdc36dd..f21615d8e 100644 --- a/Tests/AdaScriptCompilerCoreTests/AdaWebPlayerTests.swift +++ b/Tests/AdaScriptCompilerCoreTests/AdaWebPlayerTests.swift @@ -67,11 +67,13 @@ struct AdaWebPlayerTests { func unsupportedSystems() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: root) } - try makeProject(at: root) - try (source("First") + "\n@system class Tick { func update(context) {} }").write( + try FileManager.default.createDirectory(at: root.appendingPathComponent("Sources"), withIntermediateDirectories: true) + let legacyProject = AdaWebPlayerProject(title: "Game", entryView: "Game", sources: ["Sources/Main.ada"]) + try JSONEncoder().encode(legacyProject).write(to: root.appendingPathComponent("project.json")) + try "@system class Tick { func update(context) {} }".write( to: root.appendingPathComponent("Sources/Main.ada"), atomically: true, encoding: .utf8 ) - #expect(throws: AdaWebPlayerProjectError.self) { + #expect(throws: AdaWebPlayerProjectError.invalid("This Web Player profile supports views without ECS systems or custom native data.")) { try AdaWebPlayerBundle.assemble(template: root.appendingPathComponent("missing"), project: root, output: root.appendingPathComponent("out")) } #expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent("out").path)) @@ -86,7 +88,6 @@ struct AdaWebPlayerTests { try FileManager.default.createDirectory(at: projectRoot.appendingPathComponent("Sources"), withIntermediateDirectories: true) try FileManager.default.createDirectory(at: projectRoot.appendingPathComponent("Assets/Scenes"), withIntermediateDirectories: true) try makeTemplate(at: template) - try Data(#"{"runtimeAPI":4,"profile":"universal"}"#.utf8).write(to: template.appendingPathComponent("ada-web-player.json")) let script = "@system class Tick { func update(context) {} }" try script.write(to: projectRoot.appendingPathComponent("Sources/Main.ada"), atomically: true, encoding: .utf8) let scene = "format: ada.scene\nschemaVersion: 1\nscene:\n id: test\n name: Test\nentities: []\n" @@ -114,9 +115,15 @@ struct AdaWebPlayerTests { try FileManager.default.createDirectory(at: project.appendingPathComponent("Sources"), withIntermediateDirectories: true) try FileManager.default.createDirectory(at: project.appendingPathComponent("Assets/Scenes"), withIntermediateDirectories: true) try makeTemplate(at: template) - try Data(#"{"runtimeAPI":4,"profile":"universal"}"#.utf8).write(to: template.appendingPathComponent("ada-web-player.json")) try Data("lobby".utf8).write(to: template.appendingPathComponent("player-lobby.js")) - let settings = #"{"build":{"system":"adascript"},"project":{"name":"SceneGame"},"paths":{"sources":"Sources","assets":"Assets"},"runtime":{"moduleName":"SceneGame","entry":{"scene":"Assets/Scenes/Main.ascn"}}}"# + let settings = #""" + { + "build": {"system": "adascript"}, + "project": {"name": "SceneGame"}, + "paths": {"sources": "Sources", "assets": "Assets"}, + "runtime": {"moduleName": "SceneGame", "entry": {"scene": "Assets/Scenes/Main.ascn"}} + } + """# try settings.write(to: project.appendingPathComponent(".ada/project.json"), atomically: true, encoding: .utf8) try "@system class Tick { func update(context) {} }".write(to: project.appendingPathComponent("Sources/Main.ada"), atomically: true, encoding: .utf8) try "format: ada.scene\n".write(to: project.appendingPathComponent("Assets/Scenes/Main.ascn"), atomically: true, encoding: .utf8) @@ -154,9 +161,7 @@ struct AdaWebPlayerTests { let template = root.appendingPathComponent("template") try makeProject(at: projectRoot) try makeTemplate(at: template) - try Data(#"{"runtimeAPI":2,"profile":"views"}"#.utf8).write(to: template.appendingPathComponent("ada-web-player.json")) var project = try AdaWebPlayerProject.load(at: projectRoot) - project.runtimeAPI = 2 project.assets = [ .init(id: "picture", kind: .texture, path: "Assets/My #picture.png"), .init(id: "font", kind: .font, path: "Assets/Font.ttf"), @@ -207,7 +212,12 @@ struct AdaWebPlayerTests { private func makeProject(at directory: URL) throws { try FileManager.default.createDirectory(at: directory.appendingPathComponent("Sources"), withIntermediateDirectories: true) - let project = AdaWebPlayerProject(title: "Game", entryView: "Game", sources: ["Sources/Main.ada"]) + let scene = "Assets/Scenes/Main.ascn" + let sceneURL = directory.appendingPathComponent(scene) + try FileManager.default.createDirectory(at: sceneURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try "format: ada.scene\n".write(to: sceneURL, atomically: true, encoding: .utf8) + var project = AdaWebPlayerProject(title: "Game", entryScene: scene, moduleName: "Game", sources: ["Sources/Main.ada"]) + project.files = [scene] try JSONEncoder().encode(project).write(to: directory.appendingPathComponent("project.json")) try source("First").write(to: directory.appendingPathComponent("Sources/Main.ada"), atomically: true, encoding: .utf8) } @@ -219,11 +229,11 @@ struct AdaWebPlayerTests { try Data("test player bytes".utf8).write(to: file) } try Data("[]".utf8).write(to: directory.appendingPathComponent("ada-resource-manifest.json")) - try Data(#"{"runtimeAPI":1,"profile":"views"}"#.utf8).write(to: directory.appendingPathComponent("ada-web-player.json")) + try Data(#"{"runtimeAPI":4,"profile":"universal"}"#.utf8).write(to: directory.appendingPathComponent("ada-web-player.json")) } private func source(_ text: String) -> String { - "@view class Game { func body() { Text(\"\(text)\"); } }" + "class Game { func title() { return \"\(text)\"; } }" } private struct Entry: Decodable, Equatable { diff --git a/Tests/AdaSpriteTests/SpritePickingTests.swift b/Tests/AdaSpriteTests/SpritePickingTests.swift index 0c252747e..efcec53f5 100644 --- a/Tests/AdaSpriteTests/SpritePickingTests.swift +++ b/Tests/AdaSpriteTests/SpritePickingTests.swift @@ -110,6 +110,7 @@ struct SpritePickingTests { #expect(hit(Sprite(texture: namedSlice, size: sprite.size), at: [-5, 0]) == nil) #expect(hit(Sprite(texture: namedSlice, size: sprite.size), at: [5, 0]) != nil) let animation = AnimatedTexture() + animation.isPaused = true animation.framesCount = 2 animation[0] = slice animation[1] = .whiteTexture diff --git a/Tests/AdaUITests/ViewVisibilityTest.swift b/Tests/AdaUITests/ViewVisibilityTest.swift index 10c56dca2..ae09a56eb 100644 --- a/Tests/AdaUITests/ViewVisibilityTest.swift +++ b/Tests/AdaUITests/ViewVisibilityTest.swift @@ -5,12 +5,12 @@ // Created by vladislav.prusakov on 11.08.2024. // -import Testing -@testable import AdaUI -@testable import AdaPlatform import AdaInput +@testable import AdaPlatform +@testable import AdaUI import AdaUtils import Math +import Testing @MainActor struct ViewVisibilityTests { @@ -322,7 +322,7 @@ private func flushLifecycleActions() async { } private func waitForTaskCancellation(_ recorder: TaskLifecycleRecorder) async { - let deadline = ContinuousClock.now.advanced(by: .seconds(2)) + let deadline = ContinuousClock.now.advanced(by: .seconds(10)) while ContinuousClock.now < deadline { if await recorder.cancellationCount > 0 { return @@ -332,7 +332,7 @@ private func waitForTaskCancellation(_ recorder: TaskLifecycleRecorder) async { } private func waitForTaskStart(_ recorder: TaskLifecycleRecorder, count: Int = 1) async { - let deadline = ContinuousClock.now.advanced(by: .seconds(2)) + let deadline = ContinuousClock.now.advanced(by: .seconds(10)) while ContinuousClock.now < deadline { if await recorder.startCount >= count { return @@ -351,9 +351,13 @@ private struct LifecycleStateMutationHost: View { @State private var value = 0 + private var recordedValue: Int { + recorder.value = value + return value + } + var body: some View { - let _ = recorder.value = value - Text("\(value)") + Text("\(recordedValue)") .onAppear { recorder.appearCount += 1 value = 1 From 112505ee22702c0bd4f9aa8e53f0ea89dff335d4 Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Sun, 8 Nov 2026 02:27:21 +0300 Subject: [PATCH 07/11] Atomically replace Linux saves and shrink Windows Tint fixtures --- Sources/AdaScripting/AdaScriptSaveWriter.swift | 11 +++++++++++ Tests/AdaUtilsTests/TintToolchainTests.swift | 6 +++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Sources/AdaScripting/AdaScriptSaveWriter.swift b/Sources/AdaScripting/AdaScriptSaveWriter.swift index fbe7a264d..740ce4797 100644 --- a/Sources/AdaScripting/AdaScriptSaveWriter.swift +++ b/Sources/AdaScripting/AdaScriptSaveWriter.swift @@ -1,4 +1,7 @@ import Foundation +#if os(Linux) + import Glibc +#endif import Gravity #if os(Windows) import WinSDK @@ -158,6 +161,14 @@ private actor AdaScriptSaveStreamState { } } guard moved else { throw SaveStreamError.commitFailed(GetLastError()) } + #elseif os(Linux) + // Rename replaces the destination in one operation on the same filesystem. + let result = unsafe temporary.path.withCString { source in + unsafe destination.path.withCString { target in + unsafe Glibc.rename(source, target) + } + } + guard result == 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } #else let fileManager = FileManager.default if fileManager.fileExists(atPath: destination.path) { diff --git a/Tests/AdaUtilsTests/TintToolchainTests.swift b/Tests/AdaUtilsTests/TintToolchainTests.swift index be6754207..9e1e8f90b 100644 --- a/Tests/AdaUtilsTests/TintToolchainTests.swift +++ b/Tests/AdaUtilsTests/TintToolchainTests.swift @@ -12,9 +12,9 @@ struct TintToolchainTests { let url = root.appendingPathComponent(path) try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) #if os(Windows) - // Windows requires a real executable. Reuse the test runner so discovery - // fixtures do not depend on Tint being installed on the host. - let hostTool = Bundle.main.executableURL ?? URL(fileURLWithPath: CommandLine.arguments[0]) + // Discovery needs a real executable, but never launches it. + let systemRoot = try #require(ProcessInfo.processInfo.environment["SystemRoot"]) + let hostTool = URL(fileURLWithPath: systemRoot, isDirectory: true).appendingPathComponent("System32/where.exe") try FileManager.default.copyItem(at: hostTool, to: url) #else try Data("fixture".utf8).write(to: url) From e2a640fbd3134f7df24399f5db28f05a2b1b2908 Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Sun, 8 Nov 2026 02:34:29 +0300 Subject: [PATCH 08/11] Refresh terrain brush overlays and serialize Windows CI tests --- .github/workflows/swift-windows.yml | 9 ++-- .../Editor/EditorSceneViewportRulerView.swift | 26 +++++++++--- .../EditorTerrainInspectorTests.swift | 42 +++++++++++-------- script/contribution/profiles.py | 4 +- script/test_contribution.py | 4 +- 5 files changed, 53 insertions(+), 32 deletions(-) diff --git a/.github/workflows/swift-windows.yml b/.github/workflows/swift-windows.yml index 4065930a9..1981221c3 100644 --- a/.github/workflows/swift-windows.yml +++ b/.github/workflows/swift-windows.yml @@ -31,9 +31,8 @@ jobs: run: swift -version - name: Test - # Swift Testing on Windows becomes unstable when all tests are - # scheduled at once. Keep bounded parallelism so the suite still - # makes progress without exhausting the runner. + # Rendering and application tests share process-wide engine state. + # Run suites serially so their frame updates cannot race on Windows. # Build the test product directly: SwiftPM otherwise compiles the # WASI-only JavaScriptKit command plugin, which cannot build on Windows. run: | @@ -41,11 +40,11 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } swift build -j 2 --product TextureAtlasBuilderTool if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - swift test --skip-build --parallel --num-workers 2 + swift test --skip-build --no-parallel - name: Test Mesh Terrain authoring kernel # Authoring is optional for compiled players; explicitly exercise it in CI. run: | swift build -j 2 --traits MeshTerrainAuthoring --product AdaEnginePackageTests if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - swift test --traits MeshTerrainAuthoring --skip-build --parallel --num-workers 2 --filter "MeshTerrain|TriangleBoolean|TriangleIndexEdges" + swift test --traits MeshTerrainAuthoring --skip-build --no-parallel --filter "MeshTerrain|TriangleBoolean|TriangleIndexEdges" diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportRulerView.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportRulerView.swift index 42fe4dca0..33d59b51e 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportRulerView.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportRulerView.swift @@ -25,6 +25,7 @@ final class EditorViewportPresentation { struct Annotations { let generation: UInt64 let revision: UInt + let brushRevision: UInt64 let size: Size let theme: Theme let grid: UIGraphicsContext @@ -48,8 +49,12 @@ final class EditorViewportPresentation { statusRevision &+= 1 } let state = viewportModel.smoothPerspectiveBlend < 1 - ? RulerState(center: viewportModel.twoDCenter, zoom: viewportModel.twoDZoom, - blend: viewportModel.smoothPerspectiveBlend, size: viewportModel.viewportSize) + ? RulerState( + center: viewportModel.twoDCenter, + zoom: viewportModel.twoDZoom, + blend: viewportModel.smoothPerspectiveBlend, + size: viewportModel.viewportSize + ) : nil if rulerState != state { rulerState = state @@ -66,8 +71,10 @@ final class EditorViewportPresentation { // logical size and stretch with that texture until the new target arrives. let cameraSize = viewportModel.cameraEntity()?.components[Camera.self]?.logicalViewport.rect.size let size = cameraSize.flatMap { $0.width > 0 && $0.height > 0 ? $0 : nil } ?? viewportModel.viewportSize + let brushRevision = viewportModel.terrainEditing.brushRevision let annotations: Annotations if let preparedAnnotations, preparedAnnotations.revision == revision, + preparedAnnotations.brushRevision == brushRevision, preparedAnnotations.size == size, preparedAnnotations.theme == theme { annotations = preparedAnnotations } else { @@ -81,13 +88,22 @@ final class EditorViewportPresentation { let orientation = viewportModel.displayMode == .threeD ? CompassOrientation(right: viewportModel.right3D, up: viewportModel.up3D, front: viewportModel.front3D) : nil annotations = Annotations( - generation: annotationGeneration, revision: revision, size: size, theme: theme, grid: grid, gizmos: gizmos, - interactionGizmo: viewportModel.captureGizmo(gizmo), compassOrientation: orientation + generation: annotationGeneration, + revision: revision, + brushRevision: brushRevision, + size: size, + theme: theme, + grid: grid, + gizmos: gizmos, + interactionGizmo: viewportModel.captureGizmo(gizmo), + compassOrientation: orientation ) preparedAnnotations = annotations } return { [weak self, weak viewportModel] in - guard let self else { return } + guard let self else { + return + } self.presentedAnnotations = annotations viewportModel?.presentedGizmo = annotations.interactionGizmo if self.compassOrientation != annotations.compassOrientation { diff --git a/Editor/Tests/AdaEditorTests/EditorTerrainInspectorTests.swift b/Editor/Tests/AdaEditorTests/EditorTerrainInspectorTests.swift index 8d639a5e0..a66aca0e0 100644 --- a/Editor/Tests/AdaEditorTests/EditorTerrainInspectorTests.swift +++ b/Editor/Tests/AdaEditorTests/EditorTerrainInspectorTests.swift @@ -6,8 +6,8 @@ import Testing @MainActor extension EditorLocalizationTests { - @Test("Brush controls fit a narrow Inspector, update the active brush and survive language changes") - func mountedInspector() async throws { + @Test("The viewport terrain dock updates the active brush and survives language changes") + func mountedTerrainDock() async throws { if unsafe RenderEngine.shared == nil { unsafe RenderEngine.configurations.preferredBackend = .headless RenderWorldPlugin().setup(in: AppWorlds(main: World(name: "TerrainInspector"))) @@ -16,13 +16,20 @@ extension EditorLocalizationTests { defer { EditorLocalization.shared.preference = previousLanguage } let viewport = EditorSceneViewportModel() viewport.setTerrainEditing(true) - let inspector = EditorInspectorSidebarViewModel() - attach(inspector, to: viewport) var creations = 0 var changes = 0 - inspector.setTerrainTools(owner: viewport, state: viewport.terrainEditing, create: { creations += 1 }, settingsChanged: { changes += 1 }) - let container = UIContainerView(rootView: EditorInspectorSidebar(viewModel: inspector).theme(.adaEditor)) - container.frame = Rect(x: 0, y: 0, width: 300, height: 720) + viewport.terrainEditing.sourceActions = .init( + create: { creations += 1 }, + materials: {}, + variation: {}, + mesh: {}, + texture: {}, + extend: {}, + generate: {} + ) + let dock = EditorTerrainDockPanel(state: viewport.terrainEditing, onChange: { changes += 1 }) + let container = UIContainerView(rootView: dock.theme(.adaEditor)) + container.frame = Rect(x: 0, y: 0, width: 680, height: 300) container.bounds.size = container.frame.size container.layoutIfNeeded() let controls = EditorTerrainBrushMode.allCases.map { "AdaEditor.Terrain.Mode.\($0.rawValue)" } @@ -33,9 +40,9 @@ extension EditorLocalizationTests { for identifier in controls { let node = try container.uiNode(matching: .accessibilityIdentifier(identifier)) #expect(node.absoluteFrame.origin.x >= 0) - #expect(node.absoluteFrame.origin.x + node.absoluteFrame.size.width <= 300.5) + #expect(node.absoluteFrame.origin.x + node.absoluteFrame.size.width <= 680.5) #expect(node.absoluteFrame.size.width > 0) - #expect(node.absoluteFrame.origin.y + node.absoluteFrame.size.height <= 720) + #expect(node.absoluteFrame.origin.y + node.absoluteFrame.size.height <= 300) } let slider = try container.uiNode(matching: .accessibilityIdentifier("AdaEditor.Terrain.Radius")) #expect(slider.absoluteFrame.size.width > 200) @@ -45,13 +52,6 @@ extension EditorLocalizationTests { #expect(changes == 1) _ = try container.uiTapNode(matching: .accessibilityIdentifier("AdaEditor.Terrain.Create")) #expect(creations == 1) - inspector.isInspectingPlayMode = true - await refresh(container) - #expect(container.uiFindNodes(matching: .accessibilityIdentifier("AdaEditor.Terrain.Panel")).isEmpty) - inspector.isInspectingPlayMode = false - viewport.setTerrainEditing(false) - await refresh(container) - #expect(container.uiFindNodes(matching: .accessibilityIdentifier("AdaEditor.Terrain.Panel")).isEmpty) } @Test("Switching scenes replaces brush ownership and closing an old viewport preserves the current controls") @@ -82,8 +82,14 @@ extension EditorLocalizationTests { private func attach(_ inspector: EditorInspectorSidebarViewModel, to viewport: EditorSceneViewportModel) { inspector.setSceneViewportActions( - owner: viewport, applyGizmoChange: { _ in }, addEntity: { _ in }, addComponent: { _ in }, removeComponent: { _ in }, - updateComponentField: { _, _, _ in }, addScriptableObject: { _ in }, removeScriptableObject: { _ in }, + owner: viewport, + applyGizmoChange: { _ in }, + addEntity: { _ in }, + addComponent: { _ in }, + removeComponent: { _ in }, + updateComponentField: { _, _, _ in }, + addScriptableObject: { _ in }, + removeScriptableObject: { _ in }, updateScriptableObjectField: { _, _, _ in } ) } diff --git a/script/contribution/profiles.py b/script/contribution/profiles.py index 623579586..83a762814 100644 --- a/script/contribution/profiles.py +++ b/script/contribution/profiles.py @@ -105,13 +105,13 @@ def module(name, category, module_name, arguments): scratch = config["swift_scratch"] + ("-editor" if package == "Editor" else "") common = ["--package-path", package, "--scratch-path", scratch] jobs = ["--jobs", "2"] if sys.platform == "win32" else [] - workers = ["--num-workers", "2"] if sys.platform == "win32" else [] + parallelism = "--no-parallel" if sys.platform == "win32" else "--parallel" checks.append(Check(prefix + "-build", "analysis", ["swift", "build", "--build-tests", "-v", *jobs, *common], timeout=timeout)) selected = [p for p in swift_paths if p.startswith("Editor/") == (package == "Editor")] checks.append(adapter(prefix + "-analysis", "analysis") if selected else Check(prefix + "-analysis", "analysis", status="not_applicable", reason="No existing Swift source changed in " + package)) - checks.append(Check(prefix + "-tests", "tests", ["swift", "test", "--parallel", *jobs, *workers, *common], tests=True, timeout=timeout)) + checks.append(Check(prefix + "-tests", "tests", ["swift", "test", parallelism, *jobs, *common], tests=True, timeout=timeout)) if "web" in profiles: selected = [str(root / p) for p in paths if p.startswith("Editor/WebUI/") and Path(p).suffix in (".js", ".jsx", ".mjs", ".ts", ".tsx", ".css") and (root / p).is_file()] diff --git a/script/test_contribution.py b/script/test_contribution.py index 7af1a59a1..d8279f178 100644 --- a/script/test_contribution.py +++ b/script/test_contribution.py @@ -67,14 +67,14 @@ def test_editor_and_root_tests_both_selected_for_shared_change(self): checks = build_checks(REPO, ["Sources/AdaUI/View.swift", "Editor/Package.swift"], CONFIG, REPO / "files.json", REPO) self.assertTrue({"swift-tests", "editor-tests"}.issubset({check.name for check in checks})) - def test_windows_swift_checks_limit_compiler_jobs_and_test_workers(self): + def test_windows_swift_checks_limit_compiler_jobs_and_run_tests_serially(self): with patch.object(sys, "platform", "win32"): checks = build_checks(REPO, ["Sources/AdaUI/View.swift"], CONFIG, REPO / "files.json", REPO) commands = {check.name: check.argv for check in checks} for prefix in ("swift", "editor"): self.assertIn("--jobs", commands[prefix + "-build"]) self.assertIn("--jobs", commands[prefix + "-tests"]) - self.assertIn("--num-workers", commands[prefix + "-tests"]) + self.assertIn("--no-parallel", commands[prefix + "-tests"]) def test_document_change_does_not_build_engine(self): checks = build_checks(REPO, ["Documentation/Designs/example.md"], CONFIG, REPO / "files.json", REPO) From 32c59477f9cc67ab792eb3c8b5923584f38195b6 Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Sun, 8 Nov 2026 02:52:39 +0300 Subject: [PATCH 09/11] Run portable compiler tests on Windows and refresh viewport fixtures --- .../EditorViewportPresentationTests.swift | 32 ++++++++++++++----- Package.swift | 19 +++++++---- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/Editor/Tests/AdaEditorTests/EditorViewportPresentationTests.swift b/Editor/Tests/AdaEditorTests/EditorViewportPresentationTests.swift index ced5cae3a..70c59485d 100644 --- a/Editor/Tests/AdaEditorTests/EditorViewportPresentationTests.swift +++ b/Editor/Tests/AdaEditorTests/EditorViewportPresentationTests.swift @@ -13,9 +13,13 @@ struct EditorViewportPresentationTests { let presentation = EditorViewportPresentation() let viewport = EditorSceneViewportModel() let model = EditorSceneModel.default(projectName: "Frame synchronization") - viewport.configure(sceneContent: try model.encodedYAML(), - onSelectionChanged: { _ in }, onDocumentContentChanged: { _ in }) + viewport.configure( + sceneContent: try model.encodedYAML(), + onSelectionChanged: { _ in }, + onDocumentContentChanged: { _ in } + ) viewport.setViewportSize(Size(width: 640, height: 400)) + viewport.showsEntityMarkers = true let container = UIContainerView(rootView: EditorViewportDrawingLayer( viewportModel: viewport, presentation: presentation, kind: .gizmos )) @@ -28,7 +32,9 @@ struct EditorViewportPresentationTests { container.viewTree.rootNode.draw(with: context) context.commitDraw() return context.getDrawCommands().compactMap { - if case let .drawQuad(transform, _, _) = $0 { return transform } + if case let .drawQuad(transform, _, _) = $0 { + return transform + } return nil } } @@ -106,6 +112,8 @@ struct EditorViewportPresentationTests { } let presentation = EditorViewportPresentation() let viewport = EditorSceneViewportModel() + viewport.setViewportSize(Size(width: 640, height: 400)) + presentation.refresh(viewportModel: viewport) let builds = HostBuildCounter() let host = PresentationHost(presentation: presentation, viewport: viewport, builds: builds) let container = UIContainerView(rootView: host) @@ -115,20 +123,26 @@ struct EditorViewportPresentationTests { let initialBuilds = builds.count let initialGeometryBuilds = builds.geometryCount let initialLabels = labels(in: container) - #expect(initialLabels.contains("Loaded 1 entities · \(viewport.statusSuffix)")) + let initialRulerRevision = presentation.rulerRevision + let initialStatusRevision = presentation.statusRevision + #expect(initialLabels.contains("Loaded 1 entities · ")) + #expect(presentation.statusSuffix == viewport.statusSuffix) viewport.twoDZoom = 75 viewport.twoDCenter = Vector2(3, 2) host.requestRedraw() - for _ in 0..<3 { + for _ in 0..<10 { await Task.yield() - container.layoutIfNeeded() container.update(0) + container.layoutIfNeeded() } let updatedLabels = labels(in: container) #expect(builds.count == initialBuilds) #expect(builds.geometryCount == initialGeometryBuilds) - #expect(updatedLabels.contains("Loaded 1 entities · \(viewport.statusSuffix)")) + #expect(updatedLabels.contains("Loaded 1 entities · ")) + #expect(presentation.rulerRevision > initialRulerRevision) + #expect(presentation.statusRevision > initialStatusRevision) + #expect(presentation.statusSuffix == viewport.statusSuffix) #expect(updatedLabels != initialLabels) } @@ -148,7 +162,9 @@ struct EditorViewportPresentationTests { container.viewTree.rootNode.draw(with: context) context.commitDraw() return try #require(context.getDrawCommands().compactMap { command -> UInt64? in - if case let .beginLayer(_, version, cacheable) = command, cacheable { return version } + if case let .beginLayer(_, version, cacheable) = command, cacheable { + return version + } return nil }.first) } diff --git a/Package.swift b/Package.swift index 115c3a5df..9e751efc4 100644 --- a/Package.swift +++ b/Package.swift @@ -173,7 +173,6 @@ var products: [Product] = [ ]) ] - // MARK: - Targets // MARK: Editor Target @@ -401,7 +400,14 @@ var targets: [Target] = [ .define("MANIFOLD_PAR", to: "-1"), .define("MANIFOLD_NO_IOSTREAM"), .define("MANIFOLD_NO_FILESYSTEM") ] ), - .target(name: "AdaGeometry", dependencies: ["Math", .target(name: "CManifold", condition: .when(platforms: [.macOS, .windows, .linux], traits: ["MeshTerrainAuthoring"]))], swiftSettings: swiftSettings), + .target( + name: "AdaGeometry", + dependencies: [ + "Math", + .target(name: "CManifold", condition: .when(platforms: [.macOS, .windows, .linux], traits: ["MeshTerrainAuthoring"])) + ], + swiftSettings: swiftSettings + ), .adaTarget(name: "AdaSpatial", dependencies: ["AdaECS", "AdaTransform", "Math"]), .testTarget(name: "AdaSpatialTests", dependencies: ["AdaSpatial", "AdaECS", "AdaTransform", "Math"], swiftSettings: swiftSettings), .adaTarget( @@ -930,10 +936,11 @@ targets += [ #endif #if os(Android) || os(Linux) -targets += [ - .testTarget(name: "AdaEngineEmbeddableTests", dependencies: ["AdaEngineEmbeddable"]), - .testTarget(name: "AdaScriptCompilerCoreTests", dependencies: ["AdaScriptCompilerCore"]), -] +targets.append(.testTarget(name: "AdaEngineEmbeddableTests", dependencies: ["AdaEngineEmbeddable"])) +#endif + +#if os(Android) || os(Linux) || os(Windows) +targets.append(.testTarget(name: "AdaScriptCompilerCoreTests", dependencies: ["AdaScriptCompilerCore"])) #endif // The target must exist on every host because AdaPlatform's dependency is conditional. From dc4826a3ce768bb0202f98a95b5af3d98caefef6 Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Sun, 8 Nov 2026 03:11:44 +0300 Subject: [PATCH 10/11] Refresh ruler content and run existing Windows test binaries --- .../AdaEditor/UI/Editor/EditorSceneViewportRulerView.swift | 4 +--- Tests/AdaUtilsTests/TintToolchainTests.swift | 4 +++- script/contribution/profiles.py | 4 ++-- script/test_contribution.py | 1 + 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportRulerView.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportRulerView.swift index 33d59b51e..b64667f9c 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportRulerView.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportRulerView.swift @@ -152,9 +152,6 @@ struct EditorViewportRulerLayer: View { var body: some View { let revision = presentation.rulerRevision return GeometryReader { proxy in - // A declaration is required inside AdaUI's result builder. - // swiftlint:disable:next redundant_discardable_let - let _ = revision let ruler = viewportModel.coordinateRuler(in: proxy.size) ZStack(anchor: .topLeading) { RectangleShape() @@ -178,6 +175,7 @@ struct EditorViewportRulerLayer: View { .offset(x: 2, y: max(20, proxy.size.height - 20)) } } + .id(revision) .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) } diff --git a/Tests/AdaUtilsTests/TintToolchainTests.swift b/Tests/AdaUtilsTests/TintToolchainTests.swift index 9e1e8f90b..78198a198 100644 --- a/Tests/AdaUtilsTests/TintToolchainTests.swift +++ b/Tests/AdaUtilsTests/TintToolchainTests.swift @@ -13,7 +13,9 @@ struct TintToolchainTests { try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) #if os(Windows) // Discovery needs a real executable, but never launches it. - let systemRoot = try #require(ProcessInfo.processInfo.environment["SystemRoot"]) + let systemRoot = try #require(ProcessInfo.processInfo.environment.first { + $0.key.caseInsensitiveCompare("SystemRoot") == .orderedSame + }?.value) let hostTool = URL(fileURLWithPath: systemRoot, isDirectory: true).appendingPathComponent("System32/where.exe") try FileManager.default.copyItem(at: hostTool, to: url) #else diff --git a/script/contribution/profiles.py b/script/contribution/profiles.py index 83a762814..3e690f730 100644 --- a/script/contribution/profiles.py +++ b/script/contribution/profiles.py @@ -105,13 +105,13 @@ def module(name, category, module_name, arguments): scratch = config["swift_scratch"] + ("-editor" if package == "Editor" else "") common = ["--package-path", package, "--scratch-path", scratch] jobs = ["--jobs", "2"] if sys.platform == "win32" else [] - parallelism = "--no-parallel" if sys.platform == "win32" else "--parallel" + test_options = ["--skip-build", "--no-parallel"] if sys.platform == "win32" else ["--parallel"] checks.append(Check(prefix + "-build", "analysis", ["swift", "build", "--build-tests", "-v", *jobs, *common], timeout=timeout)) selected = [p for p in swift_paths if p.startswith("Editor/") == (package == "Editor")] checks.append(adapter(prefix + "-analysis", "analysis") if selected else Check(prefix + "-analysis", "analysis", status="not_applicable", reason="No existing Swift source changed in " + package)) - checks.append(Check(prefix + "-tests", "tests", ["swift", "test", parallelism, *jobs, *common], tests=True, timeout=timeout)) + checks.append(Check(prefix + "-tests", "tests", ["swift", "test", *test_options, *jobs, *common], tests=True, timeout=timeout)) if "web" in profiles: selected = [str(root / p) for p in paths if p.startswith("Editor/WebUI/") and Path(p).suffix in (".js", ".jsx", ".mjs", ".ts", ".tsx", ".css") and (root / p).is_file()] diff --git a/script/test_contribution.py b/script/test_contribution.py index d8279f178..c66d6aa13 100644 --- a/script/test_contribution.py +++ b/script/test_contribution.py @@ -75,6 +75,7 @@ def test_windows_swift_checks_limit_compiler_jobs_and_run_tests_serially(self): self.assertIn("--jobs", commands[prefix + "-build"]) self.assertIn("--jobs", commands[prefix + "-tests"]) self.assertIn("--no-parallel", commands[prefix + "-tests"]) + self.assertIn("--skip-build", commands[prefix + "-tests"]) def test_document_change_does_not_build_engine(self): checks = build_checks(REPO, ["Documentation/Designs/example.md"], CONFIG, REPO / "files.json", REPO) From 940998c1529fba514fd0be4218c91033f33e825f Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Sun, 8 Nov 2026 03:46:58 +0300 Subject: [PATCH 11/11] Handle terrain noise coordinates and auto-palette limits safely --- .../AdaEditor/Assets/Localization/en.json | 1 + .../AdaEditor/Assets/Localization/ru.json | 1 + .../AdaEditor/EditorTerrainAutoPalette.swift | 17 +++++--- .../AdaEditor/EditorTerrainNoise.swift | 21 ++++++---- .../EditorSceneViewportTerrainTools.swift | 32 +++++++++++---- .../EditorTerrainToolsTests.swift | 40 +++++++++++++++++-- 6 files changed, 89 insertions(+), 23 deletions(-) diff --git a/Editor/Sources/AdaEditor/Assets/Localization/en.json b/Editor/Sources/AdaEditor/Assets/Localization/en.json index 2eda4ff78..aa0468196 100644 --- a/Editor/Sources/AdaEditor/Assets/Localization/en.json +++ b/Editor/Sources/AdaEditor/Assets/Localization/en.json @@ -2934,6 +2934,7 @@ "Saving terrain stroke…": "Saving terrain stroke…", "Release to apply the terrain stroke.": "Release to apply the terrain stroke.", "Terrain stroke could not be saved. Try again.": "Terrain stroke could not be saved. Try again.", + "Terrain material palette has no room for automatic layers or rules.": "Terrain material palette has no room for automatic layers or rules.", "Sculpt": "Sculpt", "Mesh Instancing": "Mesh Instancing", "Height Generator": "Height Generator", diff --git a/Editor/Sources/AdaEditor/Assets/Localization/ru.json b/Editor/Sources/AdaEditor/Assets/Localization/ru.json index f0b1bb2fd..c9cb50c6a 100644 --- a/Editor/Sources/AdaEditor/Assets/Localization/ru.json +++ b/Editor/Sources/AdaEditor/Assets/Localization/ru.json @@ -2934,6 +2934,7 @@ "Saving terrain stroke…": "Сохраняем поверхность…", "Release to apply the terrain stroke.": "Отпустите кнопку, чтобы применить мазок.", "Terrain stroke could not be saved. Try again.": "Не удалось сохранить мазок. Попробуйте ещё раз.", + "Terrain material palette has no room for automatic layers or rules.": "В палитре рельефа нет места для автоматических слоёв или правил.", "Sculpt": "Рельеф", "Mesh Instancing": "Инстансинг мешей", "Height Generator": "Генератор высот", diff --git a/Editor/Sources/AdaEditor/EditorTerrainAutoPalette.swift b/Editor/Sources/AdaEditor/EditorTerrainAutoPalette.swift index 686be8ddd..4881b7d43 100644 --- a/Editor/Sources/AdaEditor/EditorTerrainAutoPalette.swift +++ b/Editor/Sources/AdaEditor/EditorTerrainAutoPalette.swift @@ -2,7 +2,7 @@ /// Adds automatic roles without renumbering or removing imported layers. enum EditorTerrainAutoPalette { - static func make(existing: TerrainMaterialPalette?, variation: TerrainTextureVariation, addRules: Bool) -> TerrainMaterialPalette { + static func make(existing: TerrainMaterialPalette?, variation: TerrainTextureVariation, addRules: Bool) -> TerrainMaterialPalette? { var layers = (existing?.layers ?? []).map { layer in TerrainLayerReference( id: layer.id, @@ -27,26 +27,31 @@ enum EditorTerrainAutoPalette { rules: existing?.rules ?? [] ) } - func role(_ name: String, tint: Vector4) -> UInt16 { + func role(_ name: String, tint: Vector4) -> UInt16? { if let layer = layers.first(where: { $0.name == name }) { return layer.id } let used = Set(layers.map(\.id)) guard layers.count < 32, let id = (UInt16(0)...UInt16(31)).first(where: { !used.contains($0) }) else { - return layers.first?.id ?? 0 + return nil } layers.append(.init(id: id, name: name, scale: 4, tint: tint, projection: .triplanar, variation: variation)) return id } - let grass = role("Grass", tint: [0.22, 0.42, 0.13, 1]) - let rock = role("Rock", tint: [0.45, 0.43, 0.4, 1]) - let snow = role("Snow", tint: [0.92, 0.95, 1, 1]) + guard let grass = role("Grass", tint: [0.22, 0.42, 0.13, 1]), + let rock = role("Rock", tint: [0.45, 0.43, 0.4, 1]), + let snow = role("Snow", tint: [0.92, 0.95, 1, 1]) else { + return nil + } let automatic = Set([grass, rock, snow]) let rules = (existing?.rules ?? []).filter { !automatic.contains($0.layerID) } + [ TerrainMaterialRule(layerID: grass, height: .init(minimum: -10_000, maximum: 8, transition: 4), slope: .init(minimum: 0, maximum: 35, transition: 15)), .init(layerID: rock, slope: .init(minimum: 35, maximum: 90, transition: 15)), .init(layerID: snow, height: .init(minimum: 12, maximum: 10_000, transition: 4)), ] + guard rules.count <= 128 else { + return nil + } return .init(layers: layers, fallbackLayerID: grass, interiorLayerID: rock, rules: rules) } } diff --git a/Editor/Sources/AdaEditor/EditorTerrainNoise.swift b/Editor/Sources/AdaEditor/EditorTerrainNoise.swift index 897bfd977..71761adb8 100644 --- a/Editor/Sources/AdaEditor/EditorTerrainNoise.swift +++ b/Editor/Sources/AdaEditor/EditorTerrainNoise.swift @@ -7,9 +7,13 @@ struct EditorTerrainNoise: Sendable { var frequency: Float = 0.035 func height(x: Float, z: Float) -> Float { - var result: Float = 0, weight: Float = 1, scale = frequency, total: Float = 0 + guard x.isFinite, z.isFinite, frequency.isFinite else { + return 0 + } + var result: Float = 0, weight: Float = 1, total: Float = 0 + var scale = Double(frequency) for octave in 0..<5 { - result += value(x * scale, z * scale, seed: seed &+ UInt64(octave) &* 7919) * weight + result += value(Double(x) * scale, Double(z) * scale, seed: seed &+ UInt64(octave) &* 7919) * weight total += weight weight *= 0.5 scale *= 2 @@ -17,12 +21,15 @@ struct EditorTerrainNoise: Sendable { return result / total * amplitude } - private func value(_ x: Float, _ z: Float, seed: UInt64) -> Float { - let ix = Int64(floor(Double(x))), iz = Int64(floor(Double(z))) - var u = x - Float(ix), v = z - Float(iz) + private func value(_ x: Double, _ z: Double, seed: UInt64) -> Float { + let ix = floor(x), iz = floor(z) + var u = Float(x - ix), v = Float(z - iz) u = u * u * (3 - 2 * u); v = v * v * (3 - 2 * v) - func hash(_ x: Int64, _ z: Int64) -> Float { - var h = UInt64(bitPattern: x) &* 0x9e3779b97f4a7c15 ^ UInt64(bitPattern: z) &* 0xbf58476d1ce4e5b9 ^ seed + func coordinateBits(_ coordinate: Double) -> UInt64 { + Int64(exactly: coordinate).map(UInt64.init(bitPattern:)) ?? coordinate.bitPattern + } + func hash(_ x: Double, _ z: Double) -> Float { + var h = coordinateBits(x) &* 0x9e3779b97f4a7c15 ^ coordinateBits(z) &* 0xbf58476d1ce4e5b9 ^ seed h = (h ^ (h >> 30)) &* 0xbf58476d1ce4e5b9 h = (h ^ (h >> 27)) &* 0x94d049bb133111eb h ^= h >> 31 diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportTerrainTools.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportTerrainTools.swift index 00534b9c1..cbd56628d 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportTerrainTools.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportTerrainTools.swift @@ -30,7 +30,9 @@ extension EditorSceneViewportModel { join: { [weak self] in self?.joinSelectedMeshTerrain() }, detach: { [weak self] in self?.detachSelectedMeshTerrain() }, cancel: { [weak self] in - guard let self else { return } + guard let self else { + return + } if !self.cancelActiveMeshTerrainTransform() { self.endTerrainStroke(cancelled: true) } } ) @@ -125,12 +127,21 @@ extension EditorSceneViewportModel { private func configureTerrainMaterials(addRules: Bool = true) { let variation = TerrainTextureVariation(isEnabled: terrainEditing.usesTextureVariation, seed: terrainEditing.noise.seed) - let palette = EditorTerrainAutoPalette.make(existing: terrainEditing.sourceManifest?.materials, variation: variation, addRules: addRules) + guard let palette = EditorTerrainAutoPalette.make( + existing: terrainEditing.sourceManifest?.materials, + variation: variation, + addRules: addRules + ) else { + terrainEditing.sourceStatus = "Terrain material palette has no room for automatic layers or rules." + return + } performTerrainResourceCommand(.materials(palette)) } private func registerTerrainMesh() { - guard !terrainEditing.isSourceBusy else { return } + guard !terrainEditing.isSourceBusy else { + return + } if let file = terrainProjectFile(terrainEditing.meshReference) { performTerrainResourceCommand(.mesh(file)) return @@ -147,7 +158,9 @@ extension EditorSceneViewportModel { } private func registerTerrainTexture() { - guard !terrainEditing.isSourceBusy else { return } + guard !terrainEditing.isSourceBusy else { + return + } let used = Set(terrainEditing.sourceManifest?.materials?.layers.map(\.id) ?? []) guard let id = (UInt16(0)...UInt16(31)).first(where: { !used.contains($0) }) else { return @@ -182,7 +195,9 @@ extension EditorSceneViewportModel { } private func rejectHeightfieldTool() -> Bool { - guard isMeshTerrainSource(for: selectedEditorID) else { return false } + guard isMeshTerrainSource(for: selectedEditorID) else { + return false + } terrainEditing.sourceFailure = nil terrainEditing.sourceStatus = "Choose a Heightfield for this tool." return true @@ -237,8 +252,11 @@ extension EditorSceneViewportModel { var meshReceipt: EditorMeshTerrainCreation.Receipt? do { let file = context.assetsRoot.appendingPathComponent("terrain-\(UUID().uuidString.lowercased()).aterrain") - if backend == .mesh { meshReceipt = try await EditorMeshTerrainCreation.create(at: file, noise: settings) } - else { try await Self.writeNewTerrain(at: file, noise: settings) } + if backend == .mesh { + meshReceipt = try await EditorMeshTerrainCreation.create(at: file, noise: settings) + } else { + try await Self.writeNewTerrain(at: file, noise: settings) + } guard !Task.isCancelled, self.terrainEditing.sourceGeneration == generation, self.sceneModel == baseline else { if let meshReceipt { await EditorMeshTerrainCreation.discard(meshReceipt) } return diff --git a/Editor/Tests/AdaEditorTests/EditorTerrainToolsTests.swift b/Editor/Tests/AdaEditorTests/EditorTerrainToolsTests.swift index 049cc08ef..eec0000cc 100644 --- a/Editor/Tests/AdaEditorTests/EditorTerrainToolsTests.swift +++ b/Editor/Tests/AdaEditorTests/EditorTerrainToolsTests.swift @@ -21,6 +21,16 @@ struct EditorTerrainToolsTests { #expect(EditorTerrainNoise(seed: 42, amplitude: 0, frequency: 0.035).height(x: 3, z: 7) == 0) } + @Test("Height generation supports large finite terrain origins") + func noiseAtLargeOrigins() { + let generator = EditorTerrainNoise(frequency: 0.5) + for origin: Float in [1e22, .greatestFiniteMagnitude] { + let height = generator.height(x: origin, z: -origin) + #expect(height.isFinite && abs(height) <= generator.amplitude) + #expect(height == generator.height(x: origin, z: -origin)) + } + } + @Test("All 24 brush presets use real operation or shape settings") func presets() { #expect(EditorTerrainBrushPreset.all.count == 24) @@ -29,19 +39,43 @@ struct EditorTerrainToolsTests { } @Test("Automatic rules preserve custom layers and variation toggles preserve existing rule roles") - func automaticMaterials() { + func automaticMaterials() throws { let own = TerrainMaterialPalette(layers: [.init(id: 17, name: "Custom texture", albedoPath: "custom.png")], fallbackLayerID: 17) - let automatic = EditorTerrainAutoPalette.make(existing: own, variation: .init(isEnabled: true), addRules: true) + let automatic = try #require(EditorTerrainAutoPalette.make(existing: own, variation: .init(isEnabled: true), addRules: true)) #expect(automatic.layers.first?.id == 17) #expect(automatic.layers.first?.albedoPath == "custom.png") #expect(automatic.layers.count == 4) #expect(automatic.rules.count == 3) - let toggled = EditorTerrainAutoPalette.make(existing: automatic, variation: .init(isEnabled: false), addRules: false) + let toggled = try #require(EditorTerrainAutoPalette.make(existing: automatic, variation: .init(isEnabled: false), addRules: false)) #expect(toggled.layers.map(\.id) == automatic.layers.map(\.id)) #expect(toggled.rules == automatic.rules) #expect(toggled.layers.allSatisfy { !$0.variation.isEnabled }) } + @Test("Automatic materials require room for all three layers", arguments: [30, 32]) + func automaticMaterialLayerLimit(layerCount: Int) { + let layers = (0..