diff --git a/.github/workflows/swift-windows.yml b/.github/workflows/swift-windows.yml index aabb5835e..1981221c3 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,9 +31,8 @@ jobs: run: swift -version - name: Test - # Swift Testing 6.2.3 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: | @@ -43,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/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 new file mode 100644 index 000000000..4881b7d43 --- /dev/null +++ b/Editor/Sources/AdaEditor/EditorTerrainAutoPalette.swift @@ -0,0 +1,57 @@ +@_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 nil + } + layers.append(.init(id: id, name: name, scale: 4, tint: tint, projection: .triplanar, variation: variation)) + return id + } + 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 new file mode 100644 index 000000000..71761adb8 --- /dev/null +++ b/Editor/Sources/AdaEditor/EditorTerrainNoise.swift @@ -0,0 +1,69 @@ +@_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 { + 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(Double(x) * scale, Double(z) * scale, seed: seed &+ UInt64(octave) &* 7919) * weight + total += weight + weight *= 0.5 + scale *= 2 + } + return result / total * amplitude + } + + 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 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 + 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: [])) + } +} diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportRulerView.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportRulerView.swift index 42fe4dca0..b64667f9c 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 { @@ -136,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() @@ -162,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/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/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/Editor/Tests/AdaEditorTests/EditorTerrainToolsTests.swift b/Editor/Tests/AdaEditorTests/EditorTerrainToolsTests.swift new file mode 100644 index 000000000..eec0000cc --- /dev/null +++ b/Editor/Tests/AdaEditorTests/EditorTerrainToolsTests.swift @@ -0,0 +1,101 @@ +@_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("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) + #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() throws { + let own = TerrainMaterialPalette(layers: [.init(id: 17, name: "Custom texture", albedoPath: "custom.png")], fallbackLayerID: 17) + 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 = 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.. 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. 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/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/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/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/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..1213feee8 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.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/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/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/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() 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)) 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 diff --git a/Tests/AdaUtilsTests/TintToolchainTests.swift b/Tests/AdaUtilsTests/TintToolchainTests.swift index be6754207..78198a198 100644 --- a/Tests/AdaUtilsTests/TintToolchainTests.swift +++ b/Tests/AdaUtilsTests/TintToolchainTests.swift @@ -12,9 +12,11 @@ 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.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 try Data("fixture".utf8).write(to: url) 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/contribution/profiles.py b/script/contribution/profiles.py index d2c0a1295..3e690f730 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 [] + 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", "--parallel", *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 672fea519..c66d6aa13 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 @@ -11,6 +12,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 +67,16 @@ 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_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("--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) self.assertFalse(any("swift" in check.argv for check in checks)) @@ -166,6 +178,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)