diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..85674a7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.sh text eol=lf +*.ada text eol=lf diff --git a/.gitignore b/.gitignore index 4185367..b844878 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ .swiftpm/ dist/ **/.ada/workspace/ +__pycache__/ diff --git a/Examples/README.md b/Examples/README.md index 00413d7..59a5589 100644 --- a/Examples/README.md +++ b/Examples/README.md @@ -4,3 +4,9 @@ Examples are small, focused AdaEngine projects that demonstrate one feature or A Each example must be independently downloadable and openable in AdaEditor. Complete games and larger reference projects belong in `Samples`. + +## Technical examples + +[Technical examples](Technical/README.md) are developer studies and validation +projects, available by cloning this repository. They are deliberately absent from +`catalog.json`, Studio recommendations, website cards and catalog release archives. diff --git a/Examples/Technical/A2UIForm/.gitignore b/Examples/Technical/A2UIForm/.gitignore new file mode 100644 index 0000000..fd14c91 --- /dev/null +++ b/Examples/Technical/A2UIForm/.gitignore @@ -0,0 +1,6 @@ +.build/ +.swiftpm/ +dist/ +*.xcodeproj/ +__pycache__/ +Package.resolved diff --git a/Examples/Technical/A2UIForm/A2UIFormDemo.swift b/Examples/Technical/A2UIForm/A2UIFormDemo.swift new file mode 100644 index 0000000..e2f21d6 --- /dev/null +++ b/Examples/Technical/A2UIForm/A2UIFormDemo.swift @@ -0,0 +1,199 @@ +import AdaA2UI +import AdaEngine +@_spi(Internal) import AdaPlatform +import Foundation +import Observation +#if os(macOS) +import AppKit +#endif + +@main +struct A2UIFormDemo: App { + var body: some AppScene { + WindowGroup { FormDemoView() } + .window(with: UIWindow.Configuration(title: "AdaUI · A2UI Forms", frame: Rect(x: 160, y: 140, width: 760, height: 820))) + } +} + +@MainActor @Observable +private final class FormDemoModel { + let client = A2UIClient() + var status = "Waiting for the agent stream…" + var lastAction = "Submit the form to inspect its action payload." + var started = false + var lastEvent: A2UIClientEvent? + + init() { + client.onEvent = { [weak self] event in + guard let self else { return } + if case let .object(envelope) = event.envelope, envelope["action"] != nil { + self.lastEvent = event + self.lastAction = (try? String(decoding: JSONEncoder().encode(event.envelope), as: UTF8.self)) ?? "Action received" + demoLog("A2UI action: \(self.lastAction)") + } else { + self.status = self.client.lastError?.message ?? "Invalid message rejected" + } + } + } + + func start() async { + guard !started else { return } + started = true + do { + guard let url = Bundle.module.url(forResource: "form", withExtension: "jsonl", subdirectory: "Fixtures") else { + throw A2UIValidationError(message: "Demo fixture missing.") + } + let data = try Data(contentsOf: url) + var decoder = A2UIJSONLDecoder() + // Byte chunks exercise the same framing path a network adapter would use. + for offset in stride(from: 0, to: data.count, by: 53) { + let chunk = data.subdata(in: offset.. some View { + PlainTextFieldStyle()._body(configuration: configuration) + .padding(.horizontal, 8) + .background(Color(red: 0.16, green: 0.19, blue: 0.24)) + .border(Color(red: 0.34, green: 0.39, blue: 0.47), lineWidth: 1) + } +} + +private struct FormDemoButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .fontSize(14) + .padding(9) + .background(configuration.isPressed ? Color(red: 0.25, green: 0.38, blue: 0.55) : Color(red: 0.18, green: 0.26, blue: 0.37)) + } +} diff --git a/Examples/Technical/A2UIForm/Fixtures/form.jsonl b/Examples/Technical/A2UIForm/Fixtures/form.jsonl new file mode 100644 index 0000000..dcba20a --- /dev/null +++ b/Examples/Technical/A2UIForm/Fixtures/form.jsonl @@ -0,0 +1,5 @@ +{"version":"v0.9.1","createSurface":{"surfaceId":"form","catalogId":"https://adaengine.org/a2ui/catalogs/forms/v1","sendDataModel":true}} +{"version":"v0.9.1","updateComponents":{"surfaceId":"form","components":[{"id":"root","component":"Column","spacing":16,"children":["heading","name","copy","enabled","role","health-label","health","submit"]},{"id":"heading","component":"Text","text":"Create an NPC profile"}]}} +{"version":"v0.9.1","updateComponents":{"surfaceId":"form","components":[{"id":"name","component":"TextField","placeholder":"NPC name","text":{"path":"/npc/name"},"checks":[{"condition":{"call":"required","args":{"value":{"path":"/npc/name"}}},"message":"NPC name is required."}]},{"id":"copy","component":"Text","text":{"path":"/npc/name"}},{"id":"enabled","component":"Toggle","label":"Enable dialog","value":{"path":"/npc/dialogEnabled"}}]}} +{"version":"v0.9.1","updateDataModel":{"surfaceId":"form","value":{"npc":{"name":"Guide","dialogEnabled":true,"role":"guide","health":50}}}} +{"version":"v0.9.1","updateComponents":{"surfaceId":"form","components":[{"id":"submit","component":"Button","text":"Create profile","action":{"event":{"name":"create_npc","context":{"name":{"path":"/npc/name"},"dialogEnabled":{"path":"/npc/dialogEnabled"},"role":{"path":"/npc/role"},"health":{"path":"/npc/health"}}}},"checks":[{"condition":{"call":"required","args":{"value":{"path":"/npc/name"}}},"message":"NPC name is required."},{"condition":{"call":"range","args":{"value":{"path":"/npc/health"},"min":1,"max":100}},"message":"Health must be between 1 and 100."}]},{"id":"role","component":"ChoicePicker","label":"Role","value":{"path":"/npc/role"},"options":[{"label":"Guide","value":"guide"},{"label":"Merchant","value":"merchant"},{"label":"Guard","value":"guard"}]},{"id":"health-label","component":"Text","text":"Health"},{"id":"health","component":"Slider","value":{"path":"/npc/health"},"min":1,"max":100,"step":1,"checks":[{"condition":{"call":"range","args":{"value":{"path":"/npc/health"},"min":1,"max":100}},"message":"Health must be between 1 and 100."}]}]}} diff --git a/Examples/Technical/A2UIForm/Package.swift b/Examples/Technical/A2UIForm/Package.swift new file mode 100644 index 0000000..bb64cfe --- /dev/null +++ b/Examples/Technical/A2UIForm/Package.swift @@ -0,0 +1,29 @@ +// swift-tools-version: 6.2 +import Foundation +import PackageDescription + +let package = Package( + name: "A2UIFormDemo", + platforms: [.macOS(.v15), .iOS(.v18)], + products: [.executable(name: "A2UIFormDemo", targets: ["A2UIFormDemo"])], + dependencies: [ + .package(url: "https://github.com/AdaEngine/AdaEngine.git", revision: "a624ccded0a050fddc24ccfbb8079dfb8032805c") + ], + targets: [ + .executableTarget( + name: "A2UIFormDemo", + dependencies: [ + .product(name: "AdaA2UI", package: "adaengine"), + .product(name: "AdaEngine", package: "adaengine"), + .product(name: "AdaPlatform", package: "adaengine") + ], + path: ".", + exclude: ["README.md", "script"] + (FileManager.default.fileExists(atPath: "dist") ? ["dist"] : []), + sources: [ + "A2UIFormDemo.swift" + ], + resources: [.copy("Fixtures")], + swiftSettings: [.enableUpcomingFeature("MemberImportVisibility"), .strictMemorySafety()] + ) + ] +) diff --git a/Examples/Technical/A2UIForm/README.md b/Examples/Technical/A2UIForm/README.md new file mode 100644 index 0000000..0c3fd5b --- /dev/null +++ b/Examples/Technical/A2UIForm/README.md @@ -0,0 +1,10 @@ +# A2UI form demo + +See [the integration guide](https://github.com/AdaEngine/Ada/blob/main/Documentation/A2UI.md). + +The NPC form includes a single-choice role picker and stepped health slider. +Required/range checks show inline messages and block invalid submission. Native +verification clears the name, checks rejection, corrects it, chooses a role, +changes health, submits, and exports controls plus validation into `.ui`. + +From the AdaExamples root on macOS, run `Examples/Technical/A2UIForm/script/build_and_run.sh --verify`. diff --git a/Examples/Technical/A2UIForm/script/build_and_run.sh b/Examples/Technical/A2UIForm/script/build_and_run.sh new file mode 100755 index 0000000..093563e --- /dev/null +++ b/Examples/Technical/A2UIForm/script/build_and_run.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail +MODE="${1:-run}" +DEMO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPO_ROOT="$DEMO_ROOT" +BUILD_PATH="${ADAENGINE_A2UI_BUILD_PATH:-/private/tmp/adaengine-a2ui-build}" +mkdir -p "$BUILD_PATH" +BUILD_PATH="$(cd "$BUILD_PATH" && pwd -P)" +APP="$DEMO_ROOT/dist/A2UIFormDemo.app" +export ADAENGINE_DISABLE_SWAN=1 +export CLANG_MODULE_CACHE_PATH="$BUILD_PATH/clang-cache" +export SWIFTPM_MODULECACHE_OVERRIDE="$BUILD_PATH/swift-cache" +case "$MODE" in run|--verify|--debug|--logs|--telemetry) ;; *) echo "usage: $0 [run|--verify|--debug|--logs|--telemetry]" >&2; exit 2 ;; esac +for demo_pid in $(pgrep -f "$APP/Contents/MacOS/A2UIFormDemo" || true); do + demo_command="$(ps -p "$demo_pid" -o command= || true)" + if [[ "$demo_command" == "$APP/Contents/MacOS/A2UIFormDemo" || "$demo_command" == "$APP/Contents/MacOS/A2UIFormDemo "* ]]; then + kill "$demo_pid" + fi +done +mkdir -p "$DEMO_ROOT/dist" +if ! swift build --package-path "$REPO_ROOT" --scratch-path "$BUILD_PATH" --disable-sandbox --product A2UIFormDemo > "$DEMO_ROOT/dist/build.log" 2>&1; then + tail -60 "$DEMO_ROOT/dist/build.log" + exit 1 +fi +BIN="$(swift build --package-path "$REPO_ROOT" --scratch-path "$BUILD_PATH" --disable-sandbox --show-bin-path)" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +cp "$BIN/A2UIFormDemo" "$APP/Contents/MacOS/" +for resource in "$BIN"/*.bundle; do + [[ -d "$resource" ]] && cp -R "$resource" "$APP/" +done +cat > "$APP/Contents/Info.plist" <<'PLIST' + + + +CFBundleExecutableA2UIFormDemo +CFBundleIdentifierorg.adaengine.demo.a2ui-form +CFBundleNameAdaUI A2UI Forms +CFBundlePackageTypeAPPL +LSMinimumSystemVersion15.0 +NSPrincipalClassNSApplication + +PLIST +: > "$DEMO_ROOT/dist/runtime.log" +if [[ "$MODE" == --debug ]]; then + /usr/bin/lldb -- "$APP/Contents/MacOS/A2UIFormDemo" + exit +fi +OPEN_ARGS=(-n "$APP" --stdout "$DEMO_ROOT/dist/runtime.log" --stderr "$DEMO_ROOT/dist/runtime.log" --args) +if [[ "$MODE" == --verify ]]; then OPEN_ARGS+=(--verify --proof-directory "$DEMO_ROOT/dist/proof"); fi +/usr/bin/open "${OPEN_ARGS[@]}" +case "$MODE" in + --verify) + for attempt in {1..30}; do + if rg -q 'A2UI demo verification PASS' "$DEMO_ROOT/dist/runtime.log"; then + cat "$DEMO_ROOT/dist/runtime.log" + exit 0 + fi + if rg -q 'verification FAIL|Fatal error' "$DEMO_ROOT/dist/runtime.log"; then + tail -40 "$DEMO_ROOT/dist/runtime.log" + exit 1 + fi + sleep 1 + done + tail -40 "$DEMO_ROOT/dist/runtime.log" + echo 'A2UI verification timed out' >&2 + exit 1 + ;; + --logs|--telemetry) /usr/bin/log stream --info --style compact --predicate 'process == "A2UIFormDemo"' ;; +esac diff --git a/Examples/Technical/AndroidDemo/.gitignore b/Examples/Technical/AndroidDemo/.gitignore new file mode 100644 index 0000000..fd14c91 --- /dev/null +++ b/Examples/Technical/AndroidDemo/.gitignore @@ -0,0 +1,6 @@ +.build/ +.swiftpm/ +dist/ +*.xcodeproj/ +__pycache__/ +Package.resolved diff --git a/Examples/Technical/AndroidDemo/AndroidDemo.swift b/Examples/Technical/AndroidDemo/AndroidDemo.swift new file mode 100644 index 0000000..e2d1782 --- /dev/null +++ b/Examples/Technical/AndroidDemo/AndroidDemo.swift @@ -0,0 +1,39 @@ +#if os(Android) + import AdaEngine + import AdaPlatform + import Foundation + + @_cdecl("ada_android_start") + public func androidDemoStart() { + AndroidRuntime.start { AndroidDemo() } + } + + struct AndroidDemo: App { + var body: some AppScene { + WindowGroup( + content: { AndroidDemoView() }, + assetBundle: AndroidResourceBundle.bundle(named: "AndroidDemo_AndroidDemo") + ) + } + } + + private struct AndroidDemoView: View { + @State private var taps = 0 + var body: some View { + VStack(spacing: 24) { + Text("AdaEngine on Android").foregroundColor(.white) + Text("Native Swift • Android Surface • Touch").foregroundColor(.white) + Text("Taps: \(taps)").foregroundColor(.white) + Button(action: { + taps += 1 + AndroidRuntime.log("AndroidDemo taps=\(taps)") + }) { + Text("Tap +1").foregroundColor(.white) + } + } + } + } + +#else + public enum AndroidDemoUnsupportedHost {} +#endif diff --git a/Examples/Technical/AndroidDemo/Assets/fixture.txt b/Examples/Technical/AndroidDemo/Assets/fixture.txt new file mode 100644 index 0000000..4331b72 --- /dev/null +++ b/Examples/Technical/AndroidDemo/Assets/fixture.txt @@ -0,0 +1 @@ +AdaEngine Android packaged resource diff --git a/Examples/Technical/AndroidDemo/Package.swift b/Examples/Technical/AndroidDemo/Package.swift new file mode 100644 index 0000000..aa64794 --- /dev/null +++ b/Examples/Technical/AndroidDemo/Package.swift @@ -0,0 +1,29 @@ +// swift-tools-version: 6.2 +import Foundation +import PackageDescription + +let package = Package( + name: "AndroidDemo", + platforms: [.macOS(.v15), .iOS(.v18)], + products: [.library(name: "AndroidDemo", type: .dynamic, targets: ["AndroidDemo"])], + dependencies: [ + .package(url: "https://github.com/AdaEngine/AdaEngine.git", revision: "a624ccded0a050fddc24ccfbb8079dfb8032805c") + ], + targets: [ + .target( + name: "AndroidDemo", + dependencies: [ + .product(name: "AdaEngine", package: "adaengine"), + .product(name: "CAndroid", package: "adaengine"), + .product(name: "AdaPlatform", package: "adaengine") + ], + path: ".", + exclude: ["README.md"] + (FileManager.default.fileExists(atPath: "dist") ? ["dist"] : []), + sources: [ + "AndroidDemo.swift" + ], + resources: [.copy("Assets")], + swiftSettings: [.enableUpcomingFeature("MemberImportVisibility"), .strictMemorySafety()] + ) + ] +) diff --git a/Examples/Technical/AndroidDemo/README.md b/Examples/Technical/AndroidDemo/README.md new file mode 100644 index 0000000..eab70cd --- /dev/null +++ b/Examples/Technical/AndroidDemo/README.md @@ -0,0 +1,11 @@ +# Android native AdaUI example + +From the AdaExamples root, set ADAENGINE_ROOT to a compatible engine checkout: + +```sh +python3 "$ADAENGINE_ROOT/Tools/Android/android.py" run --package Examples/Technical/AndroidDemo --product AndroidDemo +``` + +Use the engine's documented Swift Android SDK, Swan/Dawn, NDK and adb setup. +The Swift package exports a dynamic library and bundles its resource fixture. +See the [Android guide](https://github.com/AdaEngine/Ada/blob/main/Tools/Android/README.md). diff --git a/Examples/Technical/LinuxSmoke/.gitignore b/Examples/Technical/LinuxSmoke/.gitignore new file mode 100644 index 0000000..fd14c91 --- /dev/null +++ b/Examples/Technical/LinuxSmoke/.gitignore @@ -0,0 +1,6 @@ +.build/ +.swiftpm/ +dist/ +*.xcodeproj/ +__pycache__/ +Package.resolved diff --git a/Examples/Technical/LinuxSmoke/LinuxSmoke.swift b/Examples/Technical/LinuxSmoke/LinuxSmoke.swift new file mode 100644 index 0000000..d32df8a --- /dev/null +++ b/Examples/Technical/LinuxSmoke/LinuxSmoke.swift @@ -0,0 +1,49 @@ +import AdaEngine +@_spi(Internal) import AdaPlatform +import Foundation + +@main +struct LinuxSmoke: App { + var body: some AppScene { + WindowGroup { + LinuxSmokeView() + } + .window( + with: UIWindow.Configuration( + title: "AdaEngine Linux", + frame: Rect(x: 80, y: 80, width: 800, height: 600), + minimumSize: Size(width: 480, height: 360) + ) + ) + } +} + +private struct LinuxSmokeView: View { + @State private var clicks = 0 + @State private var text = "Hello, Linux!" + + var body: some View { + VStack(spacing: 24) { + Text("AdaEngine on Linux") + .font(.system(size: 36)) + Text("Resize the window, type a message and press the button.") + TextField("Message", text: $text) + .frame(width: 360, height: 44) + Button("Clicks: \(clicks)") { + clicks += 1 + smokeLog("LINUX_SMOKE_CLICK \(clicks) text=\(text)") + } + Text(text) + } + .padding(32) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.white) + .task { + smokeLog("LINUX_SMOKE_READY backend=\(RenderEngine.shared.type)") + } + } +} + +private func smokeLog(_ message: String) { + FileHandle.standardOutput.write(Data((message + "\n").utf8)) +} diff --git a/Examples/Technical/LinuxSmoke/Package.swift b/Examples/Technical/LinuxSmoke/Package.swift new file mode 100644 index 0000000..0254362 --- /dev/null +++ b/Examples/Technical/LinuxSmoke/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version: 6.2 +import Foundation +import PackageDescription + +let package = Package( + name: "LinuxSmoke", + platforms: [.macOS(.v15), .iOS(.v18)], + products: [.executable(name: "LinuxSmoke", targets: ["LinuxSmoke"])], + dependencies: [ + .package(url: "https://github.com/AdaEngine/AdaEngine.git", revision: "a624ccded0a050fddc24ccfbb8079dfb8032805c") + ], + targets: [ + .executableTarget( + name: "LinuxSmoke", + dependencies: [ + .product(name: "AdaEngine", package: "adaengine"), + .product(name: "AdaPlatform", package: "adaengine") + ], + path: ".", + exclude: ["README.md"] + (FileManager.default.fileExists(atPath: "dist") ? ["dist"] : []), + sources: [ + "LinuxSmoke.swift" + ], + swiftSettings: [.enableUpcomingFeature("MemberImportVisibility"), .strictMemorySafety()] + ) + ] +) diff --git a/Examples/Technical/LinuxSmoke/README.md b/Examples/Technical/LinuxSmoke/README.md new file mode 100644 index 0000000..b9e0e76 --- /dev/null +++ b/Examples/Technical/LinuxSmoke/README.md @@ -0,0 +1,11 @@ +# Linux native smoke application + +From the AdaExamples root, with the native Linux toolchain and X11/Vulkan dependencies: + +```sh +swift run --package-path Examples/Technical/LinuxSmoke LinuxSmoke +``` + +Resize the window, edit the message, and click the counter. This is a native +window/input check, not a browser preview. See the +[Linux guide](https://github.com/AdaEngine/Ada/blob/main/Documentation/Linux.md). diff --git a/Examples/Technical/MovieMaker/.gitignore b/Examples/Technical/MovieMaker/.gitignore new file mode 100644 index 0000000..fd14c91 --- /dev/null +++ b/Examples/Technical/MovieMaker/.gitignore @@ -0,0 +1,6 @@ +.build/ +.swiftpm/ +dist/ +*.xcodeproj/ +__pycache__/ +Package.resolved diff --git a/Examples/Technical/MovieMaker/MovieMakerDemo.swift b/Examples/Technical/MovieMaker/MovieMakerDemo.swift new file mode 100644 index 0000000..789b1df --- /dev/null +++ b/Examples/Technical/MovieMaker/MovieMakerDemo.swift @@ -0,0 +1,75 @@ +import AdaEngine +import Math + +/// An asset-free take that exercises the same renderer and mixer used by games. +@main +struct MovieMakerDemo { + @MainActor + static func main() async throws { + let configuration = try MovieRecordingConfiguration.commandLine() ?? MovieRecordingConfiguration( + outputURL: URL(fileURLWithPath: "Movies/demo.png"), + width: 640, + height: 360, + frameCount: 180 + ) + let maker = MovieMaker() + let scene = MovieDemoScene(configuration: configuration) + defer { scene.music?.stop() } + let recording = try await maker.record(configuration: configuration, make: { app in + scene.configure(app) + }, update: { world, frame in + try scene.update(world: world, frame: frame) + }, progress: { count in + if count % configuration.framesPerSecond == 0 { print("Recorded \(count) frames") } + }) + print("Saved \(recording.frameCount) frames (\(recording.duration) seconds) to \(configuration.outputURL.deletingLastPathComponent().path)") + } +} + +@MainActor +private final class MovieDemoScene { + let configuration: MovieRecordingConfiguration + var sprites: [Entity] = [] + var music: AudioPlaybackController? + + init(configuration: MovieRecordingConfiguration) { self.configuration = configuration } + + func configure(_ app: AppWorlds) { + app.addPlugin(TransformPlugin()) + .addPlugin(RenderWorldPlugin()) + .addPlugin(CameraPlugin()) + .addPlugin(VisibilityPlugin()) + .addPlugin(Core2DPlugin()) + .addPlugin(SpritePlugin()) + .addPlugin(AudioPlugin()) + var camera = Camera() + camera.backgroundColor = Color(red: 0.025, green: 0.035, blue: 0.08) + app.spawn("TrailerCamera", bundle: Camera2D(camera: camera)) + } + + func update(world: World, frame: MovieFrame) throws { + if sprites.isEmpty { + for index in 0..<9 { + let hue = Float(index) / 8 + let color = Color(red: 0.25 + hue * 0.7, green: 0.85 - hue * 0.4, blue: 1 - hue * 0.35) + sprites.append(world.spawn("Light \(index)") { + Sprite(tintColor: color, size: [32, 32]) + Transform() + }) + } + var spec = SoundSpec() + spec.peak = 0.15 + spec.voices = [SoundVoice(wave: .sine, frequency: 220, duration: 1)] + let resource = try AudioResource.create(from: WAVWriter().encode(SoundSynth.render(spec))) + music = AudioServer.shared.prepareAudio(resource).setLoop(true) + music?.play() + } + let time = Float(frame.time) + for (index, entity) in sprites.enumerated() { + let phase = time * 1.4 + Float(index) * .pi * 2 / 9 + let x = Math.cos(phase) * Float(configuration.width) * 0.32 + let y = Math.sin(phase * 2) * Float(configuration.height) * 0.28 + entity.components += Transform(rotation: Quat(axis: [0, 0, 1], angle: phase), position: [x, y, 0]) + } + } +} diff --git a/Examples/Technical/MovieMaker/Package.swift b/Examples/Technical/MovieMaker/Package.swift new file mode 100644 index 0000000..33de126 --- /dev/null +++ b/Examples/Technical/MovieMaker/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version: 6.2 +import Foundation +import PackageDescription + +let package = Package( + name: "MovieMakerDemo", + platforms: [.macOS(.v15), .iOS(.v18)], + products: [.executable(name: "MovieMakerDemo", targets: ["MovieMakerDemo"])], + dependencies: [ + .package(url: "https://github.com/AdaEngine/AdaEngine.git", revision: "a624ccded0a050fddc24ccfbb8079dfb8032805c") + ], + targets: [ + .executableTarget( + name: "MovieMakerDemo", + dependencies: [ + .product(name: "AdaEngine", package: "adaengine"), + .product(name: "Math", package: "adaengine") + ], + path: ".", + exclude: ["README.md"] + (FileManager.default.fileExists(atPath: "dist") ? ["dist"] : []), + sources: [ + "MovieMakerDemo.swift" + ], + swiftSettings: [.enableUpcomingFeature("MemberImportVisibility"), .strictMemorySafety()] + ) + ] +) diff --git a/Examples/Technical/MovieMaker/README.md b/Examples/Technical/MovieMaker/README.md new file mode 100644 index 0000000..b250c02 --- /dev/null +++ b/Examples/Technical/MovieMaker/README.md @@ -0,0 +1,10 @@ +# Movie recording example + +From the AdaExamples root: + +```sh +swift run --package-path Examples/Technical/MovieMaker MovieMakerDemo --write-movie Examples/Technical/MovieMaker/dist/demo.png --fixed-fps 60 --resolution 1280x720 --quit-after 180 +``` + +This records native rendering and audio through the engine's MovieMaker. See the +[recording guide](https://github.com/AdaEngine/Ada/blob/main/Sources/AdaEngine/AdaEngine.docc/MovieMaker.md). diff --git a/Examples/Technical/Navigation/.gitignore b/Examples/Technical/Navigation/.gitignore new file mode 100644 index 0000000..fd14c91 --- /dev/null +++ b/Examples/Technical/Navigation/.gitignore @@ -0,0 +1,6 @@ +.build/ +.swiftpm/ +dist/ +*.xcodeproj/ +__pycache__/ +Package.resolved diff --git a/Examples/Technical/Navigation/NavigationDemoApp.swift b/Examples/Technical/Navigation/NavigationDemoApp.swift new file mode 100644 index 0000000..716abf0 --- /dev/null +++ b/Examples/Technical/Navigation/NavigationDemoApp.swift @@ -0,0 +1,201 @@ +import AdaEngine +import Foundation +import Observation + +@MainActor +@Observable +final class NavigationDemoSession: Resource { + var status = "Готовим площадку…" + var showNavigation = true + var rebakeRequested = false + var nextGoalRequested = false + var resetRequested = false + var patrolRequested = false + var script: AdaScriptPlugin? + var triangles: [Vector3] = [] + var robot: Entity.ID? + var goalIndex = 0 + var wasMouseDown = false + + func setStatus(_ value: String) { + if status != value { status = value } + } +} + +struct NavigationDemoApp: App { + var body: some AppScene { + WindowGroup { NavigationDemoView() } + .window(with: UIWindow.Configuration(title: "AdaEngine · Navigation playground", frame: Rect(x: 80, y: 60, width: 1100, height: 800))) + } +} + +private struct NavigationDemoView: View { + @State private var session = NavigationDemoSession() + + var body: some View { + VStack(spacing: 12) { + Text("Проложи путь для робота").font(.system(size: 24)) + Text("Нажми на пол — робот найдёт дорогу вокруг препятствий.") + HStack(spacing: 18) { + Button("Другая цель") { session.nextGoalRequested = true } + Button(session.showNavigation ? "Скрыть сетку" : "Показать сетку") { session.showNavigation.toggle() } + Button("В начало") { session.resetRequested = true } + Button("Запечь заново") { session.rebakeRequested = true } + Button("Патруль AdaScript") { session.patrolRequested = true } + } + Text(session.status) + SceneView(useSceneCameras: true, make: { app in + configureNavigationDemo(&app, session: session) + }, updateContent: { _, _ in }) + .frameCaptureLabel("NavigationDemo") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .padding(16) + .background(Color(0.06, 0.08, 0.13, 1)) + .foregroundColor(.white) + } +} + +@MainActor +func configureNavigationDemo(_ app: inout AppWorlds, session: NavigationDemoSession) { + app.addPlugin(TransformPlugin()).addPlugin(InputPlugin()).addPlugin(RenderWorldPlugin()) + .addPlugin(CameraPlugin()).addPlugin(AssetsPlugin()).addPlugin(VisibilityPlugin()) + .addPlugin(ScenePlugin()).addPlugin(Core3DPlugin(includes2D: false)).addPlugin(UpscalePlugin()) + .addPlugin(Physics3DPlugin()).addPlugin(Navigation3DPlugin()).addPlugin(NavigationDemoPlugin()) + app.insertResource(session) +} + +/// SceneView installs its schedulers after make(); register systems when plugins are built. +private struct NavigationDemoPlugin: Plugin { + func setup(in app: AppWorlds) { + app.addSystem(NavigationDemoStartupSystem.self, on: .startup) + app.addSystem(NavigationDemoControlsSystem.self, on: .update) + app.addSystem(NavigationDemoOverlaySystem.self, on: .postUpdate) + do { + app.main.getResource(NavigationDemoSession.self)?.script = try NavigationDemoScript.install(in: app) + } catch { + app.main.getResource(NavigationDemoSession.self)?.setStatus("Ошибка AdaScript: \(error)") + } + if CommandLine.arguments.contains("--capture") { + app.getSubworldBuilder(by: .renderWorld)?.getResource(RenderGraphDiagnostics.self)?.configure(isEnabled: true) + } + } +} + +@System +@MainActor +func NavigationDemoStartup(_ context: WorldUpdateContext, _ session: Res) async { + let robot = NavigationDemoFixture.installPhysics(in: context.world) + session.wrappedValue.robot = robot.id + robot.components[NavigationDemoPatrolComponent.self] = NavigationDemoPatrolComponent() + robot.components[NavigationTarget3DComponent.self] = NavigationTarget3DComponent() + NavigationDemoFixture.installGraphics(in: context.world, robot: robot) + do { + let mesh = try await NavigationMesh(data: NavigationDemoFixture.bakedAsset()) + context.world.insertResource(NavigationMap3D(mesh: mesh)) + session.wrappedValue.triangles = try await mesh.debugTriangles() + } catch { + session.wrappedValue.status = "Не удалось построить сетку: \(error)" + } +} + +@PlainSystem(dependencies: [.before(NavigationPathRequest3DSystem.self)]) +struct NavigationDemoControlsSystem { + @Res private var session + @Res private var input + @Query private var cameras + // Reset/picking use the robot's stable ID; declare those component writes to the scheduler. + @Query, Ref, Ref, Ref, Ref> + private var robotAccess + + init(world _: World) {} + + @MainActor + func update(context: UpdateContext) async { + let state = session + _ = robotAccess + guard let id = state.robot, let robot = context.world.getEntityByID(id) else { return } + if state.resetRequested { + robot.components[NavigationDemoPatrolComponent.self] = NavigationDemoPatrolComponent(enabled: false) + state.resetRequested = false + robot.components[Transform.self] = Transform(position: NavigationDemoFixture.start) + var controller = robot.components[CharacterController3DComponent.self] ?? CharacterController3DComponent() + controller.resetMotion = true + robot.components[CharacterController3DComponent.self] = controller + // Invalidate the pending request even when returning to the same goal. + robot.components[NavigationAgent3DComponent.self] = NavigationAgent3DComponent() + } + if state.nextGoalRequested { + robot.components[NavigationDemoPatrolComponent.self] = NavigationDemoPatrolComponent(enabled: false) + state.nextGoalRequested = false + state.goalIndex = (state.goalIndex + 1) % NavigationDemoFixture.goals.count + robot.components[NavigationTarget3DComponent.self] = NavigationTarget3DComponent(destination: NavigationDemoFixture.goals[state.goalIndex]) + } + let down = input.isMouseButtonPressed(.left) + if down && !state.wasMouseDown, let (camera, transform) = cameras.first, + let ray = camera.viewportToWorld( + cameraGlobalTransform: transform.matrix, + point: Vector2(input.getMousePosition().x, camera.logicalViewport.rect.size.height - input.getMousePosition().y) + ), + abs(ray.direction.y) > 0.0001 { + let distance = -ray.origin.y / ray.direction.y + let point = ray.origin + ray.direction * distance + if distance > 0, abs(point.x) < 10, abs(point.z) < 7 { + robot.components[NavigationDemoPatrolComponent.self] = NavigationDemoPatrolComponent(enabled: false) + robot.components[NavigationTarget3DComponent.self] = NavigationTarget3DComponent(destination: [point.x, 0, point.z]) + } + } + state.wasMouseDown = down + if state.patrolRequested { + state.patrolRequested = false + robot.components[NavigationDemoPatrolComponent.self] = NavigationDemoPatrolComponent() + robot.components[NavigationTarget3DComponent.self] = NavigationTarget3DComponent() + } + if state.rebakeRequested { + state.rebakeRequested = false + state.status = "Запекаем площадку…" + do { + let mesh = try await NavigationMesh(data: NavigationDemoFixture.bakedAsset()) + context.world.insertResource(NavigationMap3D(mesh: mesh)) + state.triangles = try await mesh.debugTriangles() + } catch { + state.status = "Ошибка запекания: \(error)" + } + } + } +} + +@System +@MainActor +func NavigationDemoOverlay( + _ session: Res, + _ agents: Query, + _ draw: Res +) { + let state = session.wrappedValue + if state.showNavigation { + let triangles = state.triangles + for offset in stride(from: 0, to: triangles.count, by: 3) { + draw.wrappedValue.polyline(Array(triangles[offset..<(offset + 3)]).map { $0 + Vector3(0, 0.06, 0) }, closed: true, color: Color(0.15, 0.85, 0.7, 1)) + } + } + agents.forEach { agent, target, transform in + if let path = agent.follower.path { + draw.wrappedValue.polyline(path.points.map { $0 + Vector3(0, 0.16, 0) }, color: .yellow, depthTested: false) + } + if let destination = target.destination { + draw.wrappedValue.sphere(center: destination + Vector3(0, 0.2, 0), radius: 0.3, color: agent.lastError == nil ? .yellow : .red) + } + draw.wrappedValue.sphere(center: transform.position + Vector3(0, 0.06, 0), radius: 0.4, color: .mint) + if let error = agent.lastError { + state.setStatus(error == .targetOutsideMesh || error == .unreachable ? "Здесь нет прохода. Выбери другую точку." : "Ошибка маршрута: \(error)") + } else { + switch agent.follower.state { + case .following: state.setStatus("Идём к цели · поворотов: \(max(0, (agent.follower.path?.points.count ?? 0) - 2))") + case .arrived: state.setStatus("Пришли! Выбери следующую цель.") + case .blocked: state.setStatus("Проход занят — ищем путь снова…") + default: break + } + } + } +} diff --git a/Examples/Technical/Navigation/NavigationDemoFixture.swift b/Examples/Technical/Navigation/NavigationDemoFixture.swift new file mode 100644 index 0000000..9af5141 --- /dev/null +++ b/Examples/Technical/Navigation/NavigationDemoFixture.swift @@ -0,0 +1,115 @@ +import AdaEngine +import AdaNavigationBaking +import Foundation + +/// The same geometry drives the bake, physical colliders, and visible meshes. +@MainActor +enum NavigationDemoFixture { + struct Obstacle { + let center: Vector3 + let size: Vector3 + } + + static let start = Vector3(-8, 0, -4) + static let goals: [Vector3] = [[8, 0, 4], [-8, 0, 5], [8, 0, -5], [0, 0, 5]] + static let obstacles = [ + Obstacle(center: [0, 1.5, 0], size: [4, 3, 4]), + Obstacle(center: [-5.5, 1.1, 0], size: [1.4, 2.2, 6]), + Obstacle(center: [5, 1.1, 2.5], size: [2, 2.2, 3]) + ] + + static func bakedAsset() async throws -> NavigationMeshData { + var vertices: [Vector3] = [[-10, 0, -7], [-10, 0, 7], [10, 0, 7], [10, 0, -7]] + var indices: [UInt32] = [0, 1, 2, 0, 2, 3] + for box in obstacles { + let min = box.center - box.size / 2 + let max = box.center + box.size / 2 + let corners: [Vector3] = [ + [min.x, min.y, min.z], [min.x, min.y, max.z], [max.x, min.y, max.z], [max.x, min.y, min.z], + [min.x, max.y, min.z], [min.x, max.y, max.z], [max.x, max.y, max.z], [max.x, max.y, min.z] + ] + let offset = UInt32(vertices.count) + vertices += corners + // Outward winding: ceiling on the bottom, walkable top, and blocking walls. + indices += [0, 2, 1, 0, 3, 2, 4, 5, 6, 4, 6, 7, + 0, 1, 5, 0, 5, 4, 3, 7, 6, 3, 6, 2, + 0, 4, 7, 0, 7, 3, 1, 2, 6, 1, 6, 5].map { offset + $0 } + } + let baked = try await NavigationMeshBaker.bake(vertices: vertices, indices: indices) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("AdaEngine-NavigationDemo", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent("playground.navmesh") + try baked.encoded().write(to: url, options: .atomic) + // Exercise the production file decoder before installing the map. + return try NavigationMeshData(encoded: Data(contentsOf: url)) + } + + @discardableResult + static func installPhysics(in world: World) -> Entity { + func box(_ name: String, center: Vector3, size: Vector3) { + world.spawn(name) { + Transform(position: center) + PhysicsBody3DComponent(shapes: [.generateBox(width: size.x, height: size.y, depth: size.z)], mode: .static) + } + } + box("Floor", center: [0, -0.3, 0], size: [20, 0.6, 14]) + for (index, obstacle) in obstacles.enumerated() { box("Obstacle \(index)", center: obstacle.center, size: obstacle.size) } + return world.spawn("Navigation robot") { + Transform(position: start) + CharacterController3DComponent() + NavigationAgent3DComponent() + NavigationTarget3DComponent(destination: goals[0]) + } + } + + static func installGraphics(in world: World, robot: Entity) { + let device = unsafe RenderEngine.shared.renderDevice + func material(_ color: Vector4) -> PBRMaterial { + let result = PBRMaterial() + result.baseColorFactor = color + result.metallicFactor = 0 + result.roughnessFactor = 0.75 + return result + } + let cube = Mesh.generateCube(renderDevice: device) + for entity in world.getEntities() where entity.components[PhysicsBody3DComponent.self] != nil { + let size = entity.name == "Floor" ? Vector3(20, 0.6, 14) + : obstacles[Int(entity.name.split(separator: " ").last ?? "0") ?? 0].size + var transform = entity.components[Transform.self] ?? Transform() + transform.scale = size + entity.components[Transform.self] = transform + entity.components[Mesh3DComponent.self] = Mesh3DComponent( + mesh: cube, materials: [material(entity.name == "Floor" ? [0.18, 0.24, 0.31, 1] : [0.55, 0.48, 0.8, 1])] + ) + } + let sphere = Mesh.generateSphere(radius: 1, segments: 24, rings: 16, renderDevice: device) + func part(_ name: String, at position: Vector3, scale: Vector3, color: Vector4) { + let entity = world.spawn(name) { + Transform(scale: scale, position: position) + Mesh3DComponent(mesh: sphere, materials: [material(color)]) + } + robot.addChild(entity) + } + part("Body", at: [0, 0.7, 0], scale: [0.36, 0.55, 0.32], color: [0.05, 0.8, 0.85, 1]) + part("Head", at: [0, 1.5, 0], scale: [0.32, 0.32, 0.32], color: [1, 0.8, 0.2, 1]) + for x: Float in [-0.12, 0.12] { + part("Eye", at: [x, 1.55, -0.28], scale: [0.055, 0.075, 0.045], color: [0.03, 0.05, 0.1, 1]) + } + var camera = Camera() + camera.backgroundColor = Color(0.035, 0.055, 0.09, 1) + let eye = Vector3(12, 16, -18) + let forward = (-eye).normalized + let right = Vector3.up.cross(forward).normalized + let up = forward.cross(right) + let cameraMatrix = Transform3D(columns: [Vector4(right, 0), Vector4(up, 0), Vector4(forward, 0), Vector4(eye, 1)]) + world.spawn("Playground camera", bundle: Camera3D( + camera: camera, + perspectiveProjection: PerspectiveProjection(fieldOfView: .degrees(45)), + transform: Transform(matrix: cameraMatrix) + )) + world.spawn("Sun") { + Transform(rotation: Quat(axis: .right, angle: .pi / 3)) + DirectionalLightComponent(intensity: 3, castShadows: true) + } + } +} diff --git a/Examples/Technical/Navigation/NavigationDemoMain.swift b/Examples/Technical/Navigation/NavigationDemoMain.swift new file mode 100644 index 0000000..e8d1fcc --- /dev/null +++ b/Examples/Technical/Navigation/NavigationDemoMain.swift @@ -0,0 +1,148 @@ +@_spi(Internal) import AdaApp +import AdaEngine +import Foundation + +@main +@MainActor +struct NavigationDemoMain { + static func main() async throws { + if CommandLine.arguments.contains("--validate-script") { + try await validateScript() + } else if CommandLine.arguments.contains("--validate") { + try await validate() + } else if let index = CommandLine.arguments.firstIndex(of: "--capture"), CommandLine.arguments.indices.contains(index + 1) { + try await capture(to: CommandLine.arguments[index + 1]) + } else { + try await AppRuntime.run(NavigationDemoApp()) + } + } + + private static func validateScript() async throws { + let app = AppWorlds(main: World()) + app.addPlugin(MainSchedulerPlugin()).addPlugin(Physics3DPlugin()).addPlugin(Navigation3DPlugin()) + try await app.build() + let script = try NavigationDemoScript.install(in: app) + let robot = NavigationDemoFixture.installPhysics(in: app.main) + robot.components[NavigationDemoPatrolComponent.self] = NavigationDemoPatrolComponent() + robot.components[NavigationTarget3DComponent.self] = NavigationTarget3DComponent() + app.insertResource(NavigationMap3D(mesh: try await NavigationMesh(data: NavigationDemoFixture.bakedAsset()))) + app.main.insertResource(FixedTime(deltaTime: 1.0 / 60)) + await app.main.runScheduler(.physicsSync) + for _ in 0..<4800 { + await app.main.runScheduler(.update) + await app.main.runScheduler(.physicsStep) + await app.main.runScheduler(.physicsWriteback) + if robot.components[NavigationDemoPatrolComponent.self]?.completedGoals == 4 { break } + } + guard robot.components[NavigationDemoPatrolComponent.self]?.completedGoals == 4, script.diagnostics.isEmpty else { + throw DemoFailure.didNotArrive + } + print("PASS: NavigationRobot.ada completed four goals through the navigation and physics systems") + } + + /// Runs the demo's actual bake/file load/target requests/physical movement without a GPU. + private static func validate() async throws { + let app = AppWorlds(main: World()) + app.addPlugin(MainSchedulerPlugin()).addPlugin(Physics3DPlugin()).addPlugin(Navigation3DPlugin()) + try await app.build() + let robot = NavigationDemoFixture.installPhysics(in: app.main) + let mesh = try await NavigationMesh(data: NavigationDemoFixture.bakedAsset()) + app.insertResource(NavigationMap3D(mesh: mesh)) + app.main.insertResource(FixedTime(deltaTime: 1.0 / 60)) + await app.main.runScheduler(.physicsSync) + for goal in NavigationDemoFixture.goals { + robot.components[NavigationTarget3DComponent.self] = NavigationTarget3DComponent(destination: goal) + for _ in 0..<1200 { + await app.main.runScheduler(.update) + await app.main.runScheduler(.physicsStep) + await app.main.runScheduler(.physicsWriteback) + if robot.components[NavigationAgent3DComponent.self]?.follower.state == .arrived { break } + } + guard robot.components[NavigationAgent3DComponent.self]?.follower.state == .arrived else { + throw DemoFailure.didNotArrive + } + print("PASS: reached \(goal)") + } + print("PASS: saved/loaded \(mesh.data.polygonCount) polygons; four goals reached through the physics controller") + } + + private static func capture(to path: String) async throws { + weak var capturedApp: AppWorlds? + let runtime = OffscreenSceneRuntime(label: "NavigationDemo", size: [1100, 760], useSceneCameras: true) { app in + configureNavigationDemo(&app, session: NavigationDemoSession()) + capturedApp = app + } + try await runtime.start(timeout: .seconds(30)) + var completed = false + defer { + if !completed, let app = capturedApp { + let status = app.main.getResource(NavigationDemoSession.self)?.status ?? "missing" + report("Capture diagnostics: frame=\(runtime.frameSource.frameSequence), cameras=\(runtime.frameSource.activeCameraNames)") + report("Entities=\(app.main.getEntities().count), status=\(status)") + if let render = app.getSubworldBuilder(by: .renderWorld) { + report("Render entities: \(render.main.getEntities().map(\.name))") + if let records = render.getResource(RenderGraphDiagnostics.self)?.recentFrames(limit: 1), + let data = try? JSONEncoder().encode(records) { report(String(decoding: data, as: UTF8.self)) } + } + } + runtime.stop() + } + var frame = try await runtime.frameSource.captureFrame(after: 0, timeout: .seconds(30)) + try write(frame.image, to: path) + for _ in 0..<20 { + frame = try await runtime.frameSource.captureFrame(after: frame.sequence, timeout: .seconds(10)) + } + try write(frame.image, to: path) + guard let world = runtime.frameSource.world, + let cameraEntity = world.getEntities().first(where: { $0.components[Camera.self] != nil }), + let camera = cameraEntity.components[Camera.self], + let transform = cameraEntity.components[GlobalTransform.self], + let session = world.getResource(NavigationDemoSession.self), + let robotID = session.robot, let robot = world.getEntityByID(robotID) else { throw DemoFailure.sceneUnavailable } + let probe = Vector3(7.5, 0, 4.5) + let clip = camera.projection.makeClipView() * (transform.matrix.inverse * Vector4(probe, 1)) + guard clip.w.isFinite, clip.w > 0 else { throw DemoFailure.sceneUnavailable } + let projected = clip.xyz / clip.w + guard projected.x.isFinite, projected.y.isFinite, projected.z >= 0, projected.z <= 1 else { + throw DemoFailure.sceneUnavailable + } + let size = camera.logicalViewport.rect.size + let pointer = Vector2((projected.x + 1) * size.width / 2, (1 - projected.y) * size.height / 2) + let window = world.getResource(PrimaryWindowId.self)?.windowId ?? RID() + runtime.frameSource.movePointer(to: pointer) + runtime.frameSource.send(MouseEvent(window: window, button: .left, mousePosition: pointer, phase: .began, modifierKeys: [], time: 1)) + for _ in 0..<10 { + frame = try await runtime.frameSource.captureFrame(after: frame.sequence, timeout: .seconds(10)) + if let selected = robot.components[NavigationTarget3DComponent.self]?.destination, (selected - probe).length < 0.02 { break } + } + runtime.frameSource.send(MouseEvent(window: window, button: .left, mousePosition: pointer, phase: .ended, modifierKeys: [], time: 2)) + for _ in 0..<8 { + frame = try await runtime.frameSource.captureFrame(after: frame.sequence, timeout: .seconds(10)) + } + guard let selected = robot.components[NavigationTarget3DComponent.self]?.destination, + (selected - probe).length < 0.02 else { throw DemoFailure.pickingFailed } + print("PASS: viewport click selected \(selected)") + try write(frame.image, to: path) + completed = true + print("PASS: captured real GPU frame \(frame.sequence) to \(path)") + } + + private static func write(_ image: Image, to path: String) throws { + var bytes = Array(image.data) + if image.format == .bgra8 || image.format == .bgra8_sRGB { + for index in stride(from: 0, to: bytes.count, by: 4) { bytes.swapAt(index, index + 2) } + } + let png = try PNGWriter().encode(width: image.width, height: image.height, rgba: bytes) + try png.write(to: URL(fileURLWithPath: path), options: .atomic) + } + + private static func report(_ message: String) { + FileHandle.standardError.write(Data((message + "\n").utf8)) + } +} + +private enum DemoFailure: Error { + case didNotArrive + case sceneUnavailable + case pickingFailed +} diff --git a/Examples/Technical/Navigation/NavigationDemoScript.swift b/Examples/Technical/Navigation/NavigationDemoScript.swift new file mode 100644 index 0000000..45d9479 --- /dev/null +++ b/Examples/Technical/Navigation/NavigationDemoScript.swift @@ -0,0 +1,24 @@ +import AdaEngine +import Foundation + +@Component +struct NavigationDemoPatrolComponent { + var enabled: Bool = true + var completedGoals: Int = 0 +} + +@MainActor +enum NavigationDemoScript { + static func install(in app: AppWorlds) throws -> AdaScriptPlugin { + RuntimeTypeRegistry.registerComponent( + NavigationDemoPatrolComponent.self, names: ["NavigationDemoPatrolComponent"], + makeDefault: { NavigationDemoPatrolComponent() } + ) + guard let file = Bundle.module.url(forResource: "NavigationRobot", withExtension: "ada") else { + throw CocoaError(.fileNoSuchFile) + } + let script = try AdaScriptPlugin(source: String(contentsOf: file, encoding: .utf8), name: "NavigationRobot") + script.setup(in: app) + return script + } +} diff --git a/Examples/Technical/Navigation/NavigationRobot.ada b/Examples/Technical/Navigation/NavigationRobot.ada new file mode 100644 index 0000000..5e48891 --- /dev/null +++ b/Examples/Technical/Navigation/NavigationRobot.ada @@ -0,0 +1,20 @@ +// Navigation runs in the engine. This script chooses goals and reads the agent's state. +@system(id: "navigation.demo.patrol") +class RobotPatrol { + @query(NavigationAgent3DComponent, NavigationTarget3DComponent, NavigationDemoPatrolComponent) var robots; + var goals = [[8.0, 0.0, 4.0], [-8.0, 0.0, 5.0], [8.0, 0.0, -5.0], [0.0, 0.0, 5.0]]; + + func update(context) { + for (var row in robots) { + if (!row.navigationDemoPatrolComponent.enabled) { continue; } + if (row.navigationAgent3DComponent.hasArrived) { + row.navigationDemoPatrolComponent.completedGoals = row.navigationDemoPatrolComponent.completedGoals + 1; + row.navigationTarget3DComponent.destination = null; + } + if (row.navigationTarget3DComponent.destination == null) { + var next = row.navigationDemoPatrolComponent.completedGoals % 4; + row.navigationTarget3DComponent.destination = goals[next]; + } + } + } +} diff --git a/Examples/Technical/Navigation/Package.swift b/Examples/Technical/Navigation/Package.swift new file mode 100644 index 0000000..8355254 --- /dev/null +++ b/Examples/Technical/Navigation/Package.swift @@ -0,0 +1,32 @@ +// swift-tools-version: 6.2 +import Foundation +import PackageDescription + +let package = Package( + name: "NavigationDemo", + platforms: [.macOS(.v15), .iOS(.v18)], + products: [.executable(name: "NavigationDemo", targets: ["NavigationDemo"])], + dependencies: [ + .package(url: "https://github.com/AdaEngine/AdaEngine.git", revision: "a624ccded0a050fddc24ccfbb8079dfb8032805c") + ], + targets: [ + .executableTarget( + name: "NavigationDemo", + dependencies: [ + .product(name: "AdaEngine", package: "adaengine"), + .product(name: "AdaNavigationBaking", package: "adaengine"), + .product(name: "AdaApp", package: "adaengine") + ], + path: ".", + exclude: ["README.md", "script"] + (FileManager.default.fileExists(atPath: "dist") ? ["dist"] : []), + sources: [ + "NavigationDemoApp.swift", + "NavigationDemoFixture.swift", + "NavigationDemoMain.swift", + "NavigationDemoScript.swift" + ], + resources: [.copy("NavigationRobot.ada")], + swiftSettings: [.enableUpcomingFeature("MemberImportVisibility"), .strictMemorySafety()] + ) + ] +) diff --git a/Examples/Technical/Navigation/README.md b/Examples/Technical/Navigation/README.md new file mode 100644 index 0000000..632c057 --- /dev/null +++ b/Examples/Technical/Navigation/README.md @@ -0,0 +1,18 @@ +# Navigation playground + +From the AdaExamples root: + +```sh +swift run --package-path Examples/Technical/Navigation NavigationDemo +swift run --package-path Examples/Technical/Navigation NavigationDemo --validate +swift run --package-path Examples/Technical/Navigation NavigationDemo --validate-script +``` + +The robot patrols goals using the bundled NavigationRobot.ada. Click to choose a +manual destination, rebake the navmesh, or resume the patrol. `--capture ` +uses the actual native renderer and checks picking. For Windows set ADAENGINE_ROOT +to an engine checkout, then use `Examples/Technical/Navigation/script/run_windows.ps1`; +`-ValidateScript` selects headless script validation and `-CapturePath` saves a frame. +The launcher prepares Windows Gravity/Tint from the selected engine's scripts. + +See the [navigation guide](https://github.com/AdaEngine/Ada/blob/main/Sources/AdaNavigation/AdaNavigation.docc/AdaNavigation.md). diff --git a/Examples/Technical/Navigation/script/run_windows.ps1 b/Examples/Technical/Navigation/script/run_windows.ps1 new file mode 100644 index 0000000..5e43437 --- /dev/null +++ b/Examples/Technical/Navigation/script/run_windows.ps1 @@ -0,0 +1,43 @@ +# Run the navigation playground with the same Windows AdaScript dependency used by Studio. +[CmdletBinding()] +param( + [switch]$ValidateScript, + [string]$CapturePath, + [string]$ScratchPath +) + +$ErrorActionPreference = 'Stop' +$demoRoot = Split-Path -Parent $PSScriptRoot +$engineRoot = $env:ADAENGINE_ROOT +if (-not $engineRoot) { throw 'Set ADAENGINE_ROOT to the engine checkout for Windows dependency preparation.' } +$engineScripts = Join-Path $engineRoot 'script' +$demoEnvironment = @{} +$demoVariables = @('ADAENGINE_HEADLESS', 'ADAENGINE_DISABLE_SWAN', 'ADAENGINE_GRAVITY_PACKAGE_PATH', 'TINT_EXECUTABLE') +foreach ($name in $demoVariables) { $demoEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') } +try { + if (-not $env:ADAENGINE_GRAVITY_PACKAGE_PATH) { + $demoGravity = & python (Join-Path $engineScripts 'ensure_windows_gravity.py') + if ($LASTEXITCODE -ne 0) { throw 'Windows AdaScript dependency preparation failed.' } + $env:ADAENGINE_GRAVITY_PACKAGE_PATH = ($demoGravity | Select-Object -Last 1).Trim() + } + $env:ADAENGINE_HEADLESS = if ($ValidateScript) { '1' } else { '0' } + if (-not $ValidateScript) { + $env:ADAENGINE_DISABLE_SWAN = '0' + if (-not $env:TINT_EXECUTABLE) { + $demoTint = & python (Join-Path $engineScripts 'ensure_tint.py') --jobs 2 + if ($LASTEXITCODE -ne 0) { throw 'Tint preparation failed.' } + $env:TINT_EXECUTABLE = ($demoTint | Select-Object -Last 1).Trim() + } + } + $demoArguments = @('run', '--package-path', $demoRoot) + if ($ScratchPath) { $demoArguments += @('--scratch-path', $ScratchPath) } + $demoArguments += 'NavigationDemo' + if ($ValidateScript) { $demoArguments += '--validate-script' } + elseif ($CapturePath) { + $demoArguments += @('--capture', $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($CapturePath)) + } + & swift @demoArguments + if ($LASTEXITCODE -ne 0) { throw "Navigation demo failed with exit code $LASTEXITCODE." } +} finally { + foreach ($name in $demoVariables) { [Environment]::SetEnvironmentVariable($name, $demoEnvironment[$name], 'Process') } +} diff --git a/Examples/Technical/Physics2D/.gitignore b/Examples/Technical/Physics2D/.gitignore new file mode 100644 index 0000000..fd14c91 --- /dev/null +++ b/Examples/Technical/Physics2D/.gitignore @@ -0,0 +1,6 @@ +.build/ +.swiftpm/ +dist/ +*.xcodeproj/ +__pycache__/ +Package.resolved diff --git a/Examples/Technical/Physics2D/Package.swift b/Examples/Technical/Physics2D/Package.swift new file mode 100644 index 0000000..e541d9e --- /dev/null +++ b/Examples/Technical/Physics2D/Package.swift @@ -0,0 +1,38 @@ +// swift-tools-version: 6.2 +import Foundation +import PackageDescription + +let package = Package( + name: "Physics2DDemo", + platforms: [.macOS(.v15), .iOS(.v18)], + products: [.executable(name: "Physics2DDemo", targets: ["Physics2DDemo"])], + dependencies: [ + .package(url: "https://github.com/AdaEngine/AdaEngine.git", revision: "a624ccded0a050fddc24ccfbb8079dfb8032805c") + ], + targets: [ + .executableTarget( + name: "Physics2DDemo", + dependencies: [ + .product(name: "AdaEngine", package: "adaengine"), + .product(name: "AdaApp", package: "adaengine"), + .product(name: "AdaPlatform", package: "adaengine"), + .product(name: "Math", package: "adaengine") + ], + path: ".", + exclude: ["README.md", "script"] + (FileManager.default.fileExists(atPath: "dist") ? ["dist"] : []), + sources: [ + "Physics2DAdvancedChecks.swift", + "Physics2DAdvancedDemo.swift", + "Physics2DCharactersDemo.swift", + "Physics2DDemoMain.swift", + "Physics2DDemoScenes.swift", + "Physics2DDemoView.swift", + "Physics2DLargeWorldChecks.swift", + "Physics2DLargeWorldDemo.swift", + "Physics2DMechanismsDemo.swift", + "Physics2DQueriesDemo.swift" + ], + swiftSettings: [.enableUpcomingFeature("MemberImportVisibility"), .strictMemorySafety()] + ) + ] +) diff --git a/Examples/Technical/Physics2D/Physics2DAdvancedChecks.swift b/Examples/Technical/Physics2D/Physics2DAdvancedChecks.swift new file mode 100644 index 0000000..1a045f0 --- /dev/null +++ b/Examples/Technical/Physics2D/Physics2DAdvancedChecks.swift @@ -0,0 +1,81 @@ +import AdaEngine + +extension Physics2DDemoMain { + static func validateAdvanced(_ app: AppWorlds, _ session: PhysicsDemoSession) async throws { + let state = session.advanced + guard state.pointCount > 0, let air = state.air, let projectile = state.projectile else { throw Failure.advanced } + state.windMode = 1; state.dirty = true + await advance(app, steps: 30) + let speed = air.components[PhysicsBody2DComponent.self]?.linearVelocity.x ?? 0 + guard speed > 0.2 else { throw Failure.advanced } + state.windMode = 2; state.dirty = true + await advance(app, steps: 20) + guard (air.components[PhysicsBody2DComponent.self]?.linearVelocity.x ?? 0) < speed else { throw Failure.advanced } + state.locked = true; state.dirty = true + await advance(app, steps: 2) + guard abs(air.components[PhysicsBody2DComponent.self]?.linearVelocity.x ?? 1) < 0.01 else { throw Failure.advanced } + state.locked = false; state.damping = true; state.softSolver = true + state.reverseBelt = true; state.rolling = false; state.lowGrip = true; state.dirty = true + await advance(app, steps: 2) + guard app.main.physicsWorld2D?.solverSettings.restitutionIterations == 8, + state.belt?.components[Collision2DComponent.self]?.material.surfaceSpeed == -1.8, + state.circle?.components[PhysicsBody2DComponent.self]?.material.mixing.friction == .minimum else { throw Failure.advanced } + air.components[PhysicsBody2DComponent.self]?.setAwake(true) + state.sleepRequested = true + await advance(app, steps: 1) + guard air.components[PhysicsBody2DComponent.self]?.isAwake == false else { throw Failure.advanced } + state.sleepRequested = true; state.bullet = false; state.dirty = true; state.refireRequested = true + await advance(app, steps: 30) + guard (projectile.components[PhysicsBody2DComponent.self]?.worldCenter.x ?? -1) > 0 else { throw Failure.advanced } + state.bullet = true; state.dirty = true; state.refireRequested = true + await advance(app, steps: 30) + guard (projectile.components[PhysicsBody2DComponent.self]?.worldCenter.x ?? 1) < 0, state.hitPoints > 0 else { throw Failure.advanced } + report("PASS: advanced · wind / drag / locks / sleep / materials / CCD / contacts / solver") + } + + static func verifyAdvanced(to directory: String, tap: (String) throws -> Void) async throws { + try tap("physics-demo.advanced") + let source = try await waitForSource("Physics2D Advanced") + guard let session = source.world?.getResource(PhysicsDemoSession.self), let air = session.advanced.air else { throw Failure.advanced } + let state = session.advanced + try tap("physics-demo.advanced-wind") + _ = try await nextFrames(source, count: 30) + let speed = air.components[PhysicsBody2DComponent.self]?.linearVelocity.x ?? 0 + guard speed > 0.2 else { throw Failure.advanced } + try tap("physics-demo.advanced-wind") + _ = try await nextFrames(source, count: 20) + guard (air.components[PhysicsBody2DComponent.self]?.linearVelocity.x ?? 0) < speed else { throw Failure.advanced } + try tap("physics-demo.advanced-lock") + _ = try await nextFrames(source) + guard air.components[PhysicsBody2DComponent.self]?.motion.lockTranslationX == true, + abs(air.components[PhysicsBody2DComponent.self]?.linearVelocity.x ?? 1) < 0.01 else { throw Failure.advanced } + try tap("physics-demo.advanced-lock") + _ = try await nextFrames(source) + if air.components[PhysicsBody2DComponent.self]?.isAwake == false { try tap("physics-demo.advanced-sleep"); _ = try await nextFrames(source) } + try tap("physics-demo.advanced-sleep") + _ = try await nextFrames(source) + guard air.components[PhysicsBody2DComponent.self]?.isAwake == false else { throw Failure.advanced } + try tap("physics-demo.advanced-sleep") + try tap("physics-demo.advanced-damping") + try tap("physics-demo.advanced-belt") + try tap("physics-demo.advanced-rolling") + try tap("physics-demo.advanced-grip") + try tap("physics-demo.advanced-solver") + _ = try await nextFrames(source) + guard source.world?.physicsWorld2D?.solverSettings.contactHertz == 8, + air.components[PhysicsBody2DComponent.self]?.dynamics.linearDamping == 2, + state.circle?.components[PhysicsBody2DComponent.self]?.material.rollingResistance == 0, + state.belt?.components[Collision2DComponent.self]?.material.surfaceSpeed == -1.8 else { throw Failure.advanced } + try tap("physics-demo.advanced-bullet") + _ = try await nextFrames(source, count: 30) + guard (state.projectile?.components[PhysicsBody2DComponent.self]?.worldCenter.x ?? -1) > 0 else { throw Failure.advanced } + try tap("physics-demo.advanced-bullet") + let frame = try await nextFrames(source, count: 30) + guard (state.projectile?.components[PhysicsBody2DComponent.self]?.worldCenter.x ?? 1) < 0, + state.pointCount > 0, state.hitPoints > 0 else { throw Failure.advanced } + try write(frame.image, to: directory + "/advanced-physics.png") + try tap("physics-demo.advanced-reset") + _ = try await nextFrames(source) + report("PASS: native advanced · wind / drag / locks / sleep / materials / CCD / contact points / solver controls") + } +} diff --git a/Examples/Technical/Physics2D/Physics2DAdvancedDemo.swift b/Examples/Technical/Physics2D/Physics2DAdvancedDemo.swift new file mode 100644 index 0000000..ba90de1 --- /dev/null +++ b/Examples/Technical/Physics2D/Physics2DAdvancedDemo.swift @@ -0,0 +1,202 @@ +import AdaEngine +import Foundation +import Math +import Observation + +@MainActor +@Observable +final class PhysicsAdvancedState { + var windMode = 0 + var reverseBelt = false + var rolling = true + var lowGrip = false + var damping = false + var bullet = true + var locked = false + var softSolver = false + var dirty = true + var sleepRequested = false + var resetRequested = false + var refireRequested = false + var belt: Entity? + var box: Entity? + var circle: Entity? + var air: Entity? + var projectile: Entity? + var markers: [Entity] = [] + var defaults: PhysicsSolver2DSettings? + var pointCount = 0 + var hitPoints = 0 + var status = "Waiting for contacts" +} + +@MainActor +func setupAdvanced(in world: World, session: PhysicsDemoSession) throws { + let state = session.advanced + state.defaults = world.physicsWorld2D?.solverSettings + world.insertResource(PhysicsDebugOptions([.showPhysicsShapes])) + let beltMaterial = try PhysicsMaterial.generate2D(surfaceSpeed: 1.8) + state.belt = world.spawn("Conveyor") { + Collision2DComponent(shapes: [.generateBox(width: 17, height: 0.4)], material: beltMaterial) + Sprite(tintColor: Color(0.24, 0.35, 0.5, 1), size: [17, 0.4]) + Transform(position: [0, -3.3, 0]) + } + for x: Float in [-8.5, 8.5] { + world.spawn("Boundary") { Collision2DComponent(shapes: [.generateBox(width: 0.25, height: 8)]); Transform(position: [x, 0, 0]) } + } + state.box = world.spawn("Conveyor box") { + PhysicsBody2DComponent(shapes: [.generateBox(width: 0.8, height: 0.8)], mass: 1) + Sprite(tintColor: Color(1, 0.65, 0.25, 1), size: [0.8, 0.8]) + Transform(position: [-3, -2.7, 0]) + } + var circle = PhysicsBody2DComponent(shapes: [.generateCircle(radius: 1.2)], mass: 1) + circle.debugColor = Color(0.3, 1, 0.5, 1) + circle.linearVelocity = [2, 0]; circle.angularVelocity = -2 / 0.6 + state.circle = world.spawn("Rolling circle") { circle; Transform(position: [-6, -2.5, 0]) } + let capsule = try Shape2DResource.generateCapsule(from: [0, -0.35], to: [0, 0.35], radius: 0.25) + var air = PhysicsBody2DComponent(shapes: [capsule], mass: 2) + air.gravityScale = 0; air.debugColor = Color(0.2, 0.85, 1, 1) + state.air = world.spawn("Wind capsule") { air; PhysicsWind2DComponent(.init(drag: 0)); Transform(position: [0, 0.7, 0]) } + world.spawn("Thin kinematic CCD target") { + PhysicsBody2DComponent(shapes: [.generateBox(width: 0.04, height: 1.5)], mode: .kinematic) + Sprite(tintColor: .white, size: [0.04, 1.5]) + Transform(position: [0, 2.5, 0]) + } + var projectile = PhysicsBody2DComponent(shapes: [.generateBox(width: 0.2, height: 0.2)], mass: 1) + projectile.gravityScale = 0; projectile.fixedRotation = true + state.projectile = world.spawn("CCD box") { + projectile + Sprite(tintColor: Color(1, 0.3, 0.3, 1), size: [0.2, 0.2]) + Transform(position: [-6, 2.5, 0]) + } + state.refireRequested = true + for _ in 0..<4 { + state.markers.append(world.spawn("Contact point") { Sprite(tintColor: Color(1, 0.2, 0.55, 1), size: [0.1, 0.1]); Transform(position: [0, 50, 3]) }) + } +} + +@MainActor +func controlAdvanced(in world: World, session: PhysicsDemoSession) { + let state = session.advanced + do { + if state.dirty { + if let belt = state.belt, belt.components[Collision2DComponent.self]?.material.surfaceSpeed != (state.reverseBelt ? -1.8 : 1.8) { + belt.components[Collision2DComponent.self]?.material = try .generate2D(surfaceSpeed: state.reverseBelt ? -1.8 : 1.8) + } + var mixing = PhysicsMaterialMixing() + mixing.friction = state.lowGrip ? .minimum : .maximum; mixing.priority = 10 + let friction: Float = state.lowGrip ? 0.05 : 0.8 + let rolling: Float = state.rolling ? 0.2 : 0 + if let circle = state.circle, let current = circle.components[PhysicsBody2DComponent.self]?.material, + current.friction != friction || current.rollingResistance != rolling || current.mixing != mixing { + circle.components[PhysicsBody2DComponent.self]?.material = try .generate2D(friction: friction, rollingResistance: rolling, mixing: mixing) + } + var dynamics = PhysicsBody2DDynamics() + dynamics.linearDamping = state.damping ? 2 : 0; dynamics.angularDamping = state.damping ? 3 : 0 + try state.air?.components[PhysicsBody2DComponent.self]?.configureDynamics(dynamics) + var motion = PhysicsBody2DMotion() + motion.lockTranslationX = state.locked; motion.lockTranslationY = state.locked; motion.lockRotation = state.locked + try state.air?.components[PhysicsBody2DComponent.self]?.configureMotion(motion) + state.air?.components[PhysicsWind2DComponent.self]?.settings = .init(velocity: state.windMode == 1 ? [6, 0] : .zero, drag: state.windMode == 0 ? 0 : 1) + if state.windMode == 1 { state.air?.components[PhysicsBody2DComponent.self]?.setAwake(true) } + dynamics = .init(); dynamics.isBullet = state.bullet; dynamics.safetyFactor = 0.01 + try state.projectile?.components[PhysicsBody2DComponent.self]?.configureDynamics(dynamics) + if var solver = state.defaults { + if state.softSolver { + solver.contactHertz = 8; solver.contactDampingRatio = 1; solver.contactPushSpeed = 1 + solver.restitutionIterations = 8; solver.isRestitutionPropagationEnabled = true + } + try world.physicsWorld2D?.configureSolver(solver) + } + state.dirty = false + } + if state.resetRequested { + state.box?.components[PhysicsBody2DComponent.self]?.setPosition([-3, -2.7]) + state.box?.components[PhysicsBody2DComponent.self]?.clearForces() + state.circle?.components[PhysicsBody2DComponent.self]?.setPosition([-6, -2.5]) + state.circle?.components[PhysicsBody2DComponent.self]?.linearVelocity = [2, 0] + state.circle?.components[PhysicsBody2DComponent.self]?.angularVelocity = -2 / 0.6 + state.air?.components[PhysicsBody2DComponent.self]?.setPosition([0, 0.7]) + state.air?.components[PhysicsBody2DComponent.self]?.clearForces() + state.air?.components[PhysicsBody2DComponent.self]?.setAwake(true) + state.refireRequested = true; state.resetRequested = false + } + if state.refireRequested { + state.projectile?.components[PhysicsBody2DComponent.self]?.setPosition([-6, 2.5]) + state.projectile?.components[PhysicsBody2DComponent.self]?.linearVelocity = [50, 0] + state.refireRequested = false + } + if state.sleepRequested { + if let entity = state.air, let body = entity.components[PhysicsBody2DComponent.self] { entity.components[PhysicsBody2DComponent.self]?.setAwake(!body.isAwake) } + state.sleepRequested = false + } + } catch { state.status = "Control failed: \(error)" } +} + +@MainActor +func frameAdvanced(in world: World, session: PhysicsDemoSession) { + let state = session.advanced + guard let physics = world.physicsWorld2D, let box = state.box else { + return + } + let contacts = physics.contacts(for: box) + let points = contacts.flatMap { $0.manifold.points } + state.pointCount = points.count + let impulse = points.reduce(Float(0)) { $0 + $1.totalNormalImpulse } + let separation = points.first?.separation ?? 0 + let identifier = contacts.first.map { String(UInt(bitPattern: $0.id.hashValue), radix: 16).suffix(6) } ?? "none" + let velocity = state.air?.components[PhysicsBody2DComponent.self]?.linearVelocity.x ?? 0 + let awake = state.air?.components[PhysicsBody2DComponent.self]?.isAwake == true + state.status = String( + format: "Contact #%@ · %d points · separation %.3f · impulse %.3f · wind vx %.2f · %@ · hit points %d", + String(identifier), + points.count, + separation, + impulse, + velocity, + awake ? "awake" : "sleeping", + state.hitPoints + ) + for (index, marker) in state.markers.enumerated() { + marker.components[Transform.self]?.position = index < points.count ? Vector3(points[index].point, 3) : [0, 50, 3] + } +} + +struct PhysicsAdvancedDemoPane: View { + let session: PhysicsDemoSession + var body: some View { + let state = session.advanced + VStack(spacing: 8) { + HStack(spacing: 14) { + Button(["Wind: off", "Wind: airflow", "Wind: still-air drag"][state.windMode]) { state.windMode = (state.windMode + 1) % 3; state.dirty = true } + .accessibilityIdentifier("physics-demo.advanced-wind") + Button(state.reverseBelt ? "Belt: left" : "Belt: right") { state.reverseBelt.toggle(); state.dirty = true } + .accessibilityIdentifier("physics-demo.advanced-belt") + Button(state.rolling ? "Rolling resistance: on" : "Rolling resistance: off") { state.rolling.toggle(); state.dirty = true } + .accessibilityIdentifier("physics-demo.advanced-rolling") + Button(state.lowGrip ? "Mixing: minimum grip" : "Mixing: maximum grip") { state.lowGrip.toggle(); state.dirty = true } + .accessibilityIdentifier("physics-demo.advanced-grip") + } + HStack(spacing: 14) { + Button(state.damping ? "Damping: high" : "Damping: off") { state.damping.toggle(); state.dirty = true } + .accessibilityIdentifier("physics-demo.advanced-damping") + Button(state.bullet ? "CCD bullet: on" : "CCD bullet: off") { state.bullet.toggle(); state.dirty = true; state.refireRequested = true } + .accessibilityIdentifier("physics-demo.advanced-bullet") + Button(state.locked ? "Axes: locked" : "Axes: free") { state.locked.toggle(); state.dirty = true } + .accessibilityIdentifier("physics-demo.advanced-lock") + Button("Sleep / wake capsule") { state.sleepRequested = true } + .accessibilityIdentifier("physics-demo.advanced-sleep") + Button(state.softSolver ? "Solver: soft / 8 restitution" : "Solver: default") { state.softSolver.toggle(); state.dirty = true } + .accessibilityIdentifier("physics-demo.advanced-solver") + Button("Reset") { state.resetRequested = true } + .accessibilityIdentifier("physics-demo.advanced-reset") + } + Text("Green: rolling + grip mixing · Cyan: wind + damping · Red: fast box vs thin kinematic wall · Pink: contact points") + Text(state.status).font(.system(size: 12)) + SceneView(useSceneCameras: true, make: { configurePhysicsDemo(&$0, session: session) }, updateContent: { _, _ in }) + .frameCaptureLabel("Physics2D Advanced").id(PhysicsDemoScene.advanced) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/Examples/Technical/Physics2D/Physics2DCharactersDemo.swift b/Examples/Technical/Physics2D/Physics2DCharactersDemo.swift new file mode 100644 index 0000000..ce8fff3 --- /dev/null +++ b/Examples/Technical/Physics2D/Physics2DCharactersDemo.swift @@ -0,0 +1,93 @@ +import AdaEngine +import Math + +@MainActor +func setupCharacters(in world: World, session: PhysicsDemoSession) throws { + world.insertResource(PhysicsDebugOptions.showPhysicsShapes) + world.spawn("Floor") { + Sprite(tintColor: Color(0.25, 0.35, 0.5, 1), size: [18, 0.4]) + Collision2DComponent(shapes: [.generateBox(width: 18, height: 0.4)]) + Transform(position: [0, -2.2, 0]) + } + let ramp = try Shape2DResource.generateChain(points: [[7, -1.4], [5, -2], [3, -2]]) + world.spawn("Ramp") { Collision2DComponent(shapes: [ramp]); Transform() } + world.spawn("Right wall") { Collision2DComponent(shapes: [.generateBox(width: 0.2, height: 4)]); Transform(position: [7.7, -0.2, 0]) } + session.sensor = world.spawn("Sensor") { + Sprite(tintColor: Color(0.1, 0.6, 0.4, 0.35), size: [0.5, 2]) + Collision2DComponent(shapes: [.generateBox(width: 0.5, height: 2)], mode: .trigger) + Transform(position: [-4, -1, -1]) + } + var controller = CharacterController2DComponent() + controller.mode = session.characterMode + session.character = world.spawn("Character") { controller; Transform(position: [-5, 0, 1]) } + session.crate = world.spawn("Light crate") { + Sprite(tintColor: Color(1, 0.57, 0.2, 1), size: [0.8, 0.8]) + PhysicsBody2DComponent(shapes: [.generateBox(width: 0.8, height: 0.8)], massProperties: .init(mass: 0.2, inertia: [0, 0, 0.2])) + Transform(position: [-1, -1.6, 0]) + } + var platform = PhysicsBody2DComponent(shapes: [.generateBox(width: 3, height: 0.4)], mass: 1, mode: .kinematic) + platform.linearVelocity = [0.8, 0] + session.platform = world.spawn("Moving platform") { + Sprite(tintColor: Color(0.25, 0.6, 1, 1), size: [3, 0.4]) + platform + Transform(position: [2, -1.3, 0]) + } +} + +@MainActor +func controlCharacter(_ session: PhysicsDemoSession) { + guard let character = session.character else { + return + } + character.components[CharacterController2DComponent.self]?.mode = session.characterMode + character.components[CharacterController2DComponent.self]?.desiredVelocity = [2.5 * session.moveDirection, 0] + if session.jumpRequest { + session.jumpRequest = false + character.components[CharacterController2DComponent.self]?.jumpRequested = true + } + if session.resetRequested { + session.resetRequested = false; session.moveDirection = 0 + character.components[Transform.self]?.position = [-5, 0, 1] + character.components[CharacterController2DComponent.self]?.desiredVelocity = .zero + character.components[CharacterController2DComponent.self]?.resetMotion = true + session.crate?.components[PhysicsBody2DComponent.self]?.setPosition([-1, -1.6], angle: .radians(0)) + session.crate?.components[PhysicsBody2DComponent.self]?.linearVelocity = .zero + session.crate?.components[PhysicsBody2DComponent.self]?.angularVelocity = 0 + } + let x = session.platform?.components[Transform.self]?.position.x ?? 2 + if x > 3.2 { session.platformDirection = -1 } + if x < 1.2 { session.platformDirection = 1 } + session.platform?.components[PhysicsBody2DComponent.self]?.linearVelocity = [0.8 * session.platformDirection, 0] +} + +@MainActor +func updateCharacterStatus(_ session: PhysicsDemoSession) { + guard let controller = session.character?.components[CharacterController2DComponent.self] else { + return + } + let velocity = Vector2((controller.velocity.x * 10).rounded() / 10, (controller.velocity.y * 10).rounded() / 10) + session.rayStatus = "\(controller.mode.rawValue) · \(controller.isGrounded ? "grounded" : "airborne") · velocity \(velocity.x), \(velocity.y)" +} + +struct PhysicsCharactersDemoPane: View { + let session: PhysicsDemoSession + var body: some View { + VStack(spacing: 12) { + HStack(spacing: 14) { + Button("Left") { session.moveDirection = -1 }.accessibilityIdentifier("physics-demo.left") + Button("Stop") { session.moveDirection = 0 }.accessibilityIdentifier("physics-demo.stop") + Button("Right") { session.moveDirection = 1 }.accessibilityIdentifier("physics-demo.right") + Button("Jump") { session.jumpRequest = true }.accessibilityIdentifier("physics-demo.jump") + Button(session.characterMode == .query ? "Mode: Query" : "Mode: Dynamic") { session.characterMode = session.characterMode == .query ? .dynamic : .query } + .accessibilityIdentifier("physics-demo.character-mode") + Button("Reset") { session.resetRequested = true }.accessibilityIdentifier("physics-demo.character-reset") + } + Text("Move and jump onto the moving platform. Dynamic mode pushes the orange crate; query mode is virtual.") + Text(session.rayStatus) + SceneView(useSceneCameras: true, make: { configurePhysicsDemo(&$0, session: session) }, updateContent: { _, _ in }) + .frameCaptureLabel("Physics2D Characters").id(PhysicsDemoScene.characters) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/Examples/Technical/Physics2D/Physics2DDemoMain.swift b/Examples/Technical/Physics2D/Physics2DDemoMain.swift new file mode 100644 index 0000000..d7497f5 --- /dev/null +++ b/Examples/Technical/Physics2D/Physics2DDemoMain.swift @@ -0,0 +1,364 @@ +@_spi(Internal) import AdaApp +import AdaEngine +@_spi(Internal) import AdaPlatform +import Foundation + +@main +@MainActor +struct Physics2DDemoMain { + static func main() async throws { + if CommandLine.arguments.contains("--validate") { + try await validate() + return + } + if CommandLine.arguments.contains("--verify") { + Task { @MainActor in + do { + let path = argument(after: "--proof-directory") ?? "/tmp/ada-physics2d-proof" + try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true) + try await verifyWindow(to: path) + report("Physics2DDemo verification PASS") + } catch { + report("Physics2DDemo verification FAIL: \(error)") + } + } + } + try await AppRuntime.run(Physics2DDemoApp()) + } + + private static func argument(after flag: String) -> String? { + guard let index = CommandLine.arguments.firstIndex(of: flag), CommandLine.arguments.indices.contains(index + 1) else { + return nil + } + return CommandLine.arguments[index + 1] + } + + private static func validate() async throws { + for scene in PhysicsDemoScene.allCases { + let session = PhysicsDemoSession(scene: scene, rendering: false) + session.scan = false + let app = AppWorlds(main: World()) + app.addPlugin(MainSchedulerPlugin()).addPlugin(TransformPlugin()) + .addPlugin(Physics2DPlugin(gravity: scene.gravity)) + .addPlugin(PhysicsDemoPlugin(session: session)) + try await app.build() + app.main.insertResource(FixedTime(deltaTime: 1.0 / 60)) + await app.main.runScheduler(.startup) + await advance(app, steps: 240) + if scene == .largeWorld { + try await validateLargeWorld(app, session) + } else if scene == .advanced { + try await validateAdvanced(app, session) + } else if scene == .mechanisms { + guard session.connectedJoints == 9 else { throw Failure.mechanisms } + session.reverseRequested = true + await advance(app, steps: 30) + guard (session.targets[4].components[PhysicsBody2DComponent.self]?.angularVelocity ?? 0) < -1 else { throw Failure.mechanisms } + session.breakRequested = true + await advance(app, steps: 5) + guard session.jointBreaks > 0, session.connectedJoints == 8 else { throw Failure.mechanisms } + session.resetRequested = true + await advance(app, steps: 5) + guard session.connectedJoints == 9 else { throw Failure.mechanisms } + report("PASS: mechanisms · nine joint kinds / live reverse / break / repair") + } else if scene == .characters { + guard session.character?.components[CharacterController2DComponent.self]?.isGrounded == true else { throw Failure.character } + session.characterMode = .dynamic + await advance(app, steps: 90) + session.moveDirection = 1 + await advance(app, steps: 150) + session.moveDirection = 0 + await advance(app, steps: 30) + guard (session.crate?.components[Transform.self]?.position.x ?? -1) > -0.8 else { throw Failure.character } + session.jumpRequest = true + await advance(app, steps: 1) + guard session.character?.components[CharacterController2DComponent.self]?.isGrounded == false else { throw Failure.character } + report("PASS: characters · query ground / dynamic push / jump") + } else if scene == .queries { + guard session.queryEntities.count == 4 else { throw Failure.queries } + session.querySensors = false + await app.main.runScheduler(.postUpdate) + guard session.queryEntities.count == 3 else { throw Failure.queries } + session.probeMode = .overlap + await app.main.runScheduler(.postUpdate) + guard session.queryEntities.count == 1 else { throw Failure.queries } + session.probeMode = .area + await app.main.runScheduler(.postUpdate) + guard session.queryEntities.count == 3 else { throw Failure.queries } + report("PASS: geometry/query scene · cast / sensor exclusion / exact overlap / area") + } else if scene == .raycast { + guard session.lastHits.count == 3, session.lastHits[0].distance < session.lastHits[1].distance else { throw Failure.raycast } + session.allHits = false + await app.main.runScheduler(.postUpdate) + guard session.lastHits.count == 1 else { throw Failure.raycast } + session.allHits = true + session.blueOnly = true + await app.main.runScheduler(.postUpdate) + guard session.lastHits.count == 2 else { throw Failure.raycast } + report("PASS: raycast scene · 3 ordered hits / nearest / 2 blue-layer hits") + } else { + try requireEvents(session) + report("PASS: event scene · contacts=\(session.contactBegins)/\(session.contactEnds), sensors=\(session.sensorBegins)/\(session.sensorEnds), impacts=\(session.impacts)") + } + } + } + + static func advance(_ app: AppWorlds, steps: Int) async { + for _ in 0.. SceneViewFrameSource { + for _ in 0..<100 { + if let source = SceneViewFrameRegistry.latest(label: label), source.frameSequence > 2, source !== previous { + return source + } + try await Task.sleep(for: .milliseconds(100)) + } + report("No ready source for \(label); labels: \(SceneViewFrameRegistry.sources.map(\.label))") + throw Failure.sceneUnavailable + } + + static func nextFrames(_ source: SceneViewFrameSource, count: Int = 5) async throws -> SceneViewCapturedFrame { + var frame = try await source.captureFrame(after: source.frameSequence, timeout: .seconds(10)) + for _ in 1.. 0 && events.contactBegins > 0 && events.contactEnds > 0 && events.impacts > 0 { break } + } + try requireEvents(events) + let eventFrame = try await nextFrames(eventSource) + try write(eventFrame.image, to: directory + "/events.png") + let initialImpacts = events.impacts + try tap("physics-demo.drop") + var observedDrop = false + for _ in 0..<20 { + _ = try await nextFrames(eventSource, count: 1) + if (events.falling?.components[Transform.self]?.position.y ?? 0) > 2 { + observedDrop = true + break + } + } + guard observedDrop else { throw Failure.events } + for _ in 0..<150 { + _ = try await nextFrames(eventSource, count: 1) + if events.impacts > initialImpacts { break } + } + guard events.impacts > initialImpacts else { throw Failure.events } + var verification: [String: Any] = [ + "passed": true, "backend": String(describing: unsafe RenderEngine.shared.type), "raycastAll": 3, "raycastNearest": 1, "raycastBlueMask": 2, + "contactBegins": events.contactBegins, "contactEnds": events.contactEnds, + "sensorBegins": events.sensorBegins, "sensorEnds": events.sensorEnds, "impacts": events.impacts, + "lastImpactSpeed": events.lastImpactSpeed, "sceneReentry": true, "dropButton": true, "rayFrame": rayFrame.sequence, "eventFrame": eventFrame.sequence, + ] + try tap("physics-demo.raycasts") + let rebuiltRay = try await waitForSource("Physics2D Raycasts", excluding: raySource) + _ = try await nextFrames(rebuiltRay) + guard let rebuilt = rebuiltRay.world?.getResource(PhysicsDemoSession.self), + rebuilt.lastHits.count == 3, rebuilt.markers.count == 3 else { throw Failure.raycast } + try tap("physics-demo.events") + _ = try await waitForSource("Physics2D Events", excluding: eventSource) + report("PASS: native events · sensor entry/exit, contact begin/end, impact, re-drop, scene re-entry") + try tap("physics-demo.queries") + let queriesSource = try await waitForSource("Physics2D Geometry & queries") + guard let queries = queriesSource.world?.getResource(PhysicsDemoSession.self) else { throw Failure.sceneUnavailable } + try tap("physics-demo.query-scan") + _ = try await nextFrames(queriesSource) + guard queries.queryEntities.count == 4 else { throw Failure.queries } + try tap("physics-demo.query-sensors") + _ = try await nextFrames(queriesSource) + guard queries.queryEntities.count == 3 else { throw Failure.queries } + try tap("physics-demo.query-mask") + _ = try await nextFrames(queriesSource) + guard queries.queryEntities.count == 2 else { throw Failure.queries } + try tap("physics-demo.query-order") + _ = try await nextFrames(queriesSource) + guard queries.queryEntities.count == 1 else { throw Failure.queries } + try tap("physics-demo.query-order") + try tap("physics-demo.query-mask") + try tap("physics-demo.query-sensors") + let geometryFrame = try await nextFrames(queriesSource) + report("Geometry debug \(queries.debugDrawState), options=\(queriesSource.world?.getResource(PhysicsDebugOptions.self)?.rawValue ?? 0)") + try write(geometryFrame.image, to: directory + "/geometry-cast.png") + try tap("physics-demo.query-kind") + _ = try await nextFrames(queriesSource) + guard queries.queryEntities.count == 1 else { throw Failure.queries } + try tap("physics-demo.query-kind") + let areaFrame = try await nextFrames(queriesSource) + guard queries.queryEntities.count == 4 else { throw Failure.queries } + try write(areaFrame.image, to: directory + "/geometry-area.png") + try tap("physics-demo.query-kind") + try tap("physics-demo.raycasts") + _ = try await waitForSource("Physics2D Raycasts", excluding: rebuiltRay) + try tap("physics-demo.queries") + let rebuiltQueries = try await waitForSource("Physics2D Geometry & queries", excluding: queriesSource) + _ = try await nextFrames(rebuiltQueries) + guard queries.queryEntities.count == 4, queries.targets.count == 8 else { throw Failure.queries } + verification["querySceneReentry"] = true + verification["geometryQueries"] = true + report("PASS: native geometry · convex cast / overlap / area / masks / sensors / nearest") + try tap("physics-demo.mechanisms") + let mechanismSource = try await waitForSource("Physics2D Mechanisms") + guard let mechanisms = mechanismSource.world?.getResource(PhysicsDemoSession.self), mechanisms.connectedJoints == 9 else { throw Failure.mechanisms } + try tap("physics-demo.reverse") + _ = try await nextFrames(mechanismSource, count: 30) + guard mechanisms.driveDirection < 0, (mechanisms.targets[4].components[PhysicsBody2DComponent.self]?.angularVelocity ?? 0) < -1 else { throw Failure.mechanisms } + let mechanismFrame = try await nextFrames(mechanismSource) + try write(mechanismFrame.image, to: directory + "/mechanisms.png") + try tap("physics-demo.break") + for _ in 0..<30 { + _ = try await nextFrames(mechanismSource, count: 1) + if mechanisms.jointBreaks > 0 { break } + } + guard mechanisms.jointBreaks > 0, mechanisms.connectedJoints == 8 else { throw Failure.mechanisms } + try tap("physics-demo.repair") + _ = try await nextFrames(mechanismSource) + guard mechanisms.connectedJoints == 9 else { throw Failure.mechanisms } + verification["mechanisms"] = true + verification["jointBreaks"] = mechanisms.jointBreaks + report("PASS: native mechanisms · nine constraints / reverse / break / repair") + try tap("physics-demo.characters") + let characterSource = try await waitForSource("Physics2D Characters") + guard let characters = characterSource.world?.getResource(PhysicsDemoSession.self) else { throw Failure.character } + try await waitForGround(characterSource, characters) + guard !hasCharacterCollider(characterSource, characters) else { throw Failure.character } + let startX = characters.character?.components[Transform.self]?.position.x ?? -5 + try tap("physics-demo.right") + _ = try await nextFrames(characterSource, count: 25) + guard (characters.character?.components[Transform.self]?.position.x ?? -5) > startX + 0.2 else { throw Failure.character } + try tap("physics-demo.stop") + _ = try await nextFrames(characterSource, count: 15) + try tap("physics-demo.jump") + try await waitForJump(characterSource, characters) + let queryFrame = try await nextFrames(characterSource, count: 10) + try write(queryFrame.image, to: directory + "/character-query.png") + try await waitForGround(characterSource, characters) + try tap("physics-demo.character-mode") + _ = try await nextFrames(characterSource) + guard characters.characterMode == .dynamic, hasCharacterCollider(characterSource, characters) else { throw Failure.character } + try tap("physics-demo.right") + _ = try await nextFrames(characterSource, count: 120) + try tap("physics-demo.stop") + _ = try await nextFrames(characterSource, count: 15) + guard (characters.crate?.components[Transform.self]?.position.x ?? -1) > -0.8 else { throw Failure.character } + let dynamicFrame = try await nextFrames(characterSource) + try write(dynamicFrame.image, to: directory + "/character-dynamic.png") + try tap("physics-demo.jump") + try await waitForJump(characterSource, characters) + try await waitForGround(characterSource, characters) + try tap("physics-demo.character-mode") + _ = try await nextFrames(characterSource) + guard !hasCharacterCollider(characterSource, characters) else { throw Failure.character } + let oldPlayer = characters.character + try tap("physics-demo.mechanisms") + _ = try await waitForSource("Physics2D Mechanisms", excluding: mechanismSource) + try tap("physics-demo.characters") + let rebuiltCharacter = try await waitForSource("Physics2D Characters", excluding: characterSource) + try await waitForGround(rebuiltCharacter, characters) + guard characters.character !== oldPlayer else { throw Failure.character } + verification["characterModes"] = ["query", "dynamic"] + verification["dynamicPush"] = true + verification["characterJumpBothModes"] = true + verification["characterColliderCleanup"] = true + verification["characterSceneReentry"] = true + report("PASS: native characters · move / stop / both jumps / physical push / mode cleanup / re-entry") + try await verifyAdvanced(to: directory, tap: tap) + verification["advancedPhysics"] = true + try await verifyLargeWorld(to: directory, tap: tap) + verification["largeWorldPrecision"] = true + verification["largeWorldMillimeterNudge"] = true + verification["largeWorldOriginShift"] = true + verification["largeWorldCharacters"] = true + verification["largeWorldSceneReentry"] = true + try JSONSerialization.data(withJSONObject: verification, options: [.prettyPrinted, .sortedKeys]) + .write(to: URL(fileURLWithPath: directory + "/verification.json"), options: .atomic) + } + + static func waitForGround(_ source: SceneViewFrameSource, _ session: PhysicsDemoSession) async throws { + for _ in 0..<150 { + if session.character?.components[CharacterController2DComponent.self]?.isGrounded == true { + return + } + _ = try await nextFrames(source, count: 1) + } + throw Failure.character + } + + static func waitForJump(_ source: SceneViewFrameSource, _ session: PhysicsDemoSession) async throws { + for _ in 0..<30 { + _ = try await nextFrames(source, count: 1) + if session.character?.components[CharacterController2DComponent.self]?.isGrounded == false { + return + } + } + throw Failure.character + } + + static func hasCharacterCollider(_ source: SceneViewFrameSource, _ session: PhysicsDemoSession) -> Bool { + guard let player = session.character, let bottom = player.components[Transform.self]?.position.xy, let physics = source.world?.physicsWorld2D else { + return false + } + return physics.overlap(at: bottom + [0, 0.9]).contains { $0.entity === player } + } + + private static func requireEvents(_ session: PhysicsDemoSession) throws { + guard session.contactBegins > 0, session.contactEnds > 0, session.sensorBegins > 0, + session.sensorEnds > 0, session.impacts > 0, session.lastImpactSpeed > 0 else { throw Failure.events } + } + + static func write(_ image: Image, to path: String) throws { + guard image.width > 0, image.height > 0, !image.data.isEmpty else { throw Failure.emptyFrame } + var bytes = Array(image.data) + if image.format == .bgra8 || image.format == .bgra8_sRGB { + for index in stride(from: 0, to: bytes.count, by: 4) { bytes.swapAt(index, index + 2) } + } + var colors = Set() + for offset in stride(from: 0, to: bytes.count - 3, by: 64) { + colors.insert(UInt32(bytes[offset]) << 16 | UInt32(bytes[offset + 1]) << 8 | UInt32(bytes[offset + 2])) + } + let png = try PNGWriter().encode(width: image.width, height: image.height, rgba: bytes) + try png.write(to: URL(fileURLWithPath: path), options: .atomic) + guard image.width > 200, image.height > 200, colors.count > 3 else { report("Frame \(path) contains \(colors.count) sampled colors"); throw Failure.emptyFrame } + } + + static func report(_ message: String) { + FileHandle.standardOutput.write(Data((message + "\n").utf8)) + } + + enum Failure: Error { + case raycast, events, queries, mechanisms, character, advanced, largeWorld, sceneUnavailable, emptyFrame + } +} diff --git a/Examples/Technical/Physics2D/Physics2DDemoScenes.swift b/Examples/Technical/Physics2D/Physics2DDemoScenes.swift new file mode 100644 index 0000000..f902f0d --- /dev/null +++ b/Examples/Technical/Physics2D/Physics2DDemoScenes.swift @@ -0,0 +1,302 @@ +import AdaEngine +import Math +import Observation + +/// The same scenes run in the interactive window, offscreen capture and deterministic validation. +enum PhysicsDemoScene: String, CaseIterable { + case raycast, events, queries, mechanisms, characters, advanced, largeWorld + var gravity: Vector2 { self == .raycast || self == .queries ? .zero : [0, -9.81] } +} + +@MainActor +@Observable +final class PhysicsDemoSession: Resource { + let scene: PhysicsDemoScene + let rendering: Bool + var largeWorld = PhysicsLargeWorldState() + var advanced = PhysicsAdvancedState() + var mechanismOwners: [Entity] = [] + var mechanismStarts: [Vector2] = [] + var reverseRequested = false + var breakRequested = false + var driveDirection: Float = 1 + var moverDirection: Float = 1 + var connectedJoints = 0 + var jointBreaks = 0 + var character: Entity? + var crate: Entity? + var platform: Entity? + var characterMode = CharacterController2DMode.query + var moveDirection: Float = 0 + var jumpRequest = false + var platformDirection: Float = 1 + var debugDrawState = "extract pending" + var probeMode = PhysicsProbeMode.cast + var querySensors = true + var queryShape: Shape2DResource? + var queryEntities: [Entity] = [] + var queryPoints: [Vector2] = [] + var probeY: Float = 1.6 + var probeContact: Vector2? + var allHits = true + var blueOnly = false + var scan = true + var elapsed: Float = 0 + var resetRequested = false + var rayStatus = "Waiting for physics sync" + var contactBegins = 0 + var contactEnds = 0 + var sensorBegins = 0 + var sensorEnds = 0 + var impacts = 0 + var lastImpactSpeed: Float = 0 + var sensorOccupied = false + var targets: [Entity] = [] + var markers: [Entity] = [] + var beam: Entity? + var sensor: Entity? + var visitor: Entity? + var falling: Entity? + var lastHits: [Raycast2DHit] = [] + + func resetScene() { + largeWorld = PhysicsLargeWorldState() + advanced = PhysicsAdvancedState() + elapsed = 0 + mechanismOwners.removeAll(); mechanismStarts.removeAll() + reverseRequested = false; breakRequested = false; driveDirection = 1; moverDirection = 1 + connectedJoints = 0; jointBreaks = 0 + character = nil; crate = nil; platform = nil; moveDirection = 0; jumpRequest = false; platformDirection = 1 + resetRequested = false + rayStatus = "Waiting for physics sync" + contactBegins = 0 + contactEnds = 0 + sensorBegins = 0 + sensorEnds = 0 + impacts = 0 + lastImpactSpeed = 0 + sensorOccupied = false + targets.removeAll() + markers.removeAll() + beam = nil + sensor = nil + visitor = nil + falling = nil + lastHits.removeAll() + queryEntities.removeAll(); queryPoints.removeAll(); queryShape = nil; probeContact = nil + } + + init(scene: PhysicsDemoScene, rendering: Bool = true) { + self.scene = scene + self.rendering = rendering + } +} + +@MainActor +func configurePhysicsDemo(_ app: inout AppWorlds, session: PhysicsDemoSession) { + app.addPlugin(TransformPlugin()).addPlugin(InputPlugin()).addPlugin(RenderWorldPlugin()) + .addPlugin(CameraPlugin()).addPlugin(AssetsPlugin()).addPlugin(VisibilityPlugin()) + .addPlugin(SpritePlugin()).addPlugin(Core2DPlugin()).addPlugin(UpscalePlugin()) + .addPlugin(Physics2DPlugin(gravity: session.scene.gravity)) + .addPlugin(PhysicsDemoPlugin(session: session)) +} + +struct PhysicsDemoPlugin: Plugin { + let session: PhysicsDemoSession + + func setup(in app: AppWorlds) { + app.insertResource(session) + .addSystem(PhysicsDemoStartupSystem.self, on: .startup) + .addSystem(PhysicsDemoControlsSystem.self, on: .fixedPreUpdate) + .addSystem(PhysicsDemoFrameSystem.self, on: .postUpdate) + app.getSubworldBuilder(by: .renderWorld)?.addSystem(PhysicsDemoProbeExtractSystem.self, on: .extract) + } +} + +@System +@MainActor +func PhysicsDemoStartup(_ context: WorldUpdateContext, _ state: Res) { + let session = state.wrappedValue + session.resetScene() + let world = context.world + if session.rendering { + var camera = Camera() + camera.backgroundColor = Color(0.035, 0.055, 0.10, 1) + let entity = world.spawn("Physics camera", bundle: Camera2D(camera: camera, orthographicProjection: OrthographicProjection(scale: 48))) + if session.scene == .largeWorld { entity.components[PhysicsPosition2DComponent.self] = .init(session.largeWorld.base) } + } + if session.scene == .largeWorld { + do { try setupLargeWorld(in: world, session: session) } catch { session.rayStatus = "Large-world setup failed: \(error)" } + return + } + if session.scene == .advanced { + do { try setupAdvanced(in: world, session: session) } catch { session.rayStatus = "Advanced setup failed: \(error)" } + return + } + if session.scene == .mechanisms || session.scene == .characters { + do { + if session.scene == .mechanisms { try setupMechanisms(in: world, session: session) } else { try setupCharacters(in: world, session: session) } + } catch { session.rayStatus = "Setup failed: \(error)" } + return + } + if session.scene == .queries { + do { try setupGeometryQueries(in: world, session: session) } catch { session.rayStatus = "Geometry failed: \(error)" } + return + } + if session.scene == .raycast { + for (index, x) in [Float(-3), 0, 3].enumerated() { + let color: Color = index == 1 ? Color(1, 0.57, 0.20, 1) : Color(0.25, 0.55, 1, 1) + let entity = world.spawn("Target \(index + 1)") { + Sprite(tintColor: color, size: [0.8, 3]) + Collision2DComponent( + shapes: [.generateBox(width: 0.8, height: 3)], + filter: CollisionFilter(categoryBitMask: CollisionGroup(rawValue: index == 1 ? 2 : 1)) + ) + Transform(position: [x, 0, 0]) + } + session.targets.append(entity) + } + session.beam = world.spawn("Ray") { + Sprite(tintColor: Color(0.3, 1, 0.8, 1), size: [12, 0.035]) + Transform(position: [0, 0, 1]) + } + for _ in 0..<3 { + session.markers.append(world.spawn("Hit marker") { + Sprite(tintColor: .white, size: [0.16, 0.16]) + Transform(position: [0, 50, 2]) + }) + } + } else { + world.spawn("Floor") { + Sprite(tintColor: Color(0.3, 0.4, 0.55, 1), size: [14, 0.5]) + Collision2DComponent(shapes: [.generateBox(width: 14, height: 0.5)]) + Transform(position: [0, -2, 0]) + } + session.sensor = world.spawn("Sensor") { + Sprite(tintColor: Color(0.15, 0.6, 0.4, 0.45), size: [3, 3]) + Collision2DComponent(shapes: [.generateBox(width: 3, height: 3)], mode: .trigger) + Transform(position: [-2, 0.5, -1]) + } + var visitorBody = PhysicsBody2DComponent(shapes: [.generateBox(width: 0.5, height: 0.5)], mass: 1) + visitorBody.gravityScale = 0 + visitorBody.linearVelocity = [2.5, 0] + session.visitor = world.spawn("Sensor visitor") { + Sprite(tintColor: Color(0.3, 0.75, 1, 1), size: [0.5, 0.5]) + visitorBody + Transform(position: [-5, 0.5, 1]) + } + session.falling = world.spawn("Bouncing box") { + Sprite(tintColor: Color(1, 0.57, 0.2, 1), size: [0.8, 0.8]) + PhysicsBody2DComponent( + shapes: [.generateBox(width: 0.8, height: 0.8)], + mass: 1, + material: .generate(friction: 0.4, restitution: 0.65, density: 1) + ) + Transform(position: [3, 3, 1]) + } + } +} + +@System +@MainActor +func PhysicsDemoControls(_ context: WorldUpdateContext, _ state: Res, _ time: Res) { + let session = state.wrappedValue + session.elapsed += Float(time.deltaTime) + if session.scene == .largeWorld { + controlLargeWorld(in: context.world, session: session) + return + } + if session.scene == .advanced { + controlAdvanced(in: context.world, session: session) + return + } + if session.scene == .mechanisms { + controlMechanisms(session) + return + } + if session.scene == .characters { + controlCharacter(session) + return + } + guard session.scene == .events else { + return + } + if let visitor = session.visitor, (visitor.components[Transform.self]?.position.x ?? 0) > 1.5 { + visitor.components[PhysicsBody2DComponent.self]?.setPosition([-5, 0.5]) + visitor.components[PhysicsBody2DComponent.self]?.linearVelocity = [2.5, 0] + } + if session.resetRequested { + session.resetRequested = false + session.falling?.components[PhysicsBody2DComponent.self]?.setPosition([3, 3]) + session.falling?.components[PhysicsBody2DComponent.self]?.linearVelocity = [0, -0.1] + } +} + +@System +@MainActor +func PhysicsDemoFrame( + _ context: WorldUpdateContext, + _ state: Res, + _ begins: Events, + _ ends: Events, + _ hits: Events, + _ jointEvents: Events +) { + let session = state.wrappedValue + for event in begins { + if event.isSensor { session.sensorBegins += 1; session.sensorOccupied = true } else { session.contactBegins += 1 } + } + for event in ends { + if event.isSensor { session.sensorEnds += 1; session.sensorOccupied = false } else { session.contactEnds += 1 } + } + for _ in jointEvents { session.jointBreaks += 1 } + for event in hits { + session.impacts += 1; session.lastImpactSpeed = event.approachSpeed + if session.scene == .advanced { session.advanced.hitPoints = event.contact?.manifold.points.count ?? 0 } + } + session.sensor?.components[Sprite.self]?.tintColor = session.sensorOccupied ? Color(0.2, 1, 0.5, 0.8) : Color(0.15, 0.6, 0.4, 0.45) + if session.scene == .largeWorld { + frameLargeWorld(in: context.world, session: session) + return + } + if session.scene == .advanced { + frameAdvanced(in: context.world, session: session) + return + } + if session.scene == .mechanisms { + session.connectedJoints = session.mechanismOwners.filter { $0.components[PhysicsJoint2DComponent.self]?.isConnected == true }.count + session.rayStatus = "\(session.connectedJoints) / 9 connected · \(session.jointBreaks) break events · filter box passes through its block" + return + } + if session.scene == .characters { + updateCharacterStatus(session) + return + } + if session.scene == .queries { + updateGeometryQueries(in: context.world, session: session) + return + } + guard session.scene == .raycast, let physics = context.world.physicsWorld2D else { + return + } + let start = Vector2(-6, 0) + let end = Vector2(6, session.scan ? Math.sin(session.elapsed * 0.7) * 3 : 0) + let hits = physics.raycast( + from: start, + to: end, + query: session.allHits ? .all : .first, + mask: session.blueOnly ? .default : .all + ) + session.lastHits = hits + let description = hits.map { "\($0.entity.name): \((($0.distance * 100).rounded()) / 100) m" }.joined(separator: " · ") + session.rayStatus = hits.isEmpty ? "No hits" : description + let translation = end - start + session.beam?.components[Sprite.self]?.size = Size(width: translation.squaredLength.squareRoot(), height: 0.035) + session.beam?.components[Transform.self] = Transform( + rotation: Quat(axis: [0, 0, 1], angle: Math.atan2(translation.y, translation.x)), + position: Vector3((start + end) / 2, 1) + ) + for (index, marker) in session.markers.enumerated() { + marker.components[Transform.self]?.position = index < hits.count ? Vector3(hits[index].point, 2) : [0, 50, 2] + } +} diff --git a/Examples/Technical/Physics2D/Physics2DDemoView.swift b/Examples/Technical/Physics2D/Physics2DDemoView.swift new file mode 100644 index 0000000..849a59b --- /dev/null +++ b/Examples/Technical/Physics2D/Physics2DDemoView.swift @@ -0,0 +1,70 @@ +import AdaEngine +import Observation + +struct Physics2DDemoApp: App { + var body: some AppScene { + WindowGroup { Physics2DDemoView() } + .window(with: UIWindow.Configuration(title: "AdaEngine · Box2D playground", frame: Rect(x: 100, y: 100, width: 1000, height: 700))) + } +} + +private struct Physics2DDemoView: View { + @State private var scene = PhysicsDemoScene.raycast + @State private var raycast = PhysicsDemoSession(scene: .raycast) + @State private var events = PhysicsDemoSession(scene: .events) + @State private var queries = PhysicsDemoSession(scene: .queries) + @State private var mechanisms = PhysicsDemoSession(scene: .mechanisms) + @State private var characters = PhysicsDemoSession(scene: .characters) + @State private var advanced = PhysicsDemoSession(scene: .advanced) + @State private var largeWorld = PhysicsDemoSession(scene: .largeWorld) + + var body: some View { + VStack(spacing: 12) { + Text("Box2D playground").font(.system(size: 26)) + HStack(spacing: 20) { + Button("Raycasts") { scene = .raycast }.accessibilityIdentifier("physics-demo.raycasts") + Button("Collisions & sensors") { scene = .events }.accessibilityIdentifier("physics-demo.events") + Button("Geometry & queries") { scene = .queries }.accessibilityIdentifier("physics-demo.queries") + Button("Mechanisms") { scene = .mechanisms }.accessibilityIdentifier("physics-demo.mechanisms") + Button("Characters") { scene = .characters }.accessibilityIdentifier("physics-demo.characters") + Button("Advanced") { scene = .advanced }.accessibilityIdentifier("physics-demo.advanced") + Button("Large worlds") { scene = .largeWorld }.accessibilityIdentifier("physics-demo.large-world") + } + if scene == .raycast { + HStack(spacing: 18) { + Button(raycast.allHits ? "All hits" : "Nearest hit") { raycast.allHits.toggle() }.accessibilityIdentifier("physics-demo.mode") + Button(raycast.blueOnly ? "Blue targets only" : "All layers") { raycast.blueOnly.toggle() }.accessibilityIdentifier("physics-demo.mask") + Button(raycast.scan ? "Hold horizontal ray" : "Animate ray") { raycast.scan.toggle() }.accessibilityIdentifier("physics-demo.scan") + } + Text("White markers show hits in distance order. Toggle the orange layer or nearest hit.") + Text(raycast.rayStatus) + SceneView(useSceneCameras: true, make: { configurePhysicsDemo(&$0, session: raycast) }, updateContent: { _, _ in }) + .frameCaptureLabel("Physics2D Raycasts") + .id(PhysicsDemoScene.raycast) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if scene == .largeWorld { + PhysicsLargeWorldDemoPane(session: largeWorld) + } else if scene == .advanced { + PhysicsAdvancedDemoPane(session: advanced) + } else if scene == .mechanisms { + PhysicsMechanismsDemoPane(session: mechanisms) + } else if scene == .characters { + PhysicsCharactersDemoPane(session: characters) + } else if scene == .queries { + PhysicsQueriesDemoPane(session: queries) + } else { + Button("Drop box again") { events.resetRequested = true }.accessibilityIdentifier("physics-demo.drop") + Text("Blue box crosses the green sensor freely. Orange box bounces on the floor.") + Text("Contacts: \(events.contactBegins) begin / \(events.contactEnds) end · Impacts: \(events.impacts)") + Text("Sensor: \(events.sensorBegins) enter / \(events.sensorEnds) exit · \(events.sensorOccupied ? "occupied" : "empty")") + SceneView(useSceneCameras: true, make: { configurePhysicsDemo(&$0, session: events) }, updateContent: { _, _ in }) + .frameCaptureLabel("Physics2D Events") + .id(PhysicsDemoScene.events) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .padding(18) + .background(Color(0.035, 0.055, 0.10, 1)) + .foregroundColor(.white) + } +} diff --git a/Examples/Technical/Physics2D/Physics2DLargeWorldChecks.swift b/Examples/Technical/Physics2D/Physics2DLargeWorldChecks.swift new file mode 100644 index 0000000..b08bb88 --- /dev/null +++ b/Examples/Technical/Physics2D/Physics2DLargeWorldChecks.swift @@ -0,0 +1,86 @@ +import AdaEngine + +extension Physics2DDemoMain { + static func validateLargeWorld(_ app: AppWorlds, _ session: PhysicsDemoSession) async throws { + let state = session.largeWorld + guard let physics = app.main.physicsWorld2D, let player = session.character, let hit = state.hit, + state.pointCount > 0, state.hinge?.components[PhysicsJoint2DComponent.self]?.isConnected == true else { throw Failure.largeWorld } + let before = hit.x + state.nudgeRequested = true + await advance(app, steps: 2) + guard let nudged = state.hit, nudged.x - before > 0.0009, nudged.x - before < 0.0011 else { throw Failure.largeWorld } + let contact = state.contactID + state.shifted = true + await advance(app, steps: 2) + guard physics.coordinateOrigin == state.base.offset(by: [1000, -500]), state.hit == nudged, + state.contactID == contact, player.components[CharacterController2DComponent.self]?.isGrounded == true else { throw Failure.largeWorld } + let start = player.components[PhysicsPosition2DComponent.self]?.position.x ?? 0 + state.direction = 1 + await advance(app, steps: 30) + state.direction = 0 + await advance(app, steps: 15) + guard (player.components[PhysicsPosition2DComponent.self]?.position.x ?? 0) > start + 0.1 else { throw Failure.largeWorld } + state.jumpRequested = true + await advance(app, steps: 1) + guard player.components[CharacterController2DComponent.self]?.isGrounded == false else { throw Failure.largeWorld } + await advance(app, steps: 90) + state.mode = .dynamic + await advance(app, steps: 60) + guard player.components[CharacterController2DComponent.self]?.isGrounded == true else { throw Failure.largeWorld } + state.jumpRequested = true + await advance(app, steps: 1) + guard player.components[CharacterController2DComponent.self]?.isGrounded == false else { throw Failure.largeWorld } + report("PASS: large worlds · exact queries / 1 mm nudge / origin shift / stable contacts and hinge / both character modes") + } + + static func verifyLargeWorld(to directory: String, tap: (String) throws -> Void) async throws { + let oldAdvanced = SceneViewFrameRegistry.latest(label: "Physics2D Advanced") + try tap("physics-demo.large-world") + let source = try await waitForSource("Physics2D Large worlds") + guard let session = source.world?.getResource(PhysicsDemoSession.self), let physics = source.world?.physicsWorld2D, + let player = session.character else { throw Failure.largeWorld } + let state = session.largeWorld + try await waitForGround(source, session) + guard let hit = state.hit, state.pointCount > 0 else { throw Failure.largeWorld } + let contact = state.contactID + try tap("physics-demo.large-nudge") + _ = try await nextFrames(source) + guard let nudged = state.hit, nudged.x - hit.x > 0.0009, nudged.x - hit.x < 0.0011 else { throw Failure.largeWorld } + let before = try await nextFrames(source) + try write(before.image, to: directory + "/large-world-before.png") + try tap("physics-demo.large-origin") + let shifted = try await nextFrames(source) + guard physics.coordinateOrigin == state.base.offset(by: [1000, -500]), state.hit == nudged, + state.contactID == contact, state.hinge?.components[PhysicsJoint2DComponent.self]?.isConnected == true else { throw Failure.largeWorld } + try write(shifted.image, to: directory + "/large-world-shifted.png") + let start = player.components[PhysicsPosition2DComponent.self]?.position.x ?? 0 + try tap("physics-demo.large-right") + _ = try await nextFrames(source, count: 30) + try tap("physics-demo.large-stop") + _ = try await nextFrames(source, count: 15) + guard (player.components[PhysicsPosition2DComponent.self]?.position.x ?? 0) > start + 0.1 else { throw Failure.largeWorld } + try tap("physics-demo.large-jump") + try await waitForJump(source, session) + try await waitForGround(source, session) + try tap("physics-demo.large-mode") + _ = try await nextFrames(source) + try await waitForGround(source, session) + guard hasCharacterCollider(source, session) else { throw Failure.largeWorld } + let dynamic = try await nextFrames(source) + try write(dynamic.image, to: directory + "/large-world-dynamic.png") + try tap("physics-demo.large-jump") + try await waitForJump(source, session) + try await waitForGround(source, session) + try tap("physics-demo.large-mode") + _ = try await nextFrames(source) + guard !hasCharacterCollider(source, session) else { throw Failure.largeWorld } + try tap("physics-demo.advanced") + _ = try await waitForSource("Physics2D Advanced", excluding: oldAdvanced) + try tap("physics-demo.large-world") + let rebuilt = try await waitForSource("Physics2D Large worlds", excluding: source) + guard let fresh = rebuilt.world?.getResource(PhysicsDemoSession.self), fresh.character !== player, + let freshPhysics = rebuilt.world?.physicsWorld2D, freshPhysics.coordinateOrigin == state.base else { throw Failure.largeWorld } + if let contact { guard !freshPhysics.isValid(contact) else { throw Failure.largeWorld } } + report("PASS: native large worlds · exact query / millimeter nudge / camera origin shift / contacts / both character jumps / re-entry") + } +} diff --git a/Examples/Technical/Physics2D/Physics2DLargeWorldDemo.swift b/Examples/Technical/Physics2D/Physics2DLargeWorldDemo.swift new file mode 100644 index 0000000..1bc7541 --- /dev/null +++ b/Examples/Technical/Physics2D/Physics2DLargeWorldDemo.swift @@ -0,0 +1,152 @@ +import AdaEngine +import Foundation +import Math +import Observation + +@MainActor +@Observable +final class PhysicsLargeWorldState { + let base = PhysicsPosition2D(x: 1_000_000_000.125, y: -999_999_999.75) + var shifted = false + var nudgeRequested = false + var mode = CharacterController2DMode.query + var direction: Float = 0 + var jumpRequested = false + var target: Entity? + var crate: Entity? + var hinge: Entity? + var marker: Entity? + var hit: PhysicsPosition2D? + var distance: Float = 0 + var probeStart = Vector2.zero + var probeEnd = Vector2.zero + var hitLocal: Vector2? + var contactID: Contact2DID? + var pointCount = 0 + var status = "Waiting for precise queries" +} + +@MainActor +func setupLargeWorld(in world: World, session: PhysicsDemoSession) throws { + let state = session.largeWorld + guard let physics = world.physicsWorld2D, physics.setCoordinateOrigin(state.base) else { + return + } + world.insertResource(PhysicsDebugOptions([.showPhysicsShapes, .showJoints])) + world.spawn("Global floor") { + Collision2DComponent(shapes: [.generateBox(width: 16, height: 0.4)]) + Sprite(tintColor: Color(0.25, 0.35, 0.5, 1), size: [16, 0.4]) + Transform() + PhysicsPosition2DComponent(state.base.offset(by: [0, -2.5])) + } + for x: Float in [-8, 8] { + world.spawn("Global boundary") { + Collision2DComponent(shapes: [.generateBox(width: 0.2, height: 4)]) + Transform() + PhysicsPosition2DComponent(state.base.offset(by: [x, -0.3])) + } + } + state.target = world.spawn("Millimeter query target") { + Collision2DComponent(shapes: [.generateBox(width: 0.4, height: 1.2)], filter: CollisionFilter(categoryBitMask: CollisionGroup(rawValue: 8))) + Sprite(tintColor: Color(0.2, 0.65, 1, 1), size: [0.4, 1.2]) + Transform() + PhysicsPosition2DComponent(state.base.offset(by: [3, 0.8])) + } + state.crate = world.spawn("Global contact box") { + PhysicsBody2DComponent(shapes: [.generateBox(width: 0.8, height: 0.8)], massProperties: .init(mass: 1, inertia: [0, 0, 1])) + Sprite(tintColor: Color(1, 0.6, 0.2, 1), size: [0.8, 0.8]) + Transform() + PhysicsPosition2DComponent(state.base.offset(by: [-1, -1.9])) + } + var bob = PhysicsBody2DComponent(shapes: [.generateCircle(radius: 0.8)], massProperties: .init(mass: 1, inertia: [0, 0, 0.2])) + bob.debugColor = Color(0.5, 1, 0.65, 1); bob.angularVelocity = 1 + let pendulum = world.spawn("Global hinge bob") { bob; Transform(); PhysicsPosition2DComponent(state.base.offset(by: [-3, 0.3])) } + let descriptor = try PhysicsJoint2DDescriptor.revolute(entity: pendulum, globalAnchor: state.base.offset(by: [-3, 2])) + state.hinge = world.spawn("Exact global pivot") { PhysicsJoint2DComponent(joint: descriptor) } + let controller = CharacterController2DComponent(radius: 0.3, height: 1.5) + session.character = world.spawn("Global character") { controller; Transform(); PhysicsPosition2DComponent(state.base.offset(by: [1, 0])) } + state.marker = world.spawn("Precise hit marker") { + Sprite(tintColor: Color(1, 0.85, 0.2, 1), size: [0.12, 0.12]) + Transform(position: [0, 0, 2]) + PhysicsPosition2DComponent(state.base.offset(by: [3, 0.8])) + } +} + +@MainActor +func controlLargeWorld(in world: World, session: PhysicsDemoSession) { + let state = session.largeWorld + let origin = state.shifted ? state.base.offset(by: [1000, -500]) : state.base + if world.physicsWorld2D?.coordinateOrigin != origin { world.physicsWorld2D?.setCoordinateOrigin(origin) } + if state.nudgeRequested, let target = state.target, let position = target.components[PhysicsPosition2DComponent.self]?.position { + target.components[PhysicsPosition2DComponent.self]?.position = .init(x: position.x + 0.001, y: position.y) + state.nudgeRequested = false + } + if let player = session.character { + if player.components[CharacterController2DComponent.self]?.mode != state.mode { + player.components[CharacterController2DComponent.self]?.mode = state.mode + player.components[CharacterController2DComponent.self]?.resetMotion = true + } + player.components[CharacterController2DComponent.self]?.desiredVelocity = [state.direction * 2, 0] + if state.jumpRequested { player.components[CharacterController2DComponent.self]?.jumpRequested = true; state.jumpRequested = false } + } +} + +@MainActor +func frameLargeWorld(in world: World, session: PhysicsDemoSession) { + let state = session.largeWorld + guard let physics = world.physicsWorld2D, let target = state.target, + let targetPosition = target.components[PhysicsPosition2DComponent.self]?.position else { + return + } + let start = state.base.offset(by: [-6, 0.8]) + let hit = physics.raycast(from: start, translation: [12, 0], query: .first, mask: CollisionGroup(rawValue: 8)).first + state.hit = hit?.point; state.distance = hit?.distance ?? 0 + state.probeStart = start.displacement(from: physics.coordinateOrigin) + state.probeEnd = start.offset(by: [12, 0]).displacement(from: physics.coordinateOrigin) + state.hitLocal = hit?.point.displacement(from: physics.coordinateOrigin) + if let marker = state.marker { + let position = hit?.point ?? state.base.offset(by: [0, 50]) + marker.components[PhysicsPosition2DComponent.self]?.position = position + marker.components[Transform.self]?.position = Vector3(position.displacement(from: physics.coordinateOrigin), 2) + } + let contact = state.crate.flatMap { physics.contacts(for: $0).first } + state.contactID = contact?.id; state.pointCount = contact?.manifold.points.count ?? 0 + let delta = targetPosition.x - state.base.x - 3 + let grounded = session.character?.components[CharacterController2DComponent.self]?.isGrounded == true + state.status = String( + format: "Global hit x %.6f · travel %.3f · target delta %.6f · %d contact points · %@ %@", + hit?.point.x ?? 0, + state.distance, + delta, + state.pointCount, + state.mode.rawValue, + grounded ? "grounded" : "airborne" + ) +} + +struct PhysicsLargeWorldDemoPane: View { + let session: PhysicsDemoSession + var body: some View { + let state = session.largeWorld + VStack(spacing: 10) { + HStack(spacing: 14) { + Button("Left") { state.direction = -1 }.accessibilityIdentifier("physics-demo.large-left") + Button("Stop") { state.direction = 0 }.accessibilityIdentifier("physics-demo.large-stop") + Button("Right") { state.direction = 1 }.accessibilityIdentifier("physics-demo.large-right") + Button("Jump") { state.jumpRequested = true }.accessibilityIdentifier("physics-demo.large-jump") + Button(state.mode == .query ? "Mode: Query" : "Mode: Dynamic") { state.mode = state.mode == .query ? .dynamic : .query } + .accessibilityIdentifier("physics-demo.large-mode") + Button(state.shifted ? "Restore render origin" : "Shift render origin 1 km") { state.shifted.toggle() } + .accessibilityIdentifier("physics-demo.large-origin") + Button("Nudge target 1 mm") { state.nudgeRequested = true }.accessibilityIdentifier("physics-demo.large-nudge") + } + Text("Exact physics at 1,000,000,000 units · Camera and tagged roots share the render frame · Yellow probe targets the blue pillar") + .font(.system(size: 12)) + Text(state.status).font(.system(size: 12)) + SceneView(useSceneCameras: true, make: { configurePhysicsDemo(&$0, session: session) }, updateContent: { _, _ in }) + .frameCaptureLabel("Physics2D Large worlds").id(PhysicsDemoScene.largeWorld) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/Examples/Technical/Physics2D/Physics2DMechanismsDemo.swift b/Examples/Technical/Physics2D/Physics2DMechanismsDemo.swift new file mode 100644 index 0000000..122199d --- /dev/null +++ b/Examples/Technical/Physics2D/Physics2DMechanismsDemo.swift @@ -0,0 +1,132 @@ +import AdaEngine +import Math + +@MainActor +func demoHinge(_ direction: Float) -> RevoluteJoint2DSettings { + .init(frameB: .init(position: [-0.7, 1]), limits: .init(lower: -0.7, upper: 0.7), motor: .init(speed: direction, maximumEffort: 20)) +} +@MainActor +func demoSlider(_ direction: Float) -> PrismaticJoint2DSettings { + .init(spring: .init(hertz: 2), limits: .init(lower: -0.6, upper: 0.6), motor: .init(speed: direction, maximumEffort: 80)) +} +@MainActor +func demoWheel(_ direction: Float) -> WheelJoint2DSettings { + .init(frameA: .init(angle: .pi / 2), limits: .init(lower: -0.8, upper: 0.25), motor: .init(speed: 3 * direction, maximumEffort: 5)) +} +@MainActor +func demoMotor(_ direction: Float) -> MotorJoint2DSettings { + .init(frameA: .init(position: [0.7, 0]), angularVelocity: direction, maximumVelocityForce: 0, maximumVelocityTorque: 3, linearSpring: .init(hertz: 2)) +} + +@MainActor +func setupMechanisms(in world: World, session: PhysicsDemoSession) throws { + world.insertResource(PhysicsDebugOptions([.showPhysicsShapes, .showJoints])) + world.physicsWorld2D?.isSleepEnabled = false + let positions: [(Vector2, Vector2)] = [ + ([-6, 2.6], [-5, 1.6]), ([0, 2.6], [0.7, 1.6]), ([5, 2.2], [5, 2.2]), + ([-6, 0.1], [-5, 0.1]), ([0, 0.1], [0, -0.4]), ([5, 0.1], [5, 0.1]), + ([-6, -2.2], [-6.7, -2.2]), ([0, -2.2], [0, -2.2]), ([5, -2.6], [5, -1.8]) + ] + for (index, pair) in positions.enumerated() { + let anchorShape = index == 6 ? Shape2DResource.generateBox(width: 1.2, height: 0.8) : .generateCircle(radius: 0.18) + var anchorBody = PhysicsBody2DComponent(shapes: [anchorShape], mass: 1, mode: .static) + anchorBody.debugColor = Color(0.65, 0.7, 0.8, 1) + let a = world.spawn("Anchor \(index)") { anchorBody; Transform(position: Vector3(pair.0, 0)) } + let shape: Shape2DResource + if index == 4 { + shape = .generateCircle(radius: 0.8) + } else if index == 8 { + shape = try .generateCapsule(from: [0, -0.25], to: [0, 0.25], radius: 0.18) + } else { + shape = .generateBox(width: 0.45, height: 0.45) + } + var body = PhysicsBody2DComponent(shapes: [shape], massProperties: .init(mass: 1, inertia: [0, 0, 0.1])) + body.debugColor = index == 0 ? Color(1, 0.57, 0.2, 1) : Color(0.3, 0.7, 1, 1) + if index == 6 { body.gravityScale = 0; body.linearVelocity = [0.5, 0] } + if index == 3 { body.linearVelocity = [0, 1]; body.angularVelocity = 1 } + let b = world.spawn("Mechanism body \(index)") { body; Transform(position: Vector3(pair.1, 0)) } + let descriptor: PhysicsJoint2DDescriptor + switch index { + case 0: descriptor = try PhysicsJoint2DDescriptor.rope(entityA: a, entityB: b).withBreakSettings(.init(forceThreshold: 40)) + case 1: descriptor = try .revolute(entityA: a, entityB: b, settings: demoHinge(1)) + case 2: descriptor = try .prismatic(entityA: a, entityB: b, settings: demoSlider(1)) + case 3: + descriptor = try .weld( + entityA: a, + entityB: b, + settings: .init(frameA: .init(position: [1, 0]), linearSpring: .init(hertz: 2, dampingRatio: 0.3), angularSpring: .init(hertz: 2)) + ) + case 4: descriptor = try .wheel(entityA: a, entityB: b, settings: demoWheel(1)) + case 5: descriptor = try .motor(entityA: a, entityB: b, settings: demoMotor(1)) + case 6: descriptor = try .filter(entityA: a, entityB: b) + case 7: + descriptor = try .mover(entityA: a, entityB: b, settings: .init(linearVelocity: [1, 0], maximumVelocityForce: [20, 20], collideConnected: false)) + default: descriptor = try .pogo(entityA: a, entityB: b, settings: .init(restLength: 0.8, maximumCompressionForce: 100)) + } + session.mechanismOwners.append(world.spawn("Joint \(index)") { PhysicsJoint2DComponent(joint: descriptor) }) + session.targets.append(b) + session.mechanismStarts.append(pair.1) + } +} + +@MainActor +func controlMechanisms(_ session: PhysicsDemoSession) { + guard session.targets.count == 9, session.mechanismOwners.count == 9 else { + return + } + if session.resetRequested { + session.resetRequested = false + for (index, entity) in session.targets.enumerated() { + entity.components[PhysicsBody2DComponent.self]?.setPosition(session.mechanismStarts[index], angle: .radians(0)) + entity.components[PhysicsBody2DComponent.self]?.linearVelocity = index == 6 ? [0.5, 0] : .zero + entity.components[PhysicsBody2DComponent.self]?.angularVelocity = 0 + session.mechanismOwners[index].components[PhysicsJoint2DComponent.self]?.reconnect() + } + return + } + if session.breakRequested { + session.breakRequested = false + session.targets[0].components[PhysicsBody2DComponent.self]?.applyForceToCenter([0, -5000], wake: true) + } + do { + var retuneMover = false + if session.reverseRequested { + session.reverseRequested = false; session.driveDirection *= -1; session.moverDirection *= -1 + try session.mechanismOwners[1].components[PhysicsJoint2DComponent.self]?.updateSettings(demoHinge(session.driveDirection)) + try session.mechanismOwners[2].components[PhysicsJoint2DComponent.self]?.updateSettings(demoSlider(session.driveDirection)) + try session.mechanismOwners[4].components[PhysicsJoint2DComponent.self]?.updateSettings(demoWheel(session.driveDirection)) + try session.mechanismOwners[5].components[PhysicsJoint2DComponent.self]?.updateSettings(demoMotor(session.driveDirection)) + retuneMover = true + } + let x = session.targets[7].components[Transform.self]?.position.x ?? 0 + if (x > 0.8 && session.moverDirection > 0) || (x < -0.8 && session.moverDirection < 0) { session.moverDirection *= -1; retuneMover = true } + if retuneMover { + try session.mechanismOwners[7].components[PhysicsJoint2DComponent.self]?.updateSettings( + MoverJoint2DSettings(linearVelocity: [session.moverDirection, 0], maximumVelocityForce: [20, 20], collideConnected: false) + ) + } + } catch { session.rayStatus = "Tuning failed: \(error)" } + let filter = session.targets[6] + let x = filter.components[Transform.self]?.position.x ?? -6 + let velocity = filter.components[PhysicsBody2DComponent.self]?.linearVelocity.x ?? 0.5 + if (x > -5.2 && velocity > 0) || (x < -6.8 && velocity < 0) { filter.components[PhysicsBody2DComponent.self]?.linearVelocity = [-velocity, 0] } +} + +struct PhysicsMechanismsDemoPane: View { + let session: PhysicsDemoSession + var body: some View { + VStack(spacing: 12) { + HStack(spacing: 18) { + Button("Reverse motors") { session.reverseRequested = true }.accessibilityIdentifier("physics-demo.reverse") + Button("Break rope") { session.breakRequested = true }.accessibilityIdentifier("physics-demo.break") + Button("Repair all") { session.resetRequested = true }.accessibilityIdentifier("physics-demo.repair") + } + Text("Top: rope / hinge / slider · middle: weld / wheel / motor · bottom: filter / mover / pogo") + Text(session.rayStatus) + SceneView(useSceneCameras: true, make: { configurePhysicsDemo(&$0, session: session) }, updateContent: { _, _ in }) + .frameCaptureLabel("Physics2D Mechanisms").id(PhysicsDemoScene.mechanisms) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/Examples/Technical/Physics2D/Physics2DQueriesDemo.swift b/Examples/Technical/Physics2D/Physics2DQueriesDemo.swift new file mode 100644 index 0000000..b277210 --- /dev/null +++ b/Examples/Technical/Physics2D/Physics2DQueriesDemo.swift @@ -0,0 +1,135 @@ +import AdaEngine +import Math + +@MainActor +enum PhysicsProbeMode: String, CaseIterable { + case cast = "Shape cast", overlap = "Capsule overlap", area = "Area candidates" + var next: Self { self == .cast ? .overlap : self == .overlap ? .area : .cast } +} + +@MainActor +func setupGeometryQueries(in world: World, session: PhysicsDemoSession) throws { + world.insertResource(PhysicsDebugOptions.showPhysicsShapes) + func add(_ name: String, _ shape: Shape2DResource, at position: Vector2 = .zero, layer: UInt64 = 1, sensor: Bool = false) { + var collision = Collision2DComponent(shapes: [shape], mode: sensor ? .trigger : .default, filter: .init(categoryBitMask: CollisionGroup(rawValue: layer))) + collision.debugColor = layer == 2 ? Color(1, 0.57, 0.2, 1) : sensor ? Color(0.2, 0.9, 0.5, 1) : Color(0.3, 0.65, 1, 1) + session.targets.append(world.spawn(name) { collision; Transform(position: Vector3(position, 0)) }) + } + add("Rounded box", try .generateRoundedBox(width: 1.8, height: 1.2, radius: 0.25), at: [-4, 1.6], layer: 2) + add("Capsule", try .generateCapsule(from: [0, -0.6], to: [0, 0.6], radius: 0.45), at: [0, 1.6]) + add("Sensor", .generateBox(width: 0.6, height: 2), at: [-2, 1.6], sensor: true) + add("Segment", try .generateSegment(from: [-0.8, -0.6], to: [0.8, 0.6]), at: [3.7, 1.6]) + add("Rounded triangle", try .generateRoundedPolygon(vertices: [[-0.6, -0.5], [0.6, -0.5], [0, 0.6]], radius: 0.15), at: [1.5, -0.6], layer: 2) + add("Open chain", try .generateChain(points: [[6, -1.6], [3, -2], [-1, -1.3], [-6, -2]])) + add("Loop", try .generateChain(points: [[-0.7, -0.5], [0.7, -0.5], [0.7, 0.5], [-0.7, 0.5]], isLoop: true), at: [5, -0.5]) + add("Loose chain segment", try .generateChainSegment(from: [1, 0], to: [-1, 0], ghostStart: [2, 0], ghostEnd: [-2, 0]), at: [-4, -0.6]) + session.queryShape = try .generateCapsule(from: [0, -0.25], to: [0, 0.25], radius: 0.2) +} + +@MainActor +func updateGeometryQueries(in world: World, session: PhysicsDemoSession) { + guard let physics = world.physicsWorld2D, let shape = session.queryShape else { + return + } + session.probeY = 1.6 + (session.scan ? Math.sin(session.elapsed * 0.7) : 0) + session.probeContact = nil + do { + let mask: CollisionGroup = session.blueOnly ? .default : .all + switch session.probeMode { + case .cast: + let hits = try physics.shapeCast( + shape, + from: Transform(position: [-7, session.probeY, 0]), + to: [7, session.probeY], + query: session.allHits ? .all : .first, + mask: mask, + includeSensors: session.querySensors + ) + session.queryEntities = hits.map(\.entity) + session.queryPoints = hits.map(\.point) + session.probeContact = hits.first.map { [-7 + $0.distance, session.probeY] } + case .overlap: + session.queryEntities = try physics.overlap(shape, at: Transform(position: [0, session.probeY, 0]), mask: mask, includeSensors: session.querySensors).map(\.entity) + session.queryPoints = session.queryEntities.compactMap { $0.components[Transform.self]?.position.xy } + case .area: + session.queryEntities = physics.overlap(in: Rect(x: -6, y: 0.5, width: 12, height: 2.2), mask: mask, includeSensors: session.querySensors).map(\.entity) + session.queryPoints = session.queryEntities.compactMap { $0.components[Transform.self]?.position.xy } + } + session.rayStatus = "\(session.probeMode.rawValue) · \(session.queryEntities.count) hits: " + session.queryEntities.map(\.name).joined(separator: " · ") + } catch { + session.queryEntities.removeAll(); session.queryPoints.removeAll() + session.rayStatus = "Query failed: \(error)" + } +} + +@System(dependencies: [.after(ExtractPhysicsDebugSystem.self)]) +@MainActor +func PhysicsDemoProbeExtract( + _ state: Extract>, + _ shapes: ResMut, + _ data: Res, + _ sorted: Res?> +) { + let session = state.wrappedValue.wrappedValue + if session.scene == .largeWorld { + let state = session.largeWorld + shapes.wrappedValue.addLine(from: state.probeStart, to: state.probeEnd, color: Color(1, 0.8, 0.2, 1)) + } + if session.scene == .characters || session.scene == .largeWorld { + if let character = session.character, let controller = character.components[CharacterController2DComponent.self], + controller.mode == .query, let bottom = character.components[Transform.self]?.position.xy { + shapes.wrappedValue.addCapsule( + from: bottom + [0, controller.settings.radius], + to: bottom + [0, controller.settings.height - controller.settings.radius], + radius: controller.settings.radius, + color: .white + ) + } + return + } + guard session.scene == .queries else { + return + } + if session.probeMode == .area { + let corners: [Vector2] = [[-6, 0.5], [6, 0.5], [6, 2.7], [-6, 2.7]] + for index in 0..<4 { shapes.wrappedValue.addLine(from: corners[index], to: corners[(index + 1) % 4], color: .white) } + } else { + let x: Float = session.probeMode == .cast ? -7 : 0 + shapes.wrappedValue.addCapsule(from: [x, session.probeY - 0.25], to: [x, session.probeY + 0.25], radius: 0.2, color: .white) + if session.probeMode == .cast { + shapes.wrappedValue.addLine(from: [-7, session.probeY], to: [7, session.probeY], color: Color(0.5, 0.6, 0.7, 1)) + if let contact = session.probeContact { + shapes.wrappedValue.addCapsule(from: contact - [0, 0.25], to: contact + [0, 0.25], radius: 0.2, color: Color(1, 0.85, 0.3, 1)) + } + } + } + session.debugDrawState = "lines=\(shapes.lines.count), vertices=\(data.wrappedValue?.lineVertexBuffer.count ?? -1), items=\(sorted.wrappedValue?.items.items.count ?? -1)" + for point in session.queryPoints { + shapes.wrappedValue.addLine(from: point - [0.08, 0], to: point + [0.08, 0], color: .white) + shapes.wrappedValue.addLine(from: point - [0, 0.08], to: point + [0, 0.08], color: .white) + } +} + +struct PhysicsQueriesDemoPane: View { + let session: PhysicsDemoSession + var body: some View { + VStack(spacing: 12) { + HStack(spacing: 14) { + Button(session.probeMode.rawValue) { session.probeMode = session.probeMode.next }.accessibilityIdentifier("physics-demo.query-kind") + Button(session.querySensors ? "Include sensors" : "Solids only") { session.querySensors.toggle() }.accessibilityIdentifier("physics-demo.query-sensors") + Button(session.blueOnly ? "Layer 1 only" : "All layers") { session.blueOnly.toggle() }.accessibilityIdentifier("physics-demo.query-mask") + Button(session.scan ? "Hold probe" : "Animate probe") { session.scan.toggle() }.accessibilityIdentifier("physics-demo.query-scan") + if session.probeMode == .cast { + Button(session.allHits ? "All hits" : "Nearest hit") { session.allHits.toggle() }.accessibilityIdentifier("physics-demo.query-order") + } + } + Text("White probe queries colored colliders. Area mode shows broad-phase AABB candidates.") + Text(session.rayStatus) + SceneView(useSceneCameras: true, make: { configurePhysicsDemo(&$0, session: session) }, updateContent: { _, _ in }) + .frameCaptureLabel("Physics2D Geometry & queries") + .id(PhysicsDemoScene.queries) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/Examples/Technical/Physics2D/README.md b/Examples/Technical/Physics2D/README.md new file mode 100644 index 0000000..2b85fa9 --- /dev/null +++ b/Examples/Technical/Physics2D/README.md @@ -0,0 +1,61 @@ +# Box2D playground + +Five interactive AdaEngine scenes, using the same physics systems as games: + +- **Raycasts:** a scanning ray, distance-ordered white hit markers, nearest/all + selection and a blue-only collision mask. The orange block is on another layer. +- **Collisions & sensors:** an orange box bounces on a floor while a blue box + crosses a green sensor without a physical response. Live counters distinguish + solid contacts, sensor enter/exit and impact events delivered through ECS. + “Drop box again” repeats the impact. + +- **Geometry & queries:** capsule, rounded box/polygon, segment, open/closed chains + and a loose chain segment. A white capsule sweeps or overlaps the colored + colliders; area mode draws broad-phase candidates. Toggle sensor inclusion, + layer mask, nearest/all and animation. The outlines follow native geometry. + +- **Mechanisms:** a 3x3 grid of rope, hinge, slider, weld, suspension wheel, + pose/velocity motor, pair filter, mover and pogo rigs. Reverse live motors, + overload the rope to emit a break event, and repair the connections. Pogo is + experimental in upstream Box2D 3.2. +- **Characters:** left/stop/right, jump, reset and query/dynamic mode controls. + The query capsule is virtual; the native dynamic capsule pushes the orange + crate. A moving platform and ramp exercise support and slope behavior. + +On macOS run `./Examples/Technical/Physics2D/script/build_and_run.sh`. Use `--verify` to launch +all scenes, exercise query/character/motor controls, check live event counters and +save real GPU frames plus `verification.json` in `Examples/Technical/Physics2D/dist/proof/`. + +Verification also checks both jumps, physical crate movement, native collider +cleanup after a mode switch, repair, and scene re-entry. GPU frames include +`geometry-cast.png`, `geometry-area.png`, `mechanisms.png`, +`character-query.png`, and `character-dynamic.png`. + +Deterministic simulation without a window: + +```sh +swift run --package-path Examples/Technical/Physics2D --scratch-path /tmp/adaengine-box2d-3.2-build Physics2DDemo --validate +``` + +## Query and event semantics + +`raycast(from:to:)` casts a finite segment and returns distance-sorted hits. +`.first` means nearest. Invalid/zero-length input and misses return an empty +array. Initial overlaps are ignored. `raycast(from: Ray, maxDistance:)` uses +its origin and normalized XY direction; the default range is 10,000 world units. +Masks select shape categories; the native Box2D query filter also applies. + +`CollisionEvents.Began` / `.Ended` include `isSensor`; for sensors `entityA` +is the sensor and the impulse is zero. `.Hit` contains the impact point, normal, +approach speed and the contact's total normal impulse for the current step. +Native shape pairs produce separate events, including compound bodies. +End events whose entities/shapes have already been destroyed are skipped. +A fast visitor swept through a sensor emits begin in that step and end in the next. + +Subscribe through `world.subscribe(to:)` for immediate delivery. `Events` +receives the same events through the world-local ECS buffer on the next frame. +The physics plugin installs the event lifecycle if it has not already been set up. + +The Advanced tab exercises live body settings, native CCD, conveyors, rolling and material mixing, wind/still-air drag, contact point snapshots and solver presets. `--validate` and native `--verify` include these controls; the latter writes `proof/advanced-physics.png` from the actual scene camera. + +The Large worlds tab demonstrates exact Double physics at 1e9, a one-millimeter target nudge, camera/render-origin shifts, stable contacts/global hinges and both character modes. Native `--verify` captures before/after origin frames and the dynamic character, and checks scene re-entry without stale contact handles. diff --git a/Examples/Technical/Physics2D/script/build_and_run.sh b/Examples/Technical/Physics2D/script/build_and_run.sh new file mode 100755 index 0000000..70232af --- /dev/null +++ b/Examples/Technical/Physics2D/script/build_and_run.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail +MODE="${1:-run}" +DEMO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPO_ROOT="$DEMO_ROOT" +BUILD_PATH="${ADAENGINE_PHYSICS2D_BUILD_PATH:-/tmp/adaengine-box2d-3.2-build}" +mkdir -p "$BUILD_PATH" +APP="$DEMO_ROOT/dist/Physics2DDemo.app" +export ADAENGINE_DISABLE_SWAN=1 +export ADAENGINE_SKIP_SWIFTLINT=1 +case "$MODE" in run|--verify|--debug|--logs|--telemetry) ;; *) echo "usage: $0 [run|--verify|--debug|--logs|--telemetry]" >&2; exit 2 ;; esac +for demo_pid in $(pgrep -f "$APP/Contents/MacOS/Physics2DDemo" || true); do + demo_command="$(ps -p "$demo_pid" -o command= || true)" + if [[ "$demo_command" == "$APP/Contents/MacOS/Physics2DDemo" || "$demo_command" == "$APP/Contents/MacOS/Physics2DDemo "* ]]; then + kill "$demo_pid" + fi +done +mkdir -p "$DEMO_ROOT/dist" +if ! swift build --package-path "$REPO_ROOT" --scratch-path "$BUILD_PATH" --disable-sandbox --product Physics2DDemo > "$DEMO_ROOT/dist/build.log" 2>&1; then + tail -60 "$DEMO_ROOT/dist/build.log" + exit 1 +fi +BIN="$(swift build --package-path "$REPO_ROOT" --scratch-path "$BUILD_PATH" --disable-sandbox --show-bin-path)" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +cp "$BIN/Physics2DDemo" "$APP/Contents/MacOS/" +for resource in "$BIN"/*.bundle; do + [[ -d "$resource" ]] && cp -R "$resource" "$APP/" +done +cat > "$APP/Contents/Info.plist" <<'PLIST' + + + +CFBundleExecutablePhysics2DDemo +CFBundleIdentifierorg.adaengine.demo.physics2d +CFBundleNameAdaEngine Box2D playground +CFBundlePackageTypeAPPL +LSMinimumSystemVersion15.0 +NSPrincipalClassNSApplication + +PLIST +: > "$DEMO_ROOT/dist/runtime.log" +rm -f "$DEMO_ROOT/dist/proof/verification.json" +if [[ "$MODE" == --debug ]]; then + /usr/bin/lldb -- "$APP/Contents/MacOS/Physics2DDemo" + exit +fi +OPEN_ARGS=(-n "$APP" --stdout "$DEMO_ROOT/dist/runtime.log" --stderr "$DEMO_ROOT/dist/runtime.log" --args) +if [[ "$MODE" == --verify ]]; then OPEN_ARGS+=(--verify --proof-directory "$DEMO_ROOT/dist/proof"); fi +/usr/bin/open "${OPEN_ARGS[@]}" +case "$MODE" in + --verify) + for attempt in {1..60}; do + if rg -q 'Physics2DDemo verification PASS' "$DEMO_ROOT/dist/runtime.log"; then + cat "$DEMO_ROOT/dist/runtime.log" + exit 0 + fi + if rg -q 'verification FAIL|Fatal error|BOX2D ASSERTION' "$DEMO_ROOT/dist/runtime.log"; then + tail -40 "$DEMO_ROOT/dist/runtime.log" + exit 1 + fi + sleep 1 + done + tail -40 "$DEMO_ROOT/dist/runtime.log" + echo 'Physics2DDemo verification timed out' >&2 + exit 1 + ;; + --logs|--telemetry) /usr/bin/log stream --info --style compact --predicate 'process == "Physics2DDemo"' ;; +esac diff --git a/Examples/Technical/ProceduralSkeleton/.ada/project.json b/Examples/Technical/ProceduralSkeleton/.ada/project.json new file mode 100644 index 0000000..3ac8032 --- /dev/null +++ b/Examples/Technical/ProceduralSkeleton/.ada/project.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 3, + "project": { + "name": "ProceduralSkeleton", + "displayName": "Skeleton — AdaScript rig and walk", + "id": "4b888882-6278-4d72-a10c-fcae68cb5e97", + "bundleIdentifier": "org.adaengine.proceduralskeleton" + }, + "paths": { "sources": "Sources", "assets": "Assets", "resourceRoots": ["Assets"] }, + "build": { "system": "adascript" }, + "runtime": { + "moduleName": "ProceduralSkeleton", + "entry": { "scene": "Assets/Scenes/Walk.ascn" }, + "plugins": { "preset": "game3d", "presetVersion": 1 } + }, + "editor": { "startupScene": "Assets/Scenes/Walk.ascn" }, + "inputActions": [] +} diff --git a/Examples/Technical/ProceduralSkeleton/.gitignore b/Examples/Technical/ProceduralSkeleton/.gitignore new file mode 100644 index 0000000..cee5c04 --- /dev/null +++ b/Examples/Technical/ProceduralSkeleton/.gitignore @@ -0,0 +1,8 @@ +.ada/* +!.ada/project.json +.builds/ +Evidence/ +References/ +__pycache__/ +Assets/Models/Skeleton-v*.glb +Assets/Models/Skeleton-Walk-samples.glb diff --git a/Examples/Technical/ProceduralSkeleton/Assets/Models/Skeleton-AdaScript.glb b/Examples/Technical/ProceduralSkeleton/Assets/Models/Skeleton-AdaScript.glb new file mode 100644 index 0000000..df9f50e Binary files /dev/null and b/Examples/Technical/ProceduralSkeleton/Assets/Models/Skeleton-AdaScript.glb differ diff --git a/Examples/Technical/ProceduralSkeleton/Assets/Models/Skeleton-Walk.glb b/Examples/Technical/ProceduralSkeleton/Assets/Models/Skeleton-Walk.glb new file mode 100644 index 0000000..a8b6e3f Binary files /dev/null and b/Examples/Technical/ProceduralSkeleton/Assets/Models/Skeleton-Walk.glb differ diff --git a/Examples/Technical/ProceduralSkeleton/Assets/Models/Skeleton.glb b/Examples/Technical/ProceduralSkeleton/Assets/Models/Skeleton.glb new file mode 100644 index 0000000..0912174 Binary files /dev/null and b/Examples/Technical/ProceduralSkeleton/Assets/Models/Skeleton.glb differ diff --git a/Examples/Technical/ProceduralSkeleton/Assets/Models/WalkStage.glb b/Examples/Technical/ProceduralSkeleton/Assets/Models/WalkStage.glb new file mode 100644 index 0000000..d2b7f28 Binary files /dev/null and b/Examples/Technical/ProceduralSkeleton/Assets/Models/WalkStage.glb differ diff --git a/Examples/Technical/ProceduralSkeleton/Assets/Scenes/Showcase.ascn b/Examples/Technical/ProceduralSkeleton/Assets/Scenes/Showcase.ascn new file mode 100644 index 0000000..37308a2 --- /dev/null +++ b/Examples/Technical/ProceduralSkeleton/Assets/Scenes/Showcase.ascn @@ -0,0 +1,248 @@ +format: ada.scene +schemaVersion: 1 +engineVersion: 1.0.0 +scene: + id: procedural-skeleton + name: Skeleton showcase +entities: +- id: camera + name: Camera + enabled: false + components: + AdaRender.Camera: + fieldOfViewDegrees: 36 + projection: perspective + backgroundColor: + green: 5.5e-2 + red: 4.5e-2 + blue: 7.5e-2 + alpha: 1 + isActive: true + AdaCorePipelines.Environment3D: + screenSpaceReflection: + maxSteps: 48 + edgeFade: 7.99999982118607e-2 + isEnabled: false + stride: 2.5e-1 + thickness: 1.80000007152557e-1 + maxDistance: 35 + intensity: 6.99999988079071e-1 + postProcess: + depthOfField: + isEnabled: false + maximumBlurRadius: 10 + aperture: 1 + focusDistance: 5 + bloom: + isEnabled: true + scatter: 6.99999988079071e-1 + intensity: 2.2e-1 + threshold: 8e-1 + softKnee: 5e-1 + exposureEV: 0 + toneMapper: aces + colorGrading: + lut: null + correctionMode: lut + saturation: 1 + contrast: 1 + lutIntensity: 1 + curves: + master: + points: + - output: 0 + input: 0 + - output: 1 + input: 1 + green: + points: + - output: 0 + input: 0 + - output: 1 + input: 1 + red: + points: + - input: 0 + output: 0 + - input: 1 + output: 1 + blue: + points: + - output: 0 + input: 0 + - output: 1 + input: 1 + vignette: + smoothness: 4.00000005960464e-1 + radius: 7.5e-1 + intensity: 0 + autoExposure: + minimumEV: -3 + maximumEV: 3 + isEnabled: false + speedDown: 2.5e+0 + speedUp: 1 + localShadows: + isEnabled: true + maximumLights: 4 + debugView: none + skybox: + horizonColor: + alpha: 1 + blue: 1.1e-1 + green: 8e-2 + red: 6e-2 + intensity: 1 + starfield: + size: 1 + isEnabled: false + density: 1.80000007152557e-1 + intensity: 2 + seed: 0 + zenithColor: + green: 3e-2 + alpha: 1 + blue: 4.5e-2 + red: 2.2e-2 + isEnabled: true + groundColor: + blue: 4.5e-2 + alpha: 1 + green: 3.5e-2 + red: 2.8e-2 + texture: null + AdaTransform.Transform: + rotation: + - 0 + - 0 + - 0 + - 1 + scale: + - 1 + - 1 + - 1 + position: + - 0 + - 8.5e-1 + - -2.25e+0 +- id: lighting + name: Studio lighting + enabled: true + components: + AdaTransform.Transform: + scale: + - 1 + - 1 + - 1 + rotation: + - 0 + - 0 + - 0 + - 1 + position: + - 0 + - 0 + - 0 +- id: sun + name: Warm key light + enabled: true + parent: lighting + components: + AdaTransform.Transform: + position: + - -2 + - 4 + - -3 + rotation: + - 3.2e-1 + - -2.4e-1 + - 0 + - 9.165e-1 + scale: + - 1 + - 1 + - 1 + AdaRender.Visibility: + value: visible + AdaScene.DirectionalLightComponent: + radiance: + - 1 + - 9.1e-1 + - 7.8e-1 + intensity: 3 + castShadows: true +- id: subject + name: Skeleton minion + enabled: true + components: + AdaScene.Model3DSource: + autoplay: false + repeats: true + source: '@res://Models/Skeleton.glb' + animation: '' + AdaTransform.Transform: + position: + - 0 + - 0 + - 0 + scale: + - 1 + - 1 + - 1 + rotation: + - 0 + - -2.41921895599668e-1 + - 0 + - 9.70295726275996e-1 +- id: fill + name: Cool fill light + enabled: true + parent: lighting + components: + AdaRender.Visibility: + value: visible + AdaScene.DirectionalLightComponent: + castShadows: false + intensity: 1.5e+0 + radiance: + - 6.6e-1 + - 7.9e-1 + - 1 + AdaTransform.Transform: + rotation: + - 1.8e-1 + - 9.2e-1 + - -2.5e-1 + - 2.4e-1 + scale: + - 1 + - 1 + - 1 + position: + - 2 + - 3 + - 2 +- id: 79B9B0A5-A526-42FD-BF49-2A2895257025 + name: Group + enabled: true + components: + AdaTransform.Transform: + rotation: + - 0e+0 + - 0e+0 + - 0e+0 + - 1e+0 + position: + - 0e+0 + - 0e+0 + - 0e+0 + scale: + - 1e+0 + - 1e+0 + - 1e+0 +editor: + selectedEntity: 79B9B0A5-A526-42FD-BF49-2A2895257025 + expandedEntities: + - lighting + - fill + - 79B9B0A5-A526-42FD-BF49-2A2895257025 diff --git a/Examples/Technical/ProceduralSkeleton/Assets/Scenes/Walk.ascn b/Examples/Technical/ProceduralSkeleton/Assets/Scenes/Walk.ascn new file mode 100644 index 0000000..a99211e --- /dev/null +++ b/Examples/Technical/ProceduralSkeleton/Assets/Scenes/Walk.ascn @@ -0,0 +1,271 @@ +format: ada.scene +schemaVersion: 1 +engineVersion: 1.0.0 +scene: + id: procedural-skeleton-walk + name: Skeleton walk +entities: +- id: camera + name: Camera + enabled: false + components: + AdaCorePipelines.Environment3D: + screenSpaceReflection: + edgeFade: 7.99999982118607e-2 + maxDistance: 35 + intensity: 6.99999988079071e-1 + maxSteps: 48 + thickness: 1.80000007152557e-1 + stride: 2.5e-1 + isEnabled: false + skybox: + starfield: + intensity: 2 + density: 1.80000007152557e-1 + isEnabled: false + seed: 0 + size: 1 + zenithColor: + green: 3e-2 + red: 2.2e-2 + alpha: 1 + blue: 4.5e-2 + groundColor: + red: 2.8e-2 + blue: 4.5e-2 + alpha: 1 + green: 3.5e-2 + intensity: 1 + isEnabled: true + horizonColor: + green: 8e-2 + red: 6e-2 + alpha: 1 + blue: 1.1e-1 + texture: null + postProcess: + vignette: + smoothness: 4.00000005960464e-1 + intensity: 0 + radius: 7.5e-1 + colorGrading: + saturation: 1 + contrast: 1 + lut: null + correctionMode: lut + lutIntensity: 1 + curves: + red: + points: + - output: 0 + input: 0 + - input: 1 + output: 1 + blue: + points: + - output: 0 + input: 0 + - output: 1 + input: 1 + green: + points: + - input: 0 + output: 0 + - input: 1 + output: 1 + master: + points: + - input: 0 + output: 0 + - input: 1 + output: 1 + bloom: + intensity: 2.2e-1 + scatter: 6.99999988079071e-1 + threshold: 8e-1 + softKnee: 5e-1 + isEnabled: true + toneMapper: aces + exposureEV: 0 + autoExposure: + speedUp: 1 + maximumEV: 3 + minimumEV: -3 + speedDown: 2.5e+0 + isEnabled: false + depthOfField: + isEnabled: false + focusDistance: 5 + maximumBlurRadius: 10 + aperture: 1 + debugView: none + localShadows: + isEnabled: true + maximumLights: 4 + AdaRender.Camera: + backgroundColor: + alpha: 1 + blue: 7.5e-2 + red: 4.5e-2 + green: 5.5e-2 + isActive: true + projection: perspective + fieldOfViewDegrees: 36 + AdaTransform.Transform: + position: + - 0 + - 8.5e-1 + - -2.25e+0 + scale: + - 1 + - 1 + - 1 + rotation: + - 0 + - 0 + - 0 + - 1 +- id: lighting + name: Studio lighting + enabled: true + components: + AdaTransform.Transform: + scale: + - 1 + - 1 + - 1 + position: + - 0 + - 0 + - 0 + rotation: + - 0 + - 0 + - 0 + - 1 +- id: sun + name: Warm key light + enabled: true + parent: lighting + components: + AdaScene.DirectionalLightComponent: + intensity: 3 + castShadows: true + radiance: + - 1 + - 9.1e-1 + - 7.8e-1 + AdaRender.Visibility: + value: visible + AdaTransform.Transform: + rotation: + - 3.2e-1 + - -2.4e-1 + - 0 + - 9.165e-1 + position: + - -2 + - 4 + - -3 + scale: + - 1 + - 1 + - 1 +- id: subject + name: Skeleton minion + enabled: true + components: + AdaScene.Model3DSource: + animation: Walk + repeats: true + source: '@res://Models/Skeleton-AdaScript.glb' + autoplay: true + AdaTransform.Transform: + rotation: + - 0 + - -2.41921895599668e-1 + - 0 + - 9.70295726275996e-1 + scale: + - 1 + - 1 + - 1 + position: + - 0 + - 0 + - 0 +- id: fill + name: Cool fill light + enabled: true + parent: lighting + components: + AdaScene.DirectionalLightComponent: + castShadows: false + radiance: + - 6.6e-1 + - 7.9e-1 + - 1 + intensity: 1.5e+0 + AdaTransform.Transform: + position: + - 2 + - 3 + - 2 + rotation: + - 1.8e-1 + - 9.2e-1 + - -2.5e-1 + - 2.4e-1 + scale: + - 1 + - 1 + - 1 + AdaRender.Visibility: + value: visible +- id: 79B9B0A5-A526-42FD-BF49-2A2895257025 + name: Group + enabled: true + components: + AdaTransform.Transform: + position: + - 0e+0 + - 0e+0 + - 0e+0 + rotation: + - 0e+0 + - 0e+0 + - 0e+0 + - 1e+0 + scale: + - 1e+0 + - 1e+0 + - 1e+0 +- id: walk-stage + name: Walk preview stage + enabled: true + components: + AdaTransform.Transform: + rotation: + - 0 + - 0 + - 0 + - 1 + scale: + - 1 + - 1 + - 1 + position: + - 0 + - 0 + - 0 + AdaScene.Model3DSource: + animation: '' + source: '@res://Models/WalkStage.glb' + autoplay: false + repeats: true +editor: + selectedEntity: 79B9B0A5-A526-42FD-BF49-2A2895257025 + expandedEntities: + - lighting + - fill + - 79B9B0A5-A526-42FD-BF49-2A2895257025 diff --git a/Examples/Technical/ProceduralSkeleton/DESIGN.md b/Examples/Technical/ProceduralSkeleton/DESIGN.md new file mode 100644 index 0000000..384adb9 --- /dev/null +++ b/Examples/Technical/ProceduralSkeleton/DESIGN.md @@ -0,0 +1,16 @@ +# Implementation map + +- `Generators/Skeleton-Rig.ada`: complete interpreted recipe. +- `Generators/SkeletonRigRecipe.ada`: joints, semantic weights, two-bone IK, keys. +- `Generators/Skeleton.ada`, `author_recipe.py`: generated static geometry. +- `Assets/Scenes/Walk.ascn`: default native playback using Skeleton-AdaScript.glb. +- `Assets/Scenes/Showcase.ascn`: original static comparison. +- `script_rig.py`: optional developer harness; ADAENGINE_ROOT selects engine sources. +- `NativeRig/`, `native_rig.py`: previous Swift baseline for comparison only. +- `Validation/`: real importer/player acceptance and standalone harness adapter. +- `check_geometry.py`, `check_rig.py`: independent checks of actual GLB buffers. +- `rig_capture.py`: native playback capture; outputs remain ignored locally. + +The example is not registered in catalog.json. The public validator rejects its +technical section, keeping Studio/website recommendations and release archives +unchanged. Units are meters, Y is up, and the character faces -Z. diff --git a/Examples/Technical/ProceduralSkeleton/Generators/Skeleton-Rig.ada b/Examples/Technical/ProceduralSkeleton/Generators/Skeleton-Rig.ada new file mode 100644 index 0000000..b63f1e2 --- /dev/null +++ b/Examples/Technical/ProceduralSkeleton/Generators/Skeleton-Rig.ada @@ -0,0 +1,1103 @@ +// Geometry, rig, weights and walk are authored by this interpreted AdaScript recipe. +// Generated by author_recipe.py; run directly through editor.model.generate. +// Units: meters. Front: -Z. Base geometry; the rig recipe follows below. +func part(m, p, parent, mat, name, x, y, z) { + m.parent(p, parent); + m.paint(p, mat); + m.name(p, name); + m.place(p, x, y, z); + return p; +} + +func buildStaticSkeleton(m) { + m.material(0, 0.73, 0.63, 0.43); + m.surface(0, 0, 0.85); + m.material(1, 0.94, 0.83, 0.59); + m.surface(1, 0, 0.76); + m.material(2, 0.12, 0.065, 0.025); + m.surface(2, 0, 0.95); + m.material(3, 0.56, 0.25, 0.022); + m.surface(3, 0, 0.82); + m.material(4, 1, 0.65, 0.015); + m.surface(4, 0, 0.35); + m.material(5, 0.46, 0.16, 0.085); + m.surface(5, 0, 0.95); + m.material(6, 0.24, 0.075, 0.038); + m.surface(6, 0, 0.95); + m.material(7, 0.078, 0.067, 0.058); + m.surface(7, 0, 0.95); + m.material(8, 0.24, 0.32, 0.35); + m.surface(8, 0.6, 0.55); + m.material(9, 0.39, 0.17, 0.08); + m.surface(9, 0, 0.9); + m.material(10, 0.55, 0.64, 0.64); + m.surface(10, 0.5, 0.48); + m.emissive(4, 1, 0.65, 0.008); + var g1 = m.group(); + m.name(g1, "Skeleton minion"); + var g2 = m.group(); + m.name(g2, "Head"); + m.parent(g2, g1); + m.vertex(-0.22, 1.7, -0.265); + m.vertex(0.22, 1.7, -0.265); + m.vertex(0.46, 1.55, -0.265); + m.vertex(0.47, 1.25, -0.265); + m.vertex(0.37, 1.115, -0.265); + m.vertex(0.19, 1.06, -0.265); + m.vertex(-0.19, 1.06, -0.265); + m.vertex(-0.37, 1.115, -0.265); + m.vertex(-0.47, 1.25, -0.265); + m.vertex(-0.46, 1.55, -0.265); + m.vertex(-0.2024, 1.684, 0.19); + m.vertex(0.2024, 1.684, 0.19); + m.vertex(0.4232, 1.5415, 0.19); + m.vertex(0.4324, 1.2565, 0.19); + m.vertex(0.3404, 1.12825, 0.19); + m.vertex(0.1748, 1.076, 0.19); + m.vertex(-0.1748, 1.076, 0.19); + m.vertex(-0.3404, 1.12825, 0.19); + m.vertex(-0.4324, 1.2565, 0.19); + m.vertex(-0.4232, 1.5415, 0.19); + m.vertex(-0.1254, 1.5912, 0.29); + m.vertex(0.1254, 1.5912, 0.29); + m.vertex(0.2622, 1.4922, 0.29); + m.vertex(0.2679, 1.2942, 0.29); + m.vertex(0.2109, 1.2051, 0.29); + m.vertex(0.1083, 1.1688, 0.29); + m.vertex(-0.1083, 1.1688, 0.29); + m.vertex(-0.2109, 1.2051, 0.29); + m.vertex(-0.2679, 1.2942, 0.29); + m.vertex(-0.2622, 1.4922, 0.29); + m.vertex(0, 1.38, 0.31); + m.triangle(0, 10, 1); + m.triangle(1, 10, 11); + m.triangle(1, 11, 2); + m.triangle(2, 11, 12); + m.triangle(2, 12, 3); + m.triangle(3, 12, 13); + m.triangle(3, 13, 4); + m.triangle(4, 13, 14); + m.triangle(4, 14, 5); + m.triangle(5, 14, 15); + m.triangle(5, 15, 6); + m.triangle(6, 15, 16); + m.triangle(6, 16, 7); + m.triangle(7, 16, 17); + m.triangle(7, 17, 8); + m.triangle(8, 17, 18); + m.triangle(8, 18, 9); + m.triangle(9, 18, 19); + m.triangle(9, 19, 0); + m.triangle(0, 19, 10); + m.triangle(10, 20, 11); + m.triangle(11, 20, 21); + m.triangle(11, 21, 12); + m.triangle(12, 21, 22); + m.triangle(12, 22, 13); + m.triangle(13, 22, 23); + m.triangle(13, 23, 14); + m.triangle(14, 23, 24); + m.triangle(14, 24, 15); + m.triangle(15, 24, 25); + m.triangle(15, 25, 16); + m.triangle(16, 25, 26); + m.triangle(16, 26, 17); + m.triangle(17, 26, 27); + m.triangle(17, 27, 18); + m.triangle(18, 27, 28); + m.triangle(18, 28, 19); + m.triangle(19, 28, 29); + m.triangle(19, 29, 10); + m.triangle(10, 29, 20); + m.triangle(20, 30, 21); + m.triangle(21, 30, 22); + m.triangle(22, 30, 23); + m.triangle(23, 30, 24); + m.triangle(24, 30, 25); + m.triangle(25, 30, 26); + m.triangle(26, 30, 27); + m.triangle(27, 30, 28); + m.triangle(28, 30, 29); + m.triangle(29, 30, 20); + var p3 = part(m, m.mesh(), g2, 1, "Faceted cranium shell", 0, 0, 0); + m.vertex(-0.22, 1.7, -0.265); + m.vertex(0, 1.7, -0.265); + m.vertex(0, 1.38, -0.265); + m.vertex(0, 1.1, -0.265); + m.vertex(-0.19, 1.06, -0.265); + m.vertex(-0.37, 1.115, -0.265); + m.vertex(-0.47, 1.25, -0.265); + m.vertex(-0.46, 1.55, -0.265); + m.vertex(-0.235, 1.525, -0.295); + m.vertex(-0.1254, 1.47814, -0.295); + m.vertex(-0.08, 1.365, -0.295); + m.vertex(-0.1254, 1.25186, -0.295); + m.vertex(-0.235, 1.205, -0.295); + m.vertex(-0.3446, 1.25186, -0.295); + m.vertex(-0.39, 1.365, -0.295); + m.vertex(-0.3446, 1.47814, -0.295); + m.triangle(0, 1, 8); + m.triangle(1, 9, 8); + m.triangle(1, 2, 9); + m.triangle(2, 10, 9); + m.triangle(2, 3, 10); + m.triangle(3, 11, 10); + m.triangle(3, 4, 11); + m.triangle(4, 12, 11); + m.triangle(4, 5, 12); + m.triangle(5, 13, 12); + m.triangle(5, 6, 13); + m.triangle(6, 14, 13); + m.triangle(6, 7, 14); + m.triangle(7, 15, 14); + m.triangle(7, 0, 15); + m.triangle(0, 8, 15); + var p4 = part(m, m.mesh(), g2, 1, "Left orbital ivory rim", 0, 0, 0); + m.vertex(-0.235, 1.525, -0.295); + m.vertex(-0.1254, 1.47814, -0.295); + m.vertex(-0.08, 1.365, -0.295); + m.vertex(-0.1254, 1.25186, -0.295); + m.vertex(-0.235, 1.205, -0.295); + m.vertex(-0.3446, 1.25186, -0.295); + m.vertex(-0.39, 1.365, -0.295); + m.vertex(-0.3446, 1.47814, -0.295); + m.vertex(-0.235, 1.4802, -0.17); + m.vertex(-0.15609, 1.44646, -0.17); + m.vertex(-0.1234, 1.365, -0.17); + m.vertex(-0.15609, 1.28354, -0.17); + m.vertex(-0.235, 1.2498, -0.17); + m.vertex(-0.31391, 1.28354, -0.17); + m.vertex(-0.3466, 1.365, -0.17); + m.vertex(-0.31391, 1.44646, -0.17); + m.triangle(0, 1, 8); + m.triangle(1, 9, 8); + m.triangle(1, 2, 9); + m.triangle(2, 10, 9); + m.triangle(2, 3, 10); + m.triangle(3, 11, 10); + m.triangle(3, 4, 11); + m.triangle(4, 12, 11); + m.triangle(4, 5, 12); + m.triangle(5, 13, 12); + m.triangle(5, 6, 13); + m.triangle(6, 14, 13); + m.triangle(6, 7, 14); + m.triangle(7, 15, 14); + m.triangle(7, 0, 15); + m.triangle(0, 8, 15); + var p5 = part(m, m.mesh(), g2, 3, "Left recessed socket walls", 0, 0, 0); + m.vertex(-0.235, 1.4802, -0.17); + m.vertex(-0.15609, 1.44646, -0.17); + m.vertex(-0.1234, 1.365, -0.17); + m.vertex(-0.15609, 1.28354, -0.17); + m.vertex(-0.235, 1.2498, -0.17); + m.vertex(-0.31391, 1.28354, -0.17); + m.vertex(-0.3466, 1.365, -0.17); + m.vertex(-0.31391, 1.44646, -0.17); + m.triangle(0, 1, 2); + m.triangle(0, 2, 3); + m.triangle(0, 3, 4); + m.triangle(0, 4, 5); + m.triangle(0, 5, 6); + m.triangle(0, 6, 7); + var p6 = part(m, m.mesh(), g2, 2, "Left socket shadow", 0, 0, 0); + var p7 = part(m, m.sphere(1, 10), g2, 4, "Left ember eye", -0.235, 1.36, -0.205); + m.scale(p7, 0.061, 0.084, 0.043); + m.vertex(0.22, 1.7, -0.265); + m.vertex(0, 1.7, -0.265); + m.vertex(0, 1.38, -0.265); + m.vertex(0, 1.1, -0.265); + m.vertex(0.19, 1.06, -0.265); + m.vertex(0.37, 1.115, -0.265); + m.vertex(0.47, 1.25, -0.265); + m.vertex(0.46, 1.55, -0.265); + m.vertex(0.235, 1.525, -0.295); + m.vertex(0.1254, 1.47814, -0.295); + m.vertex(0.08, 1.365, -0.295); + m.vertex(0.1254, 1.25186, -0.295); + m.vertex(0.235, 1.205, -0.295); + m.vertex(0.3446, 1.25186, -0.295); + m.vertex(0.39, 1.365, -0.295); + m.vertex(0.3446, 1.47814, -0.295); + m.triangle(0, 8, 1); + m.triangle(1, 8, 9); + m.triangle(1, 9, 2); + m.triangle(2, 9, 10); + m.triangle(2, 10, 3); + m.triangle(3, 10, 11); + m.triangle(3, 11, 4); + m.triangle(4, 11, 12); + m.triangle(4, 12, 5); + m.triangle(5, 12, 13); + m.triangle(5, 13, 6); + m.triangle(6, 13, 14); + m.triangle(6, 14, 7); + m.triangle(7, 14, 15); + m.triangle(7, 15, 0); + m.triangle(0, 15, 8); + var p8 = part(m, m.mesh(), g2, 1, "Right orbital ivory rim", 0, 0, 0); + m.vertex(0.235, 1.525, -0.295); + m.vertex(0.1254, 1.47814, -0.295); + m.vertex(0.08, 1.365, -0.295); + m.vertex(0.1254, 1.25186, -0.295); + m.vertex(0.235, 1.205, -0.295); + m.vertex(0.3446, 1.25186, -0.295); + m.vertex(0.39, 1.365, -0.295); + m.vertex(0.3446, 1.47814, -0.295); + m.vertex(0.235, 1.4802, -0.17); + m.vertex(0.15609, 1.44646, -0.17); + m.vertex(0.1234, 1.365, -0.17); + m.vertex(0.15609, 1.28354, -0.17); + m.vertex(0.235, 1.2498, -0.17); + m.vertex(0.31391, 1.28354, -0.17); + m.vertex(0.3466, 1.365, -0.17); + m.vertex(0.31391, 1.44646, -0.17); + m.triangle(0, 8, 1); + m.triangle(1, 8, 9); + m.triangle(1, 9, 2); + m.triangle(2, 9, 10); + m.triangle(2, 10, 3); + m.triangle(3, 10, 11); + m.triangle(3, 11, 4); + m.triangle(4, 11, 12); + m.triangle(4, 12, 5); + m.triangle(5, 12, 13); + m.triangle(5, 13, 6); + m.triangle(6, 13, 14); + m.triangle(6, 14, 7); + m.triangle(7, 14, 15); + m.triangle(7, 15, 0); + m.triangle(0, 15, 8); + var p9 = part(m, m.mesh(), g2, 3, "Right recessed socket walls", 0, 0, 0); + m.vertex(0.235, 1.4802, -0.17); + m.vertex(0.15609, 1.44646, -0.17); + m.vertex(0.1234, 1.365, -0.17); + m.vertex(0.15609, 1.28354, -0.17); + m.vertex(0.235, 1.2498, -0.17); + m.vertex(0.31391, 1.28354, -0.17); + m.vertex(0.3466, 1.365, -0.17); + m.vertex(0.31391, 1.44646, -0.17); + m.triangle(0, 2, 1); + m.triangle(0, 3, 2); + m.triangle(0, 4, 3); + m.triangle(0, 5, 4); + m.triangle(0, 6, 5); + m.triangle(0, 7, 6); + var p10 = part(m, m.mesh(), g2, 2, "Right socket shadow", 0, 0, 0); + var p11 = part(m, m.sphere(1, 10), g2, 4, "Right ember eye", 0.235, 1.36, -0.205); + m.scale(p11, 0.061, 0.084, 0.043); + m.vertex(-0.065, 1.145, -0.288); + m.vertex(0.065, 1.145, -0.288); + m.vertex(0, 1.285, -0.288); + m.vertex(-0.065, 1.145, -0.22); + m.vertex(0.065, 1.145, -0.22); + m.vertex(0, 1.285, -0.22); + m.triangle(0, 2, 1); + m.triangle(3, 4, 5); + m.triangle(0, 1, 3); + m.triangle(1, 4, 3); + m.triangle(1, 2, 4); + m.triangle(2, 5, 4); + m.triangle(2, 0, 5); + m.triangle(0, 3, 5); + var p12 = part(m, m.mesh(), g2, 2, "Nasal cavity", 0, 0, 0); + m.vertex(-0.075, 1.135, -0.29); + m.vertex(-0.065, 1.145, -0.29); + m.vertex(0, 1.17, -0.295); + m.vertex(0.065, 1.145, -0.29); + m.vertex(0.075, 1.135, -0.29); + m.triangle(0, 1, 2); + m.triangle(2, 3, 4); + var p13 = part(m, m.mesh(), g2, 0, "Nasal bevel", 0, 0, 0); + m.vertex(-0.322, 1.045, -0.27); + m.vertex(-0.258, 1.01, -0.27); + m.vertex(-0.221, 1.045, -0.27); + m.vertex(-0.209, 1.14, -0.27); + m.vertex(-0.331, 1.14, -0.27); + m.vertex(-0.322, 1.045, -0.12); + m.vertex(-0.258, 1.01, -0.12); + m.vertex(-0.221, 1.045, -0.12); + m.vertex(-0.209, 1.14, -0.12); + m.vertex(-0.331, 1.14, -0.12); + m.triangle(0, 2, 1); + m.triangle(5, 6, 7); + m.triangle(0, 3, 2); + m.triangle(5, 7, 8); + m.triangle(0, 4, 3); + m.triangle(5, 8, 9); + m.triangle(0, 1, 5); + m.triangle(1, 6, 5); + m.triangle(1, 2, 6); + m.triangle(2, 7, 6); + m.triangle(2, 3, 7); + m.triangle(3, 8, 7); + m.triangle(3, 4, 8); + m.triangle(4, 9, 8); + m.triangle(4, 0, 9); + m.triangle(0, 5, 9); + var p14 = part(m, m.mesh(), g2, 1, "Upper tooth 1", 0, 0, 0); + m.vertex(-0.187, 1.045, -0.27); + m.vertex(-0.123, 1.01, -0.27); + m.vertex(-0.086, 1.045, -0.27); + m.vertex(-0.074, 1.14, -0.27); + m.vertex(-0.196, 1.14, -0.27); + m.vertex(-0.187, 1.045, -0.12); + m.vertex(-0.123, 1.01, -0.12); + m.vertex(-0.086, 1.045, -0.12); + m.vertex(-0.074, 1.14, -0.12); + m.vertex(-0.196, 1.14, -0.12); + m.triangle(0, 2, 1); + m.triangle(5, 6, 7); + m.triangle(0, 3, 2); + m.triangle(5, 7, 8); + m.triangle(0, 4, 3); + m.triangle(5, 8, 9); + m.triangle(0, 1, 5); + m.triangle(1, 6, 5); + m.triangle(1, 2, 6); + m.triangle(2, 7, 6); + m.triangle(2, 3, 7); + m.triangle(3, 8, 7); + m.triangle(3, 4, 8); + m.triangle(4, 9, 8); + m.triangle(4, 0, 9); + m.triangle(0, 5, 9); + var p15 = part(m, m.mesh(), g2, 1, "Upper tooth 2", 0, 0, 0); + m.vertex(-0.052, 1.045, -0.27); + m.vertex(0.012, 1.01, -0.27); + m.vertex(0.049, 1.045, -0.27); + m.vertex(0.061, 1.14, -0.27); + m.vertex(-0.061, 1.14, -0.27); + m.vertex(-0.052, 1.045, -0.12); + m.vertex(0.012, 1.01, -0.12); + m.vertex(0.049, 1.045, -0.12); + m.vertex(0.061, 1.14, -0.12); + m.vertex(-0.061, 1.14, -0.12); + m.triangle(0, 2, 1); + m.triangle(5, 6, 7); + m.triangle(0, 3, 2); + m.triangle(5, 7, 8); + m.triangle(0, 4, 3); + m.triangle(5, 8, 9); + m.triangle(0, 1, 5); + m.triangle(1, 6, 5); + m.triangle(1, 2, 6); + m.triangle(2, 7, 6); + m.triangle(2, 3, 7); + m.triangle(3, 8, 7); + m.triangle(3, 4, 8); + m.triangle(4, 9, 8); + m.triangle(4, 0, 9); + m.triangle(0, 5, 9); + var p16 = part(m, m.mesh(), g2, 1, "Upper tooth 3", 0, 0, 0); + m.vertex(0.083, 1.045, -0.27); + m.vertex(0.147, 1.01, -0.27); + m.vertex(0.184, 1.045, -0.27); + m.vertex(0.196, 1.14, -0.27); + m.vertex(0.074, 1.14, -0.27); + m.vertex(0.083, 1.045, -0.12); + m.vertex(0.147, 1.01, -0.12); + m.vertex(0.184, 1.045, -0.12); + m.vertex(0.196, 1.14, -0.12); + m.vertex(0.074, 1.14, -0.12); + m.triangle(0, 2, 1); + m.triangle(5, 6, 7); + m.triangle(0, 3, 2); + m.triangle(5, 7, 8); + m.triangle(0, 4, 3); + m.triangle(5, 8, 9); + m.triangle(0, 1, 5); + m.triangle(1, 6, 5); + m.triangle(1, 2, 6); + m.triangle(2, 7, 6); + m.triangle(2, 3, 7); + m.triangle(3, 8, 7); + m.triangle(3, 4, 8); + m.triangle(4, 9, 8); + m.triangle(4, 0, 9); + m.triangle(0, 5, 9); + var p17 = part(m, m.mesh(), g2, 1, "Upper tooth 4", 0, 0, 0); + m.vertex(0.218, 1.045, -0.27); + m.vertex(0.282, 1.01, -0.27); + m.vertex(0.319, 1.045, -0.27); + m.vertex(0.331, 1.14, -0.27); + m.vertex(0.209, 1.14, -0.27); + m.vertex(0.218, 1.045, -0.12); + m.vertex(0.282, 1.01, -0.12); + m.vertex(0.319, 1.045, -0.12); + m.vertex(0.331, 1.14, -0.12); + m.vertex(0.209, 1.14, -0.12); + m.triangle(0, 2, 1); + m.triangle(5, 6, 7); + m.triangle(0, 3, 2); + m.triangle(5, 7, 8); + m.triangle(0, 4, 3); + m.triangle(5, 8, 9); + m.triangle(0, 1, 5); + m.triangle(1, 6, 5); + m.triangle(1, 2, 6); + m.triangle(2, 7, 6); + m.triangle(2, 3, 7); + m.triangle(3, 8, 7); + m.triangle(3, 4, 8); + m.triangle(4, 9, 8); + m.triangle(4, 0, 9); + m.triangle(0, 5, 9); + var p18 = part(m, m.mesh(), g2, 1, "Upper tooth 5", 0, 0, 0); + var g19 = m.group(); + m.name(g19, "Lower jaw"); + m.parent(g19, g2); + m.vertex(-0.07, 0.925, -0.2); + m.vertex(0.045, 0.91, -0.2); + m.vertex(0.1, 0.945, -0.2); + m.vertex(0.12, 1.016, -0.2); + m.vertex(-0.12, 1.016, -0.2); + m.vertex(-0.07, 0.925, -0.07); + m.vertex(0.045, 0.91, -0.07); + m.vertex(0.1, 0.945, -0.07); + m.vertex(0.12, 1.016, -0.07); + m.vertex(-0.12, 1.016, -0.07); + m.triangle(0, 2, 1); + m.triangle(5, 6, 7); + m.triangle(0, 3, 2); + m.triangle(5, 7, 8); + m.triangle(0, 4, 3); + m.triangle(5, 8, 9); + m.triangle(0, 1, 5); + m.triangle(1, 6, 5); + m.triangle(1, 2, 6); + m.triangle(2, 7, 6); + m.triangle(2, 3, 7); + m.triangle(3, 8, 7); + m.triangle(3, 4, 8); + m.triangle(4, 9, 8); + m.triangle(4, 0, 9); + m.triangle(0, 5, 9); + var p20 = part(m, m.mesh(), g19, 0, "Chin", 0, 0, 0); + var p21 = part(m, m.cylinder(0.0272, 0.032, 0.1995, 7), g19, 1, "Jaw hinge", -0.185, 1.03, -0.065); + m.rotate(p21, 32.73523, 0, 33.46175); + var p22 = part(m, m.cylinder(0.0272, 0.032, 0.1995, 7), g19, 1, "Jaw hinge", 0.185, 1.03, -0.065); + m.rotate(p22, 32.73523, 0, -33.46175); + m.vertex(-0.06, 0.94, -0.205); + m.vertex(0.065, 0.925, -0.205); + m.vertex(0.04, 0.98, -0.205); + m.vertex(-0.1, 1.005, -0.205); + m.vertex(-0.06, 0.94, -0.2); + m.vertex(0.065, 0.925, -0.2); + m.vertex(0.04, 0.98, -0.2); + m.vertex(-0.1, 1.005, -0.2); + m.triangle(0, 2, 1); + m.triangle(4, 5, 6); + m.triangle(0, 3, 2); + m.triangle(4, 6, 7); + m.triangle(0, 1, 4); + m.triangle(1, 5, 4); + m.triangle(1, 2, 5); + m.triangle(2, 6, 5); + m.triangle(2, 3, 6); + m.triangle(3, 7, 6); + m.triangle(3, 0, 7); + m.triangle(0, 4, 7); + var p23 = part(m, m.mesh(), g19, 1, "Chin light facet", 0, 0, 0); + var g24 = m.group(); + m.name(g24, "Torso"); + m.parent(g24, g1); + var p25 = part(m, m.cylinder(0.02975, 0.035, 0.61, 7), g24, 0, "Spine", 0, 0.705, 0.055); + m.rotate(p25, 0, 0, 0); + var p26 = part(m, m.sphere(1, 8), g24, 0, "Neck joint", 0, 1.01, 0); + m.scale(p26, 0.07, 0.06, 0.065); + var g27 = m.group(); + m.name(g27, "Rib cage row 1"); + m.parent(g27, g24); + var p28 = part(m, m.cylinder(0.01955, 0.023, 0.10374, 7), g27, 1, "Rib 1 segment -1 0", 0.05905, 0.55285, -0.12201); + m.rotate(p28, -74.93151, 0, 80.90257); + var p29 = part(m, m.cylinder(0.01955, 0.023, 0.10483, 7), g27, 1, "Rib 1 segment -1 1", -0.04422, 0.55338, -0.12399); + m.rotate(p29, 105.06849, 0, 83.27063); + var p30 = part(m, m.cylinder(0.01955, 0.023, 0.09196, 7), g27, 1, "Rib 1 segment -1 2", -0.13806, 0.5468, -0.09955); + m.rotate(p30, 105.06849, 0, 65.35156); + var p31 = part(m, m.cylinder(0.01955, 0.023, 0.07218, 7), g27, 1, "Rib 1 segment -1 3", -0.20247, 0.53451, -0.05388); + m.rotate(p31, 105.06849, 0, 38.80903); + var p32 = part(m, m.cylinder(0.01955, 0.023, 0.0622, 7), g27, 1, "Rib 1 segment -1 4", -0.22371, 0.51912, 0.00328); + m.rotate(p32, 105.06849, 0, -2.53819); + var p33 = part(m, m.cylinder(0.01955, 0.023, 0.07428, 7), g27, 1, "Rib 1 segment -1 5", -0.19726, 0.50392, 0.05974); + m.rotate(p33, 105.06849, 0, -42.47025); + var p34 = part(m, m.cylinder(0.01955, 0.023, 0.09394, 7), g27, 1, "Rib 1 segment -1 6", -0.12874, 0.49215, 0.10346); + m.rotate(p34, 105.06849, 0, -67.62294); + var p35 = part(m, m.cylinder(0.01955, 0.023, 0.10374, 7), g27, 1, "Rib 1 segment 1 0", -0.05905, 0.55285, -0.12201); + m.rotate(p35, -74.93151, 0, -80.90257); + var p36 = part(m, m.cylinder(0.01955, 0.023, 0.10483, 7), g27, 1, "Rib 1 segment 1 1", 0.04422, 0.55338, -0.12399); + m.rotate(p36, 105.06849, 0, -83.27063); + var p37 = part(m, m.cylinder(0.01955, 0.023, 0.09196, 7), g27, 1, "Rib 1 segment 1 2", 0.13806, 0.5468, -0.09955); + m.rotate(p37, 105.06849, 0, -65.35156); + var p38 = part(m, m.cylinder(0.01955, 0.023, 0.07218, 7), g27, 1, "Rib 1 segment 1 3", 0.20247, 0.53451, -0.05388); + m.rotate(p38, 105.06849, 0, -38.80903); + var p39 = part(m, m.cylinder(0.01955, 0.023, 0.0622, 7), g27, 1, "Rib 1 segment 1 4", 0.22371, 0.51912, 0.00328); + m.rotate(p39, 105.06849, 0, 2.53819); + var p40 = part(m, m.cylinder(0.01955, 0.023, 0.07428, 7), g27, 1, "Rib 1 segment 1 5", 0.19726, 0.50392, 0.05974); + m.rotate(p40, 105.06849, 0, 42.47025); + var p41 = part(m, m.cylinder(0.01955, 0.023, 0.09394, 7), g27, 1, "Rib 1 segment 1 6", 0.12874, 0.49215, 0.10346); + m.rotate(p41, 105.06849, 0, 67.62294); + var g42 = m.group(); + m.name(g42, "Rib cage row 2"); + m.parent(g42, g24); + var p43 = part(m, m.cylinder(0.01955, 0.023, 0.11511, 7), g42, 1, "Rib 2 segment -1 0", 0.06547, 0.66285, -0.14078); + m.rotate(p43, -76.86598, 0, 80.61714); + var p44 = part(m, m.cylinder(0.01955, 0.023, 0.11627, 7), g42, 1, "Rib 2 segment -1 1", -0.04903, 0.66338, -0.14307); + m.rotate(p44, 103.13402, 0, 83.0578); + var p45 = part(m, m.cylinder(0.01955, 0.023, 0.10253, 7), g42, 1, "Rib 2 segment -1 2", -0.15307, 0.6568, -0.11486); + m.rotate(p45, 103.13402, 0, 64.66189); + var p46 = part(m, m.cylinder(0.01955, 0.023, 0.08158, 7), g42, 1, "Rib 2 segment -1 3", -0.22448, 0.64451, -0.06216); + m.rotate(p46, 103.13402, 0, 37.93254); + var p47 = part(m, m.cylinder(0.01955, 0.023, 0.07116, 7), g42, 1, "Rib 2 segment -1 4", -0.24803, 0.62912, 0.00379); + m.rotate(p47, 103.13402, 0, -2.45975); + var p48 = part(m, m.cylinder(0.01955, 0.023, 0.0838, 7), g42, 1, "Rib 2 segment -1 5", -0.2187, 0.61392, 0.06893); + m.rotate(p48, 103.13402, 0, -41.57462); + var p49 = part(m, m.cylinder(0.01955, 0.023, 0.10464, 7), g42, 1, "Rib 2 segment -1 6", -0.14274, 0.60215, 0.11938); + m.rotate(p49, 103.13402, 0, -66.98184); + var p50 = part(m, m.cylinder(0.01955, 0.023, 0.11511, 7), g42, 1, "Rib 2 segment 1 0", -0.06547, 0.66285, -0.14078); + m.rotate(p50, -76.86598, 0, -80.61714); + var p51 = part(m, m.cylinder(0.01955, 0.023, 0.11627, 7), g42, 1, "Rib 2 segment 1 1", 0.04903, 0.66338, -0.14307); + m.rotate(p51, 103.13402, 0, -83.0578); + var p52 = part(m, m.cylinder(0.01955, 0.023, 0.10253, 7), g42, 1, "Rib 2 segment 1 2", 0.15307, 0.6568, -0.11486); + m.rotate(p52, 103.13402, 0, -64.66189); + var p53 = part(m, m.cylinder(0.01955, 0.023, 0.08158, 7), g42, 1, "Rib 2 segment 1 3", 0.22448, 0.64451, -0.06216); + m.rotate(p53, 103.13402, 0, -37.93254); + var p54 = part(m, m.cylinder(0.01955, 0.023, 0.07116, 7), g42, 1, "Rib 2 segment 1 4", 0.24803, 0.62912, 0.00379); + m.rotate(p54, 103.13402, 0, 2.45975); + var p55 = part(m, m.cylinder(0.01955, 0.023, 0.0838, 7), g42, 1, "Rib 2 segment 1 5", 0.2187, 0.61392, 0.06893); + m.rotate(p55, 103.13402, 0, 41.57462); + var p56 = part(m, m.cylinder(0.01955, 0.023, 0.10464, 7), g42, 1, "Rib 2 segment 1 6", 0.14274, 0.60215, 0.11938); + m.rotate(p56, 103.13402, 0, 66.98184); + var g57 = m.group(); + m.name(g57, "Rib cage row 3"); + m.parent(g57, g24); + var p58 = part(m, m.cylinder(0.01955, 0.023, 0.1218, 7), g57, 1, "Rib 3 segment -1 0", 0.06932, 0.77285, -0.14547); + m.rotate(p58, -77.27564, 0, 80.85392); + var p59 = part(m, m.cylinder(0.01955, 0.023, 0.12307, 7), g57, 1, "Rib 3 segment -1 1", -0.05191, 0.77338, -0.14784); + m.rotate(p59, 102.72436, 0, 83.23437); + var p60 = part(m, m.cylinder(0.01955, 0.023, 0.10805, 7), g57, 1, "Rib 3 segment -1 2", -0.16207, 0.7668, -0.11869); + m.rotate(p60, 102.72436, 0, 65.23355); + var p61 = part(m, m.cylinder(0.01955, 0.023, 0.08501, 7), g57, 1, "Rib 3 segment -1 3", -0.23768, 0.75451, -0.06424); + m.rotate(p61, 102.72436, 0, 38.65734); + var p62 = part(m, m.cylinder(0.01955, 0.023, 0.07342, 7), g57, 1, "Rib 3 segment -1 4", -0.26262, 0.73912, 0.00391); + m.rotate(p62, 102.72436, 0, -2.52448); + var p63 = part(m, m.cylinder(0.01955, 0.023, 0.08746, 7), g57, 1, "Rib 3 segment -1 5", -0.23156, 0.72392, 0.07123); + m.rotate(p63, 102.72436, 0, -42.3155); + var p64 = part(m, m.cylinder(0.01955, 0.023, 0.11036, 7), g57, 1, "Rib 3 segment -1 6", -0.15113, 0.71215, 0.12336); + m.rotate(p64, 102.72436, 0, -67.51333); + var p65 = part(m, m.cylinder(0.01955, 0.023, 0.1218, 7), g57, 1, "Rib 3 segment 1 0", -0.06932, 0.77285, -0.14547); + m.rotate(p65, -77.27564, 0, -80.85392); + var p66 = part(m, m.cylinder(0.01955, 0.023, 0.12307, 7), g57, 1, "Rib 3 segment 1 1", 0.05191, 0.77338, -0.14784); + m.rotate(p66, 102.72436, 0, -83.23437); + var p67 = part(m, m.cylinder(0.01955, 0.023, 0.10805, 7), g57, 1, "Rib 3 segment 1 2", 0.16207, 0.7668, -0.11869); + m.rotate(p67, 102.72436, 0, -65.23355); + var p68 = part(m, m.cylinder(0.01955, 0.023, 0.08501, 7), g57, 1, "Rib 3 segment 1 3", 0.23768, 0.75451, -0.06424); + m.rotate(p68, 102.72436, 0, -38.65734); + var p69 = part(m, m.cylinder(0.01955, 0.023, 0.07342, 7), g57, 1, "Rib 3 segment 1 4", 0.26262, 0.73912, 0.00391); + m.rotate(p69, 102.72436, 0, 2.52448); + var p70 = part(m, m.cylinder(0.01955, 0.023, 0.08746, 7), g57, 1, "Rib 3 segment 1 5", 0.23156, 0.72392, 0.07123); + m.rotate(p70, 102.72436, 0, 42.3155); + var p71 = part(m, m.cylinder(0.01955, 0.023, 0.11036, 7), g57, 1, "Rib 3 segment 1 6", 0.15113, 0.71215, 0.12336); + m.rotate(p71, 102.72436, 0, 67.51333); + var g72 = m.group(); + m.name(g72, "Rib cage row 4"); + m.parent(g72, g24); + var p73 = part(m, m.cylinder(0.01955, 0.023, 0.11712, 7), g72, 1, "Rib 4 segment -1 0", 0.06675, 0.88285, -0.13139); + m.rotate(p73, -75.96376, 0, 81.36637); + var p74 = part(m, m.cylinder(0.01955, 0.023, 0.11842, 7), g72, 1, "Rib 4 segment -1 1", -0.04999, 0.88338, -0.13353); + m.rotate(p74, 104.03624, 0, 83.61614); + var p75 = part(m, m.cylinder(0.01955, 0.023, 0.10304, 7), g72, 1, "Rib 4 segment -1 2", -0.15607, 0.8768, -0.1072); + m.rotate(p75, 104.03624, 0, 66.48624); + var p76 = part(m, m.cylinder(0.01955, 0.023, 0.07905, 7), g72, 1, "Rib 4 segment -1 3", -0.22888, 0.86451, -0.05802); + m.rotate(p76, 104.03624, 0, 40.30468); + var p77 = part(m, m.cylinder(0.01955, 0.023, 0.06668, 7), g72, 1, "Rib 4 segment -1 4", -0.25289, 0.84912, 0.00353); + m.rotate(p77, 104.03624, 0, -2.67661); + var p78 = part(m, m.cylinder(0.01955, 0.023, 0.08164, 7), g72, 1, "Rib 4 segment -1 5", -0.22298, 0.83392, 0.06433); + m.rotate(p78, 104.03624, 0, -43.99041); + var p79 = part(m, m.cylinder(0.01955, 0.023, 0.10541, 7), g72, 1, "Rib 4 segment -1 6", -0.14554, 0.82215, 0.11142); + m.rotate(p79, 104.03624, 0, -68.67518); + var p80 = part(m, m.cylinder(0.01955, 0.023, 0.11712, 7), g72, 1, "Rib 4 segment 1 0", -0.06675, 0.88285, -0.13139); + m.rotate(p80, -75.96376, 0, -81.36637); + var p81 = part(m, m.cylinder(0.01955, 0.023, 0.11842, 7), g72, 1, "Rib 4 segment 1 1", 0.04999, 0.88338, -0.13353); + m.rotate(p81, 104.03624, 0, -83.61614); + var p82 = part(m, m.cylinder(0.01955, 0.023, 0.10304, 7), g72, 1, "Rib 4 segment 1 2", 0.15607, 0.8768, -0.1072); + m.rotate(p82, 104.03624, 0, -66.48624); + var p83 = part(m, m.cylinder(0.01955, 0.023, 0.07905, 7), g72, 1, "Rib 4 segment 1 3", 0.22888, 0.86451, -0.05802); + m.rotate(p83, 104.03624, 0, -40.30468); + var p84 = part(m, m.cylinder(0.01955, 0.023, 0.06668, 7), g72, 1, "Rib 4 segment 1 4", 0.25289, 0.84912, 0.00353); + m.rotate(p84, 104.03624, 0, 2.67661); + var p85 = part(m, m.cylinder(0.01955, 0.023, 0.08164, 7), g72, 1, "Rib 4 segment 1 5", 0.22298, 0.83392, 0.06433); + m.rotate(p85, 104.03624, 0, 43.99041); + var p86 = part(m, m.cylinder(0.01955, 0.023, 0.10541, 7), g72, 1, "Rib 4 segment 1 6", 0.14554, 0.82215, 0.11142); + m.rotate(p86, 104.03624, 0, 68.67518); + var p87 = part(m, m.cylinder(0.02295, 0.027, 0.33015, 7), g24, 0, "Sternum", 0, 0.695, -0.135); + m.rotate(p87, -1.7357, 0, 0); + var p88 = part(m, m.cylinder(0.02805, 0.033, 0.31016, 7), g24, 1, "Clavicle", -0.155, 0.905, 0); + m.rotate(p88, 0, 0, 88.15239); + var p89 = part(m, m.cylinder(0.02805, 0.033, 0.31016, 7), g24, 1, "Clavicle", 0.155, 0.905, 0); + m.rotate(p89, 0, 0, -88.15239); + var g90 = m.group(); + m.name(g90, "Terracotta neckerchief"); + m.parent(g90, g24); + m.vertex(-0.23, 0.76, -0.205); + m.vertex(-0.05, 0.65, -0.205); + m.vertex(0.09, 0.665, -0.205); + m.vertex(0.24, 0.76, -0.205); + m.vertex(0.33, 0.93, -0.205); + m.vertex(0.23, 1.005, -0.205); + m.vertex(0, 0.96, -0.205); + m.vertex(-0.22, 1.01, -0.205); + m.vertex(-0.32, 0.94, -0.205); + m.vertex(-0.23, 0.76, -0.16); + m.vertex(-0.05, 0.65, -0.16); + m.vertex(0.09, 0.665, -0.16); + m.vertex(0.24, 0.76, -0.16); + m.vertex(0.33, 0.93, -0.16); + m.vertex(0.23, 1.005, -0.16); + m.vertex(0, 0.96, -0.16); + m.vertex(-0.22, 1.01, -0.16); + m.vertex(-0.32, 0.94, -0.16); + m.triangle(0, 2, 1); + m.triangle(9, 10, 11); + m.triangle(0, 3, 2); + m.triangle(9, 11, 12); + m.triangle(0, 4, 3); + m.triangle(9, 12, 13); + m.triangle(0, 5, 4); + m.triangle(9, 13, 14); + m.triangle(0, 6, 5); + m.triangle(9, 14, 15); + m.triangle(0, 7, 6); + m.triangle(9, 15, 16); + m.triangle(0, 8, 7); + m.triangle(9, 16, 17); + m.triangle(0, 1, 9); + m.triangle(1, 10, 9); + m.triangle(1, 2, 10); + m.triangle(2, 11, 10); + m.triangle(2, 3, 11); + m.triangle(3, 12, 11); + m.triangle(3, 4, 12); + m.triangle(4, 13, 12); + m.triangle(4, 5, 13); + m.triangle(5, 14, 13); + m.triangle(5, 6, 14); + m.triangle(6, 15, 14); + m.triangle(6, 7, 15); + m.triangle(7, 16, 15); + m.triangle(7, 8, 16); + m.triangle(8, 17, 16); + m.triangle(8, 0, 17); + m.triangle(0, 9, 17); + var p91 = part(m, m.mesh(), g90, 5, "Scarf bib", 0, 0, 0); + m.vertex(-0.05, 0.65, -0.215); + m.vertex(0.09, 0.665, -0.215); + m.vertex(0.22, 0.82, -0.215); + m.vertex(-0.04, 0.91, -0.215); + m.vertex(-0.22, 1.01, -0.215); + m.vertex(-0.32, 0.94, -0.215); + m.vertex(-0.05, 0.65, -0.21); + m.vertex(0.09, 0.665, -0.21); + m.vertex(0.22, 0.82, -0.21); + m.vertex(-0.04, 0.91, -0.21); + m.vertex(-0.22, 1.01, -0.21); + m.vertex(-0.32, 0.94, -0.21); + m.triangle(0, 2, 1); + m.triangle(6, 7, 8); + m.triangle(0, 3, 2); + m.triangle(6, 8, 9); + m.triangle(0, 4, 3); + m.triangle(6, 9, 10); + m.triangle(0, 5, 4); + m.triangle(6, 10, 11); + m.triangle(0, 1, 6); + m.triangle(1, 7, 6); + m.triangle(1, 2, 7); + m.triangle(2, 8, 7); + m.triangle(2, 3, 8); + m.triangle(3, 9, 8); + m.triangle(3, 4, 9); + m.triangle(4, 10, 9); + m.triangle(4, 5, 10); + m.triangle(5, 11, 10); + m.triangle(5, 0, 11); + m.triangle(0, 6, 11); + var p92 = part(m, m.mesh(), g90, 5, "Scarf left folded facet", 0, 0, 0); + var p93 = part(m, m.cylinder(0.0051, 0.006, 0.15267, 7), g90, 6, "Scarf seam", -0.185, 0.885, -0.2235); + m.rotate(p93, -177.85241, 0, -58.37453); + var p94 = part(m, m.cylinder(0.0051, 0.006, 0.13946, 7), g90, 6, "Scarf seam", -0.0525, 0.8275, -0.225); + m.rotate(p94, -180, 0, -75.46554); + var p95 = part(m, m.cylinder(0.0051, 0.006, 0.13733, 7), g90, 6, "Scarf seam", 0.0825, 0.8225, -0.2235); + m.rotate(p95, 6.84277, 0, -79.43494); + var p96 = part(m, m.cylinder(0.0051, 0.006, 0.13796, 7), g90, 6, "Scarf seam", 0.2, 0.8825, -0.2205); + m.rotate(p96, 1.80874, 0, -46.45454); + m.vertex(0.02, 0.74, 0.115); + m.vertex(0.075, 0.78, 0.115); + m.vertex(0.18, 0.73, 0.115); + m.vertex(0.12, 0.93, 0.115); + m.vertex(-0.04, 0.96, 0.115); + m.vertex(0.02, 0.74, 0.16); + m.vertex(0.075, 0.78, 0.16); + m.vertex(0.18, 0.73, 0.16); + m.vertex(0.12, 0.93, 0.16); + m.vertex(-0.04, 0.96, 0.16); + m.triangle(0, 2, 1); + m.triangle(5, 6, 7); + m.triangle(0, 3, 2); + m.triangle(5, 7, 8); + m.triangle(0, 4, 3); + m.triangle(5, 8, 9); + m.triangle(0, 1, 5); + m.triangle(1, 6, 5); + m.triangle(1, 2, 6); + m.triangle(2, 7, 6); + m.triangle(2, 3, 7); + m.triangle(3, 8, 7); + m.triangle(3, 4, 8); + m.triangle(4, 9, 8); + m.triangle(4, 0, 9); + m.triangle(0, 5, 9); + var p97 = part(m, m.mesh(), g90, 5, "Back scarf tail", 0, 0, 0); + m.profile(1, 0.0375); + m.profile(1, -0.0375); + m.profile(0.8, -0.0375); + m.profile(0.8, 0.0375); + m.profile(1, 0.0375); + var p98 = part(m, m.lathe(16), g90, 5, "Scarf collar", 0, 0.96, 0); + m.scale(p98, 0.14, 1, 0.12); + var g99 = m.group(); + m.name(g99, "Pelvis and belt"); + m.parent(g99, g1); + var p100 = part(m, m.sphere(1, 8), g99, 7, "Dark shorts", 0, 0.395, 0); + m.scale(p100, 0.245, 0.14, 0.13); + m.profile(1, 0.0375); + m.profile(1, -0.0375); + m.profile(0.86111, -0.0375); + m.profile(0.86111, 0.0375); + m.profile(1, 0.0375); + var p101 = part(m, m.lathe(16), g99, 6, "Leather belt", 0, 0.475, 0); + m.scale(p101, 0.252, 1, 0.155); + var p102 = part(m, m.box(0.1, 0.016, 0.025), g99, 8, "Buckle upper", 0, 0.511, -0.175); + var p103 = part(m, m.box(0.1, 0.016, 0.025), g99, 8, "Buckle lower", 0, 0.44, -0.175); + var p104 = part(m, m.box(0.016, 0.075, 0.025), g99, 8, "Buckle left", -0.047, 0.475, -0.175); + var p105 = part(m, m.box(0.016, 0.075, 0.025), g99, 8, "Buckle right", 0.047, 0.475, -0.175); + var p106 = part(m, m.box(0.065, 0.012, 0.012), g99, 8, "Buckle tongue", 0.007, 0.475, -0.19); + var g107 = m.group(); + m.name(g107, "Left leg"); + m.parent(g107, g1); + var p108 = part(m, m.cylinder(0.095, 0.087, 0.11, 8), g107, 7, "Shorts leg", -0.14, 0.35, 0); + var p109 = part(m, m.cylinder(0.098, 0.098, 0.04, 8), g107, 6, "Leather cuff", -0.14, 0.303, 0); + var p110 = part(m, m.cylinder(0.0289, 0.034, 0.1607, 7), g107, 1, "Shin", -0.14, 0.2, -0.0075); + m.rotate(p110, -174.64417, 0, 0); + var p111 = part(m, m.sphere(1, 8), g107, 1, "Knee", -0.14, 0.26, 0); + m.scale(p111, 0.046, 0.05, 0.047); + var p112 = part(m, m.sphere(1, 8), g107, 0, "Ankle", -0.14, 0.12, -0.02); + m.scale(p112, 0.045, 0.045, 0.045); + var p113 = part(m, m.sphere(1, 8), g107, 1, "Chunky foot", -0.14, 0.055, -0.063); + m.scale(p113, 0.112, 0.055, 0.145); + var p114 = part(m, m.cylinder(0.0034, 0.004, 0.03302, 7), g107, 0, "Toe separation", -0.168, 0.0715, -0.1675); + m.rotate(p114, 35.13419, 0, 0); + var p115 = part(m, m.cylinder(0.0034, 0.004, 0.03302, 7), g107, 0, "Toe separation", -0.112, 0.0715, -0.1675); + m.rotate(p115, 35.13419, 0, 0); + var g116 = m.group(); + m.name(g116, "Left arm"); + m.parent(g116, g1); + var p117 = part(m, m.sphere(1, 8), g116, 0, "Shoulder", -0.31, 0.91, 0); + m.scale(p117, 0.05, 0.055, 0.052); + var p118 = part(m, m.cylinder(0.02465, 0.029, 0.1872, 7), g116, 1, "Humerus", -0.4, 0.885, -0.006); + m.rotate(p118, -166.50427, 0, 74.05717); + var p119 = part(m, m.sphere(1, 8), g116, 1, "Elbow", -0.49, 0.86, -0.012); + m.scale(p119, 0.039, 0.042, 0.04); + var p120 = part(m, m.cylinder(0.01615, 0.019, 0.16226, 7), g116, 1, "Radius", -0.575, 0.88, -0.0415); + m.rotate(p120, -90, 0, 80.42158); + var p121 = part(m, m.cylinder(0.0136, 0.016, 0.16194, 7), g116, 1, "Ulna", -0.575, 0.84, -0.0125); + m.rotate(p121, -90, 0, 81.11934); + var p122 = part(m, m.sphere(1, 8), g116, 1, "Hand", -0.7, 0.86, -0.045); + m.scale(p122, 0.063, 0.063, 0.058); + var g123 = m.group(); + m.name(g123, "Right leg"); + m.parent(g123, g1); + var p124 = part(m, m.cylinder(0.095, 0.087, 0.11, 8), g123, 7, "Shorts leg", 0.14, 0.35, 0); + var p125 = part(m, m.cylinder(0.098, 0.098, 0.04, 8), g123, 6, "Leather cuff", 0.14, 0.303, 0); + var p126 = part(m, m.cylinder(0.0289, 0.034, 0.1607, 7), g123, 1, "Shin", 0.14, 0.2, -0.0075); + m.rotate(p126, -174.64417, 0, 0); + var p127 = part(m, m.sphere(1, 8), g123, 1, "Knee", 0.14, 0.26, 0); + m.scale(p127, 0.046, 0.05, 0.047); + var p128 = part(m, m.sphere(1, 8), g123, 0, "Ankle", 0.14, 0.12, -0.02); + m.scale(p128, 0.045, 0.045, 0.045); + var p129 = part(m, m.sphere(1, 8), g123, 1, "Chunky foot", 0.14, 0.055, -0.063); + m.scale(p129, 0.112, 0.055, 0.145); + var p130 = part(m, m.cylinder(0.0034, 0.004, 0.03302, 7), g123, 0, "Toe separation", 0.112, 0.0715, -0.1675); + m.rotate(p130, 35.13419, 0, 0); + var p131 = part(m, m.cylinder(0.0034, 0.004, 0.03302, 7), g123, 0, "Toe separation", 0.168, 0.0715, -0.1675); + m.rotate(p131, 35.13419, 0, 0); + var g132 = m.group(); + m.name(g132, "Right arm"); + m.parent(g132, g1); + var p133 = part(m, m.sphere(1, 8), g132, 0, "Shoulder", 0.31, 0.91, 0); + m.scale(p133, 0.05, 0.055, 0.052); + var p134 = part(m, m.cylinder(0.02465, 0.029, 0.1872, 7), g132, 1, "Humerus", 0.4, 0.885, -0.006); + m.rotate(p134, -166.50427, 0, -74.05717); + var p135 = part(m, m.sphere(1, 8), g132, 1, "Elbow", 0.49, 0.86, -0.012); + m.scale(p135, 0.039, 0.042, 0.04); + var p136 = part(m, m.cylinder(0.01615, 0.019, 0.16226, 7), g132, 1, "Radius", 0.575, 0.88, -0.0415); + m.rotate(p136, -90, 0, -80.42158); + var p137 = part(m, m.cylinder(0.0136, 0.016, 0.16194, 7), g132, 1, "Ulna", 0.575, 0.84, -0.0125); + m.rotate(p137, -90, 0, -81.11934); + var p138 = part(m, m.sphere(1, 8), g132, 1, "Hand", 0.7, 0.86, -0.045); + m.scale(p138, 0.063, 0.063, 0.058); + var g139 = m.group(); + m.name(g139, "Equipment"); + m.parent(g139, g1); + var g140 = m.group(); + m.name(g140, "Broken sword"); + m.parent(g140, g139); + m.vertex(-0.75, 0.9, -0.12); + m.vertex(-0.88, 1.12, -0.12); + m.vertex(-0.98, 1.28, -0.12); + m.vertex(-0.96, 1.4, -0.12); + m.vertex(-1.055, 1.34, -0.12); + m.vertex(-1.075, 1.25, -0.12); + m.vertex(-1.04, 1.2, -0.12); + m.vertex(-1.08, 1.17, -0.12); + m.vertex(-1.02, 1.04, -0.12); + m.vertex(-0.87, 0.84, -0.12); + m.vertex(-0.75, 0.9, -0.075); + m.vertex(-0.88, 1.12, -0.075); + m.vertex(-0.98, 1.28, -0.075); + m.vertex(-0.96, 1.4, -0.075); + m.vertex(-1.055, 1.34, -0.075); + m.vertex(-1.075, 1.25, -0.075); + m.vertex(-1.04, 1.2, -0.075); + m.vertex(-1.08, 1.17, -0.075); + m.vertex(-1.02, 1.04, -0.075); + m.vertex(-0.87, 0.84, -0.075); + m.triangle(0, 2, 1); + m.triangle(10, 11, 12); + m.triangle(0, 3, 2); + m.triangle(10, 12, 13); + m.triangle(0, 4, 3); + m.triangle(10, 13, 14); + m.triangle(0, 5, 4); + m.triangle(10, 14, 15); + m.triangle(0, 6, 5); + m.triangle(10, 15, 16); + m.triangle(0, 7, 6); + m.triangle(10, 16, 17); + m.triangle(0, 8, 7); + m.triangle(10, 17, 18); + m.triangle(0, 9, 8); + m.triangle(10, 18, 19); + m.triangle(0, 1, 10); + m.triangle(1, 11, 10); + m.triangle(1, 2, 11); + m.triangle(2, 12, 11); + m.triangle(2, 3, 12); + m.triangle(3, 13, 12); + m.triangle(3, 4, 13); + m.triangle(4, 14, 13); + m.triangle(4, 5, 14); + m.triangle(5, 15, 14); + m.triangle(5, 6, 15); + m.triangle(6, 16, 15); + m.triangle(6, 7, 16); + m.triangle(7, 17, 16); + m.triangle(7, 8, 17); + m.triangle(8, 18, 17); + m.triangle(8, 9, 18); + m.triangle(9, 19, 18); + m.triangle(9, 0, 19); + m.triangle(0, 10, 19); + var p141 = part(m, m.mesh(), g140, 10, "Notched blade", 0, 0, 0); + var p142 = part(m, m.cylinder(0.00765, 0.009, 0.4406, 7), g140, 8, "Blade fuller", -0.9125, 1.105, -0.124); + m.rotate(p142, 0, 0, 27.72826); + var p143 = part(m, m.cylinder(0.0272, 0.032, 0.23324, 7), g140, 8, "Cross guard", -0.78, 0.88, -0.1); + m.rotate(p143, 0, 0, -59.03624); + var p144 = part(m, m.cylinder(0.0306, 0.036, 0.21095, 7), g140, 6, "Leather grip", -0.715, 0.76, -0.1); + m.rotate(p144, -180, 0, -31.42957); + var p145 = part(m, m.cylinder(0.00765, 0.009, 0.05831, 7), g140, 9, "Grip wrap", -0.75, 0.85, -0.135); + m.rotate(p145, 0, 0, -59.03624); + var p146 = part(m, m.cylinder(0.00765, 0.009, 0.05831, 7), g140, 9, "Grip wrap", -0.72, 0.8, -0.135); + m.rotate(p146, 0, 0, -59.03624); + var p147 = part(m, m.cylinder(0.00765, 0.009, 0.05831, 7), g140, 9, "Grip wrap", -0.69, 0.75, -0.135); + m.rotate(p147, 0, 0, -59.03624); + var p148 = part(m, m.cylinder(0.02125, 0.025, 0.07632, 7), g140, 8, "Pommel", -0.6575, 0.69, -0.1); + m.rotate(p148, 0, 0, -58.3925); + var g149 = m.group(); + m.name(g149, "Round shield"); + m.parent(g149, g139); + m.profile(0.3, 0.027); + m.profile(0.3, -0.027); + m.profile(0.255, -0.027); + m.profile(0.255, 0.027); + m.profile(0.3, 0.027); + var p150 = part(m, m.lathe(12), g149, 10, "Steel shield rim", 0.8, 0.67, -0.145); + m.rotate(p150, 90, 0, 0); + var p151 = part(m, m.cylinder(0.257, 0.257, 0.045, 12), g149, 9, "Wood shield face", 0.8, 0.67, -0.145); + m.rotate(p151, 90, 0, 0); + var p152 = part(m, m.sphere(1, 10), g149, 8, "Steel shield boss", 0.8, 0.67, -0.195); + m.scale(p152, 0.095, 0.095, 0.048); + var p153 = part(m, m.cylinder(0.00425, 0.005, 0.36, 7), g149, 6, "Wood plank groove", 0.665, 0.67, -0.172); + m.rotate(p153, 0, 0, 0); + var p154 = part(m, m.cylinder(0.00425, 0.005, 0.36, 7), g149, 6, "Wood plank groove", 0.935, 0.67, -0.172); + m.rotate(p154, 0, 0, 0); + } + +// Recipe executed by the embedded AdaScript VM. No process, file access, JIT or Swift compilation. +func rigHasParent(model, partId, name) { + while (partId >= 0) { + if (model.partName(partId) == name) { return true; } + partId = model.partParent(partId); + } + return false; +} + +func rigRigidJoint(model, partId, joints) { + var name = model.partName(partId); + if (rigHasParent(model, partId, "Broken sword")) { return joints[18]; } + if (rigHasParent(model, partId, "Round shield")) { return joints[22]; } + if (rigHasParent(model, partId, "Lower jaw")) { return joints[6]; } + if (rigHasParent(model, partId, "Head")) { return joints[5]; } + for (var side in 0..<2) { + var leg = "Left leg"; + var arm = "Left arm"; + var hip = 7; + var shoulder = 15; + if (side == 1) { + leg = "Right leg"; + arm = "Right arm"; + hip = 11; + shoulder = 19; + } + if (rigHasParent(model, partId, leg)) { + if (name == "Shorts leg" || name == "Leather cuff") { return joints[hip]; } + if (name == "Shin" || name == "Knee") { return joints[hip + 1]; } + return joints[hip + 2]; + } + if (rigHasParent(model, partId, arm)) { + if (name == "Shoulder" || name == "Humerus") { return joints[shoulder]; } + if (name == "Hand") { return joints[shoulder + 3]; } + return joints[shoulder + 1]; + } + } + if (rigHasParent(model, partId, "Terracotta neckerchief") || name == "Spine" || name == "Sternum") { return joints[2]; } + if (rigHasParent(model, partId, "Rib cage row 1")) { return joints[2]; } + if (rigHasParent(model, partId, "Torso")) { return joints[3]; } + if (rigHasParent(model, partId, "Pelvis and belt")) { return joints[1]; } + assert(false, "No weight rule for " + name); + return joints[0]; +} + +func rigSkinParts(model, joints) { + for (var partId in 0..= 1) { rightPhase = rightPhase - 1; } + var left = rigLegAngles(phase, 0.34 + bob); + var right = rigLegAngles(rightPhase, 0.34 + bob); + model.translationKey(walk, joints[1], time, 0, 0.39 + bob, 0); + model.rotationKey(walk, joints[7], time, left[0], 0, 0); + model.rotationKey(walk, joints[8], time, left[1], 0, 0); + model.rotationKey(walk, joints[9], time, left[2], 0, 0); + model.rotationKey(walk, joints[11], time, right[0], 0, 0); + model.rotationKey(walk, joints[12], time, right[1], 0, 0); + model.rotationKey(walk, joints[13], time, right[2], 0, 0); + model.rotationKey(walk, joints[2], time, 1.2 * Math.sin(2 * cycle), 2.5 * Math.sin(cycle), 0); + model.rotationKey(walk, joints[3], time, 0, -4 * Math.sin(cycle), 1.8 * Math.sin(cycle)); + model.rotationKey(walk, joints[5], time, 0, 1.5 * Math.sin(cycle), -1.8 * Math.sin(cycle)); + model.rotationKey(walk, joints[6], time, 2 + Math.sin(2 * cycle), 0, 0); + model.rotationKey(walk, joints[15], time, -12 * Math.cos(cycle), 0, 43); + model.rotationKey(walk, joints[19], time, 12 * Math.cos(cycle), 0, -43); + model.rotationKey(walk, joints[16], time, -7 - 4 * Math.sin(cycle), 0, 0); + model.rotationKey(walk, joints[20], time, -7 + 4 * Math.sin(cycle), 0, 0); + model.rotationKey(walk, joints[17], time, 0, 0, -43); + model.rotationKey(walk, joints[21], time, 0, 0, 43); + } +} + +func build(model) { + buildStaticSkeleton(model); + var joints = []; + joints.push(model.joint("Rig.root", -1, 0, 0, 0)); + joints.push(model.joint("Rig.hips", joints[0], 0, 0.39, 0)); + joints.push(model.joint("Rig.spine", joints[1], 0, 0.29, 0)); + joints.push(model.joint("Rig.chest", joints[2], 0, 0.22, 0)); + joints.push(model.joint("Rig.neck", joints[3], 0, 0.105, 0)); + joints.push(model.joint("Rig.head", joints[4], 0, 0.135, 0)); + joints.push(model.joint("Rig.jaw", joints[5], 0, -0.15, -0.1)); + joints.push(model.joint("Rig.leftHip", joints[1], -0.14, -0.05, 0)); + joints.push(model.joint("Rig.leftKnee", joints[7], 0, -0.08, 0)); + joints.push(model.joint("Rig.leftAnkle", joints[8], 0, -0.14, -0.015)); + joints.push(model.joint("Rig.leftToe", joints[9], 0, -0.07, -0.145)); + joints.push(model.joint("Rig.rightHip", joints[1], 0.14, -0.05, 0)); + joints.push(model.joint("Rig.rightKnee", joints[11], 0, -0.08, 0)); + joints.push(model.joint("Rig.rightAnkle", joints[12], 0, -0.14, -0.015)); + joints.push(model.joint("Rig.rightToe", joints[13], 0, -0.07, -0.145)); + joints.push(model.joint("Rig.leftShoulder", joints[3], -0.31, 0.01, 0)); + joints.push(model.joint("Rig.leftElbow", joints[15], -0.18, -0.05, -0.012)); + joints.push(model.joint("Rig.leftWrist", joints[16], -0.17, 0, -0.033)); + joints.push(model.joint("Rig.leftHand", joints[17], -0.04, 0, 0)); + joints.push(model.joint("Rig.rightShoulder", joints[3], 0.31, 0.01, 0)); + joints.push(model.joint("Rig.rightElbow", joints[19], 0.18, -0.05, -0.012)); + joints.push(model.joint("Rig.rightWrist", joints[20], 0.17, 0, -0.033)); + joints.push(model.joint("Rig.rightHand", joints[21], 0.04, 0, 0)); + rigSkinParts(model, joints); + rigWalkClips(model, joints); +} diff --git a/Examples/Technical/ProceduralSkeleton/Generators/Skeleton.ada b/Examples/Technical/ProceduralSkeleton/Generators/Skeleton.ada new file mode 100644 index 0000000..25f96bf --- /dev/null +++ b/Examples/Technical/ProceduralSkeleton/Generators/Skeleton.ada @@ -0,0 +1,910 @@ +// Generated by author_recipe.py; run directly through editor.model.generate. +// Units: meters. Front: -Z. Static multipart study, not a skinned mesh. +func part(m, p, parent, mat, name, x, y, z) { + m.parent(p, parent); m.paint(p, mat); m.name(p, name); m.place(p, x, y, z); return p; +} +func build(m) { + m.material(0, 0.73, 0.63, 0.43); m.surface(0, 0, 0.85); + m.material(1, 0.94, 0.83, 0.59); m.surface(1, 0, 0.76); + m.material(2, 0.12, 0.065, 0.025); m.surface(2, 0, 0.95); + m.material(3, 0.56, 0.25, 0.022); m.surface(3, 0, 0.82); + m.material(4, 1, 0.65, 0.015); m.surface(4, 0, 0.35); + m.material(5, 0.46, 0.16, 0.085); m.surface(5, 0, 0.95); + m.material(6, 0.24, 0.075, 0.038); m.surface(6, 0, 0.95); + m.material(7, 0.078, 0.067, 0.058); m.surface(7, 0, 0.95); + m.material(8, 0.24, 0.32, 0.35); m.surface(8, 0.6, 0.55); + m.material(9, 0.39, 0.17, 0.08); m.surface(9, 0, 0.9); + m.material(10, 0.55, 0.64, 0.64); m.surface(10, 0.5, 0.48); + m.emissive(4, 1, 0.65, 0.008); + var g1 = m.group(); m.name(g1, "Skeleton minion"); + var g2 = m.group(); m.name(g2, "Head"); + m.parent(g2, g1); + m.vertex(-0.22, 1.7, -0.265); + m.vertex(0.22, 1.7, -0.265); + m.vertex(0.46, 1.55, -0.265); + m.vertex(0.47, 1.25, -0.265); + m.vertex(0.37, 1.115, -0.265); + m.vertex(0.19, 1.06, -0.265); + m.vertex(-0.19, 1.06, -0.265); + m.vertex(-0.37, 1.115, -0.265); + m.vertex(-0.47, 1.25, -0.265); + m.vertex(-0.46, 1.55, -0.265); + m.vertex(-0.2024, 1.684, 0.19); + m.vertex(0.2024, 1.684, 0.19); + m.vertex(0.4232, 1.5415, 0.19); + m.vertex(0.4324, 1.2565, 0.19); + m.vertex(0.3404, 1.12825, 0.19); + m.vertex(0.1748, 1.076, 0.19); + m.vertex(-0.1748, 1.076, 0.19); + m.vertex(-0.3404, 1.12825, 0.19); + m.vertex(-0.4324, 1.2565, 0.19); + m.vertex(-0.4232, 1.5415, 0.19); + m.vertex(-0.1254, 1.5912, 0.29); + m.vertex(0.1254, 1.5912, 0.29); + m.vertex(0.2622, 1.4922, 0.29); + m.vertex(0.2679, 1.2942, 0.29); + m.vertex(0.2109, 1.2051, 0.29); + m.vertex(0.1083, 1.1688, 0.29); + m.vertex(-0.1083, 1.1688, 0.29); + m.vertex(-0.2109, 1.2051, 0.29); + m.vertex(-0.2679, 1.2942, 0.29); + m.vertex(-0.2622, 1.4922, 0.29); + m.vertex(0, 1.38, 0.31); + m.triangle(0, 10, 1); + m.triangle(1, 10, 11); + m.triangle(1, 11, 2); + m.triangle(2, 11, 12); + m.triangle(2, 12, 3); + m.triangle(3, 12, 13); + m.triangle(3, 13, 4); + m.triangle(4, 13, 14); + m.triangle(4, 14, 5); + m.triangle(5, 14, 15); + m.triangle(5, 15, 6); + m.triangle(6, 15, 16); + m.triangle(6, 16, 7); + m.triangle(7, 16, 17); + m.triangle(7, 17, 8); + m.triangle(8, 17, 18); + m.triangle(8, 18, 9); + m.triangle(9, 18, 19); + m.triangle(9, 19, 0); + m.triangle(0, 19, 10); + m.triangle(10, 20, 11); + m.triangle(11, 20, 21); + m.triangle(11, 21, 12); + m.triangle(12, 21, 22); + m.triangle(12, 22, 13); + m.triangle(13, 22, 23); + m.triangle(13, 23, 14); + m.triangle(14, 23, 24); + m.triangle(14, 24, 15); + m.triangle(15, 24, 25); + m.triangle(15, 25, 16); + m.triangle(16, 25, 26); + m.triangle(16, 26, 17); + m.triangle(17, 26, 27); + m.triangle(17, 27, 18); + m.triangle(18, 27, 28); + m.triangle(18, 28, 19); + m.triangle(19, 28, 29); + m.triangle(19, 29, 10); + m.triangle(10, 29, 20); + m.triangle(20, 30, 21); + m.triangle(21, 30, 22); + m.triangle(22, 30, 23); + m.triangle(23, 30, 24); + m.triangle(24, 30, 25); + m.triangle(25, 30, 26); + m.triangle(26, 30, 27); + m.triangle(27, 30, 28); + m.triangle(28, 30, 29); + m.triangle(29, 30, 20); + var p3 = part(m, m.mesh(), g2, 1, "Faceted cranium shell", 0, 0, 0); + m.vertex(-0.22, 1.7, -0.265); + m.vertex(0, 1.7, -0.265); + m.vertex(0, 1.38, -0.265); + m.vertex(0, 1.1, -0.265); + m.vertex(-0.19, 1.06, -0.265); + m.vertex(-0.37, 1.115, -0.265); + m.vertex(-0.47, 1.25, -0.265); + m.vertex(-0.46, 1.55, -0.265); + m.vertex(-0.235, 1.525, -0.295); + m.vertex(-0.1254, 1.47814, -0.295); + m.vertex(-0.08, 1.365, -0.295); + m.vertex(-0.1254, 1.25186, -0.295); + m.vertex(-0.235, 1.205, -0.295); + m.vertex(-0.3446, 1.25186, -0.295); + m.vertex(-0.39, 1.365, -0.295); + m.vertex(-0.3446, 1.47814, -0.295); + m.triangle(0, 1, 8); + m.triangle(1, 9, 8); + m.triangle(1, 2, 9); + m.triangle(2, 10, 9); + m.triangle(2, 3, 10); + m.triangle(3, 11, 10); + m.triangle(3, 4, 11); + m.triangle(4, 12, 11); + m.triangle(4, 5, 12); + m.triangle(5, 13, 12); + m.triangle(5, 6, 13); + m.triangle(6, 14, 13); + m.triangle(6, 7, 14); + m.triangle(7, 15, 14); + m.triangle(7, 0, 15); + m.triangle(0, 8, 15); + var p4 = part(m, m.mesh(), g2, 1, "Left orbital ivory rim", 0, 0, 0); + m.vertex(-0.235, 1.525, -0.295); + m.vertex(-0.1254, 1.47814, -0.295); + m.vertex(-0.08, 1.365, -0.295); + m.vertex(-0.1254, 1.25186, -0.295); + m.vertex(-0.235, 1.205, -0.295); + m.vertex(-0.3446, 1.25186, -0.295); + m.vertex(-0.39, 1.365, -0.295); + m.vertex(-0.3446, 1.47814, -0.295); + m.vertex(-0.235, 1.4802, -0.17); + m.vertex(-0.15609, 1.44646, -0.17); + m.vertex(-0.1234, 1.365, -0.17); + m.vertex(-0.15609, 1.28354, -0.17); + m.vertex(-0.235, 1.2498, -0.17); + m.vertex(-0.31391, 1.28354, -0.17); + m.vertex(-0.3466, 1.365, -0.17); + m.vertex(-0.31391, 1.44646, -0.17); + m.triangle(0, 1, 8); + m.triangle(1, 9, 8); + m.triangle(1, 2, 9); + m.triangle(2, 10, 9); + m.triangle(2, 3, 10); + m.triangle(3, 11, 10); + m.triangle(3, 4, 11); + m.triangle(4, 12, 11); + m.triangle(4, 5, 12); + m.triangle(5, 13, 12); + m.triangle(5, 6, 13); + m.triangle(6, 14, 13); + m.triangle(6, 7, 14); + m.triangle(7, 15, 14); + m.triangle(7, 0, 15); + m.triangle(0, 8, 15); + var p5 = part(m, m.mesh(), g2, 3, "Left recessed socket walls", 0, 0, 0); + m.vertex(-0.235, 1.4802, -0.17); + m.vertex(-0.15609, 1.44646, -0.17); + m.vertex(-0.1234, 1.365, -0.17); + m.vertex(-0.15609, 1.28354, -0.17); + m.vertex(-0.235, 1.2498, -0.17); + m.vertex(-0.31391, 1.28354, -0.17); + m.vertex(-0.3466, 1.365, -0.17); + m.vertex(-0.31391, 1.44646, -0.17); + m.triangle(0, 1, 2); + m.triangle(0, 2, 3); + m.triangle(0, 3, 4); + m.triangle(0, 4, 5); + m.triangle(0, 5, 6); + m.triangle(0, 6, 7); + var p6 = part(m, m.mesh(), g2, 2, "Left socket shadow", 0, 0, 0); + var p7 = part(m, m.sphere(1, 10), g2, 4, "Left ember eye", -0.235, 1.36, -0.205); + m.scale(p7, 0.061, 0.084, 0.043); + m.vertex(0.22, 1.7, -0.265); + m.vertex(0, 1.7, -0.265); + m.vertex(0, 1.38, -0.265); + m.vertex(0, 1.1, -0.265); + m.vertex(0.19, 1.06, -0.265); + m.vertex(0.37, 1.115, -0.265); + m.vertex(0.47, 1.25, -0.265); + m.vertex(0.46, 1.55, -0.265); + m.vertex(0.235, 1.525, -0.295); + m.vertex(0.1254, 1.47814, -0.295); + m.vertex(0.08, 1.365, -0.295); + m.vertex(0.1254, 1.25186, -0.295); + m.vertex(0.235, 1.205, -0.295); + m.vertex(0.3446, 1.25186, -0.295); + m.vertex(0.39, 1.365, -0.295); + m.vertex(0.3446, 1.47814, -0.295); + m.triangle(0, 8, 1); + m.triangle(1, 8, 9); + m.triangle(1, 9, 2); + m.triangle(2, 9, 10); + m.triangle(2, 10, 3); + m.triangle(3, 10, 11); + m.triangle(3, 11, 4); + m.triangle(4, 11, 12); + m.triangle(4, 12, 5); + m.triangle(5, 12, 13); + m.triangle(5, 13, 6); + m.triangle(6, 13, 14); + m.triangle(6, 14, 7); + m.triangle(7, 14, 15); + m.triangle(7, 15, 0); + m.triangle(0, 15, 8); + var p8 = part(m, m.mesh(), g2, 1, "Right orbital ivory rim", 0, 0, 0); + m.vertex(0.235, 1.525, -0.295); + m.vertex(0.1254, 1.47814, -0.295); + m.vertex(0.08, 1.365, -0.295); + m.vertex(0.1254, 1.25186, -0.295); + m.vertex(0.235, 1.205, -0.295); + m.vertex(0.3446, 1.25186, -0.295); + m.vertex(0.39, 1.365, -0.295); + m.vertex(0.3446, 1.47814, -0.295); + m.vertex(0.235, 1.4802, -0.17); + m.vertex(0.15609, 1.44646, -0.17); + m.vertex(0.1234, 1.365, -0.17); + m.vertex(0.15609, 1.28354, -0.17); + m.vertex(0.235, 1.2498, -0.17); + m.vertex(0.31391, 1.28354, -0.17); + m.vertex(0.3466, 1.365, -0.17); + m.vertex(0.31391, 1.44646, -0.17); + m.triangle(0, 8, 1); + m.triangle(1, 8, 9); + m.triangle(1, 9, 2); + m.triangle(2, 9, 10); + m.triangle(2, 10, 3); + m.triangle(3, 10, 11); + m.triangle(3, 11, 4); + m.triangle(4, 11, 12); + m.triangle(4, 12, 5); + m.triangle(5, 12, 13); + m.triangle(5, 13, 6); + m.triangle(6, 13, 14); + m.triangle(6, 14, 7); + m.triangle(7, 14, 15); + m.triangle(7, 15, 0); + m.triangle(0, 15, 8); + var p9 = part(m, m.mesh(), g2, 3, "Right recessed socket walls", 0, 0, 0); + m.vertex(0.235, 1.4802, -0.17); + m.vertex(0.15609, 1.44646, -0.17); + m.vertex(0.1234, 1.365, -0.17); + m.vertex(0.15609, 1.28354, -0.17); + m.vertex(0.235, 1.2498, -0.17); + m.vertex(0.31391, 1.28354, -0.17); + m.vertex(0.3466, 1.365, -0.17); + m.vertex(0.31391, 1.44646, -0.17); + m.triangle(0, 2, 1); + m.triangle(0, 3, 2); + m.triangle(0, 4, 3); + m.triangle(0, 5, 4); + m.triangle(0, 6, 5); + m.triangle(0, 7, 6); + var p10 = part(m, m.mesh(), g2, 2, "Right socket shadow", 0, 0, 0); + var p11 = part(m, m.sphere(1, 10), g2, 4, "Right ember eye", 0.235, 1.36, -0.205); + m.scale(p11, 0.061, 0.084, 0.043); + m.vertex(-0.065, 1.145, -0.288); + m.vertex(0.065, 1.145, -0.288); + m.vertex(0, 1.285, -0.288); + m.vertex(-0.065, 1.145, -0.22); + m.vertex(0.065, 1.145, -0.22); + m.vertex(0, 1.285, -0.22); + m.triangle(0, 2, 1); + m.triangle(3, 4, 5); + m.triangle(0, 1, 3); + m.triangle(1, 4, 3); + m.triangle(1, 2, 4); + m.triangle(2, 5, 4); + m.triangle(2, 0, 5); + m.triangle(0, 3, 5); + var p12 = part(m, m.mesh(), g2, 2, "Nasal cavity", 0, 0, 0); + m.vertex(-0.075, 1.135, -0.29); + m.vertex(-0.065, 1.145, -0.29); + m.vertex(0, 1.17, -0.295); + m.vertex(0.065, 1.145, -0.29); + m.vertex(0.075, 1.135, -0.29); + m.triangle(0, 1, 2); + m.triangle(2, 3, 4); + var p13 = part(m, m.mesh(), g2, 0, "Nasal bevel", 0, 0, 0); + m.vertex(-0.322, 1.045, -0.27); + m.vertex(-0.258, 1.01, -0.27); + m.vertex(-0.221, 1.045, -0.27); + m.vertex(-0.209, 1.14, -0.27); + m.vertex(-0.331, 1.14, -0.27); + m.vertex(-0.322, 1.045, -0.12); + m.vertex(-0.258, 1.01, -0.12); + m.vertex(-0.221, 1.045, -0.12); + m.vertex(-0.209, 1.14, -0.12); + m.vertex(-0.331, 1.14, -0.12); + m.triangle(0, 2, 1); + m.triangle(5, 6, 7); + m.triangle(0, 3, 2); + m.triangle(5, 7, 8); + m.triangle(0, 4, 3); + m.triangle(5, 8, 9); + m.triangle(0, 1, 5); + m.triangle(1, 6, 5); + m.triangle(1, 2, 6); + m.triangle(2, 7, 6); + m.triangle(2, 3, 7); + m.triangle(3, 8, 7); + m.triangle(3, 4, 8); + m.triangle(4, 9, 8); + m.triangle(4, 0, 9); + m.triangle(0, 5, 9); + var p14 = part(m, m.mesh(), g2, 1, "Upper tooth 1", 0, 0, 0); + m.vertex(-0.187, 1.045, -0.27); + m.vertex(-0.123, 1.01, -0.27); + m.vertex(-0.086, 1.045, -0.27); + m.vertex(-0.074, 1.14, -0.27); + m.vertex(-0.196, 1.14, -0.27); + m.vertex(-0.187, 1.045, -0.12); + m.vertex(-0.123, 1.01, -0.12); + m.vertex(-0.086, 1.045, -0.12); + m.vertex(-0.074, 1.14, -0.12); + m.vertex(-0.196, 1.14, -0.12); + m.triangle(0, 2, 1); + m.triangle(5, 6, 7); + m.triangle(0, 3, 2); + m.triangle(5, 7, 8); + m.triangle(0, 4, 3); + m.triangle(5, 8, 9); + m.triangle(0, 1, 5); + m.triangle(1, 6, 5); + m.triangle(1, 2, 6); + m.triangle(2, 7, 6); + m.triangle(2, 3, 7); + m.triangle(3, 8, 7); + m.triangle(3, 4, 8); + m.triangle(4, 9, 8); + m.triangle(4, 0, 9); + m.triangle(0, 5, 9); + var p15 = part(m, m.mesh(), g2, 1, "Upper tooth 2", 0, 0, 0); + m.vertex(-0.052, 1.045, -0.27); + m.vertex(0.012, 1.01, -0.27); + m.vertex(0.049, 1.045, -0.27); + m.vertex(0.061, 1.14, -0.27); + m.vertex(-0.061, 1.14, -0.27); + m.vertex(-0.052, 1.045, -0.12); + m.vertex(0.012, 1.01, -0.12); + m.vertex(0.049, 1.045, -0.12); + m.vertex(0.061, 1.14, -0.12); + m.vertex(-0.061, 1.14, -0.12); + m.triangle(0, 2, 1); + m.triangle(5, 6, 7); + m.triangle(0, 3, 2); + m.triangle(5, 7, 8); + m.triangle(0, 4, 3); + m.triangle(5, 8, 9); + m.triangle(0, 1, 5); + m.triangle(1, 6, 5); + m.triangle(1, 2, 6); + m.triangle(2, 7, 6); + m.triangle(2, 3, 7); + m.triangle(3, 8, 7); + m.triangle(3, 4, 8); + m.triangle(4, 9, 8); + m.triangle(4, 0, 9); + m.triangle(0, 5, 9); + var p16 = part(m, m.mesh(), g2, 1, "Upper tooth 3", 0, 0, 0); + m.vertex(0.083, 1.045, -0.27); + m.vertex(0.147, 1.01, -0.27); + m.vertex(0.184, 1.045, -0.27); + m.vertex(0.196, 1.14, -0.27); + m.vertex(0.074, 1.14, -0.27); + m.vertex(0.083, 1.045, -0.12); + m.vertex(0.147, 1.01, -0.12); + m.vertex(0.184, 1.045, -0.12); + m.vertex(0.196, 1.14, -0.12); + m.vertex(0.074, 1.14, -0.12); + m.triangle(0, 2, 1); + m.triangle(5, 6, 7); + m.triangle(0, 3, 2); + m.triangle(5, 7, 8); + m.triangle(0, 4, 3); + m.triangle(5, 8, 9); + m.triangle(0, 1, 5); + m.triangle(1, 6, 5); + m.triangle(1, 2, 6); + m.triangle(2, 7, 6); + m.triangle(2, 3, 7); + m.triangle(3, 8, 7); + m.triangle(3, 4, 8); + m.triangle(4, 9, 8); + m.triangle(4, 0, 9); + m.triangle(0, 5, 9); + var p17 = part(m, m.mesh(), g2, 1, "Upper tooth 4", 0, 0, 0); + m.vertex(0.218, 1.045, -0.27); + m.vertex(0.282, 1.01, -0.27); + m.vertex(0.319, 1.045, -0.27); + m.vertex(0.331, 1.14, -0.27); + m.vertex(0.209, 1.14, -0.27); + m.vertex(0.218, 1.045, -0.12); + m.vertex(0.282, 1.01, -0.12); + m.vertex(0.319, 1.045, -0.12); + m.vertex(0.331, 1.14, -0.12); + m.vertex(0.209, 1.14, -0.12); + m.triangle(0, 2, 1); + m.triangle(5, 6, 7); + m.triangle(0, 3, 2); + m.triangle(5, 7, 8); + m.triangle(0, 4, 3); + m.triangle(5, 8, 9); + m.triangle(0, 1, 5); + m.triangle(1, 6, 5); + m.triangle(1, 2, 6); + m.triangle(2, 7, 6); + m.triangle(2, 3, 7); + m.triangle(3, 8, 7); + m.triangle(3, 4, 8); + m.triangle(4, 9, 8); + m.triangle(4, 0, 9); + m.triangle(0, 5, 9); + var p18 = part(m, m.mesh(), g2, 1, "Upper tooth 5", 0, 0, 0); + var g19 = m.group(); m.name(g19, "Lower jaw"); + m.parent(g19, g2); + m.vertex(-0.07, 0.925, -0.2); + m.vertex(0.045, 0.91, -0.2); + m.vertex(0.1, 0.945, -0.2); + m.vertex(0.12, 1.016, -0.2); + m.vertex(-0.12, 1.016, -0.2); + m.vertex(-0.07, 0.925, -0.07); + m.vertex(0.045, 0.91, -0.07); + m.vertex(0.1, 0.945, -0.07); + m.vertex(0.12, 1.016, -0.07); + m.vertex(-0.12, 1.016, -0.07); + m.triangle(0, 2, 1); + m.triangle(5, 6, 7); + m.triangle(0, 3, 2); + m.triangle(5, 7, 8); + m.triangle(0, 4, 3); + m.triangle(5, 8, 9); + m.triangle(0, 1, 5); + m.triangle(1, 6, 5); + m.triangle(1, 2, 6); + m.triangle(2, 7, 6); + m.triangle(2, 3, 7); + m.triangle(3, 8, 7); + m.triangle(3, 4, 8); + m.triangle(4, 9, 8); + m.triangle(4, 0, 9); + m.triangle(0, 5, 9); + var p20 = part(m, m.mesh(), g19, 0, "Chin", 0, 0, 0); + var p21 = part(m, m.cylinder(0.0272, 0.032, 0.1995, 7), g19, 1, "Jaw hinge", -0.185, 1.03, -0.065); + m.rotate(p21, 32.73523, 0, 33.46175); + var p22 = part(m, m.cylinder(0.0272, 0.032, 0.1995, 7), g19, 1, "Jaw hinge", 0.185, 1.03, -0.065); + m.rotate(p22, 32.73523, 0, -33.46175); + m.vertex(-0.06, 0.94, -0.205); + m.vertex(0.065, 0.925, -0.205); + m.vertex(0.04, 0.98, -0.205); + m.vertex(-0.1, 1.005, -0.205); + m.vertex(-0.06, 0.94, -0.2); + m.vertex(0.065, 0.925, -0.2); + m.vertex(0.04, 0.98, -0.2); + m.vertex(-0.1, 1.005, -0.2); + m.triangle(0, 2, 1); + m.triangle(4, 5, 6); + m.triangle(0, 3, 2); + m.triangle(4, 6, 7); + m.triangle(0, 1, 4); + m.triangle(1, 5, 4); + m.triangle(1, 2, 5); + m.triangle(2, 6, 5); + m.triangle(2, 3, 6); + m.triangle(3, 7, 6); + m.triangle(3, 0, 7); + m.triangle(0, 4, 7); + var p23 = part(m, m.mesh(), g19, 1, "Chin light facet", 0, 0, 0); + var g24 = m.group(); m.name(g24, "Torso"); + m.parent(g24, g1); + var p25 = part(m, m.cylinder(0.02975, 0.035, 0.61, 7), g24, 0, "Spine", 0, 0.705, 0.055); + m.rotate(p25, 0, 0, 0); + var p26 = part(m, m.sphere(1, 8), g24, 0, "Neck joint", 0, 1.01, 0); + m.scale(p26, 0.07, 0.06, 0.065); + var g27 = m.group(); m.name(g27, "Rib cage row 1"); + m.parent(g27, g24); + var p28 = part(m, m.cylinder(0.01955, 0.023, 0.10374, 7), g27, 1, "Rib 1 segment -1 0", 0.05905, 0.55285, -0.12201); + m.rotate(p28, -74.93151, 0, 80.90257); + var p29 = part(m, m.cylinder(0.01955, 0.023, 0.10483, 7), g27, 1, "Rib 1 segment -1 1", -0.04422, 0.55338, -0.12399); + m.rotate(p29, 105.06849, 0, 83.27063); + var p30 = part(m, m.cylinder(0.01955, 0.023, 0.09196, 7), g27, 1, "Rib 1 segment -1 2", -0.13806, 0.5468, -0.09955); + m.rotate(p30, 105.06849, 0, 65.35156); + var p31 = part(m, m.cylinder(0.01955, 0.023, 0.07218, 7), g27, 1, "Rib 1 segment -1 3", -0.20247, 0.53451, -0.05388); + m.rotate(p31, 105.06849, 0, 38.80903); + var p32 = part(m, m.cylinder(0.01955, 0.023, 0.0622, 7), g27, 1, "Rib 1 segment -1 4", -0.22371, 0.51912, 0.00328); + m.rotate(p32, 105.06849, 0, -2.53819); + var p33 = part(m, m.cylinder(0.01955, 0.023, 0.07428, 7), g27, 1, "Rib 1 segment -1 5", -0.19726, 0.50392, 0.05974); + m.rotate(p33, 105.06849, 0, -42.47025); + var p34 = part(m, m.cylinder(0.01955, 0.023, 0.09394, 7), g27, 1, "Rib 1 segment -1 6", -0.12874, 0.49215, 0.10346); + m.rotate(p34, 105.06849, 0, -67.62294); + var p35 = part(m, m.cylinder(0.01955, 0.023, 0.10374, 7), g27, 1, "Rib 1 segment 1 0", -0.05905, 0.55285, -0.12201); + m.rotate(p35, -74.93151, 0, -80.90257); + var p36 = part(m, m.cylinder(0.01955, 0.023, 0.10483, 7), g27, 1, "Rib 1 segment 1 1", 0.04422, 0.55338, -0.12399); + m.rotate(p36, 105.06849, 0, -83.27063); + var p37 = part(m, m.cylinder(0.01955, 0.023, 0.09196, 7), g27, 1, "Rib 1 segment 1 2", 0.13806, 0.5468, -0.09955); + m.rotate(p37, 105.06849, 0, -65.35156); + var p38 = part(m, m.cylinder(0.01955, 0.023, 0.07218, 7), g27, 1, "Rib 1 segment 1 3", 0.20247, 0.53451, -0.05388); + m.rotate(p38, 105.06849, 0, -38.80903); + var p39 = part(m, m.cylinder(0.01955, 0.023, 0.0622, 7), g27, 1, "Rib 1 segment 1 4", 0.22371, 0.51912, 0.00328); + m.rotate(p39, 105.06849, 0, 2.53819); + var p40 = part(m, m.cylinder(0.01955, 0.023, 0.07428, 7), g27, 1, "Rib 1 segment 1 5", 0.19726, 0.50392, 0.05974); + m.rotate(p40, 105.06849, 0, 42.47025); + var p41 = part(m, m.cylinder(0.01955, 0.023, 0.09394, 7), g27, 1, "Rib 1 segment 1 6", 0.12874, 0.49215, 0.10346); + m.rotate(p41, 105.06849, 0, 67.62294); + var g42 = m.group(); m.name(g42, "Rib cage row 2"); + m.parent(g42, g24); + var p43 = part(m, m.cylinder(0.01955, 0.023, 0.11511, 7), g42, 1, "Rib 2 segment -1 0", 0.06547, 0.66285, -0.14078); + m.rotate(p43, -76.86598, 0, 80.61714); + var p44 = part(m, m.cylinder(0.01955, 0.023, 0.11627, 7), g42, 1, "Rib 2 segment -1 1", -0.04903, 0.66338, -0.14307); + m.rotate(p44, 103.13402, 0, 83.0578); + var p45 = part(m, m.cylinder(0.01955, 0.023, 0.10253, 7), g42, 1, "Rib 2 segment -1 2", -0.15307, 0.6568, -0.11486); + m.rotate(p45, 103.13402, 0, 64.66189); + var p46 = part(m, m.cylinder(0.01955, 0.023, 0.08158, 7), g42, 1, "Rib 2 segment -1 3", -0.22448, 0.64451, -0.06216); + m.rotate(p46, 103.13402, 0, 37.93254); + var p47 = part(m, m.cylinder(0.01955, 0.023, 0.07116, 7), g42, 1, "Rib 2 segment -1 4", -0.24803, 0.62912, 0.00379); + m.rotate(p47, 103.13402, 0, -2.45975); + var p48 = part(m, m.cylinder(0.01955, 0.023, 0.0838, 7), g42, 1, "Rib 2 segment -1 5", -0.2187, 0.61392, 0.06893); + m.rotate(p48, 103.13402, 0, -41.57462); + var p49 = part(m, m.cylinder(0.01955, 0.023, 0.10464, 7), g42, 1, "Rib 2 segment -1 6", -0.14274, 0.60215, 0.11938); + m.rotate(p49, 103.13402, 0, -66.98184); + var p50 = part(m, m.cylinder(0.01955, 0.023, 0.11511, 7), g42, 1, "Rib 2 segment 1 0", -0.06547, 0.66285, -0.14078); + m.rotate(p50, -76.86598, 0, -80.61714); + var p51 = part(m, m.cylinder(0.01955, 0.023, 0.11627, 7), g42, 1, "Rib 2 segment 1 1", 0.04903, 0.66338, -0.14307); + m.rotate(p51, 103.13402, 0, -83.0578); + var p52 = part(m, m.cylinder(0.01955, 0.023, 0.10253, 7), g42, 1, "Rib 2 segment 1 2", 0.15307, 0.6568, -0.11486); + m.rotate(p52, 103.13402, 0, -64.66189); + var p53 = part(m, m.cylinder(0.01955, 0.023, 0.08158, 7), g42, 1, "Rib 2 segment 1 3", 0.22448, 0.64451, -0.06216); + m.rotate(p53, 103.13402, 0, -37.93254); + var p54 = part(m, m.cylinder(0.01955, 0.023, 0.07116, 7), g42, 1, "Rib 2 segment 1 4", 0.24803, 0.62912, 0.00379); + m.rotate(p54, 103.13402, 0, 2.45975); + var p55 = part(m, m.cylinder(0.01955, 0.023, 0.0838, 7), g42, 1, "Rib 2 segment 1 5", 0.2187, 0.61392, 0.06893); + m.rotate(p55, 103.13402, 0, 41.57462); + var p56 = part(m, m.cylinder(0.01955, 0.023, 0.10464, 7), g42, 1, "Rib 2 segment 1 6", 0.14274, 0.60215, 0.11938); + m.rotate(p56, 103.13402, 0, 66.98184); + var g57 = m.group(); m.name(g57, "Rib cage row 3"); + m.parent(g57, g24); + var p58 = part(m, m.cylinder(0.01955, 0.023, 0.1218, 7), g57, 1, "Rib 3 segment -1 0", 0.06932, 0.77285, -0.14547); + m.rotate(p58, -77.27564, 0, 80.85392); + var p59 = part(m, m.cylinder(0.01955, 0.023, 0.12307, 7), g57, 1, "Rib 3 segment -1 1", -0.05191, 0.77338, -0.14784); + m.rotate(p59, 102.72436, 0, 83.23437); + var p60 = part(m, m.cylinder(0.01955, 0.023, 0.10805, 7), g57, 1, "Rib 3 segment -1 2", -0.16207, 0.7668, -0.11869); + m.rotate(p60, 102.72436, 0, 65.23355); + var p61 = part(m, m.cylinder(0.01955, 0.023, 0.08501, 7), g57, 1, "Rib 3 segment -1 3", -0.23768, 0.75451, -0.06424); + m.rotate(p61, 102.72436, 0, 38.65734); + var p62 = part(m, m.cylinder(0.01955, 0.023, 0.07342, 7), g57, 1, "Rib 3 segment -1 4", -0.26262, 0.73912, 0.00391); + m.rotate(p62, 102.72436, 0, -2.52448); + var p63 = part(m, m.cylinder(0.01955, 0.023, 0.08746, 7), g57, 1, "Rib 3 segment -1 5", -0.23156, 0.72392, 0.07123); + m.rotate(p63, 102.72436, 0, -42.3155); + var p64 = part(m, m.cylinder(0.01955, 0.023, 0.11036, 7), g57, 1, "Rib 3 segment -1 6", -0.15113, 0.71215, 0.12336); + m.rotate(p64, 102.72436, 0, -67.51333); + var p65 = part(m, m.cylinder(0.01955, 0.023, 0.1218, 7), g57, 1, "Rib 3 segment 1 0", -0.06932, 0.77285, -0.14547); + m.rotate(p65, -77.27564, 0, -80.85392); + var p66 = part(m, m.cylinder(0.01955, 0.023, 0.12307, 7), g57, 1, "Rib 3 segment 1 1", 0.05191, 0.77338, -0.14784); + m.rotate(p66, 102.72436, 0, -83.23437); + var p67 = part(m, m.cylinder(0.01955, 0.023, 0.10805, 7), g57, 1, "Rib 3 segment 1 2", 0.16207, 0.7668, -0.11869); + m.rotate(p67, 102.72436, 0, -65.23355); + var p68 = part(m, m.cylinder(0.01955, 0.023, 0.08501, 7), g57, 1, "Rib 3 segment 1 3", 0.23768, 0.75451, -0.06424); + m.rotate(p68, 102.72436, 0, -38.65734); + var p69 = part(m, m.cylinder(0.01955, 0.023, 0.07342, 7), g57, 1, "Rib 3 segment 1 4", 0.26262, 0.73912, 0.00391); + m.rotate(p69, 102.72436, 0, 2.52448); + var p70 = part(m, m.cylinder(0.01955, 0.023, 0.08746, 7), g57, 1, "Rib 3 segment 1 5", 0.23156, 0.72392, 0.07123); + m.rotate(p70, 102.72436, 0, 42.3155); + var p71 = part(m, m.cylinder(0.01955, 0.023, 0.11036, 7), g57, 1, "Rib 3 segment 1 6", 0.15113, 0.71215, 0.12336); + m.rotate(p71, 102.72436, 0, 67.51333); + var g72 = m.group(); m.name(g72, "Rib cage row 4"); + m.parent(g72, g24); + var p73 = part(m, m.cylinder(0.01955, 0.023, 0.11712, 7), g72, 1, "Rib 4 segment -1 0", 0.06675, 0.88285, -0.13139); + m.rotate(p73, -75.96376, 0, 81.36637); + var p74 = part(m, m.cylinder(0.01955, 0.023, 0.11842, 7), g72, 1, "Rib 4 segment -1 1", -0.04999, 0.88338, -0.13353); + m.rotate(p74, 104.03624, 0, 83.61614); + var p75 = part(m, m.cylinder(0.01955, 0.023, 0.10304, 7), g72, 1, "Rib 4 segment -1 2", -0.15607, 0.8768, -0.1072); + m.rotate(p75, 104.03624, 0, 66.48624); + var p76 = part(m, m.cylinder(0.01955, 0.023, 0.07905, 7), g72, 1, "Rib 4 segment -1 3", -0.22888, 0.86451, -0.05802); + m.rotate(p76, 104.03624, 0, 40.30468); + var p77 = part(m, m.cylinder(0.01955, 0.023, 0.06668, 7), g72, 1, "Rib 4 segment -1 4", -0.25289, 0.84912, 0.00353); + m.rotate(p77, 104.03624, 0, -2.67661); + var p78 = part(m, m.cylinder(0.01955, 0.023, 0.08164, 7), g72, 1, "Rib 4 segment -1 5", -0.22298, 0.83392, 0.06433); + m.rotate(p78, 104.03624, 0, -43.99041); + var p79 = part(m, m.cylinder(0.01955, 0.023, 0.10541, 7), g72, 1, "Rib 4 segment -1 6", -0.14554, 0.82215, 0.11142); + m.rotate(p79, 104.03624, 0, -68.67518); + var p80 = part(m, m.cylinder(0.01955, 0.023, 0.11712, 7), g72, 1, "Rib 4 segment 1 0", -0.06675, 0.88285, -0.13139); + m.rotate(p80, -75.96376, 0, -81.36637); + var p81 = part(m, m.cylinder(0.01955, 0.023, 0.11842, 7), g72, 1, "Rib 4 segment 1 1", 0.04999, 0.88338, -0.13353); + m.rotate(p81, 104.03624, 0, -83.61614); + var p82 = part(m, m.cylinder(0.01955, 0.023, 0.10304, 7), g72, 1, "Rib 4 segment 1 2", 0.15607, 0.8768, -0.1072); + m.rotate(p82, 104.03624, 0, -66.48624); + var p83 = part(m, m.cylinder(0.01955, 0.023, 0.07905, 7), g72, 1, "Rib 4 segment 1 3", 0.22888, 0.86451, -0.05802); + m.rotate(p83, 104.03624, 0, -40.30468); + var p84 = part(m, m.cylinder(0.01955, 0.023, 0.06668, 7), g72, 1, "Rib 4 segment 1 4", 0.25289, 0.84912, 0.00353); + m.rotate(p84, 104.03624, 0, 2.67661); + var p85 = part(m, m.cylinder(0.01955, 0.023, 0.08164, 7), g72, 1, "Rib 4 segment 1 5", 0.22298, 0.83392, 0.06433); + m.rotate(p85, 104.03624, 0, 43.99041); + var p86 = part(m, m.cylinder(0.01955, 0.023, 0.10541, 7), g72, 1, "Rib 4 segment 1 6", 0.14554, 0.82215, 0.11142); + m.rotate(p86, 104.03624, 0, 68.67518); + var p87 = part(m, m.cylinder(0.02295, 0.027, 0.33015, 7), g24, 0, "Sternum", 0, 0.695, -0.135); + m.rotate(p87, -1.7357, 0, 0); + var p88 = part(m, m.cylinder(0.02805, 0.033, 0.31016, 7), g24, 1, "Clavicle", -0.155, 0.905, 0); + m.rotate(p88, 0, 0, 88.15239); + var p89 = part(m, m.cylinder(0.02805, 0.033, 0.31016, 7), g24, 1, "Clavicle", 0.155, 0.905, 0); + m.rotate(p89, 0, 0, -88.15239); + var g90 = m.group(); m.name(g90, "Terracotta neckerchief"); + m.parent(g90, g24); + m.vertex(-0.23, 0.76, -0.205); + m.vertex(-0.05, 0.65, -0.205); + m.vertex(0.09, 0.665, -0.205); + m.vertex(0.24, 0.76, -0.205); + m.vertex(0.33, 0.93, -0.205); + m.vertex(0.23, 1.005, -0.205); + m.vertex(0, 0.96, -0.205); + m.vertex(-0.22, 1.01, -0.205); + m.vertex(-0.32, 0.94, -0.205); + m.vertex(-0.23, 0.76, -0.16); + m.vertex(-0.05, 0.65, -0.16); + m.vertex(0.09, 0.665, -0.16); + m.vertex(0.24, 0.76, -0.16); + m.vertex(0.33, 0.93, -0.16); + m.vertex(0.23, 1.005, -0.16); + m.vertex(0, 0.96, -0.16); + m.vertex(-0.22, 1.01, -0.16); + m.vertex(-0.32, 0.94, -0.16); + m.triangle(0, 2, 1); + m.triangle(9, 10, 11); + m.triangle(0, 3, 2); + m.triangle(9, 11, 12); + m.triangle(0, 4, 3); + m.triangle(9, 12, 13); + m.triangle(0, 5, 4); + m.triangle(9, 13, 14); + m.triangle(0, 6, 5); + m.triangle(9, 14, 15); + m.triangle(0, 7, 6); + m.triangle(9, 15, 16); + m.triangle(0, 8, 7); + m.triangle(9, 16, 17); + m.triangle(0, 1, 9); + m.triangle(1, 10, 9); + m.triangle(1, 2, 10); + m.triangle(2, 11, 10); + m.triangle(2, 3, 11); + m.triangle(3, 12, 11); + m.triangle(3, 4, 12); + m.triangle(4, 13, 12); + m.triangle(4, 5, 13); + m.triangle(5, 14, 13); + m.triangle(5, 6, 14); + m.triangle(6, 15, 14); + m.triangle(6, 7, 15); + m.triangle(7, 16, 15); + m.triangle(7, 8, 16); + m.triangle(8, 17, 16); + m.triangle(8, 0, 17); + m.triangle(0, 9, 17); + var p91 = part(m, m.mesh(), g90, 5, "Scarf bib", 0, 0, 0); + m.vertex(-0.05, 0.65, -0.215); + m.vertex(0.09, 0.665, -0.215); + m.vertex(0.22, 0.82, -0.215); + m.vertex(-0.04, 0.91, -0.215); + m.vertex(-0.22, 1.01, -0.215); + m.vertex(-0.32, 0.94, -0.215); + m.vertex(-0.05, 0.65, -0.21); + m.vertex(0.09, 0.665, -0.21); + m.vertex(0.22, 0.82, -0.21); + m.vertex(-0.04, 0.91, -0.21); + m.vertex(-0.22, 1.01, -0.21); + m.vertex(-0.32, 0.94, -0.21); + m.triangle(0, 2, 1); + m.triangle(6, 7, 8); + m.triangle(0, 3, 2); + m.triangle(6, 8, 9); + m.triangle(0, 4, 3); + m.triangle(6, 9, 10); + m.triangle(0, 5, 4); + m.triangle(6, 10, 11); + m.triangle(0, 1, 6); + m.triangle(1, 7, 6); + m.triangle(1, 2, 7); + m.triangle(2, 8, 7); + m.triangle(2, 3, 8); + m.triangle(3, 9, 8); + m.triangle(3, 4, 9); + m.triangle(4, 10, 9); + m.triangle(4, 5, 10); + m.triangle(5, 11, 10); + m.triangle(5, 0, 11); + m.triangle(0, 6, 11); + var p92 = part(m, m.mesh(), g90, 5, "Scarf left folded facet", 0, 0, 0); + var p93 = part(m, m.cylinder(0.0051, 0.006, 0.15267, 7), g90, 6, "Scarf seam", -0.185, 0.885, -0.2235); + m.rotate(p93, -177.85241, 0, -58.37453); + var p94 = part(m, m.cylinder(0.0051, 0.006, 0.13946, 7), g90, 6, "Scarf seam", -0.0525, 0.8275, -0.225); + m.rotate(p94, -180, 0, -75.46554); + var p95 = part(m, m.cylinder(0.0051, 0.006, 0.13733, 7), g90, 6, "Scarf seam", 0.0825, 0.8225, -0.2235); + m.rotate(p95, 6.84277, 0, -79.43494); + var p96 = part(m, m.cylinder(0.0051, 0.006, 0.13796, 7), g90, 6, "Scarf seam", 0.2, 0.8825, -0.2205); + m.rotate(p96, 1.80874, 0, -46.45454); + m.vertex(0.02, 0.74, 0.115); + m.vertex(0.075, 0.78, 0.115); + m.vertex(0.18, 0.73, 0.115); + m.vertex(0.12, 0.93, 0.115); + m.vertex(-0.04, 0.96, 0.115); + m.vertex(0.02, 0.74, 0.16); + m.vertex(0.075, 0.78, 0.16); + m.vertex(0.18, 0.73, 0.16); + m.vertex(0.12, 0.93, 0.16); + m.vertex(-0.04, 0.96, 0.16); + m.triangle(0, 2, 1); + m.triangle(5, 6, 7); + m.triangle(0, 3, 2); + m.triangle(5, 7, 8); + m.triangle(0, 4, 3); + m.triangle(5, 8, 9); + m.triangle(0, 1, 5); + m.triangle(1, 6, 5); + m.triangle(1, 2, 6); + m.triangle(2, 7, 6); + m.triangle(2, 3, 7); + m.triangle(3, 8, 7); + m.triangle(3, 4, 8); + m.triangle(4, 9, 8); + m.triangle(4, 0, 9); + m.triangle(0, 5, 9); + var p97 = part(m, m.mesh(), g90, 5, "Back scarf tail", 0, 0, 0); + m.profile(1, 0.0375); + m.profile(1, -0.0375); + m.profile(0.8, -0.0375); + m.profile(0.8, 0.0375); + m.profile(1, 0.0375); + var p98 = part(m, m.lathe(16), g90, 5, "Scarf collar", 0, 0.96, 0); + m.scale(p98, 0.14, 1, 0.12); + var g99 = m.group(); m.name(g99, "Pelvis and belt"); + m.parent(g99, g1); + var p100 = part(m, m.sphere(1, 8), g99, 7, "Dark shorts", 0, 0.395, 0); + m.scale(p100, 0.245, 0.14, 0.13); + m.profile(1, 0.0375); + m.profile(1, -0.0375); + m.profile(0.86111, -0.0375); + m.profile(0.86111, 0.0375); + m.profile(1, 0.0375); + var p101 = part(m, m.lathe(16), g99, 6, "Leather belt", 0, 0.475, 0); + m.scale(p101, 0.252, 1, 0.155); + var p102 = part(m, m.box(0.1, 0.016, 0.025), g99, 8, "Buckle upper", 0, 0.511, -0.175); + var p103 = part(m, m.box(0.1, 0.016, 0.025), g99, 8, "Buckle lower", 0, 0.44, -0.175); + var p104 = part(m, m.box(0.016, 0.075, 0.025), g99, 8, "Buckle left", -0.047, 0.475, -0.175); + var p105 = part(m, m.box(0.016, 0.075, 0.025), g99, 8, "Buckle right", 0.047, 0.475, -0.175); + var p106 = part(m, m.box(0.065, 0.012, 0.012), g99, 8, "Buckle tongue", 0.007, 0.475, -0.19); + var g107 = m.group(); m.name(g107, "Left leg"); + m.parent(g107, g1); + var p108 = part(m, m.cylinder(0.095, 0.087, 0.11, 8), g107, 7, "Shorts leg", -0.14, 0.35, 0); + var p109 = part(m, m.cylinder(0.098, 0.098, 0.04, 8), g107, 6, "Leather cuff", -0.14, 0.303, 0); + var p110 = part(m, m.cylinder(0.0289, 0.034, 0.1607, 7), g107, 1, "Shin", -0.14, 0.2, -0.0075); + m.rotate(p110, -174.64417, 0, 0); + var p111 = part(m, m.sphere(1, 8), g107, 1, "Knee", -0.14, 0.26, 0); + m.scale(p111, 0.046, 0.05, 0.047); + var p112 = part(m, m.sphere(1, 8), g107, 0, "Ankle", -0.14, 0.12, -0.02); + m.scale(p112, 0.045, 0.045, 0.045); + var p113 = part(m, m.sphere(1, 8), g107, 1, "Chunky foot", -0.14, 0.055, -0.063); + m.scale(p113, 0.112, 0.055, 0.145); + var p114 = part(m, m.cylinder(0.0034, 0.004, 0.03302, 7), g107, 0, "Toe separation", -0.168, 0.0715, -0.1675); + m.rotate(p114, 35.13419, 0, 0); + var p115 = part(m, m.cylinder(0.0034, 0.004, 0.03302, 7), g107, 0, "Toe separation", -0.112, 0.0715, -0.1675); + m.rotate(p115, 35.13419, 0, 0); + var g116 = m.group(); m.name(g116, "Left arm"); + m.parent(g116, g1); + var p117 = part(m, m.sphere(1, 8), g116, 0, "Shoulder", -0.31, 0.91, 0); + m.scale(p117, 0.05, 0.055, 0.052); + var p118 = part(m, m.cylinder(0.02465, 0.029, 0.1872, 7), g116, 1, "Humerus", -0.4, 0.885, -0.006); + m.rotate(p118, -166.50427, 0, 74.05717); + var p119 = part(m, m.sphere(1, 8), g116, 1, "Elbow", -0.49, 0.86, -0.012); + m.scale(p119, 0.039, 0.042, 0.04); + var p120 = part(m, m.cylinder(0.01615, 0.019, 0.16226, 7), g116, 1, "Radius", -0.575, 0.88, -0.0415); + m.rotate(p120, -90, 0, 80.42158); + var p121 = part(m, m.cylinder(0.0136, 0.016, 0.16194, 7), g116, 1, "Ulna", -0.575, 0.84, -0.0125); + m.rotate(p121, -90, 0, 81.11934); + var p122 = part(m, m.sphere(1, 8), g116, 1, "Hand", -0.7, 0.86, -0.045); + m.scale(p122, 0.063, 0.063, 0.058); + var g123 = m.group(); m.name(g123, "Right leg"); + m.parent(g123, g1); + var p124 = part(m, m.cylinder(0.095, 0.087, 0.11, 8), g123, 7, "Shorts leg", 0.14, 0.35, 0); + var p125 = part(m, m.cylinder(0.098, 0.098, 0.04, 8), g123, 6, "Leather cuff", 0.14, 0.303, 0); + var p126 = part(m, m.cylinder(0.0289, 0.034, 0.1607, 7), g123, 1, "Shin", 0.14, 0.2, -0.0075); + m.rotate(p126, -174.64417, 0, 0); + var p127 = part(m, m.sphere(1, 8), g123, 1, "Knee", 0.14, 0.26, 0); + m.scale(p127, 0.046, 0.05, 0.047); + var p128 = part(m, m.sphere(1, 8), g123, 0, "Ankle", 0.14, 0.12, -0.02); + m.scale(p128, 0.045, 0.045, 0.045); + var p129 = part(m, m.sphere(1, 8), g123, 1, "Chunky foot", 0.14, 0.055, -0.063); + m.scale(p129, 0.112, 0.055, 0.145); + var p130 = part(m, m.cylinder(0.0034, 0.004, 0.03302, 7), g123, 0, "Toe separation", 0.112, 0.0715, -0.1675); + m.rotate(p130, 35.13419, 0, 0); + var p131 = part(m, m.cylinder(0.0034, 0.004, 0.03302, 7), g123, 0, "Toe separation", 0.168, 0.0715, -0.1675); + m.rotate(p131, 35.13419, 0, 0); + var g132 = m.group(); m.name(g132, "Right arm"); + m.parent(g132, g1); + var p133 = part(m, m.sphere(1, 8), g132, 0, "Shoulder", 0.31, 0.91, 0); + m.scale(p133, 0.05, 0.055, 0.052); + var p134 = part(m, m.cylinder(0.02465, 0.029, 0.1872, 7), g132, 1, "Humerus", 0.4, 0.885, -0.006); + m.rotate(p134, -166.50427, 0, -74.05717); + var p135 = part(m, m.sphere(1, 8), g132, 1, "Elbow", 0.49, 0.86, -0.012); + m.scale(p135, 0.039, 0.042, 0.04); + var p136 = part(m, m.cylinder(0.01615, 0.019, 0.16226, 7), g132, 1, "Radius", 0.575, 0.88, -0.0415); + m.rotate(p136, -90, 0, -80.42158); + var p137 = part(m, m.cylinder(0.0136, 0.016, 0.16194, 7), g132, 1, "Ulna", 0.575, 0.84, -0.0125); + m.rotate(p137, -90, 0, -81.11934); + var p138 = part(m, m.sphere(1, 8), g132, 1, "Hand", 0.7, 0.86, -0.045); + m.scale(p138, 0.063, 0.063, 0.058); + var g139 = m.group(); m.name(g139, "Equipment"); + m.parent(g139, g1); + var g140 = m.group(); m.name(g140, "Broken sword"); + m.parent(g140, g139); + m.vertex(-0.75, 0.9, -0.12); + m.vertex(-0.88, 1.12, -0.12); + m.vertex(-0.98, 1.28, -0.12); + m.vertex(-0.96, 1.4, -0.12); + m.vertex(-1.055, 1.34, -0.12); + m.vertex(-1.075, 1.25, -0.12); + m.vertex(-1.04, 1.2, -0.12); + m.vertex(-1.08, 1.17, -0.12); + m.vertex(-1.02, 1.04, -0.12); + m.vertex(-0.87, 0.84, -0.12); + m.vertex(-0.75, 0.9, -0.075); + m.vertex(-0.88, 1.12, -0.075); + m.vertex(-0.98, 1.28, -0.075); + m.vertex(-0.96, 1.4, -0.075); + m.vertex(-1.055, 1.34, -0.075); + m.vertex(-1.075, 1.25, -0.075); + m.vertex(-1.04, 1.2, -0.075); + m.vertex(-1.08, 1.17, -0.075); + m.vertex(-1.02, 1.04, -0.075); + m.vertex(-0.87, 0.84, -0.075); + m.triangle(0, 2, 1); + m.triangle(10, 11, 12); + m.triangle(0, 3, 2); + m.triangle(10, 12, 13); + m.triangle(0, 4, 3); + m.triangle(10, 13, 14); + m.triangle(0, 5, 4); + m.triangle(10, 14, 15); + m.triangle(0, 6, 5); + m.triangle(10, 15, 16); + m.triangle(0, 7, 6); + m.triangle(10, 16, 17); + m.triangle(0, 8, 7); + m.triangle(10, 17, 18); + m.triangle(0, 9, 8); + m.triangle(10, 18, 19); + m.triangle(0, 1, 10); + m.triangle(1, 11, 10); + m.triangle(1, 2, 11); + m.triangle(2, 12, 11); + m.triangle(2, 3, 12); + m.triangle(3, 13, 12); + m.triangle(3, 4, 13); + m.triangle(4, 14, 13); + m.triangle(4, 5, 14); + m.triangle(5, 15, 14); + m.triangle(5, 6, 15); + m.triangle(6, 16, 15); + m.triangle(6, 7, 16); + m.triangle(7, 17, 16); + m.triangle(7, 8, 17); + m.triangle(8, 18, 17); + m.triangle(8, 9, 18); + m.triangle(9, 19, 18); + m.triangle(9, 0, 19); + m.triangle(0, 10, 19); + var p141 = part(m, m.mesh(), g140, 10, "Notched blade", 0, 0, 0); + var p142 = part(m, m.cylinder(0.00765, 0.009, 0.4406, 7), g140, 8, "Blade fuller", -0.9125, 1.105, -0.124); + m.rotate(p142, 0, 0, 27.72826); + var p143 = part(m, m.cylinder(0.0272, 0.032, 0.23324, 7), g140, 8, "Cross guard", -0.78, 0.88, -0.1); + m.rotate(p143, 0, 0, -59.03624); + var p144 = part(m, m.cylinder(0.0306, 0.036, 0.21095, 7), g140, 6, "Leather grip", -0.715, 0.76, -0.1); + m.rotate(p144, -180, 0, -31.42957); + var p145 = part(m, m.cylinder(0.00765, 0.009, 0.05831, 7), g140, 9, "Grip wrap", -0.75, 0.85, -0.135); + m.rotate(p145, 0, 0, -59.03624); + var p146 = part(m, m.cylinder(0.00765, 0.009, 0.05831, 7), g140, 9, "Grip wrap", -0.72, 0.8, -0.135); + m.rotate(p146, 0, 0, -59.03624); + var p147 = part(m, m.cylinder(0.00765, 0.009, 0.05831, 7), g140, 9, "Grip wrap", -0.69, 0.75, -0.135); + m.rotate(p147, 0, 0, -59.03624); + var p148 = part(m, m.cylinder(0.02125, 0.025, 0.07632, 7), g140, 8, "Pommel", -0.6575, 0.69, -0.1); + m.rotate(p148, 0, 0, -58.3925); + var g149 = m.group(); m.name(g149, "Round shield"); + m.parent(g149, g139); + m.profile(0.3, 0.027); + m.profile(0.3, -0.027); + m.profile(0.255, -0.027); + m.profile(0.255, 0.027); + m.profile(0.3, 0.027); + var p150 = part(m, m.lathe(12), g149, 10, "Steel shield rim", 0.8, 0.67, -0.145); + m.rotate(p150, 90, 0, 0); + var p151 = part(m, m.cylinder(0.257, 0.257, 0.045, 12), g149, 9, "Wood shield face", 0.8, 0.67, -0.145); + m.rotate(p151, 90, 0, 0); + var p152 = part(m, m.sphere(1, 10), g149, 8, "Steel shield boss", 0.8, 0.67, -0.195); + m.scale(p152, 0.095, 0.095, 0.048); + var p153 = part(m, m.cylinder(0.00425, 0.005, 0.36, 7), g149, 6, "Wood plank groove", 0.665, 0.67, -0.172); + m.rotate(p153, 0, 0, 0); + var p154 = part(m, m.cylinder(0.00425, 0.005, 0.36, 7), g149, 6, "Wood plank groove", 0.935, 0.67, -0.172); + m.rotate(p154, 0, 0, 0); + } diff --git a/Examples/Technical/ProceduralSkeleton/Generators/SkeletonRigRecipe.ada b/Examples/Technical/ProceduralSkeleton/Generators/SkeletonRigRecipe.ada new file mode 100644 index 0000000..54dce34 --- /dev/null +++ b/Examples/Technical/ProceduralSkeleton/Generators/SkeletonRigRecipe.ada @@ -0,0 +1,158 @@ +// Recipe executed by the embedded AdaScript VM. No process, file access, JIT or Swift compilation. +func rigHasParent(model, partId, name) { + while (partId >= 0) { + if (model.partName(partId) == name) { return true; } + partId = model.partParent(partId); + } + return false; +} + +func rigRigidJoint(model, partId, joints) { + var name = model.partName(partId); + if (rigHasParent(model, partId, "Broken sword")) { return joints[18]; } + if (rigHasParent(model, partId, "Round shield")) { return joints[22]; } + if (rigHasParent(model, partId, "Lower jaw")) { return joints[6]; } + if (rigHasParent(model, partId, "Head")) { return joints[5]; } + for (var side in 0..<2) { + var leg = "Left leg"; + var arm = "Left arm"; + var hip = 7; + var shoulder = 15; + if (side == 1) { + leg = "Right leg"; + arm = "Right arm"; + hip = 11; + shoulder = 19; + } + if (rigHasParent(model, partId, leg)) { + if (name == "Shorts leg" || name == "Leather cuff") { return joints[hip]; } + if (name == "Shin" || name == "Knee") { return joints[hip + 1]; } + return joints[hip + 2]; + } + if (rigHasParent(model, partId, arm)) { + if (name == "Shoulder" || name == "Humerus") { return joints[shoulder]; } + if (name == "Hand") { return joints[shoulder + 3]; } + return joints[shoulder + 1]; + } + } + if (rigHasParent(model, partId, "Terracotta neckerchief") || name == "Spine" || name == "Sternum") { return joints[2]; } + if (rigHasParent(model, partId, "Rib cage row 1")) { return joints[2]; } + if (rigHasParent(model, partId, "Torso")) { return joints[3]; } + if (rigHasParent(model, partId, "Pelvis and belt")) { return joints[1]; } + assert(false, "No weight rule for " + name); + return joints[0]; +} + +func rigSkinParts(model, joints) { + for (var partId in 0..= 1) { rightPhase = rightPhase - 1; } + var left = rigLegAngles(phase, 0.34 + bob); + var right = rigLegAngles(rightPhase, 0.34 + bob); + model.translationKey(walk, joints[1], time, 0, 0.39 + bob, 0); + model.rotationKey(walk, joints[7], time, left[0], 0, 0); + model.rotationKey(walk, joints[8], time, left[1], 0, 0); + model.rotationKey(walk, joints[9], time, left[2], 0, 0); + model.rotationKey(walk, joints[11], time, right[0], 0, 0); + model.rotationKey(walk, joints[12], time, right[1], 0, 0); + model.rotationKey(walk, joints[13], time, right[2], 0, 0); + model.rotationKey(walk, joints[2], time, 1.2 * Math.sin(2 * cycle), 2.5 * Math.sin(cycle), 0); + model.rotationKey(walk, joints[3], time, 0, -4 * Math.sin(cycle), 1.8 * Math.sin(cycle)); + model.rotationKey(walk, joints[5], time, 0, 1.5 * Math.sin(cycle), -1.8 * Math.sin(cycle)); + model.rotationKey(walk, joints[6], time, 2 + Math.sin(2 * cycle), 0, 0); + model.rotationKey(walk, joints[15], time, -12 * Math.cos(cycle), 0, 43); + model.rotationKey(walk, joints[19], time, 12 * Math.cos(cycle), 0, -43); + model.rotationKey(walk, joints[16], time, -7 - 4 * Math.sin(cycle), 0, 0); + model.rotationKey(walk, joints[20], time, -7 + 4 * Math.sin(cycle), 0, 0); + model.rotationKey(walk, joints[17], time, 0, 0, -43); + model.rotationKey(walk, joints[21], time, 0, 0, 43); + } +} + +func build(model) { + buildStaticSkeleton(model); + var joints = []; + joints.push(model.joint("Rig.root", -1, 0, 0, 0)); + joints.push(model.joint("Rig.hips", joints[0], 0, 0.39, 0)); + joints.push(model.joint("Rig.spine", joints[1], 0, 0.29, 0)); + joints.push(model.joint("Rig.chest", joints[2], 0, 0.22, 0)); + joints.push(model.joint("Rig.neck", joints[3], 0, 0.105, 0)); + joints.push(model.joint("Rig.head", joints[4], 0, 0.135, 0)); + joints.push(model.joint("Rig.jaw", joints[5], 0, -0.15, -0.1)); + joints.push(model.joint("Rig.leftHip", joints[1], -0.14, -0.05, 0)); + joints.push(model.joint("Rig.leftKnee", joints[7], 0, -0.08, 0)); + joints.push(model.joint("Rig.leftAnkle", joints[8], 0, -0.14, -0.015)); + joints.push(model.joint("Rig.leftToe", joints[9], 0, -0.07, -0.145)); + joints.push(model.joint("Rig.rightHip", joints[1], 0.14, -0.05, 0)); + joints.push(model.joint("Rig.rightKnee", joints[11], 0, -0.08, 0)); + joints.push(model.joint("Rig.rightAnkle", joints[12], 0, -0.14, -0.015)); + joints.push(model.joint("Rig.rightToe", joints[13], 0, -0.07, -0.145)); + joints.push(model.joint("Rig.leftShoulder", joints[3], -0.31, 0.01, 0)); + joints.push(model.joint("Rig.leftElbow", joints[15], -0.18, -0.05, -0.012)); + joints.push(model.joint("Rig.leftWrist", joints[16], -0.17, 0, -0.033)); + joints.push(model.joint("Rig.leftHand", joints[17], -0.04, 0, 0)); + joints.push(model.joint("Rig.rightShoulder", joints[3], 0.31, 0.01, 0)); + joints.push(model.joint("Rig.rightElbow", joints[19], 0.18, -0.05, -0.012)); + joints.push(model.joint("Rig.rightWrist", joints[20], 0.17, 0, -0.033)); + joints.push(model.joint("Rig.rightHand", joints[21], 0.04, 0, 0)); + rigSkinParts(model, joints); + rigWalkClips(model, joints); +} diff --git a/Examples/Technical/ProceduralSkeleton/Generators/WalkStage.ada b/Examples/Technical/ProceduralSkeleton/Generators/WalkStage.ada new file mode 100644 index 0000000..e0f81a7 --- /dev/null +++ b/Examples/Technical/ProceduralSkeleton/Generators/WalkStage.ada @@ -0,0 +1,7 @@ +func build(model) { + model.material(0, 0.035, 0.048, 0.065); + model.surface(0, 0.05, 0.9); + var stage = model.cylinder(1.15, 1.18, 0.045, 48); + model.name(stage, "Turntable plinth"); + model.place(stage, 0, -0.0225, 0); +} diff --git a/Examples/Technical/ProceduralSkeleton/NativeRig/Main.swift b/Examples/Technical/ProceduralSkeleton/NativeRig/Main.swift new file mode 100644 index 0000000..70e9fd0 --- /dev/null +++ b/Examples/Technical/ProceduralSkeleton/NativeRig/Main.swift @@ -0,0 +1,45 @@ +import AdaAssets +import Foundation + +@main +struct AuthorSkeleton { + static func main() throws { + let arguments = CommandLine.arguments + guard (3...4).contains(arguments.count) else { throw RigAuthorError.invalidSource("Usage: SkeletonRig input.glb output.glb [capture-poses.glb]") } + let input = URL(fileURLWithPath: arguments[1]) + let output = URL(fileURLWithPath: arguments[2]) + let source = try NativeGLTFLoader().load(data: Data(contentsOf: input)) + guard source.skins.isEmpty, source.animations.isEmpty, source.textures.isEmpty else { + throw RigAuthorError.invalidSource("This author is specialized for the static procedural skeleton") + } + let meshes = try MeshSkinning.make(source) + let materials = source.materials.enumerated().map { index, material in + let color = material.baseColorFactor + let glow = material.emissiveFactor + return ProceduralMaterial(name: material.name ?? "Material \(index)", baseColor: [color.x, color.y, color.z, color.w], + metallic: material.metallicFactor, roughness: material.roughnessFactor, emissive: [glow.x, glow.y, glow.z]) + } + let model = ProceduralModel(materials: materials, nodes: SkeletonRig.nodes + meshes, + skins: [SkeletonRig.skin], animations: [WalkClip.bindPose(), WalkClip.make()]) + let data = try GLBWriter().encode(model) + // Round-trip through the same native importer used by the real engine. + let imported = try NativeGLTFLoader().load(data: data) + guard imported.skins.count == 1, imported.skins[0].joints.count == Joint.allCases.count, + imported.animations.contains(where: { $0.name == "Walk" && abs($0.duration - WalkClip.duration) < 0.0001 }), + imported.meshes.allSatisfy({ $0.primitives.allSatisfy { $0.skinning != nil } }) + else { throw RigAuthorError.invalidSource("Rig round-trip failed") } + try data.write(to: output, options: .atomic) + if arguments.count == 4 { + var poses = model + poses.animations = WalkClip.capturePoses() + try GLBWriter().encode(poses).write(to: URL(fileURLWithPath: arguments[3]), options: .atomic) + } + let report: [String: Any] = ["source": input.path, "output": output.path, "bytes": data.count, + "joints": Joint.allCases.map { ["name": $0.name, "node": $0.node, "paletteIndex": $0.rawValue] }, + "meshes": meshes.count, "clips": imported.animations.map { ["name": $0.name ?? "", "duration": $0.duration] }, + "skinning": "JOINTS_0 uint16 + WEIGHTS_0 float32, four slots; rigid bones and blended clothing", + "author": "Native Swift using AdaAssets NativeGLTFLoader and GLBWriter"] + let json = try JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted, .sortedKeys]) + print(String(decoding: json, as: UTF8.self)) + } +} diff --git a/Examples/Technical/ProceduralSkeleton/NativeRig/MeshSkinning.swift b/Examples/Technical/ProceduralSkeleton/NativeRig/MeshSkinning.swift new file mode 100644 index 0000000..90b1966 --- /dev/null +++ b/Examples/Technical/ProceduralSkeleton/NativeRig/MeshSkinning.swift @@ -0,0 +1,115 @@ +import AdaAssets +import Math + +enum RigAuthorError: Error { case invalidSource(String) } + +/// Bakes original part transforms into model-space vertices and merges by material. +/// Bone-like surfaces stay rigid; clothing and spine use two-joint transition weights. +enum MeshSkinning { + struct Bucket { + var mesh = ProceduralMeshData() + var joints: [SIMD4] = [] + var weights: [SIMD4] = [] + } + + static func make(_ source: GLTFImportResult) throws -> [ProceduralNode] { + var parents = [Int?](repeating: nil, count: source.nodes.count) + for (index, node) in source.nodes.enumerated() { + for child in node.children { parents[child] = index } + } + var globals = [Transform3D](repeating: .identity, count: source.nodes.count) + var buckets = [Int: Bucket]() + for (index, node) in source.nodes.enumerated() { + if let parent = parents[index] { + guard parent < index else { throw RigAuthorError.invalidSource("Expected the procedural model's parent-first hierarchy") } + globals[index] = globals[parent] * node.transform + } else { + globals[index] = node.transform + } + guard let mesh = node.meshIndex else { continue } + var ancestry = [node.name ?? ""] + var parent = parents[index] + while let current = parent { ancestry.append(source.nodes[current].name ?? ""); parent = parents[current] } + for primitive in source.meshes[mesh].primitives { + guard primitive.mode == .triangles, let material = primitive.materialIndex, + let positions = primitive.attributes[.position]?.vector3Values(), + let normals = primitive.attributes[.normal]?.vector3Values(), + let uvs = primitive.attributes[.texCoord(0)]?.vector2Values(), let indices = primitive.indices + else { throw RigAuthorError.invalidSource("Expected indexed, textured-coordinate triangle geometry") } + var bucket = buckets[material] ?? Bucket() + let offset = UInt32(bucket.mesh.positions.count) + let inverse = globals[index].inverse + // Explicit inverse transpose: the inverse's columns become the normal matrix's rows. + let normalTransform = Transform3D(rows: [inverse.x, inverse.y, inverse.z, inverse.w]) + for vertex in positions.indices { + let p = positions[vertex] + let world = globals[index] * Vector4(p.x, p.y, p.z, 1) + let n = normals[vertex] + let transformed = normalTransform * Vector4(n.x, n.y, n.z, 0) + let normal = Vector3(transformed.x, transformed.y, transformed.z).normalized + let position = SIMD3(world.x, world.y, world.z) + let binding = try influences(path: ancestry, position: position) + bucket.mesh.positions.append(position) + bucket.mesh.normals.append(normal.simd) + bucket.mesh.uvs.append(SIMD2(uvs[vertex].x, uvs[vertex].y)) + bucket.joints.append(binding.0) + bucket.weights.append(binding.1) + } + bucket.mesh.indices.append(contentsOf: indices.map { $0 + offset }) + buckets[material] = bucket + } + } + return buckets.keys.sorted().compactMap { material in + guard let bucket = buckets[material] else { return nil } + var node = ProceduralNode(name: "Skinned material \(material)", mesh: bucket.mesh) + node.parent = 0 + node.material = material + node.skinning = .init(skin: 0, joints: bucket.joints, weights: bucket.weights) + return node + } + } + + private static func influences(path: [String], position p: SIMD3) throws -> (SIMD4, SIMD4) { + let name = path.first ?? "" + if path.contains("Broken sword") { return bind(.leftHand) } + if path.contains("Round shield") { return bind(.rightHand) } + if path.contains("Lower jaw") { return bind(.jaw) } + if path.contains("Head") { return bind(.head) } + for left in [true, false] { + if path.contains(left ? "Left leg" : "Right leg") { + if ["Shorts leg", "Leather cuff"].contains(name) { return bind(left ? .leftHip : .rightHip) } + if ["Shin", "Knee"].contains(name) { return bind(left ? .leftKnee : .rightKnee) } + return bind(left ? .leftAnkle : .rightAnkle) + } + if path.contains(left ? "Left arm" : "Right arm") { + if ["Shoulder", "Humerus"].contains(name) { return bind(left ? .leftShoulder : .rightShoulder) } + if name == "Hand" { return bind(left ? .leftHand : .rightHand) } + return bind(left ? .leftElbow : .rightElbow) + } + } + if path.contains("Terracotta neckerchief") || name == "Spine" || name == "Sternum" { + return blend(.spine, .chest, (p.y - 0.63) / 0.28) + } + if path.contains("Torso") { + if p.y < 0.58 { return bind(.spine) } + return bind(.chest) + } + if path.contains("Pelvis and belt") { + if name == "Dark shorts" { + let amount = min(1, max(0, (0.43 - p.y) / 0.14)) * min(1, abs(p.x) / 0.11) + return blend(.hips, p.x < 0 ? .leftHip : .rightHip, amount) + } + return bind(.hips) + } + throw RigAuthorError.invalidSource("No semantic weight rule for \(path.joined(separator: "/"))") + } + + private static func bind(_ joint: Joint) -> (SIMD4, SIMD4) { + (SIMD4(UInt16(joint.rawValue), 0, 0, 0), SIMD4(1, 0, 0, 0)) + } + + private static func blend(_ first: Joint, _ second: Joint, _ amount: Float) -> (SIMD4, SIMD4) { + let value = min(1, max(0, amount)) + return (SIMD4(UInt16(first.rawValue), UInt16(second.rawValue), 0, 0), SIMD4(1 - value, value, 0, 0)) + } +} diff --git a/Examples/Technical/ProceduralSkeleton/NativeRig/SkeletonRig.swift b/Examples/Technical/ProceduralSkeleton/NativeRig/SkeletonRig.swift new file mode 100644 index 0000000..bd47923 --- /dev/null +++ b/Examples/Technical/ProceduralSkeleton/NativeRig/SkeletonRig.swift @@ -0,0 +1,91 @@ +import AdaAssets +import Math + +/// A deliberately authored rig for this character's proportions, in its original bind pose. +enum Joint: Int, CaseIterable { + case root, hips, spine, chest, neck, head, jaw + case leftHip, leftKnee, leftAnkle, leftToe + case rightHip, rightKnee, rightAnkle, rightToe + case leftShoulder, leftElbow, leftWrist, leftHand + case rightShoulder, rightElbow, rightWrist, rightHand + + var node: Int { rawValue + 1 } + var name: String { "Rig." + String(describing: self) } + + var parent: Joint? { + switch self { + case .root: nil + case .hips: .root + case .spine, .leftHip, .rightHip: .hips + case .chest: .spine + case .neck, .leftShoulder, .rightShoulder: .chest + case .head: .neck + case .jaw: .head + case .leftKnee: .leftHip + case .leftAnkle: .leftKnee + case .leftToe: .leftAnkle + case .rightKnee: .rightHip + case .rightAnkle: .rightKnee + case .rightToe: .rightAnkle + case .leftElbow: .leftShoulder + case .leftWrist: .leftElbow + case .leftHand: .leftWrist + case .rightElbow: .rightShoulder + case .rightWrist: .rightElbow + case .rightHand: .rightWrist + } + } + + var position: Vector3 { + switch self { + case .root: [0, 0, 0] + case .hips: [0, 0.39, 0] + case .spine: [0, 0.68, 0] + case .chest: [0, 0.9, 0] + case .neck: [0, 1.005, 0] + case .head: [0, 1.14, 0] + case .jaw: [0, 0.99, -0.1] + case .leftHip: [-0.14, 0.34, 0] + case .leftKnee: [-0.14, 0.26, 0] + case .leftAnkle: [-0.14, 0.12, -0.015] + case .leftToe: [-0.14, 0.05, -0.16] + case .rightHip: [0.14, 0.34, 0] + case .rightKnee: [0.14, 0.26, 0] + case .rightAnkle: [0.14, 0.12, -0.015] + case .rightToe: [0.14, 0.05, -0.16] + case .leftShoulder: [-0.31, 0.91, 0] + case .leftElbow: [-0.49, 0.86, -0.012] + case .leftWrist: [-0.66, 0.86, -0.045] + case .leftHand: [-0.7, 0.86, -0.045] + case .rightShoulder: [0.31, 0.91, 0] + case .rightElbow: [0.49, 0.86, -0.012] + case .rightWrist: [0.66, 0.86, -0.045] + case .rightHand: [0.7, 0.86, -0.045] + } + } + + var local: Vector3 { position - (parent?.position ?? .zero) } +} + +struct SkeletonRig { + static var nodes: [ProceduralNode] { + [ProceduralNode(name: "Skeleton skinned")] + Joint.allCases.map { joint in + var node = ProceduralNode(name: joint.name) + node.parent = joint.parent?.node ?? 0 + node.translation = joint.local.simd + return node + } + } + + static var skin: ProceduralSkin { + ProceduralSkin(name: "Skeleton humanoid", skeletonRoot: Joint.root.node, + joints: Joint.allCases.map(\.node), inverseBindMatrices: Joint.allCases.map { joint in + let p = joint.position + return SIMD16(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -p.x, -p.y, -p.z, 1) + }) + } +} + +extension Vector3 { + var simd: SIMD3 { SIMD3(x, y, z) } +} diff --git a/Examples/Technical/ProceduralSkeleton/NativeRig/WalkClip.swift b/Examples/Technical/ProceduralSkeleton/NativeRig/WalkClip.swift new file mode 100644 index 0000000..a18ca4f --- /dev/null +++ b/Examples/Technical/ProceduralSkeleton/NativeRig/WalkClip.swift @@ -0,0 +1,83 @@ +import AdaAssets +import Math + +/// Authored in-place walk. Two-bone leg IK maintains a flat planted foot during stance. +enum WalkClip { + static let duration: Float = 1.2 + static let keyCount = 49 + + static func make() -> ProceduralAnimation { + let times = (0..]]() + var hips: [SIMD4] = [] + for time in times { + // Force the final key to match the first, including floating-point rounding. + let phase: Float = time == duration ? 0 : time / duration + let cycle = phase * 2 * .pi + let bob: Float = -0.025 + 0.004 * Math.cos(2 * cycle) + hips.append(SIMD4(0, Joint.hips.local.y + bob, 0, 0)) + var pose = [Joint: SIMD3]() + for (hip, knee, ankle, offset) in [(Joint.leftHip, Joint.leftKnee, Joint.leftAnkle, Float(0)), (.rightHip, .rightKnee, .rightAnkle, 0.5)] { + let leg = legAngles(phase: (phase + offset).truncatingRemainder(dividingBy: 1), hipHeight: hip.position.y + bob) + pose[hip] = [leg.x, 0, 0] + pose[knee] = [leg.y, 0, 0] + pose[ankle] = [leg.z, 0, 0] + } + pose[.spine] = [1.2 * Math.sin(2 * cycle), 2.5 * Math.sin(cycle), 0] + pose[.chest] = [0, -4 * Math.sin(cycle), 1.8 * Math.sin(cycle)] + pose[.head] = [0, 1.5 * Math.sin(cycle), -1.8 * Math.sin(cycle)] + pose[.jaw] = [2 + Math.sin(2 * cycle), 0, 0] + pose[.leftShoulder] = [-12 * Math.cos(cycle), 0, 43] + pose[.rightShoulder] = [12 * Math.cos(cycle), 0, -43] + pose[.leftElbow] = [-7 - 4 * Math.sin(cycle), 0, 0] + pose[.rightElbow] = [-7 + 4 * Math.sin(cycle), 0, 0] + // Keep the held props upright while the shoulders lower the arms from the authored bind pose. + pose[.leftWrist] = [0, 0, -43] + pose[.rightWrist] = [0, 0, 43] + for (joint, angles) in pose { rotations[joint, default: []].append(ProceduralMath.quaternion(eulerDegrees: angles)) } + } + var tracks = [ProceduralAnimation.Track(node: Joint.hips.node, path: .translation, times: times, values: hips)] + for joint in Joint.allCases { + if let values = rotations[joint] { tracks.append(.init(node: joint.node, path: .rotation, times: times, values: values)) } + } + return ProceduralAnimation(name: "Walk", tracks: tracks) + } + + static func bindPose() -> ProceduralAnimation { + var tracks = Joint.allCases.map { joint in + ProceduralAnimation.Track(node: joint.node, path: .rotation, times: [0, duration], values: [[0, 0, 0, 1], [0, 0, 0, 1]]) + } + let p = Joint.hips.local + tracks.append(.init(node: Joint.hips.node, path: .translation, times: [0, duration], values: [[p.x, p.y, p.z, 0], [p.x, p.y, p.z, 0]])) + return ProceduralAnimation(name: "BindPose", tracks: tracks) + } + + /// Frozen native-player poses for deterministic GPU frame capture, independent of screenshot/readback stalls. + static func capturePoses() -> [ProceduralAnimation] { + let walk = make() + return (0..<24).map { frame in + let tracks = walk.tracks.map { track in + let value = track.values[frame * 2] + return ProceduralAnimation.Track(node: track.node, path: track.path, times: [0, duration], values: [value, value]) + } + return ProceduralAnimation(name: "CapturePose\(frame)", tracks: tracks) + } + } + + private static func legAngles(phase: Float, hipHeight: Float) -> SIMD3 { + let stance = phase < 0.5 + let progress = stance ? phase * 2 : (phase - 0.5) * 2 + let z: Float = stance ? -0.075 + 0.15 * progress : 0.075 - 0.15 * progress + let lift: Float = stance ? 0 : 0.035 * Math.sin(.pi * progress) + let dy = hipHeight - (Joint.leftAnkle.position.y + lift) + let dz = z - 0.015 + let upper: Float = 0.08 + let lower = Math.sqrt(Float(0.14 * 0.14 + 0.015 * 0.015)) + let distance = min(Math.sqrt(dy * dy + dz * dz), upper + lower - 0.00001) + let clamp: (Float) -> Float = { min(1, max(-1, $0)) } + let knee = -Math.acos(clamp((distance * distance - upper * upper - lower * lower) / (2 * upper * lower))) + let hip = Math.atan2(-dz, dy) - Math.atan2(lower * Math.sin(knee), upper + lower * Math.cos(knee)) + let restInclination = Math.atan2(Float(0.015), Float(0.14)) + return SIMD3(hip, knee - restInclination, restInclination - hip - knee) * (180 / .pi) + } +} diff --git a/Examples/Technical/ProceduralSkeleton/Preview.png b/Examples/Technical/ProceduralSkeleton/Preview.png new file mode 100644 index 0000000..0971049 Binary files /dev/null and b/Examples/Technical/ProceduralSkeleton/Preview.png differ diff --git a/Examples/Technical/ProceduralSkeleton/README.md b/Examples/Technical/ProceduralSkeleton/README.md new file mode 100644 index 0000000..c3e1b8a --- /dev/null +++ b/Examples/Technical/ProceduralSkeleton/README.md @@ -0,0 +1,87 @@ +# Procedural Skeleton: an AdaScript rigging study + +This technical example authors a stylized skeleton's geometry, 23 joints, vertex +weights and walk clip in one interpreted AdaScript recipe. It is intentionally +outside the public examples catalog; it does not appear in Studio recommendations. + +![Native AdaEngine preview](Preview.png) + +## Open the project + +Open this directory in Ada Studio, open `Assets/Scenes/Walk.ascn` and press Play. +The committed `Skeleton-AdaScript.glb` already contains the rig and `Walk` clip. +The scene uses the existing native importer, animation player and GPU skinning. +Select `BindPose` instead of `Walk` on the subject's `Model3DSource` to inspect +its undeformed pose. `Showcase.ascn` retains the original static model. + +The character has 7,348 vertices, 4,284 triangles, 11 materials and 306 vertices +with two nonzero joint influences. Walk lasts 1.2 seconds with 49 keys per track. +The leg IK runs while authoring keys; runtime playback evaluates a regular clip. + +## Regenerate through Studio + +Use a Studio build containing the procedural rig builder from +[Ada PR #206](https://github.com/AdaEngine/Ada/pull/206). Pass the full contents of +`Generators/Skeleton-Rig.ada` as `source` to `editor.model.generate`, with a fresh +`destination`, such as `Assets/Models/Skeleton-v2.glb`. The tool never overwrites +an existing asset. Validate the new file and point the scene at its asset reference. + +The native builder ships precompiled in the app. The embedded compiler generates +bytecode for the VM; recipe execution needs no JIT, Swift compiler, Python, shell, +Blender or cloud 3D service. Character anatomy, weight rules and leg IK are in +AdaScript. The language agent itself may still use an external model. + +`SkeletonRigRecipe.ada` holds the rig and motion rules; `Skeleton.ada` contains +static geometry generated by `author_recipe.py`. The composed `Skeleton-Rig.ada` +is self-contained and does not load those files at runtime. + +Windows VM execution, buffer validation and native GPU playback have been checked. +Physical iPad execution and a complete rebuilt Studio bundle remain unverified. +The installed validation Studio predates the new generation API: it proves output +playback; the actual extended generator was exercised in the developer harness. + +## Optional developer validation + +These commands require a compatible local Ada engine checkout and Swift, and are +not steps performed on an iPad. From this example directory, set the engine path +explicitly; no local package dependency is committed: + +```powershell +$env:ADAENGINE_ROOT = 'C:/path/to/Ada' +$env:ADAENGINE_GRAVITY_PACKAGE_PATH = 'C:/path/to/windows-compatible/gravity-lang' +python script_rig.py test +python script_rig.py generate +python check_geometry.py +python check_rig.py --asset Assets/Models/Skeleton-AdaScript.glb --output Evidence/ScriptRig/buffer-check.json +``` + +Use the repository's Windows-compatible Gravity override on Windows. On other +hosts, the engine's `Sources/gravity-lang` is the default dependency. The harness +copies actual production sources into an isolated package under the selected +engine's `.build-tools` directory and uses a standalone resource-scope adapter. +It does not build the full engine or Studio. After building the runner once, +`python script_rig.py run` executes it directly without invoking a compiler. + +With this project open in Studio and its MCP server on port 2516: + +```text +python rig_capture.py --asset Assets/Models/Skeleton-AdaScript.glb --evidence Evidence/ScriptRig --live-only +``` + +Captures, reports, logs, workspace state and auxiliary sampled GLBs are ignored. +The committed preview and four small GLBs are the playback assets and comparison +fixtures. The older Swift author in `NativeRig/` is a developer comparison baseline; +it is not needed to execute the AdaScript recipe. Its tests run with +`python native_rig.py test` using the same `ADAENGINE_ROOT`. + +## Limits + +This recipe assigns an explicit rig to known generated parts; it does not infer +anatomy or edit arbitrary imported meshes. The builder supports up to 128 joints, +two weights per vertex and linear joint translation/rotation clips. Rest-joint +rotations, scale keys, fingers, facial rigging, cloth simulation and retargeting +are not implemented here. Check actual poses visually; import and buffer checks +do not establish the absence of every self-intersection. + +The original user-supplied character reference was labeled KayKit. This is a +procedural study based on that reference, not an official KayKit asset package. diff --git a/Examples/Technical/ProceduralSkeleton/Sources/Game.ada b/Examples/Technical/ProceduralSkeleton/Sources/Game.ada new file mode 100644 index 0000000..bad6175 --- /dev/null +++ b/Examples/Technical/ProceduralSkeleton/Sources/Game.ada @@ -0,0 +1 @@ +// The showcase uses native scene components. Geometry is authored by Generators/Skeleton.ada. diff --git a/Examples/Technical/ProceduralSkeleton/Validation/ScriptRigMain.swift b/Examples/Technical/ProceduralSkeleton/Validation/ScriptRigMain.swift new file mode 100644 index 0000000..f492098 --- /dev/null +++ b/Examples/Technical/ProceduralSkeleton/Validation/ScriptRigMain.swift @@ -0,0 +1,30 @@ +import AdaAssets +import AdaScriptCompilerCore +import AdaScripting +import Foundation + +/// Developer harness: the model is authored entirely by the same interpreted generator used in the app. +@main +struct ScriptRigMain { + @MainActor + static func main() throws { + guard CommandLine.arguments.count == 3 else { return } + let source = try String(contentsOfFile: CommandLine.arguments[1], encoding: .utf8) + let issues = AdaScriptLinter.lint(source: source) + guard issues.isEmpty else { + let details = issues.prefix(8).map { "\($0.rule.rawValue) at line \($0.range.start.line + 1)" }.joined(separator: ", ") + throw NSError(domain: "ScriptRigStudy", code: 1, userInfo: [NSLocalizedDescriptionKey: "Recipe lint: \(issues.count) issues: \(details)"]) + } + let start = ContinuousClock.now + let model = try AdaScriptModelGenerator.generate(source: source) + let duration = start.duration(to: .now) + let data = try GLBWriter().encode(model) + let loaded = try NativeGLTFLoader().load(data: data) + try data.write(to: URL(fileURLWithPath: CommandLine.arguments[2]), options: .atomic) + let report: [String: Any] = ["sourceBytes": source.utf8.count, "sourceLintIssues": issues.count, "bytes": data.count, "joints": loaded.skins.first?.joints.count ?? 0, + "clips": loaded.animations.map { $0.name ?? "" }, "meshes": loaded.meshes.count, + "execution": "AdaScript bytecode VM plus precompiled native builder; no subprocess during generation", + "generationSeconds": Double(duration.components.seconds) + Double(duration.components.attoseconds) / 1e18] + print(String(decoding: try JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted, .sortedKeys]), as: UTF8.self)) + } +} diff --git a/Examples/Technical/ProceduralSkeleton/Validation/SkeletonWalkTests.swift b/Examples/Technical/ProceduralSkeleton/Validation/SkeletonWalkTests.swift new file mode 100644 index 0000000..4e470f5 --- /dev/null +++ b/Examples/Technical/ProceduralSkeleton/Validation/SkeletonWalkTests.swift @@ -0,0 +1,101 @@ +import AdaAnimation +import AdaAssets +import Math +import Testing +@testable import SkeletonRig + +@Suite("Skeleton walk acceptance") +struct SkeletonWalkTests { + private func player() throws -> SkeletalAnimationPlayer { + let nodes = SkeletonRig.nodes + let rig = try SkeletalRig(nodes: nodes.map { node in + let t = node.translation + let pose = SkeletalJointPose(translation: Vector3(t.x, t.y, t.z)) + return SkeletalRig.Node(parentIndex: node.parent, restPose: pose, restMatrix: pose.matrix, name: node.name) + }) + let clip = WalkClip.make() + let tracks = try clip.tracks.map { track in + try SkeletalAnimationTrack(nodeIndex: track.node, path: track.path == .rotation ? .rotation : .translation, + interpolation: .linear, times: track.times.map(Double.init), + values: track.values.map { Vector4($0.x, $0.y, $0.z, $0.w) }) + } + return try SkeletalAnimationPlayer(rig: rig, clips: [.init(name: clip.name, tracks: tracks)]) + } + + @Test("Native player loops continuously and leaves the model root in place") + func loop() throws { + var player = try player() + try player.play("Walk", transitionDuration: 0) + let duration = Double(WalkClip.duration) + for time in [0.0, 0.17, 0.49, 0.93] { + player.seek(to: time) + let first = player.globalTransforms + player.seek(to: time + duration) + for index in first.indices { + let a = first[index], b = player.globalTransforms[index] + for column in 0..<4 { + for row in 0..<4 { #expect(abs(a[column, row] - b[column, row]) < 0.00002) } + } + } + #expect(player.globalTransforms[0] == .identity) + } + } + + @Test("Both feet remain flat and above the floor; each alternates stance and swing") + func contacts() throws { + var player = try player() + try player.play("Walk", transitionDuration: 0) + var raised = [Joint.leftAnkle: Float(0), Joint.rightAnkle: Float(0)] + for step in 0..<96 { + player.seek(to: Double(step) / 96 * Double(WalkClip.duration)) + for ankle in [Joint.leftAnkle, .rightAnkle] { + let matrix = player.globalTransforms[ankle.node] + let height = matrix.w.y - ankle.position.y + #expect(height >= -0.001, "Sole must not sink through the floor") + #expect(height < 0.037, "Swing clearance must stay within the authored range") + #expect(abs(matrix.y.x) < 0.0001 && abs(matrix.y.z) < 0.0001 && abs(matrix.y.y - 1) < 0.0001, "Foot should stay flat") + raised[ankle] = max(raised[ankle] ?? 0, height) + } + let left = player.globalTransforms[Joint.leftAnkle.node].w.y + let right = player.globalTransforms[Joint.rightAnkle.node].w.y + #expect(min(left, right) < 0.121, "At least one planted foot is required") + } + #expect((raised[.leftAnkle] ?? 0) > 0.03 && (raised[.rightAnkle] ?? 0) > 0.03) + } + + @Test("Encoded clip keys and inverse binds survive the native importer") + func export() throws { + var mesh = ProceduralNode(name: "Probe", mesh: ProceduralGeometry.box(width: 0.1, height: 0.1, depth: 0.1)) + mesh.parent = 0 + let count = mesh.mesh?.positions.count ?? 0 + mesh.skinning = .init(skin: 0, joints: Array(repeating: [5, 0, 0, 0], count: count), weights: Array(repeating: [1, 0, 0, 0], count: count)) + let model = ProceduralModel(materials: [.init(name: "Probe")], nodes: SkeletonRig.nodes + [mesh], + skins: [SkeletonRig.skin], animations: [WalkClip.make()]) + let imported = try NativeGLTFLoader().load(data: GLBWriter().encode(model)) + #expect(imported.skins.first?.joints.count == 23) + #expect(imported.animations.first?.samplers.allSatisfy { $0.times.count == 49 } == true) + let inverse = try #require(imported.skins.first?.inverseBindMatrices[Joint.head.rawValue]) + #expect(abs(inverse.w.y + 1.14) < 0.00001) + } + + @Test("Baking rotated source geometry preserves outward-facing normals") + func bakedNormals() throws { + let root = ProceduralNode(name: "Head") + var box = ProceduralNode(name: "Rotated skull probe", mesh: ProceduralGeometry.box(width: 1, height: 2, depth: 3)) + box.parent = 0 + box.rotation = ProceduralMath.quaternion(eulerDegrees: [25, 35, 70]) + box.scale = [1, 2, 3] + let source = ProceduralModel(materials: [.init(name: "Bone")], nodes: [root, box]) + let imported = try NativeGLTFLoader().load(data: GLBWriter().encode(source)) + let baked = try MeshSkinning.make(imported) + let mesh = try #require(baked.first?.mesh) + for start in stride(from: 0, to: mesh.indices.count, by: 3) { + let corners = (0..<3).map { Int(mesh.indices[start + $0]) } + let p = mesh.positions[corners[0]], q = mesh.positions[corners[1]], r = mesh.positions[corners[2]] + let u = q - p, v = r - p + let cross = SIMD3(u.y * v.z - u.z * v.y, u.z * v.x - u.x * v.z, u.x * v.y - u.y * v.x) + let face = cross / (cross * cross).sum().squareRoot() + for index in corners { #expect((face * mesh.normals[index]).sum() > 0.999) } + } + } +} diff --git a/Examples/Technical/ProceduralSkeleton/Validation/StandaloneAssetScope.swift b/Examples/Technical/ProceduralSkeleton/Validation/StandaloneAssetScope.swift new file mode 100644 index 0000000..0e63ab0 --- /dev/null +++ b/Examples/Technical/ProceduralSkeleton/Validation/StandaloneAssetScope.swift @@ -0,0 +1,9 @@ +import Foundation + +// The CLI has no ECS asset manager. Supply only the loader's resource-scope boundary; +// GLB decoding, skin/animation import and writing all use the actual engine sources. +// This host always restricts external dependencies instead of granting unrestricted access. +enum AssetsManager { + static let restrictedResourceRoot: URL? = URL(fileURLWithPath: + ProcessInfo.processInfo.environment["SKELETON_RIG_RESOURCE_ROOT"] ?? FileManager.default.currentDirectoryPath) +} diff --git a/Examples/Technical/ProceduralSkeleton/author_recipe.py b/Examples/Technical/ProceduralSkeleton/author_recipe.py new file mode 100644 index 0000000..9918bf4 --- /dev/null +++ b/Examples/Technical/ProceduralSkeleton/author_recipe.py @@ -0,0 +1,283 @@ +"""Author the AdaScript recipe. Only stdlib; no mesh writer or 3D application. + +Coordinates below use +Z for the front for readability; emitted AdaScript uses +the engine camera convention (front is -Z). All actual meshes are made by Ada. +""" +import math +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +lines = ["// Generated by author_recipe.py; run directly through editor.model.generate.", + "// Units: meters. Front: -Z. Static multipart study, not a skinned mesh.", + "func part(m, p, parent, mat, name, x, y, z) {", + " m.parent(p, parent); m.paint(p, mat); m.name(p, name); m.place(p, x, y, z); return p;", + "}", "func build(m) {"] +counter = 0 + + +def n(value): + return f"{value:.5f}".rstrip("0").rstrip(".") if value else "0" + + +def emit(text): + lines.append(" " + text) + + +def group(name, parent=None): + global counter + counter += 1 + key = f"g{counter}" + emit(f'var {key} = m.group(); m.name({key}, "{name}");') + if parent: + emit(f"m.parent({key}, {parent});") + return key + + +def shape(expr, name, parent, mat, pos=(0, 0, 0), scale=None, rotation=None): + global counter + counter += 1 + key = f"p{counter}" + x, y, z = pos + emit(f'var {key} = part(m, {expr}, {parent}, {mat}, "{name}", {n(x)}, {n(y)}, {n(-z)});') + if scale: + emit(f"m.scale({key}, {', '.join(n(v) for v in scale)});") + if rotation: + emit(f"m.rotate({key}, {', '.join(n(v) for v in rotation)});") + return key + + +def sphere(name, parent, mat, pos, scale, seg=8): + return shape(f"m.sphere(1, {seg})", name, parent, mat, pos, scale) + + +def bone(name, parent, a, b, radius=.027, mat=1): + delta = [b[0]-a[0], b[1]-a[1], -(b[2]-a[2])] + length = math.sqrt(sum(v*v for v in delta)) + pitch = math.degrees(math.atan2(delta[2], delta[1])) + roll = -math.degrees(math.asin(delta[0]/length)) + return shape(f"m.cylinder({n(radius*.85)}, {n(radius)}, {n(length)}, 7)", + name, parent, mat, tuple((x+y)/2 for x, y in zip(a, b)), rotation=(pitch, 0, roll)) + + +def mesh(name, parent, mat, verts, tris): + # Reflect Z and reverse winding to preserve outward normals. + for x, y, z in verts: + emit(f"m.vertex({n(x)}, {n(y)}, {n(-z)});") + for a, b, c in tris: + emit(f"m.triangle({a}, {c}, {b});") + return shape("m.mesh()", name, parent, mat) + + +def front(name, parent, mat, verts, tris): + fixed = [] + for a, b, c in tris: + p, q, r = verts[a], verts[b], verts[c] + cross_z = (q[0]-p[0])*(r[1]-p[1])-(q[1]-p[1])*(r[0]-p[0]) + fixed.append((a, b, c) if cross_z > 0 else (a, c, b)) + return mesh(name, parent, mat, verts, fixed) + + +def plate(name, parent, mat, polygon, front_z, back_z): + count = len(polygon) + area = sum(polygon[i][0]*polygon[(i+1)%count][1]-polygon[(i+1)%count][0]*polygon[i][1] for i in range(count)) + if area < 0: + polygon = list(reversed(polygon)) + verts = [(x, y, front_z) for x, y in polygon] + [(x, y, back_z) for x, y in polygon] + tris = [] + for i in range(1, count-1): + tris += [(0, i, i+1), (count, count+i+1, count+i)] + for i in range(count): + j = (i+1)%count + tris += [(i, count+i, j), (j, count+i, count+j)] + return mesh(name, parent, mat, verts, tris) + + +def oval_ring(name, parent, mat, y, rx, rz, thickness, height, segments=16): + # Lathe makes an actual hollow ring, rather than a solid disc hiding ribs. + for r, yy in [(1, height/2), (1, -height/2), (1-thickness/rx, -height/2), + (1-thickness/rx, height/2), (1, height/2)]: + emit(f"m.profile({n(r)}, {n(yy)});") + return shape(f"m.lathe({segments})", name, parent, mat, (0, y, 0), (rx, 1, rz)) + + +def build(): + materials = { + 0: (.73, .63, .43, 0, .85), # bone shaded facets + 1: (.94, .83, .59, 0, .76), # ivory + 2: (.12, .065, .025, 0, .95), # cavity + 3: (.56, .25, .022, 0, .82), # socket walls + 4: (1, .65, .015, 0, .35), # luminous eye + 5: (.46, .16, .085, 0, .95), # terracotta scarf + 6: (.24, .075, .038, 0, .95), # folds, belt + 7: (.078, .067, .058, 0, .95), # shorts + 8: (.24, .32, .35, .6, .55), # steel + 9: (.39, .17, .08, 0, .9), # shield wood + 10: (.55, .64, .64, .5, .48), # edge + } + for i, (r, g, b, metal, rough) in materials.items(): + emit(f"m.material({i}, {n(r)}, {n(g)}, {n(b)}); m.surface({i}, {n(metal)}, {n(rough)});") + emit("m.emissive(4, 1, 0.65, 0.008);") + root = group("Skeleton minion") + head = group("Head", root) + # Whole cranium shell: beveled perimeter, faceted sides and convex back. + outline = [(-.22, 1.70), (.22, 1.70), (.46, 1.55), + (.47, 1.25), (.37, 1.115), (.19, 1.06), (-.19, 1.06), + (-.37, 1.115), (-.47, 1.25), (-.46, 1.55)] + count = len(outline) + verts = [(x, y, .265) for x, y in outline] + verts += [(x*.92, 1.38+(y-1.38)*.95, -.19) for x, y in outline] + verts += [(x*.57, 1.38+(y-1.38)*.66, -.29) for x, y in outline] + verts.append((0, 1.38, -.31)) + tris = [] + # Outline is clockwise, side quads face out. + for level in range(2): + for i in range(count): + j = (i+1)%count + a, b = level*count+i, level*count+j + c, d = a+count, b+count + tris += [(a, b, c), (b, d, c)] + for i in range(count): + tris.append((2*count+i, 2*count+(i+1)%count, 3*count)) + mesh("Faceted cranium shell", head, 1, verts, tris) + + # Two open socket rings. Their side walls recede 11 cm into the skull. + outer_left = [(-.22, 1.70), (0, 1.70), (0, 1.38), + (0, 1.10), (-.19, 1.06), (-.37, 1.115), + (-.47, 1.25), (-.46, 1.55)] + for side in [-1, 1]: + label = "Left" if side < 0 else "Right" + outer = [(x*(-side), y, .265) for x, y in outer_left] + cx, cy = side*.235, 1.365 + inner = [(cx+.155*math.sin(i*math.pi/4), cy+.16*math.cos(i*math.pi/4), .295) for i in range(8)] + # Left inner sequence aligns clockwise top -> nose -> lower -> outer. + if side > 0: + inner = [(2*cx-x, y, z) for x, y, z in inner] + v = outer+inner + ts = [] + for i in range(8): + j = (i+1)%8 + ts += [(i, j, 8+i), (j, 8+j, 8+i)] + front(label+" orbital ivory rim", head, 1, v, ts) + back = [(cx+(x-cx)*.72, cy+(y-cy)*.72, .17) for x, y, z in inner] + # The wall normals face toward the socket center. + wall = inner+back + ts = [] + for i in range(8): + j = (i+1)%8 + tri = (i, j, i+8) + a,b,c = [wall[k] for k in tri] + normal = ((b[1]-a[1])*(c[2]-a[2])-(b[2]-a[2])*(c[1]-a[1]), + (b[2]-a[2])*(c[0]-a[0])-(b[0]-a[0])*(c[2]-a[2]), 0) + inward = (cx-(a[0]+b[0]+c[0])/3, cy-(a[1]+b[1]+c[1])/3) + if normal[0]*inward[0]+normal[1]*inward[1] < 0: + ts += [(i, i+8, j), (j, i+8, j+8)] + else: + ts += [(i, j, i+8), (j, j+8, i+8)] + mesh(label+" recessed socket walls", head, 3, wall, ts) + front(label+" socket shadow", head, 2, back, [(0, i, i+1) for i in range(1, 7)]) + sphere(label+" ember eye", head, 4, (cx, cy-.005, .205), (.061, .084, .043), 10) + # Nose reads as a dark triangular recess with ivory bridge and lower bevel. + plate("Nasal cavity", head, 2, [(-.065, 1.145), (.065, 1.145), (0, 1.285)], .288, .22) + front("Nasal bevel", head, 0, [(-.075,1.135,.29), (-.065,1.145,.29), + (0,1.17,.295), (.065,1.145,.29), (.075,1.135,.29)], [(0,1,2),(2,3,4)]) + for i, x in enumerate([-.27, -.135, 0, .135, .27]): + plate(f"Upper tooth {i+1}", head, 1, + [(x-.061,1.14), (x+.061,1.14), (x+.049,1.045), (x+.012,1.01), (x-.052,1.045)], .27, .12) + jaw = group("Lower jaw", head) + plate("Chin", jaw, 0, [(-.12,1.016), (.12,1.016), (.10,.945), (.045,.91), (-.07,.925)], .20, .07) + for side in [-1, 1]: + bone("Jaw hinge", jaw, (side*.13,.96,.11), (side*.24,1.10,.02), .032) + plate("Chin light facet", jaw, 1, [(-.10,1.005), (.04,.98), (.065,.925), (-.06,.94)], .205, .20) + + torso = group("Torso", root) + bone("Spine", torso, (0,.40,-.055), (0,1.01,-.055), .035, 0) + sphere("Neck joint", torso, 0, (0,1.01,0), (.07,.06,.065)) + for level, (y, rx, rz) in enumerate([(.52,.23,.13), (.63,.255,.15), (.74,.27,.155), (.85,.26,.14)]): + rib = group(f"Rib cage row {level+1}", torso) + # U-shaped left and right ribs, with a gap in the sternum. + for side in [-1, 1]: + points = [] + for j in range(8): + angle = -.5+j*(math.pi+.12)/7 + points.append((side*rx*math.sin(angle), y+.035*math.cos(angle), rz*math.cos(angle))) + for j in range(len(points)-1): + bone(f"Rib {level+1} segment {side} {j}", rib, points[j], points[j+1], .023) + bone("Sternum", torso, (0,.53,.13), (0,.86,.14), .027, 0) + for side in [-1,1]: + bone("Clavicle", torso, (0,.90,0), (side*.31,.91,0), .033) + scarf = group("Terracotta neckerchief", torso) + plate("Scarf bib", scarf, 5, [(-.32,.94), (-.22,1.01), (0,.96), (.23,1.005), (.33,.93), + (.24,.76), (.09,.665), (-.05,.65), (-.23,.76)], .205, .16) + plate("Scarf left folded facet", scarf, 5, [(-.32,.94), (-.22,1.01), (-.04,.91), (.22,.82), (.09,.665), (-.05,.65)], .215, .21) + fold = [(-.25,.925,.222), (-.12,.845,.225), (.015,.81,.225), (.15,.835,.222), (.25,.93,.219)] + for i in range(4): + bone("Scarf seam", scarf, fold[i], fold[i+1], .006, 6) + plate("Back scarf tail", scarf, 5, [(-.04,.96), (.12,.93), (.18,.73), (.075,.78), (.02,.74)], -.115, -.16) + oval_ring("Scarf collar", scarf, 5, .96, .14, .12, .028, .075) + + pelvis = group("Pelvis and belt", root) + sphere("Dark shorts", pelvis, 7, (0,.395,0), (.245,.14,.13), 8) + oval_ring("Leather belt", pelvis, 6, .475, .252, .155, .035, .075) + # Buckle built from four bars, the leather remains visible through it. + for name, pos, dimensions in [ + ("Buckle upper", (0,.511,.175), (.10,.016,.025)), + ("Buckle lower", (0,.44,.175), (.10,.016,.025)), + ("Buckle left", (-.047,.475,.175), (.016,.075,.025)), + ("Buckle right", (.047,.475,.175), (.016,.075,.025)), + ("Buckle tongue", (.007,.475,.19), (.065,.012,.012))]: + shape("m.box("+", ".join(n(v) for v in dimensions)+")", name, pelvis, 8, pos) + + for side in [-1, 1]: + leg = group("Left leg" if side < 0 else "Right leg", root) + x = side*.14 + shape("m.cylinder(0.095, 0.087, 0.11, 8)", "Shorts leg", leg, 7, (x,.35,0)) + shape("m.cylinder(0.098, 0.098, 0.04, 8)", "Leather cuff", leg, 6, (x,.303,0)) + bone("Shin", leg, (x,.28,0), (x,.12,.015), .034) + sphere("Knee", leg, 1, (x,.26,0), (.046,.05,.047)) + sphere("Ankle", leg, 0, (x,.12,.02), (.045,.045,.045)) + sphere("Chunky foot", leg, 1, (x,.055,.063), (.112,.055,.145)) + for j in [-1,1]: + bone("Toe separation", leg, (x+j*.028,.058,.177), (x+j*.028,.085,.158), .004, 0) + arm = group("Left arm" if side < 0 else "Right arm", root) + shoulder = (side*.31,.91,0) + elbow = (side*.49,.86,.012) + sphere("Shoulder", arm, 0, shoulder, (.05,.055,.052)) + bone("Humerus", arm, shoulder, elbow, .029) + sphere("Elbow", arm, 1, elbow, (.039,.042,.04)) + bone("Radius", arm, (side*.495,.88,.028), (side*.655,.88,.055), .019) + bone("Ulna", arm, (side*.495,.84,0), (side*.655,.84,.025), .016) + sphere("Hand", arm, 1, (side*.70,.86,.045), (.063,.063,.058)) + + # Equipment kept as independent, named subassemblies. These are static props. + equipment = group("Equipment", root) + sword = group("Broken sword", equipment) + # Hero holds sword on image-left, angled outward. + plate("Notched blade", sword, 10, [(-.75,.90), (-.88,1.12), (-.98,1.28), (-.96,1.40), + (-1.055,1.34), (-1.075,1.25), (-1.04,1.20), (-1.08,1.17), (-1.02,1.04), (-.87,.84)], .12, .075) + bone("Blade fuller", sword, (-.81,.91,.124), (-1.015,1.30,.124), .009, 8) + bone("Cross guard", sword, (-.88,.82,.10), (-.68,.94,.10), .032, 8) + bone("Leather grip", sword, (-.77,.85,.10), (-.66,.67,.10), .036, 6) + for i in range(3): + bone("Grip wrap", sword, (-.775+i*.03,.835-i*.05,.135), (-.725+i*.03,.865-i*.05,.135), .009, 9) + bone("Pommel", sword, (-.69,.67,.10), (-.625,.71,.10), .025, 8) + shield = group("Round shield", equipment) + # A vertical lathe ring, rotate +90 around X: model front is -Z. + for r, y in [(.30,.027), (.30,-.027), (.255,-.027), (.255,.027), (.30,.027)]: + emit(f"m.profile({n(r)}, {n(y)});") + shape("m.lathe(12)", "Steel shield rim", shield, 10, (.80,.67,.145), rotation=(90,0,0)) + shape("m.cylinder(0.257, 0.257, 0.045, 12)", "Wood shield face", shield, 9, (.80,.67,.145), rotation=(90,0,0)) + sphere("Steel shield boss", shield, 8, (.80,.67,.195), (.095,.095,.048), 10) + for xoff in [-.135,.135]: + bone("Wood plank groove", shield, (.80+xoff,.49,.172), (.80+xoff,.85,.172), .005, 6) + emit("}") + + +if __name__ == "__main__": + build() + target = ROOT / "Generators" / "Skeleton.ada" + target.parent.mkdir(parents=True, exist_ok=True) + source = "\n".join(lines)+"\n" + if len(source.encode()) > 65536: + raise RuntimeError("AdaScript source exceeds VM limit") + target.write_text(source, encoding="utf-8") + print(f"{target}: {len(source.encode())} bytes, {counter} nodes") diff --git a/Examples/Technical/ProceduralSkeleton/check_geometry.py b/Examples/Technical/ProceduralSkeleton/check_geometry.py new file mode 100644 index 0000000..0ed8407 --- /dev/null +++ b/Examples/Technical/ProceduralSkeleton/check_geometry.py @@ -0,0 +1,102 @@ +"""Check the actual GLB vertex/index buffers and this experiment's asset contract. + +This is not a manifold, intersection or artistic-similarity validator. +""" +import hashlib +import json +import math +from pathlib import Path +import struct + +ROOT = Path(__file__).resolve().parent + + +def check(path): + data = path.read_bytes() + magic, version, total = struct.unpack_from("<4sII", data) + assert magic == b"glTF" and version == 2 and total == len(data), "Invalid GLB header" + cursor, chunks = 12, {} + while cursor < len(data): + length, kind = struct.unpack_from("= size and end <= len(binary), "Buffer overrun" + assert end <= view.get("byteOffset", 0)+view["byteLength"], "Accessor exceeds buffer view" + return [struct.unpack_from("<"+fmt*width, binary, start+i*stride) for i in range(entry["count"])] + + triangles, vertices, min_normal, max_normal = 0, 0, 2, 0 + for mesh in gltf["meshes"]: + for primitive in mesh["primitives"]: + assert primitive.get("mode", 4) == 4, "Expected triangle lists" + attributes = primitive["attributes"] + positions, normals = accessor(attributes["POSITION"]), accessor(attributes["NORMAL"]) + indices = [value[0] for value in accessor(primitive["indices"])] + assert len(positions) == len(normals) and len(indices) % 3 == 0 + assert all(0 <= i < len(positions) for i in indices), "Index outside vertex buffer" + assert all(math.isfinite(x) for v in positions+normals for x in v), "Non-finite geometry" + assert len(accessor(attributes["TEXCOORD_0"])) == len(positions) + assert 0 <= primitive["material"] < len(gltf["materials"]) + for normal in normals: + length = math.sqrt(sum(x*x for x in normal)) + min_normal, max_normal = min(min_normal, length), max(max_normal, length) + assert abs(length-1) < .002, "Non-unit normal" + for i in range(0, len(indices), 3): + a, b, c = [positions[j] for j in indices[i:i+3]] + u, v = [b[j]-a[j] for j in range(3)], [c[j]-a[j] for j in range(3)] + normal = [u[1]*v[2]-u[2]*v[1], u[2]*v[0]-u[0]*v[2], u[0]*v[1]-u[1]*v[0]] + assert sum(x*x for x in normal) > 1e-16, f"Degenerate face: {mesh['name']}" + # A smooth normal may be nearly perpendicular, but must not point behind its face. + for j in indices[i:i+3]: + assert sum(normal[k]*normals[j][k] for k in range(3)) >= -1e-8, f"Reversed normal: {mesh['name']}" + vertices += len(positions) + triangles += len(indices)//3 + + nodes = gltf["nodes"] + parents = {} + for i, node in enumerate(nodes): + for child in node.get("children", []): + assert 0 <= child < len(nodes) and child not in parents, "Invalid hierarchy" + parents[child] = i + for i in range(len(nodes)): + seen = set() + while i in parents: + assert i not in seen, "Cycle in node hierarchy" + seen.add(i) + i = parents[i] + required = {"Head", "Torso", "Left arm", "Right arm", "Left leg", "Right leg", + "Left ember eye", "Right ember eye", "Broken sword", "Round shield"} + assert required <= {node.get("name") for node in nodes}, "Missing character parts" + assert not gltf.get("skins") and not gltf.get("animations"), "This is a static asset contract" + assert not gltf.get("images"), "No image-generation or external texture dependency expected" + assert triangles < 10000 and len(data) < 1024*1024, "Prototype budget exceeded" + report = {"passed": True, "asset": str(path.relative_to(ROOT)), "sha256": hashlib.sha256(data).hexdigest(), + "bytes": len(data), "nodes": len(nodes), "meshes": len(gltf["meshes"]), + "materials": len(gltf["materials"]), "vertices": vertices, "triangles": triangles, + "normalLengthRange": [min_normal, max_normal], + "checks": ["GLB/chunk bounds", "self-contained buffers", "accessor ranges", "index ranges", + "finite positions and unit normals", "non-degenerate triangles", "normals agree with winding", + "UV counts", "material indices", "acyclic single-parent hierarchy", "named character parts", + "static/no-texture contract", "triangle and byte budgets"], + "notChecked": ["manifoldness", "self-intersections", "rig or deformation quality", "reference similarity"]} + return report + + +if __name__ == "__main__": + result = check(ROOT / "Assets/Models/Skeleton.glb") + (ROOT / "Evidence").mkdir(exist_ok=True) + (ROOT / "Evidence/geometry-check.json").write_text(json.dumps(result, indent=2)+"\n", encoding="utf-8") + print(json.dumps(result, indent=2)) diff --git a/Examples/Technical/ProceduralSkeleton/check_rig.py b/Examples/Technical/ProceduralSkeleton/check_rig.py new file mode 100644 index 0000000..ea79812 --- /dev/null +++ b/Examples/Technical/ProceduralSkeleton/check_rig.py @@ -0,0 +1,168 @@ +"""Independent checks on the actual authored GLB: bind pose, weights and loop seam. + +Uses only the standard library. Artistic deformation and intersections still need visual review. +""" +import hashlib +import argparse +import json +import math +from pathlib import Path +import struct + +ROOT = Path(__file__).resolve().parent + + +def identity(): + return [[float(i == j) for j in range(4)] for i in range(4)] + + +def multiply(a, b): + return [[sum(a[i][k]*b[k][j] for k in range(4)) for j in range(4)] for i in range(4)] + + +def transform(matrix, vector): + return [sum(matrix[i][j]*vector[j] for j in range(4)) for i in range(4)] + + +def matrix(node): + if "matrix" in node: + return [[node["matrix"][j*4+i] for j in range(4)] for i in range(4)] + x, y, z, w = node.get("rotation", [0, 0, 0, 1]) + s = node.get("scale", [1, 1, 1]) + t = node.get("translation", [0, 0, 0]) + rows = [[1-2*(y*y+z*z), 2*(x*y-z*w), 2*(x*z+y*w)], + [2*(x*y+z*w), 1-2*(x*x+z*z), 2*(y*z-x*w)], + [2*(x*z-y*w), 2*(y*z+x*w), 1-2*(x*x+y*y)]] + return [[rows[i][j]*s[j] for j in range(3)]+[t[i]] for i in range(3)]+[[0, 0, 0, 1]] + + +class GLB: + def __init__(self, path): + data = path.read_bytes() + magic, version, size = struct.unpack_from("<4sII", data) + assert magic == b"glTF" and version == 2 and size == len(data) + offset, chunks = 12, {} + while offset < len(data): + size, kind = struct.unpack_from(" 1e-16 + assert all(sum(face[k]*normals[j][k] for k in range(3)) >= -1e-8 for j in corners) + for vertex, (p, js, ws) in enumerate(zip(positions, joints, weights)): + assert all(0 <= joint < 23 for joint in js) + assert all(math.isfinite(w) and 0 <= w <= 1 for w in ws) + sum_error = max(sum_error, abs(sum(ws)-1)) + assert sum_error < 1e-5 + influences = sum(w > 1e-6 for w in ws) + assert 1 <= influences <= 2 + blended += influences == 2 + targets = [transform(palettes[joint], [*p, 1]) for joint in js] + deformed = [sum(ws[j]*targets[j][axis] for j in range(4)) for axis in range(3)] + authored = transform(rig.globals[i], [*p, 1]) + rest_error = max(rest_error, math.dist(deformed, authored[:3])) + source_error = max(source_error, math.dist(authored[:3], original[vertex][:3])) + vertices += len(positions) + assert rest_error < 1e-5 and source_error < 1e-5 and normal_error < 1e-5 and blended > 0 + walk = next(clip for clip in rig.doc["animations"] if clip["name"] == "Walk") + seam_error = 0. + for channel in walk["channels"]: + sampler = walk["samplers"][channel["sampler"]] + times, values = rig.accessor(sampler["input"]), rig.accessor(sampler["output"]) + assert len(times) == len(values) == 49 and times[0][0] == 0 and abs(times[-1][0]-1.2) < 1e-6 + assert all(a[0] < b[0] for a, b in zip(times, times[1:])) + seam_error = max(seam_error, max(abs(a-b) for a, b in zip(values[0], values[-1]))) + assert seam_error < 1e-6 + return {"passed": True, "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), "vertices": vertices, + "triangles": triangles, "joints": 23, "skinnedMeshes": len(rig.doc["meshes"]), "blendedVertices": blended, + "maximumWeightSumError": sum_error, "maximumBindPoseErrorMeters": rest_error, + "maximumSourcePositionErrorMeters": source_error, "maximumLoopSeamError": seam_error, + "maximumSourceNormalError": normal_error, + "walkDurationSeconds": 1.2, "keysPerTrack": 49, + "notChecked": ["arbitrary character auto-rigging", "manifoldness", "all possible self-intersections"]} + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--asset", type=Path, default=ROOT / "Assets/Models/Skeleton-Walk.glb") + parser.add_argument("--output", type=Path, default=ROOT / "Evidence/Rig/buffer-check.json") + args = parser.parse_args() + result = check(args.asset.resolve()) + target = args.output + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(result, indent=2)+"\n", encoding="utf-8") + print(json.dumps(result, indent=2)) diff --git a/Examples/Technical/ProceduralSkeleton/experiment.py b/Examples/Technical/ProceduralSkeleton/experiment.py new file mode 100644 index 0000000..e01b621 --- /dev/null +++ b/Examples/Technical/ProceduralSkeleton/experiment.py @@ -0,0 +1,147 @@ +"""Generate and accept the model using a running native Ada Studio MCP server. + + python experiment.py generate --destination Assets/Models/Skeleton-next.glb + python experiment.py capture + +Open this project in Studio with --mcp-port=2516 first. No Python packages needed. +""" +import argparse +import json +import math +from pathlib import Path +import shutil +import time +import urllib.request +import urllib.parse + +ROOT = Path(__file__).resolve().parent + + +class MCP: + def __init__(self, endpoint): + if urllib.parse.urlsplit(endpoint).scheme not in ("http", "https"): + raise ValueError("The MCP endpoint must use HTTP or HTTPS") + self.endpoint, self.session, self.sequence = endpoint, None, 0 + self.request("initialize", { + "protocolVersion": "2024-11-05", "capabilities": {}, + "clientInfo": {"name": "procedural-skeleton-study", "version": "1"}, + }) + + def request(self, method, params): + self.sequence += 1 + headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"} + if self.session: + headers["Mcp-Session-Id"] = self.session + payload = {"jsonrpc": "2.0", "id": self.sequence, "method": method, "params": params} + request = urllib.request.Request(self.endpoint, headers=headers, data=json.dumps(payload).encode()) + with urllib.request.urlopen(request, timeout=60) as response: + self.session = response.headers.get("Mcp-Session-Id", self.session) + body = response.read().decode() + if body.startswith(("event:", "data:")): + body = next(line[5:].strip() for line in body.splitlines() if line.startswith("data:")) + reply = json.loads(body) + if "error" in reply: + raise RuntimeError(reply["error"]) + return reply["result"] + + def tool(self, name, **arguments): + result = self.request("tools/call", {"name": name, "arguments": arguments}) + text = next(item["text"] for item in result["content"] if item["type"] == "text") + if result.get("isError"): + raise RuntimeError(text) + return json.loads(text) + + +def save(name, payload): + target = ROOT / "Evidence" / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(payload, indent=2, ensure_ascii=False)+"\n", encoding="utf-8") + + +def build(client): + client.tool("editor.build.start") + deadline = time.monotonic()+60 + while time.monotonic() < deadline: + state = client.tool("editor.task.status") + if not state["isWorkspaceTaskRunning"]: + output = client.tool("editor.output.read", limit=40) + if state.get("buildStep", {}).get("title") != "Completed": + raise RuntimeError({"state": state, "output": output}) + return output + time.sleep(.2) + raise TimeoutError("Native project build exceeded 60 seconds") + + +def turn(client, degrees): + half = math.radians(degrees)/2 + scene = client.tool("editor.scene.get") + client.tool("editor.scene.apply", expectedRevision=scene["revision"], operations=[{ + "op": "setComponent", "entityID": "subject", "typeName": "AdaTransform.Transform", + "payload": {"position": [0, 0, 0], "rotation": [0, math.sin(half), 0, math.cos(half)], "scale": [1, 1, 1]}, + }]) + + +def capture(client): + status = client.tool("editor.task.status") + if status["isWorkspaceTaskRunning"] or status["playMode"] != "editing": + client.tool("editor.task.stop") + report = {"model": "Assets/Models/Skeleton.glb", "views": [], "limitations": [ + "Static multipart model; no skin, animation clips, retopology or texture maps.", + "These checks do not prove manifold topology, absence of intersections, or reference similarity.", + ]} + validation = client.tool("editor.model.validate", path=report["model"]) + save("native-validation.json", validation) + if not validation.get("valid"): + raise RuntimeError("Native model validation failed") + try: + for name, degrees in [("front", 0), ("three-quarter", -28), ("side", -90), ("back", 180)]: + turn(client, degrees) + build(client) + result = client.tool("editor.play.run_scenario", width=1280, height=960, maxDimension=1280, + duration=.7, steps=[{"t": .4, "capture": name}], + watch={"entities": ["Camera", "Skeleton minion"], "components": ["Transform"]}) + frames = result.get("frames", []) + if (result["status"] != "completed" or result["errors"] or result["scriptErrorCount"] + or len(frames) != 1 or frames[0].get("uniform", True) or "error" in frames[0]): + raise RuntimeError(result) + target = ROOT / "Evidence" / f"{name}.png" + shutil.copy2(frames[0]["path"], target) + save(f"session-{name}.json", result) + report["views"].append({"yawDegrees": degrees, "savedAs": target.name, **frames[0]}) + print(f"{name}: native GPU {frames[0]['width']}x{frames[0]['height']}", flush=True) + finally: + turn(client, -28) + build(client) + save("capture-report.json", report) + client.tool("editor.play.start") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=["generate", "capture"]) + parser.add_argument("--endpoint", default="http://127.0.0.1:2516/mcp") + parser.add_argument("--destination", default="Assets/Models/Skeleton.glb") + args = parser.parse_args() + client = MCP(args.endpoint) + context = client.tool("editor.project.context") + if Path(context["projectPath"]).resolve() != ROOT: + raise RuntimeError("The MCP endpoint has a different project open") + if args.action == "capture": + settings = json.loads((ROOT / ".ada/project.json").read_text(encoding="utf-8")) + if (context["activeDocument"]["path"] != "Assets/Scenes/Showcase.ascn" + or settings["runtime"]["entry"]["scene"] != "Assets/Scenes/Showcase.ascn"): + raise RuntimeError("Static capture requires Showcase.ascn as both active scene and runtime entry; use rig_capture.py for Walk.ascn") + capture(client) + else: + result = client.tool("editor.model.generate", source=(ROOT / "Generators/Skeleton.ada").read_text(encoding="utf-8"), + destination=args.destination) + save("generation.json", result) + validation = client.tool("editor.model.validate", path=result["path"]) + save("native-validation.json", validation) + if not validation.get("valid"): + raise RuntimeError("Native import failed") + print(f"{result['path']}: {result['triangleCount']} triangles, {result['vertexCount']} vertices") + + +if __name__ == "__main__": + main() diff --git a/Examples/Technical/ProceduralSkeleton/native_rig.py b/Examples/Technical/ProceduralSkeleton/native_rig.py new file mode 100644 index 0000000..52fc254 --- /dev/null +++ b/Examples/Technical/ProceduralSkeleton/native_rig.py @@ -0,0 +1,89 @@ +"""Build the native AdaAssets rig author and its production-source tests without renderer dependencies. + +The isolated SwiftPM package snapshots the checked-out Math, AdaAssets GLTF and +procedural sources verbatim. It has no third-party dependencies or downloads. +""" +import argparse +import os +from pathlib import Path +import shutil +import subprocess + +PROJECT = Path(__file__).resolve().parent +ENGINE = Path(os.environ.get("ADAENGINE_ROOT", str(PROJECT))).expanduser().resolve() +STAGE = ENGINE / ".build-tools/skeleton-rig" + + +def require_engine(): + if not (ENGINE / "Sources/AdaScripting/AdaScriptModelRigBuilder.swift").is_file(): + raise RuntimeError("Set ADAENGINE_ROOT to an Ada engine checkout containing the procedural rig builder") + + +def write(path, data): + path.parent.mkdir(parents=True, exist_ok=True) + if not path.exists() or path.read_bytes() != data: + path.write_bytes(data) + + +def stage(): + require_engine() + groups = { + "Math": [ENGINE / "Sources/Math"], + "AdaAssets": [ENGINE / "Sources/AdaAssets/GLTF", ENGINE / "Sources/AdaAssets/Procedural"], + "SkeletonRig": [PROJECT / "NativeRig"], + "AdaAnimation": [ENGINE / "Sources/AdaAnimation/Skeletal"], + } + for module, folders in groups.items(): + for folder in folders: + for source in folder.rglob("*.swift"): + write(STAGE / "Sources" / module / source.name, source.read_bytes()) + for name in ["ProceduralModelTests.swift", "ProceduralRigTests.swift"]: + source = ENGINE / "Tests/AdaAssetsTests" / name + write(STAGE / "Tests/AdaAssetsTests" / name, source.read_bytes()) + scope = PROJECT / "Validation/StandaloneAssetScope.swift" + write(STAGE / "Sources/AdaAssets/StandaloneAssetScope.swift", scope.read_bytes()) + sampler = (ENGINE / "Sources/AdaAnimation/KeyframeSampler.swift").read_text(encoding="utf-8") + interpolation = "import Math\n\n" + sampler[sampler.index("func slerpQuat("):] + write(STAGE / "Sources/AdaAnimation/SkeletalQuaternionInterpolation.swift", interpolation.encode()) + walk_test = PROJECT / "Validation/SkeletonWalkTests.swift" + write(STAGE / "Tests/SkeletonRigTests/SkeletonWalkTests.swift", walk_test.read_bytes()) + manifest = '''// swift-tools-version: 6.2 +import PackageDescription +let package = Package(name: "SkeletonRigStudy", targets: [ + .target(name: "Math"), + .target(name: "AdaAssets", dependencies: ["Math"]), + .target(name: "AdaAnimation", dependencies: ["Math"]), + .executableTarget(name: "SkeletonRig", dependencies: ["AdaAssets", "Math"]), + .testTarget(name: "AdaAssetsTests", dependencies: ["AdaAssets", "Math"]), + .testTarget(name: "SkeletonRigTests", dependencies: ["SkeletonRig", "AdaAssets", "AdaAnimation", "Math"]) +]) +''' + write(STAGE / "Package.swift", manifest.encode()) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=["test", "generate"]) + args = parser.parse_args() + stage() + swift = shutil.which("swift") + if not swift: + raise RuntimeError("Install the repository's compatible Swift toolchain first") + if args.action == "test": + subprocess.run([swift, "test", "--package-path", str(STAGE)], check=True) + else: + output = PROJECT / "Assets/Models/Skeleton-Walk.glb" + environment = dict(os.environ, SKELETON_RIG_RESOURCE_ROOT=str(PROJECT / "Assets")) + result = subprocess.run([swift, "run", "--package-path", str(STAGE), "SkeletonRig", + str(PROJECT / "Assets/Models/Skeleton.glb"), str(output), + str(PROJECT / "Assets/Models/Skeleton-Walk-samples.glb")], + check=True, capture_output=True, text=True, env=environment) + print(result.stderr, end="") + print(result.stdout, end="") + evidence = PROJECT / "Evidence/Rig" + evidence.mkdir(parents=True, exist_ok=True) + (evidence / "generation.json").write_text(result.stdout, encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/Examples/Technical/ProceduralSkeleton/rig_capture.py b/Examples/Technical/ProceduralSkeleton/rig_capture.py new file mode 100644 index 0000000..373e286 --- /dev/null +++ b/Examples/Technical/ProceduralSkeleton/rig_capture.py @@ -0,0 +1,126 @@ +"""Accept live native playback, then render 24 fixed phases through native GPU skinning. + +Run native_rig.py generate first, and open Assets/Scenes/Walk.ascn in Studio. +Only GIF encoding uses Pillow; rigging, clip evaluation and pixels come from AdaEngine. +""" +import argparse +import hashlib +import json +from pathlib import Path +import shutil + +from experiment import MCP, build + +ROOT = Path(__file__).resolve().parent +EVIDENCE = ROOT / "Evidence/Rig" +SCENE = "Assets/Scenes/Walk.ascn" +SOURCE = "@res://Models/Skeleton-Walk.glb" + + +def save(name, data): + EVIDENCE.mkdir(parents=True, exist_ok=True) + (EVIDENCE / name).write_text(json.dumps(data, indent=2)+"\n", encoding="utf-8") + + +def select(client, source, clip): + scene = client.tool("editor.scene.get", path=SCENE) + client.tool("editor.scene.apply", path=SCENE, expectedRevision=scene["revision"], operations=[{ + "op": "setComponent", "entityID": "subject", "typeName": "AdaScene.Model3DSource", + "payload": {"source": source, "animation": clip, "autoplay": True, "repeats": True}, + }]) + return build(client) + + +def scenario(client, **options): + result = client.tool("editor.play.run_scenario", width=960, height=800, maxDimension=960, **options) + if (result["status"] != "completed" or result["errors"] or result["scriptErrorCount"] + or any(frame.get("uniform", True) or "error" in frame for frame in result["frames"])): + raise RuntimeError(result) + return result + + +def gif(): + from PIL import Image + images = [Image.open(EVIDENCE / "frames" / f"frame-{index:02d}.png").convert("RGB") for index in range(24)] + palette = images[0].quantize(colors=256) + frames = [frame.quantize(palette=palette) for frame in images] + frames[0].save(EVIDENCE / "walk.gif", save_all=True, append_images=frames[1:], duration=50, loop=0, disposal=2) + for image in images: + image.close() + + +def main(): + global EVIDENCE, SOURCE + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--endpoint", default="http://127.0.0.1:2516/mcp") + parser.add_argument("--asset", default="Assets/Models/Skeleton-Walk.glb") + parser.add_argument("--evidence", type=Path, default=EVIDENCE) + parser.add_argument("--live-only", action="store_true", help="Capture playback and bind pose without the Swift baseline's 24 fixed phases") + args = parser.parse_args() + source_path = Path(args.asset) + if source_path.is_absolute() or source_path.parts[:2] != ("Assets", "Models") or ".." in source_path.parts: + parser.error("--asset must be a project-relative Assets/Models GLB") + if not args.live_only and source_path.name != "Skeleton-Walk.glb": + parser.error("Alternative assets need --live-only; fixed CapturePose clips belong to the Swift baseline") + SOURCE = "@res://" + source_path.relative_to("Assets").as_posix() + evidence_path = args.evidence if args.evidence.is_absolute() else ROOT / args.evidence + EVIDENCE = evidence_path + client = MCP(args.endpoint) + context = client.tool("editor.project.context") + if Path(context["projectPath"]).resolve() != ROOT or context["activeDocument"]["path"] != SCENE: + raise RuntimeError("Open this project's Walk.ascn in Studio before capturing") + status = client.tool("editor.task.status") + if status["isWorkspaceTaskRunning"] or status["playMode"] != "editing": + client.tool("editor.task.stop") + validation = client.tool("editor.model.validate", path=source_path.as_posix()) + save("native-validation.json", validation) + if not validation.get("valid") or validation["counts"]["skins"] != 1 or validation["skins"][0]["jointCount"] != 23: + raise RuntimeError("Rig import/profile contract failed") + report = {"renderer": client.tool("render_graph.get")["backend"], "frames": [], + "method": "Live Walk and BindPose, rendered by native GPU skinning." if args.live_only else + "Fixed CapturePose clips sampled from every second Walk key; rendered by native skinning, 20 fps.", + "sourceSha256": hashlib.sha256((ROOT / source_path).read_bytes()).hexdigest()} + try: + select(client, SOURCE, "Walk") + live = scenario(client, duration=3, steps=[ + {"t": .4, "snapshot": "a"}, {"t": .8, "capture": "walk-live-a"}, + {"t": 1.3, "snapshot": "b"}, {"t": 1.8, "capture": "walk-live-b"}, + {"t": 2.5, "snapshot": "c"}, + ], watch={"entities": ["Rig.hips", "Rig.leftHip", "Rig.rightHip", "Rig.leftKnee", "Rig.rightKnee", + "Rig.leftAnkle", "Rig.rightAnkle", "Rig.leftHand", "Rig.rightHand"], + "components": ["Transform"]}) + save("live-motion.json", live) + report["liveFrames"] = live["frames"] + for frame in live["frames"]: + shutil.copy2(frame["path"], EVIDENCE / (frame["label"] + ".png")) + hips = [next(e for e in snapshot["entities"] if e["name"] == "Rig.leftHip") for snapshot in live["snapshots"]] + rotations = {tuple(entity["componentFields"]["AdaTransform.Transform"]["rotation"]) for entity in hips} + if len(rotations) < 2 or live["snapshots"][-1]["frameSequence"] <= live["snapshots"][0]["frameSequence"]: + raise RuntimeError("Live animation did not advance") + report["liveDistinctHipPoses"] = len(rotations) + print(f"Live Walk: {len(rotations)} distinct sampled hip poses; no script errors", flush=True) + select(client, SOURCE, "BindPose") + bind = scenario(client, duration=.3, steps=[{"t": .15, "capture": "bind-pose"}], watch={"entities": ["Rig.head"]}) + report["bindFrame"] = bind["frames"][0] + shutil.copy2(bind["frames"][0]["path"], EVIDENCE / "bind-pose.png") + (EVIDENCE / "frames").mkdir(parents=True, exist_ok=True) + for frame in range(0 if args.live_only else 24): + select(client, "@res://Models/Skeleton-Walk-samples.glb", f"CapturePose{frame}") + result = scenario(client, duration=.25, steps=[{"t": .12, "capture": f"pose-{frame:02d}"}], + watch={"entities": ["Rig.leftAnkle", "Rig.rightAnkle"], "components": ["Transform"]}) + image = result["frames"][0] + target = EVIDENCE / "frames" / f"frame-{frame:02d}.png" + shutil.copy2(image["path"], target) + report["frames"].append({**image, "poseTime": frame * .05, "savedAs": str(target.relative_to(EVIDENCE))}) + if frame % 4 == 0 or frame == 23: + print(f"Native GPU phases: {frame+1}/24", flush=True) + if not args.live_only: + gif() + finally: + save("build-output.json", select(client, SOURCE, "Walk")) + save("capture-report.json", report) + client.tool("editor.play.start") + + +if __name__ == "__main__": + main() diff --git a/Examples/Technical/ProceduralSkeleton/script_rig.py b/Examples/Technical/ProceduralSkeleton/script_rig.py new file mode 100644 index 0000000..acedd46 --- /dev/null +++ b/Examples/Technical/ProceduralSkeleton/script_rig.py @@ -0,0 +1,109 @@ +"""Develop/test the AdaScript model bridge in an isolated native VM harness. + +The Swift build is a developer check, not a step required by an iPad recipe. +The app bundles this bridge ahead of time; recipe execution uses the bytecode VM. +""" +import argparse +import json +import os +from pathlib import Path +import shutil +import subprocess + +from native_rig import ENGINE, PROJECT, require_engine, write + +STAGE = ENGINE / ".build-tools/skeleton-script" + + +def stage(gravity): + require_engine() + groups = { + "Math": [ENGINE / "Sources/Math"], + "AdaAssets": [ENGINE / "Sources/AdaAssets/GLTF", ENGINE / "Sources/AdaAssets/Procedural", ENGINE / "Sources/AdaAssets/Voxel"], + "AdaScriptCompilerCore": [ENGINE / "Sources/AdaScriptCompilerCore"], + } + for module, folders in groups.items(): + for folder in folders: + for source in folder.rglob("*.swift"): + write(STAGE / "Sources" / module / source.name, source.read_bytes()) + scripting = ["AdaScriptModelGenerator.swift", "AdaScriptModelRigBuilder.swift", "AdaScriptGeneratorRunner.swift", + "AdaScriptGeneratorDelegate.swift", "AdaScriptCommunityPolicy.swift", "AdaScriptRuntimeCoordinator.swift", + "AdaScriptStandardLibrary.swift", "GravityScriptModule.swift", "AdaScriptSoundGenerator.swift", "AdaScriptSpriteGenerator.swift", "AdaScriptVoxelGenerator.swift"] + for name in scripting: + path = ENGINE / "Sources/AdaScripting" / name + if path.exists(): + write(STAGE / "Sources/AdaScripting" / name, path.read_bytes()) + identifiers = (ENGINE / "Sources/AdaScripting/AdaScriptIdentifiers.swift").read_text(encoding="utf-8") + identifiers = identifiers[:identifiers.index("\n#if canImport(GravityAOT)\nfinal class NativeIdentifiersHost")] + write(STAGE / "Sources/AdaScripting/AdaScriptIdentifiers.swift", identifiers.encode()) + test_runner = (ENGINE / "Sources/AdaScripting/AdaScriptTestRunner.swift").read_text(encoding="utf-8") + write(STAGE / "Sources/AdaScripting/AdaScriptTestCase.swift", test_runner[:test_runner.index("/// The outcome")].encode()) + write(STAGE / "Sources/AdaAssets/StandaloneAssetScope.swift", (PROJECT / "Validation/StandaloneAssetScope.swift").read_bytes()) + for name in ["AdaScriptModelGeneratorTests.swift", "AdaScriptModelRigTests.swift", "AdaScriptMediaGeneratorTests.swift", "AdaScriptVoxelGeneratorTests.swift"]: + path = ENGINE / "Tests/AdaScriptingTests" / name + if path.exists(): + write(STAGE / "Tests/AdaScriptingTests" / name, path.read_bytes()) + main = PROJECT / "Validation/ScriptRigMain.swift" + write(STAGE / "Sources/ScriptRig/Main.swift", main.read_bytes() if main.exists() else b'@main struct Main { static func main() {} }\n') + manifest = '''// swift-tools-version: 6.2 +import PackageDescription +let package = Package(name: "ScriptRigStudy", dependencies: [.package(name: "gravity-lang", path: GRAVITY_PATH)], targets: [ + .target(name: "Math"), + .target(name: "AdaAssets", dependencies: ["Math"]), + .target(name: "AdaScriptCompilerCore"), + .target(name: "AdaScripting", dependencies: ["AdaAssets", "Math", "AdaScriptCompilerCore", .product(name: "Gravity", package: "gravity-lang")]), + .executableTarget(name: "ScriptRig", dependencies: ["AdaAssets", "AdaScriptCompilerCore", "AdaScripting"]), + .testTarget(name: "AdaScriptingTests", dependencies: ["AdaAssets", "AdaScripting"]) +]) +'''.replace("GRAVITY_PATH", json.dumps(str(gravity), ensure_ascii=False)) + write(STAGE / "Package.swift", manifest.encode()) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=["bootstrap", "test", "generate", "run"]) + parser.add_argument("--gravity", type=Path, default=None) + parser.add_argument("--runtime-path", help="Override PATH for the prebuilt VM runner, for a compiler-free execution check") + args = parser.parse_args() + require_engine() + if args.runtime_path and args.action != "run": + parser.error("--runtime-path is only valid with the prebuilt 'run' action") + gravity = args.gravity or Path(os.environ.get("ADAENGINE_GRAVITY_PACKAGE_PATH", ENGINE / "Sources/gravity-lang")) + if args.action != "run": + stage(gravity.resolve()) + swift = shutil.which("swift") + if args.action != "run" and not swift: + raise RuntimeError("The developer harness requires Swift; the bundled iPad runtime does not") + if args.action == "bootstrap": + command = [swift, "build", "--package-path", str(STAGE), "--jobs", "2", "--target", "Gravity"] + elif args.action == "test": + command = [swift, "test", "--package-path", str(STAGE), "--jobs", "2"] + else: + source = (PROJECT / "Generators/Skeleton.ada").read_text(encoding="utf-8") + source = source.replace("func build(m) {", "func buildStaticSkeleton(m) {", 1) + source = source.replace("Static multipart study, not a skinned mesh.", "Base geometry; the rig recipe follows below.", 1) + # Expand the static author's packed calls in the composed, reviewable rig recipe. + source = "\n".join(line if line.lstrip().startswith("//") else line.replace("; ", ";\n ") for line in source.splitlines()) + "\n" + source = source.replace("}\nfunc ", "}\n\nfunc ") + source = "// Geometry, rig, weights and walk are authored by this interpreted AdaScript recipe.\n" + source + source += "\n" + (PROJECT / "Generators/SkeletonRigRecipe.ada").read_text(encoding="utf-8") + write(PROJECT / "Generators/Skeleton-Rig.ada", source.encode()) + arguments = [str(PROJECT / "Generators/Skeleton-Rig.ada"), str(PROJECT / "Assets/Models/Skeleton-AdaScript.glb")] + if args.action == "run": + name = "ScriptRig.exe" if os.name == "nt" else "ScriptRig" + candidates = [p for p in (STAGE / ".build").rglob(name) if p.is_file() and p.parent.name == "debug"] + if len(candidates) != 1: + raise RuntimeError("Build the developer runner once before running it without a compiler") + command = [str(candidates[0]), *arguments] + else: + command = [swift, "run", "--package-path", str(STAGE), "ScriptRig", *arguments] + environment = dict(os.environ) + if args.runtime_path: + environment["PATH"] = args.runtime_path + assert shutil.which("swift", path=args.runtime_path) is None + assert shutil.which("swiftc", path=args.runtime_path) is None + subprocess.run(command, check=True, env=environment) + + +if __name__ == "__main__": + main() diff --git a/Examples/Technical/README.md b/Examples/Technical/README.md new file mode 100644 index 0000000..1b6fe44 --- /dev/null +++ b/Examples/Technical/README.md @@ -0,0 +1,32 @@ +# Technical examples + +Developer studies and acceptance projects live here. Clone the repository and follow each README to run its SwiftPM package, XcodeGen +app or Studio project. These are not beginner recommendations or sample games; +not every technical tool is an editable Studio game project. + +| Project | Focus | +| --- | --- | +| [Procedural Skeleton](ProceduralSkeleton/README.md) | Explicit AdaScript rig, vertex weights and a walk clip; precompiled builder and interpreted VM | +| [A2UIForm](A2UIForm/README.md) | Streamed agent forms and native control validation | +| [AndroidDemo](AndroidDemo/README.md) | Native Android window, input and resource smoke test | +| [LinuxSmoke](LinuxSmoke/README.md) | Linux X11/Vulkan window and input smoke test | +| [MovieMaker](MovieMaker/README.md) | Native frame and audio recording | +| [Navigation](Navigation/README.md) | Navmesh baking, patrol scripting and physics movement | +| [Physics2D](Physics2D/README.md) | Box2D queries, mechanisms, characters and large worlds | +| [SpineNative](SpineNative/README.md) | Native Spine rendering and MCP GPU capture | +| [SpinePreview](SpinePreview/README.md) | Headless Spine pose export and browser inspection | +| [Sponza](Sponza/README.md) | External-dataset renderer and lighting diagnostics | +| [SpriteInstancingValidation](SpriteInstancingValidation/README.md) | Portable GPU sprite readback validation | + +This section is intentionally separate from `catalog.json`. Studio and website +catalogs consume only its `items`, and the release workflow packages only those +items. The catalog validator rejects `Examples/Technical/` paths to prevent an +accidental promotion into those feeds. Adding technical studies does not require a +new public catalog schema or changes to catalog consumers. + +Commit the small assets needed to open a project. Keep build outputs, per-frame +captures, logs, workspace state and generated distributions ignored locally. + +Swift packages pin a compatible engine revision. Their README commands run from the AdaExamples root. No engine checkout is required for normal SwiftPM dependency resolution; platform preparation tools, optional asset preparation and Windows capture overrides may require `ADAENGINE_ROOT`. + +Developer checks: `python scripts/validate_catalog.py`, `python -m unittest discover -s scripts -p "test_*.py"` and `python scripts/validate_technical_packages.py --engine-root /path/to/Ada`. The last command checks package manifests, source/resource paths and declared engine products without downloading dependencies or proving a native app launch. diff --git a/Examples/Technical/SpineNative/.gitignore b/Examples/Technical/SpineNative/.gitignore new file mode 100644 index 0000000..fd14c91 --- /dev/null +++ b/Examples/Technical/SpineNative/.gitignore @@ -0,0 +1,6 @@ +.build/ +.swiftpm/ +dist/ +*.xcodeproj/ +__pycache__/ +Package.resolved diff --git a/Examples/Technical/SpineNative/Package.swift b/Examples/Technical/SpineNative/Package.swift new file mode 100644 index 0000000..8453a2c --- /dev/null +++ b/Examples/Technical/SpineNative/Package.swift @@ -0,0 +1,37 @@ +// swift-tools-version: 6.2 +import Foundation +import PackageDescription + +let enginePath = ProcessInfo.processInfo.environment["ADAENGINE_PACKAGE_PATH"] +let engineDependency: Package.Dependency = enginePath.map { .package(name: "AdaEngine", path: $0) } + ?? .package(url: "https://github.com/AdaEngine/AdaEngine.git", revision: "a624ccded0a050fddc24ccfbb8079dfb8032805c") + +let mcpPath = ProcessInfo.processInfo.environment["ADA_MCP_LOCAL_PATH"] +let mcpDependency: Package.Dependency = mcpPath.map { .package(name: "AdaMCP", path: $0) } + ?? .package(url: "https://github.com/AdaEngine/AdaMCP.git", exact: "0.2.0") + +let package = Package( + name: "SpineNative", + platforms: [.macOS(.v15), .iOS(.v18)], + products: [.executable(name: "SpineNative", targets: ["SpineNative"])], + dependencies: [ + engineDependency, + mcpDependency + ], + targets: [ + .executableTarget( + name: "SpineNative", + dependencies: [ + .product(name: "AdaEngine", package: "adaengine"), + .product(name: "AdaSpine", package: "adaengine"), + .product(name: "AdaMCPPlugin", package: "AdaMCP") + ], + path: ".", + exclude: ["README.md", "capture.py"] + (FileManager.default.fileExists(atPath: "dist") ? ["dist"] : []), + sources: [ + "SpineNativeApp.swift" + ], + swiftSettings: [.enableUpcomingFeature("MemberImportVisibility"), .strictMemorySafety()] + ) + ] +) diff --git a/Examples/Technical/SpineNative/README.md b/Examples/Technical/SpineNative/README.md new file mode 100644 index 0000000..73ee7dd --- /dev/null +++ b/Examples/Technical/SpineNative/README.md @@ -0,0 +1,41 @@ +# Native Spine demo + +This separate Swift package runs `SpinePlugin` in an AdaEngine native window. +It uses the licensed example assets beside `../SpinePreview/Assets`. + +Run from an AdaExamples checkout: + +``` +swift run --package-path Examples/Technical/SpineNative SpineNative +``` + +On Windows use the native toolchain/dependencies prepared by +`$ADAENGINE_ROOT/script/build_studio_windows.ps1`. Set `TINT_EXECUTABLE` to the verified Tint +binary and `ADA_MCP_LOCAL_PATH` to the Windows AdaMCP package prepared by +`$ADAENGINE_ROOT/script/ensure_windows_mcp.py`. `ADAENGINE_PACKAGE_PATH` must point to this +engine checkout, and `ADAENGINE_GRAVITY_PACKAGE_PATH` can select the prepared +Windows Gravity package. Do not enable `ADAENGINE_HEADLESS`. + +Windows capture requires the AdaMCP compatibility package with asynchronous +GPU readback; unpatched AdaMCP 0.2.0 uses the unsupported synchronous texture +accessor. The compatible MCP holds exclusive world access during capture, since a +native frame must not replace the target while its readback is pending. + +AdaMCP listens at `http://127.0.0.1:2511/mcp` so Studio can keep port 2510. +Discovery is disabled because the endpoint is explicit. +Use `spine.pose` (`animation`, `time`, `playing`) followed by +`render.capture_screenshot` for repeatable captures from the actual engine GPU +output. `runtime.resume` resumes the engine after capture pauses it. + +With the app running, capture three poses and optionally a walk sequence: + +``` +python Examples/Technical/SpineNative/capture.py --frames 30 +``` + +The capture report includes backend/render-graph metadata, camera, native +dimensions, original GPU capture paths and exact pose times. + +Validated on Windows with NVIDIA RTX 4070 / D3D12: native 1000×760 PNGs for +walk, run and jump, followed by 32-frame and 90-frame capture sequences. These +use the actual Spine render systems, GPU textures and AdaMCP capture service. diff --git a/Examples/Technical/SpineNative/SpineNativeApp.swift b/Examples/Technical/SpineNative/SpineNativeApp.swift new file mode 100644 index 0000000..0e57370 --- /dev/null +++ b/Examples/Technical/SpineNative/SpineNativeApp.swift @@ -0,0 +1,141 @@ +import AdaEngine +import AdaMCPCore +import AdaMCPPlugin +import AdaSpine +import Foundation +import MCP + +/// Runs the production Spine render systems in a native window, with AdaMCP GPU capture. +@main +struct SpineNativeApp: App { + var body: some AppScene { + DefaultAppWindow() + .addPlugins(SpinePlugin(), SpineDemoPlugin()) + .window(with: UIWindow.Configuration( + title: "AdaEngine · Spine 4.2 · native GPU", + frame: Rect(x: 100, y: 80, width: 1000, height: 760) + )) + } +} + +private struct SpineDemoPlugin: Plugin { + func setup(in app: borrowing AppWorlds) { + app.addSystem(StartupSpineDemoSystem.self, on: .startup) + let worlds = copy app + app.addPlugin(MCPPlugin(configuration: .init( + port: 2511, + serverName: "AdaEngine Spine native demo", + additionalTools: { + [Tool( + name: "spine.pose", + description: "Select a Spine animation and exact time, optionally continuing playback.", + inputSchema: .object([ + "type": "object", + "properties": .object([ + "animation": .object(["type": "string"]), + "time": .object(["type": "number", "minimum": 0]), + "playing": .object(["type": "boolean"]), + ]), + "required": ["animation"], + ]) + )] + }, + additionalToolHandler: { name, arguments in + guard name == "spine.pose" else { return nil } + do { + let message = try await worlds.withWorldAccess { + try selectSpinePose(in: worlds.main, arguments: arguments) + } + return .init(content: [.text(text: message, annotations: nil, _meta: nil)]) + } catch { + return .init(content: [.text(text: String(describing: error), annotations: nil, _meta: nil)], isError: true) + } + }, + enableDiscovery: false + ))) + } +} + +@MainActor +private func selectSpinePose(in world: World, arguments: [String: Value]) throws -> String { + guard case let .string(clip)? = arguments["animation"], + let entity = world.getEntities().first(where: { $0.components[SpineAnimation.self] != nil }), + var animation = entity.components[SpineAnimation.self] else { + throw SpineError.invalidData("Spine character is not ready.") + } + let time: Float + switch arguments["time"] { + case let .double(value): time = Float(value) + case let .int(value): time = Float(value) + default: time = 0 + } + guard time.isFinite, time >= 0, animation.player.animationNames.contains(clip) else { + throw SpineError.invalidData("Select an existing animation and a finite, non-negative time.") + } + animation.player.resetToSetupPose() + try animation.player.play(clip, repeats: clip != "jump", transitionDuration: 0) + animation.player.advance(by: time) + animation.isPaused = arguments["playing"] != .bool(true) + entity.components[SpineAnimation.self] = animation + let vertices = animation.player.snapshot().flatMap(\.vertices) + if let minX = vertices.map(\.x).min(), let maxX = vertices.map(\.x).max(), + let minY = vertices.map(\.y).min(), let maxY = vertices.map(\.y).max(), + let owner = entity.components[Transform.self], + let cameraEntity = world.getEntities().first(where: { $0.components[Camera.self] != nil }), + var camera = cameraEntity.components[Camera.self] { + camera.projection = .custom(SpineDemoProjection(center: [ + owner.position.x + (minX + maxX) / 2, owner.position.y + (minY + maxY) / 2, + ])) + cameraEntity.components[Camera.self] = camera + } + return "\(clip) at \(time)s; bones=\(animation.player.boneSnapshot().count)" +} + +@System +@MainActor +func StartupSpineDemo(_ context: WorldUpdateContext) async { + do { + let demoDirectory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + let assets = demoDirectory.appendingPathComponent("../SpinePreview/Assets").standardizedFileURL + let image = try await AssetsManager.load(Image.self, at: assets.appendingPathComponent("spineboy.png").path) + guard let sourceImage = image.asset else { throw SpineError.invalidData("Atlas image is not loaded.") } + let texture = AssetHandle(Texture2D(image: sourceImage, samplerDescription: .init(minFilter: .linear, magFilter: .linear))) + let skeleton = try SpineSkeleton( + skeleton: Data(contentsOf: assets.appendingPathComponent("spineboy-pro.skel")), + atlas: String(contentsOf: assets.appendingPathComponent("spineboy.atlas"), encoding: .utf8), + textures: [texture], + format: .binary + ) + let animation = try SpineAnimation(skeleton: skeleton) + try animation.player.play("walk", transitionDuration: 0) + context.world.spawn("Spineboy") { + animation + Transform(position: [-50, -280, 0]) + NoFrustumCulling() + } + var camera = Camera() + camera.projection = .custom(SpineDemoProjection()) + camera.backgroundColor = Color(0.035, 0.055, 0.10, 1) + context.world.spawn("Spine Camera") { + camera + Transform() + GlobalViewUniform() + VisibleEntities() + Visibility.visible + CameraRenderGraph(subgraphLabel: .main2D, inputSlot: Main2DRenderNode.InputNode.view) + } + print("[SpineNative] Ready: native renderer; AdaMCP http://127.0.0.1:2511/mcp") + } catch { + print("[SpineNative] Failed to load character: \(error)") + } +} + +private struct SpineDemoProjection: CameraProjection { + var center: Vector2 = [15, 60] + var near: Float { -1 } + var far: Float { 1000 } + func makeClipView() -> Transform3D { + Transform3D(scale: [2 / 1000, 2 / 760, 0.001]) * Transform3D(translation: [-center.x, -center.y, 0]) + } + mutating func updateView(width: Float, height: Float) {} +} diff --git a/Examples/Technical/SpineNative/capture.py b/Examples/Technical/SpineNative/capture.py new file mode 100644 index 0000000..c96f4fb --- /dev/null +++ b/Examples/Technical/SpineNative/capture.py @@ -0,0 +1,85 @@ +"""Capture native engine pixels through AdaMCP, with no browser renderer.""" +import argparse +import json +from pathlib import Path +import shutil +from time import sleep +import urllib.request + + +class MCP: + def __init__(self, endpoint): + self.endpoint = endpoint + self.session = None + self.sequence = 0 + self.request("initialize", { + "protocolVersion": "2024-11-05", "capabilities": {}, + "clientInfo": {"name": "SpineNative capture", "version": "1.0"}, + }) + + def request(self, method, params): + self.sequence += 1 + headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"} + if self.session: + headers["Mcp-Session-Id"] = self.session + request = urllib.request.Request(self.endpoint, headers=headers, data=json.dumps({ + "jsonrpc": "2.0", "id": self.sequence, "method": method, "params": params, + }).encode()) + with urllib.request.urlopen(request, timeout=60) as response: + self.session = response.headers.get("Mcp-Session-Id", self.session) + body = response.read().decode() + if body.startswith("event:") or body.startswith("data:"): + body = next(line[5:].strip() for line in body.splitlines() if line.startswith("data:")) + reply = json.loads(body) + if "error" in reply: + raise RuntimeError(reply["error"]) + return reply["result"] + + def tool(self, name, **arguments): + result = self.request("tools/call", {"name": name, "arguments": arguments}) + if result.get("isError"): + raise RuntimeError(result) + text = next(item["text"] for item in result["content"] if item["type"] == "text") + try: + return json.loads(text) + except json.JSONDecodeError: + return text + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("output", type=Path) + parser.add_argument("--endpoint", default="http://127.0.0.1:2511/mcp") + parser.add_argument("--frames", type=int, default=0, help="Also capture this many walk frames at 20 fps.") + args = parser.parse_args() + args.output.mkdir(parents=True, exist_ok=True) + client = MCP(args.endpoint) + report = {"renderGraph": client.tool("render_graph.get"), "captures": []} + cameras = [camera for camera in report["renderGraph"]["cameras"] if camera["isActive"]] + if len(cameras) != 1: + raise RuntimeError("This demo expects exactly one active render camera.") + camera_id = cameras[0]["entityId"] + client.tool("runtime.pause", reason="Repeatable native Spine capture") + poses = [("walk", 0.3), ("run", 0.2), ("jump", 0.35)] + poses += [("walk", index / 20) for index in range(args.frames)] + try: + for index, (animation, time) in enumerate(poses): + client.tool("spine.pose", animation=animation, time=time, playing=False) + # Transform propagation and camera preparation run in successive phases. + client.tool("runtime.step_frame", frames=2) + capture = client.tool("render.capture_screenshot", cameraEntityId=camera_id) + if capture["width"] <= 1 or capture["height"] <= 1: + raise RuntimeError(f"Empty native capture: {capture}") + name = f"{animation}.png" if index < 3 else f"frame-{index - 3:03d}.png" + shutil.copy2(capture["path"], args.output / name) + report["captures"].append(dict(capture, animation=animation, time=time, savedAs=name)) + print(f"{name}: {capture['width']}x{capture['height']}", flush=True) + sleep(0.15) + finally: + (args.output / "capture-report.json").write_text(json.dumps(report, indent=2), encoding="utf-8") + client.tool("spine.pose", animation="walk", time=0, playing=True) + client.tool("runtime.resume") + + +if __name__ == "__main__": + main() diff --git a/Examples/Technical/SpinePreview/.gitignore b/Examples/Technical/SpinePreview/.gitignore new file mode 100644 index 0000000..fd14c91 --- /dev/null +++ b/Examples/Technical/SpinePreview/.gitignore @@ -0,0 +1,6 @@ +.build/ +.swiftpm/ +dist/ +*.xcodeproj/ +__pycache__/ +Package.resolved diff --git a/Examples/Technical/SpinePreview/Assets/SpineRuntime-LICENSE.txt b/Examples/Technical/SpinePreview/Assets/SpineRuntime-LICENSE.txt new file mode 100644 index 0000000..517ff9f --- /dev/null +++ b/Examples/Technical/SpinePreview/Assets/SpineRuntime-LICENSE.txt @@ -0,0 +1,26 @@ +Spine Runtimes License Agreement +Last updated April 5, 2025. Replaces all prior versions. + +Copyright (c) 2013-2025, Esoteric Software LLC + +Integration of the Spine Runtimes into software or otherwise creating +derivative works of the Spine Runtimes is permitted under the terms and +conditions of Section 2 of the Spine Editor License Agreement: +https://esotericsoftware.com/spine-editor-license + +Otherwise, it is permitted to integrate the Spine Runtimes into software +or otherwise create derivative works of the Spine Runtimes (collectively, +"Products"), provided that each user of the Products must obtain their own +Spine Editor license and redistribution of the Products in any form must +include this license and copyright notice. + +THE SPINE RUNTIMES ARE PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES, +BUSINESS INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +THE SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Examples/Technical/SpinePreview/Assets/UPSTREAM.md b/Examples/Technical/SpinePreview/Assets/UPSTREAM.md new file mode 100644 index 0000000..432fdd3 --- /dev/null +++ b/Examples/Technical/SpinePreview/Assets/UPSTREAM.md @@ -0,0 +1,5 @@ +Spineboy binary skeleton, atlas and image are unmodified official Spine 4.2 example assets. + +https://github.com/EsotericSoftware/spine-runtimes/tree/e7dc1435fa4a0083ab431f1b28e083c14a1f5c68/examples/spineboy/export + +Copyright Esoteric Software LLC. See SpineRuntime-LICENSE.txt. diff --git a/Examples/Technical/SpinePreview/Assets/preview.html b/Examples/Technical/SpinePreview/Assets/preview.html new file mode 100644 index 0000000..ab2726d --- /dev/null +++ b/Examples/Technical/SpinePreview/Assets/preview.html @@ -0,0 +1,75 @@ + + + + + +AdaEngine · Spine 2D + + + +
+
AdaEngine / Spine 4.2

Персонаж оживает

Ходьба, бег и прыжок — с настоящим движением костей и деформацией сетки.

Кадры из Swift runtime
+
+
Ходьба
Кости персонажа
+
+
0.00 / 2.00 s
+
+
Spineboy · официальный пример Esoteric Software
Рассчитано AdaSpineRuntime → Spine C 4.2 → вершины и кости
Браузерный WebGL-просмотр кадров
Не является снимком нативного GPU-рендера AdaEngine
+
+ + + + diff --git a/Examples/Technical/SpinePreview/Assets/spineboy-pro.skel b/Examples/Technical/SpinePreview/Assets/spineboy-pro.skel new file mode 100644 index 0000000..09e564b Binary files /dev/null and b/Examples/Technical/SpinePreview/Assets/spineboy-pro.skel differ diff --git a/Examples/Technical/SpinePreview/Assets/spineboy.atlas b/Examples/Technical/SpinePreview/Assets/spineboy.atlas new file mode 100644 index 0000000..eca542b --- /dev/null +++ b/Examples/Technical/SpinePreview/Assets/spineboy.atlas @@ -0,0 +1,94 @@ +spineboy.png + size: 1024, 256 + filter: Linear, Linear + scale: 0.5 +crosshair + bounds: 352, 7, 45, 45 +eye-indifferent + bounds: 862, 105, 47, 45 +eye-surprised + bounds: 505, 79, 47, 45 +front-bracer + bounds: 826, 66, 29, 40 +front-fist-closed + bounds: 786, 65, 38, 41 +front-fist-open + bounds: 710, 51, 43, 44 + rotate: 90 +front-foot + bounds: 210, 6, 63, 35 +front-shin + bounds: 665, 128, 41, 92 + rotate: 90 +front-thigh + bounds: 2, 2, 23, 56 + rotate: 90 +front-upper-arm + bounds: 250, 205, 23, 49 +goggles + bounds: 665, 171, 131, 83 +gun + bounds: 798, 152, 105, 102 +head + bounds: 2, 27, 136, 149 +hoverboard-board + bounds: 2, 178, 246, 76 +hoverboard-thruster + bounds: 722, 96, 30, 32 + rotate: 90 +hoverglow-small + bounds: 275, 81, 137, 38 +mouth-grind + bounds: 614, 97, 47, 30 +mouth-oooo + bounds: 612, 65, 47, 30 +mouth-smile + bounds: 661, 64, 47, 30 +muzzle-glow + bounds: 382, 54, 25, 25 +muzzle-ring + bounds: 275, 54, 25, 105 + rotate: 90 +muzzle01 + bounds: 911, 95, 67, 40 + rotate: 90 +muzzle02 + bounds: 792, 108, 68, 42 +muzzle03 + bounds: 956, 171, 83, 53 + rotate: 90 +muzzle04 + bounds: 275, 7, 75, 45 +muzzle05 + bounds: 140, 3, 68, 38 +neck + bounds: 250, 182, 18, 21 +portal-bg + bounds: 140, 43, 133, 133 +portal-flare1 + bounds: 554, 65, 56, 30 +portal-flare2 + bounds: 759, 112, 57, 31 + rotate: 90 +portal-flare3 + bounds: 554, 97, 58, 30 +portal-shade + bounds: 275, 121, 133, 133 +portal-streaks1 + bounds: 410, 126, 126, 128 +portal-streaks2 + bounds: 538, 129, 125, 125 +rear-bracer + bounds: 857, 67, 28, 36 +rear-foot + bounds: 663, 96, 57, 30 +rear-shin + bounds: 414, 86, 38, 89 + rotate: 90 +rear-thigh + bounds: 756, 63, 28, 47 +rear-upper-arm + bounds: 60, 5, 20, 44 + rotate: 90 +torso + bounds: 905, 164, 49, 90 diff --git a/Examples/Technical/SpinePreview/Assets/spineboy.png b/Examples/Technical/SpinePreview/Assets/spineboy.png new file mode 100644 index 0000000..0ea9737 Binary files /dev/null and b/Examples/Technical/SpinePreview/Assets/spineboy.png differ diff --git a/Examples/Technical/SpinePreview/Package.swift b/Examples/Technical/SpinePreview/Package.swift new file mode 100644 index 0000000..19780bc --- /dev/null +++ b/Examples/Technical/SpinePreview/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version: 6.2 +import Foundation +import PackageDescription + +let package = Package( + name: "SpinePreview", + platforms: [.macOS(.v15), .iOS(.v18)], + products: [.executable(name: "SpinePreview", targets: ["SpinePreview"])], + dependencies: [ + .package(url: "https://github.com/AdaEngine/AdaEngine.git", revision: "a624ccded0a050fddc24ccfbb8079dfb8032805c") + ], + targets: [ + .executableTarget( + name: "SpinePreview", + dependencies: [ + .product(name: "AdaSpineRuntime", package: "adaengine") + ], + path: ".", + exclude: ["README.md"] + (FileManager.default.fileExists(atPath: "dist") ? ["dist"] : []), + sources: [ + "SpinePreview.swift" + ], + resources: [.copy("Assets")], + swiftSettings: [.enableUpcomingFeature("MemberImportVisibility"), .strictMemorySafety()] + ) + ] +) diff --git a/Examples/Technical/SpinePreview/README.md b/Examples/Technical/SpinePreview/README.md new file mode 100644 index 0000000..77d5841 --- /dev/null +++ b/Examples/Technical/SpinePreview/README.md @@ -0,0 +1,23 @@ +# Spine runtime visual preview + +Export 240 real poses of the official Spineboy example with the new +`AdaSpineRuntime`, including skinned/clipped geometry and bone endpoints: + +```powershell +$env:ADAENGINE_HEADLESS = '1' +swift run --package-path Examples/Technical/SpinePreview SpinePreview C:/path/to/spine-preview +python -m http.server 8769 --bind 127.0.0.1 --directory C:/path/to/spine-preview +``` + +Open `http://127.0.0.1:8769`. Select walk, run or jump; show the skeleton, pause, +change playback speed or scrub individual frames. + +The Swift executable samples the production player. The browser only draws the +exported triangles and bone endpoints; it does not implement or imitate Spine +animation. This is a portable WebGL preview, **not proof of AdaEngine's native GPU +draw pass**. The production headless integration tests cover extraction and GPU +buffer preparation separately. See the [Spine guide](https://github.com/AdaEngine/Ada/blob/main/Documentation/Spine2D.md) for engine usage. + +The example character, skeleton and atlas are official Esoteric Software assets +from the pinned upstream commit. Source and license notices are included in the +exported preview directory. No generated animation data is checked into the repo. diff --git a/Examples/Technical/SpinePreview/SpinePreview.swift b/Examples/Technical/SpinePreview/SpinePreview.swift new file mode 100644 index 0000000..fee1d4d --- /dev/null +++ b/Examples/Technical/SpinePreview/SpinePreview.swift @@ -0,0 +1,80 @@ +import AdaSpineRuntime +import Foundation + +/// Exports actual runtime poses for a portable visual preview, without substituting animation in JavaScript. +@main +struct SpinePreview { + struct Draw: Encodable { + let page: Int + let blend: Int + let pma: Bool + let vertices: [[Float]] + let indices: [UInt32] + } + struct Frame: Encodable { + let draws: [Draw] + let bones: [[Float]] + } + struct Clip: Encodable { + let name: String + let fps: Int + let frames: [Frame] + } + struct Preview: Encodable { + let source = "AdaSpineRuntime / official Spine C 4.2" + let clips: [Clip] + } + + static func main() throws { + let arguments = CommandLine.arguments + guard arguments.count == 2 else { + print("Usage: swift run SpinePreview ") + return + } + let output = URL(fileURLWithPath: arguments[1], isDirectory: true) + try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) + guard let assets = Bundle.module.resourceURL?.appendingPathComponent("Assets") else { + throw SpineError.invalidData("Preview assets are missing.") + } + let skeleton = try Data(contentsOf: assets.appendingPathComponent("spineboy-pro.skel")) + let atlas = try String(contentsOf: assets.appendingPathComponent("spineboy.atlas"), encoding: .utf8) + let player = try SpineAnimationPlayer(skeleton: skeleton, atlas: atlas, format: .binary) + print("Animations: \(player.animationNames.joined(separator: ", "))") + let fps = 30 + var clips: [Clip] = [] + for name in ["idle", "walk", "run", "jump"] where player.animationNames.contains(name) { + player.resetToSetupPose() + try player.play(name, repeats: name != "jump", transitionDuration: 0) + var frames: [Frame] = [] + for index in 0..<60 { + if index > 0 { player.advance(by: 1 / Float(fps)) } + frames.append(Frame( + draws: player.snapshot().map { draw in + Draw(page: draw.pageIndex, blend: draw.blendMode.rawValue, pma: draw.premultipliedAlpha, + vertices: draw.vertices.map { vertex in + [round(vertex.x), round(vertex.y), round(vertex.u), round(vertex.v), + round(vertex.red), round(vertex.green), round(vertex.blue), round(vertex.alpha), + round(vertex.darkRed), round(vertex.darkGreen), round(vertex.darkBlue), round(vertex.darkAlpha)] + }, indices: draw.indices) + }, + bones: player.boneSnapshot().filter(\.isActive).map { [round($0.x), round($0.y), round($0.endX), round($0.endY)] } + )) + } + clips.append(Clip(name: name, fps: fps, frames: frames)) + } + let data = try JSONEncoder().encode(Preview(clips: clips)) + try data.write(to: output.appendingPathComponent("poses.json")) + var script = Data("window.SPINE_PREVIEW_DATA=".utf8) + script.append(data) + script.append(Data(";".utf8)) + try script.write(to: output.appendingPathComponent("poses.js")) + for name in ["spineboy.png", "preview.html", "SpineRuntime-LICENSE.txt", "UPSTREAM.md"] { + let content = try Data(contentsOf: assets.appendingPathComponent(name)) + let destination = name == "preview.html" ? "index.html" : name + try content.write(to: output.appendingPathComponent(destination)) + } + print("Exported \(clips.count) animations / \(clips.reduce(0) { $0 + $1.frames.count }) runtime frames to \(output.path)") + } + + private static func round(_ value: Float) -> Float { (value * 100_000).rounded() / 100_000 } +} diff --git a/Examples/Technical/Sponza/.gitignore b/Examples/Technical/Sponza/.gitignore new file mode 100644 index 0000000..fd14c91 --- /dev/null +++ b/Examples/Technical/Sponza/.gitignore @@ -0,0 +1,6 @@ +.build/ +.swiftpm/ +dist/ +*.xcodeproj/ +__pycache__/ +Package.resolved diff --git a/Examples/Technical/Sponza/Assets/intel-sponza-routes.json b/Examples/Technical/Sponza/Assets/intel-sponza-routes.json new file mode 100644 index 0000000..165b537 --- /dev/null +++ b/Examples/Technical/Sponza/Assets/intel-sponza-routes.json @@ -0,0 +1,105 @@ +{ + "schema": 1, + "dataset": "intel-sponza-2022", + "samples": 600, + "fieldOfViewDegrees": 60, + "near": 0.05, + "far": 200, + "interpolation": "linear-position-shortest-nlerp-v1", + "rotationConvention": "AdaEngine +Z camera; source glTF rotation multiplied by local Y pi", + "routes": [ + { + "id": "atrium", + "start": { + "sourceCamera": "PhysCamera001", + "position": [ + -8.80743, + 1.59221947, + -0.85825783 + ], + "rotation": [ + -0.07516095149438418, + 0.6351054436274549, + 0.062298597138649006, + 0.7662315522345637 + ] + }, + "end": { + "sourceCamera": "PhysCamera003", + "position": [ + 2.70301843, + 1.35489559, + 2.11138153 + ], + "rotation": [ + 0.024976103325682304, + 0.8155856936530769, + 0.03530248834015994, + -0.577018114869964 + ] + } + }, + { + "id": "ground-floor-corridor", + "start": { + "sourceCamera": "PhysCamera005", + "position": [ + -2.23714, + 0.7440546, + 2.3608017 + ], + "rotation": [ + -0.0645746858853836, + 0.8437104882398467, + 0.10425182535720703, + 0.522603366700382 + ] + }, + "end": { + "sourceCamera": "PhysCamera001", + "position": [ + -8.80743, + 1.59221947, + -0.85825783 + ], + "rotation": [ + -0.07516095149438418, + 0.6351054436274549, + 0.062298597138649006, + 0.7662315522345637 + ] + } + }, + { + "id": "upper-gallery", + "start": { + "sourceCamera": "PhysCamera004", + "position": [ + -9.594356, + 6.921101, + 5.35278 + ], + "rotation": [ + 0.005818486075351097, + 0.817546556587467, + -0.008261681106991187, + 0.5757738424564397 + ] + }, + "end": { + "sourceCamera": "PhysCamera002", + "position": [ + 10.5311031, + 7.352985, + 1.52825129 + ], + "rotation": [ + 0.007428735261073167, + 0.7500144768872583, + 0.008424937740373797, + -0.6613260306203164 + ] + } + } + ] +} diff --git a/Examples/Technical/Sponza/Camera/SponzaCameraMotion.swift b/Examples/Technical/Sponza/Camera/SponzaCameraMotion.swift new file mode 100644 index 0000000..348016a --- /dev/null +++ b/Examples/Technical/Sponza/Camera/SponzaCameraMotion.swift @@ -0,0 +1,30 @@ +import AdaTransform +import Foundation + +/// Repeatable six-second gallery controls, evaluated from wall time rather than frame count. +public enum SponzaCameraMotion: String, CaseIterable, Sendable { + case stationary + case translation + case rotation + + public func pose(from origin: Transform, seconds: Double) -> Transform { + guard seconds.isFinite else { return origin } + let phase = seconds.remainder(dividingBy: 6) * .pi / 3 + var pose = origin + switch self { + case .stationary: + break + case .translation: + // The upper gallery runs along world X. Keep height, heading and corridor Z fixed. + pose.position.x += Float(1.5 * (1 - cos(phase))) + case .rotation: + var camera = SponzaFlyCamera() + camera.setPose(origin) + pose.rotation = SponzaFlyCamera.rotation( + yaw: camera.yaw + Float(phase), + pitch: camera.pitch + Float(0.2 * sin(2 * phase)) + ) + } + return pose + } +} diff --git a/Examples/Technical/Sponza/Camera/SponzaFlyCamera.swift b/Examples/Technical/Sponza/Camera/SponzaFlyCamera.swift new file mode 100644 index 0000000..2d225fb --- /dev/null +++ b/Examples/Technical/Sponza/Camera/SponzaFlyCamera.swift @@ -0,0 +1,48 @@ +import AdaTransform +import Foundation +import Math + +/// +Z forward, yaw about world Y, then pitch about the camera's local X. No roll. +public struct SponzaFlyCamera { + public private(set) var transform = Transform() + public private(set) var yaw: Float = 0 + public private(set) var pitch: Float = 0 + + public init() {} + + public mutating func setPose(_ pose: Transform) { + transform = pose + let forward = pose.matrix.z + yaw = Math.atan2(forward.x, forward.z) + pitch = -asin(max(-1, min(1, forward.y))) + } + + public static func rotation(yaw: Float, pitch: Float) -> Quat { + let horizontal = Quat(axis: [0, 1, 0], angle: yaw) + let vertical = Quat(axis: [1, 0, 0], angle: pitch) + // Hamilton product Y * X; spelling out the axes avoids legacy Euler conventions. + return Quat(x: horizontal.w * vertical.x, y: horizontal.y * vertical.w, + z: -horizontal.y * vertical.x, w: horizontal.w * vertical.w).normalized + } + + public static func looking(from position: Vector3, at target: Vector3) -> Transform { + let direction = (target - position).normalized + return Transform(rotation: rotation(yaw: Math.atan2(direction.x, direction.z), pitch: -asin(max(-1, min(1, direction.y)))), position: position) + } + + public mutating func look(delta: Vector2) { + guard delta.x.isFinite, delta.y.isFinite, delta != .zero else { return } + yaw = (yaw + delta.x * 0.003).remainder(dividingBy: 2 * .pi) + pitch = max(-1.55, min(1.55, pitch + delta.y * 0.003)) + transform.rotation = Self.rotation(yaw: yaw, pitch: pitch) + } + + public mutating func move(local: Vector3, seconds: Double, fast: Bool) { + guard seconds.isFinite, seconds > 0 else { return } + let basis = transform.matrix + var direction = Vector3(basis.x.x, basis.x.y, basis.x.z) * local.x + + Vector3(0, local.y, 0) + Vector3(basis.z.x, basis.z.y, basis.z.z) * local.z + if direction.length > 1 { direction = direction.normalized } + transform.position += direction * Float(min(seconds, 0.05)) * (fast ? 10 : 3) + } +} diff --git a/Examples/Technical/Sponza/Camera/SponzaLightMotion.swift b/Examples/Technical/Sponza/Camera/SponzaLightMotion.swift new file mode 100644 index 0000000..0c42b4a --- /dev/null +++ b/Examples/Technical/Sponza/Camera/SponzaLightMotion.swift @@ -0,0 +1,13 @@ +import Foundation +import Math + +/// A repeatable twelve-second flight through the upper gallery. +public enum SponzaLightMotion { + public static func position(seconds: Double) -> Vector3 { + let phase: Double = (seconds.isFinite ? seconds : 0).remainder(dividingBy: 12) * .pi / 6 + let x = Float(8 * Foundation.sin(phase)) + let y = Float(7.4 + 0.6 * Foundation.sin(2 * phase)) + let z = Float(5.35 + 0.5 * Foundation.cos(phase)) + return Vector3(x, y, z) + } +} diff --git a/Examples/Technical/Sponza/Package.swift b/Examples/Technical/Sponza/Package.swift new file mode 100644 index 0000000..e66ac49 --- /dev/null +++ b/Examples/Technical/Sponza/Package.swift @@ -0,0 +1,35 @@ +// swift-tools-version: 6.2 +import Foundation +import PackageDescription + +let package = Package( + name: "SponzaDemo", + platforms: [.macOS(.v15), .iOS(.v18)], + products: [.executable(name: "SponzaDemo", targets: ["SponzaDemo"])], + dependencies: [ + .package(url: "https://github.com/AdaEngine/AdaEngine.git", revision: "a624ccded0a050fddc24ccfbb8079dfb8032805c") + ], + targets: [ + .executableTarget( + name: "SponzaDemo", + dependencies: [ + "SponzaCamera", + .product(name: "AdaEngine", package: "adaengine"), + .product(name: "Math", package: "adaengine") + ], + path: ".", + exclude: ["README.md", "Camera", "Tests"] + (FileManager.default.fileExists(atPath: "dist") ? ["dist"] : []), + sources: [ + "SponzaDemo.swift", + "SponzaDemoProfiler.swift", + "SponzaLighting.swift", + "SponzaStutterProfiler.swift", + "SponzaWarmup.swift" + ], + resources: [.copy("Assets")], + swiftSettings: [.enableUpcomingFeature("MemberImportVisibility"), .strictMemorySafety()] + ), + .target(name: "SponzaCamera", dependencies: [.product(name: "AdaTransform", package: "adaengine"), .product(name: "Math", package: "adaengine")], path: "Camera"), + .testTarget(name: "SponzaDemoTests", dependencies: ["SponzaCamera", .product(name: "AdaTransform", package: "adaengine")], path: "Tests") + ] +) diff --git a/Examples/Technical/Sponza/README.md b/Examples/Technical/Sponza/README.md new file mode 100644 index 0000000..06a9b4f --- /dev/null +++ b/Examples/Technical/Sponza/README.md @@ -0,0 +1,183 @@ +# Sponza windowed demo + +> Commands below run from an AdaExamples checkout. Set `ADAENGINE_ROOT` to an +> engine checkout and `ADAENGINE_SPONZA_ASSETS` to its prepared Sponza `assets` +> directory. Route metadata is bundled with this package; datasets remain external. + + +Fetch the external dataset first with `python "$ADAENGINE_ROOT/script/fetch_benchmark_scene.py"` from the AdaExamples root. +The original assets and licenses stay in the engine checkout under `.build-codex/benchmark-assets/intel-sponza`. + +Run `swift run --package-path Examples/Technical/Sponza -c release SponzaDemo`. +`ADAENGINE_SPONZA_ASSETS` can override the prepared assets directory. +On Windows, use the same Swift/Dawn/Tint configuration as the native renderer benchmark. + +WASD moves, Q/E changes world height, hold the right mouse button to look, and Shift increases speed. +Escape releases mouse capture until the right button is released. Yaw uses world Y and pitch uses local X; +there is no roll. The toolbar selects atrium, corridor, gallery or outside and toggles Hi-Z. +There is no collision controller. PBR textures, three shadow cascades and +3 EV match the benchmark profile. + +Set `ADAENGINE_SPONZA_PROFILE_OUTPUT` to an absolute JSON filename to measure actual SceneView publication +intervals without image readback. After loading, the profiler visits atrium, gallery and outside with three +alternating pairs of shadow-on/off measurements (one second warmup, two seconds measured per case). +Keep window size constant during the entire run; discard a case if its size changes. Camera input is disabled +while profiling. The profiler restores shadows and the outside viewpoint when it finishes. +Graph records contain CPU encoding times, not GPU timestamp measurements. Use the opt-in rendering tests +for the reproducible meshlet benchmark, and the window profile to investigate interactive frame pacing. + +`ADAENGINE_WEBGPU_LEGACY_COMPLETION=1` selects the old native callback polling path for diagnostic comparisons. +Leave it unset during normal use. + +For moving-camera measurements, also set `ADAENGINE_SPONZA_PROFILE_MOTION=1`. +This compares the stationary gallery pose, a 0–3 metre translation along gallery X, and a full 360-degree +turn with a small pitch sweep. Motion follows a six-second wall-clock cycle through the same ECS camera +component write path as interactive movement. Each case has one second warmup and six seconds measured; +three alternating shadow-on/off pairs are recorded for each motion. `motion` and `cameraUpdates` identify +what actually moved. Use `stationary`, `translation` or `rotation` instead of `1` to run only that motion. +The scripted motion does not emulate native mouse events or an arbitrary user flight path. + +## Lighting and path tracing + +The window now has three explicit presets: full 8.81M-triangle raster geometry, raster lighting LOD, +and path tracing on the same lighting LOD. The default interactive preset is full-scene raster with the 12 local lights. +`ADAENGINE_SPONZA_RENDER_PRESET=fullRaster|lodRaster|pathTracing` selects startup mode. +The light button cycles 0 / 4 / 12 local lights; the sun stays enabled. +Four warm gallery spots cast shadows; eight warm/cool point fill lights do not. +The demo now defaults to 2048 pixels per sun cascade and 1024 per local-light face/tile. +Sun-shadow coverage also increases from 30 to 80 metres with closer cascade splits (lambda 0.85). +The shadow button switches back to the earlier 1024/512, 30-metre setting. Other scenes retain their defaults. +Raster local-shadow budget remains four lights; the renderer selects nearby shadow-casting sources, +including the moving test light, within that budget. PT shadow rays do not use this raster atlas budget. +The separate flying-light button enables a shadow-casting point light on a twelve-second gallery orbit. +It starts enabled for interactive testing, independently of the 0 / 4 / 12 stationary lights. +Turn it off to inspect static PT accumulation; moving light intentionally resets that accumulation. + +The portable shader-BVH tracer supports at most 131072 triangles. Prepare the distinct lighting LOD first: +`python "$ADAENGINE_ROOT/script/prepare_sponza_path_tracing.py" --gltfpack `. +The current preparation yields 122286 source triangles, preserving image references and keeping the originals. +The tool records output hashes and its executable hash in `assets/lighting-pt-receipt.json`. +Pinned tool source: https://github.com/zeux/meshoptimizer/releases/tag/v1.3 ; Windows archive SHA-256: +`f6e9c09d66af23da3da71b86f0652d2413e81c734e0aecd1cd3d0e6e6e8645c0`. +This is a strongly simplified geometry preset; UVs and normals participate in attribute-aware simplification. +The relative geometric error is limited to 1%, with a larger triangle allocation for architecture than ivy. +Preparation rejects a result that loses an entire source material group. The former 100% error profile +discarded the floor and wall groups and must be regenerated. This LOD does not reproduce full-scene geometric detail. + +PT uses 1 sample per pixel, 2 interactions and up to 128 accumulated static frames. +Without DLSS the quality button selects 1/2 width and height or the faster 1/4 preview. +With DLSS it selects Quality (2/3 input scale) or Performance (1/2); PT traces at that input size, +without a second internal resolution reduction. DLSS Super Resolution is enabled by default when available. +Its separate status line reports actual encoded frames and fallback frames. Unsupported devices retain +the ordinary rendering path and report that DLSS is unavailable. +Camera/light changes reset accumulation. It is software BVH traversal, not hardware RTX. +The demo uses 512-pixel PT material tiles (the engine default remains 128); raster still samples the +original textures. The comparison is a renderer workload comparison, not an equal-image-quality benchmark. +The status line reports actual encoded geometry, lights, ray-image size or a tracing failure. + +For the lighting benchmark set `ADAENGINE_SPONZA_PROFILE_LIGHTING=1` along with the profile output, +`ADAENGINE_SPONZA_RENDER_PRESET=lodRaster`, and optionally `ADAENGINE_SPONZA_PROFILE_MOTION=rotation`. +It measures the LOD in raster and path tracing with 0, 4 and 12 local lights, three rounds of six seconds +per case after one second warmup. Cold BVH/atlas construction is outside measurement and has a 180-second +readiness deadline. PT cases require actual encoded-frame progress, expected light count and no failure; +a fallback image does not count as a path-tracing result. Profiling disables DLSS and the flying light +to preserve the explicit stationary-light cases. Full-scene legacy profiles retain 0 local lights. + +For Windows DLSS builds, prepare and stage the matching native NGX runtime using +[Documentation/DLSS.md](https://github.com/AdaEngine/Ada/blob/main/Documentation/DLSS.md). This demo uses Super Resolution, not Frame Generation +or Ray Reconstruction. DLSS cannot recover geometry removed during asset preparation. + +Native camera history is retained while offscreen render targets are being prepared; removal of the actual +camera releases that history. GPU regression coverage exercises this lifecycle. During movement, this +experimental tracer resets accumulation; it has no temporal reprojection or production denoiser. +The current static PT image still has substantial visual defects even after accumulation and is not a +production-quality reference. See the accepted workload measurements and remaining limits in +[the lighting report](https://github.com/AdaEngine/Ada/blob/main/Documentation/Performance/SponzaLightingPerformance-2026-11-03.md). + +## Meshlets and cluster map + +Prepare clusters without simplifying the source scene: + +```text +python "$ADAENGINE_ROOT/script/build_meshlet_tool.py" +python "$ADAENGINE_ROOT/script/prepare_meshlets.py" /main_sponza/benchmark-pbr.gltf --builder native +python "$ADAENGINE_ROOT/script/prepare_meshlets.py" /pkg_b_ivy/benchmark-pbr.gltf --builder native +``` + +Keep each `.gltf.meshlets` file beside its source. Repeat preparation for `lighting-pt.gltf` to use +the smaller raster LOD. Missing or invalid sidecars retain ordinary geometry. The builder pins +meshoptimizer 1.3; no offline C++ dependency enters the runtime. See [Tools/Meshlets](https://github.com/AdaEngine/Ada/blob/main/Tools/Meshlets/README.md). + +The meshlet button cycles **disabled → cpuReference → gpu**. The engine default stays disabled; +this diagnostic demo explicitly selects GPU mode. `ADAENGINE_SPONZA_MESHLETS` overrides the startup mode. +GPU v1 conservatively culls prepared static opaque singleton draws by frustum and compacts their indices, +issuing one indirect draw per original coarse draw. Existing instancing, MASK/BLEND, deformed/graph/parallax +surfaces, force-visible meshes, unknown draw passes and unsupported devices retain the original path. +Custom passes must explicitly adopt `MeshletGeometryDrawPass` to promise static vertex coverage and consume +the main-pass geometry override. Hi-Z uses the existing +legacy path; combined meshlet Hi-Z/cone culling is not enabled in this version. +Fully contained parts bypass classification/index copying and use their complete original draw; `целиком` +reports this fast path separately from unsupported fallbacks. Partial parts use workgroup-parallel prefix +scan and three batched compute passes per view, preserving source primitive order. + +The separate **Карта мешлетов** button displays stable colors from actual local cluster IDs. Gray means +ordinary fallback geometry, not a missing surface. The map selects full-scene raster, disables DLSS for +sharp diagnostic colors and preserves the source silhouette. Its cached diagnostic vertex streams are +bounded to 768 MiB and created only when requested. Exact partition display uses one draw per eligible part; +adaptive LOD display coalesces the selected node ranges. Neither path issues one draw per cluster. +this diagnostic allocation/draw path is excluded from performance measurements and bypasses unused GPU +visibility compaction. Unsupported or over-budget +parts remain gray. Set `ADAENGINE_SPONZA_MESHLET_MAP=1` to start in this view. + +These meshlets retain the full source triangles and are separate from the simplified PT lighting LOD. +The raster visibility processor does not accelerate the software path tracer's BVH. + +`ADAENGINE_SPONZA_PROFILE_MESHLETS=1` with an absolute `ADAENGINE_SPONZA_PROFILE_OUTPUT` runs +disabled/CPU/GPU comparisons in gallery rotation and a stationary outside view: three alternating rounds, +one second warmup and four seconds measured per case. It fixes full geometry, disables DLSS, local/flying +lights, Hi-Z and the map, and leaves sun shadows enabled. The run first warms the outside overview for +three seconds and a complete gallery turn for six seconds. CPU-known cluster counts, fully visible and +fallback triangles are separate from GPU-submitted index counts, which are read back only after each +measured interval. SceneView publication +and CPU graph times are not GPU timestamps. The run ends in the full-scene meshlet map. + +The current Windows/RTX 4070 gallery benchmark does **not** meet the acceleration gate: about 103 FPS +in GPU mode versus 123 FPS in disabled mode at 1890×843 without local lights or DLSS. Keep the mode +explicit while investigating this cost. Correctness, raw measurements and remaining platform limits: +[implementation report](https://github.com/AdaEngine/Ada/blob/main/Documentation/Performance/MeshletsImplementation-2026-11-03.md). + +## Adaptive meshlet LOD + +The original visibility-only meshlets do not simplify geometry. Prepare the additional hierarchy to let +groups of clusters use coarser parents with distance: + +```text +python "$ADAENGINE_ROOT/script/build_meshlet_tool.py" +python "$ADAENGINE_ROOT/script/prepare_meshlet_lod.py" /main_sponza/benchmark-pbr.gltf +python "$ADAENGINE_ROOT/script/prepare_meshlet_lod.py" /pkg_b_ivy/benchmark-pbr.gltf +``` + +Keep `.gltf.meshletlod` beside the source model; `.gltf.meshlets` remains the exact v1 partition. +The builder preserves source vertex identities and exact group boundary edges; it does not cross materials. +Missing/stale/corrupt hierarchy retains the original geometry or v1 culling. Unreducible groups remain exact +forest roots instead of inventing parent levels. The prepared full architecture has L0–L4, Ivy L0–L1. + +The **LOD** button cycles Off / 1 / 2 / 4 / 8 output pixels of allowed approximation error; the demo starts at 2. +`ADAENGINE_SPONZA_MESHLET_LOD=0|1|2|4|8` sets startup quality. Engine settings default to zero, and the +meshlet `disabled` mode still uses original geometry. Different source regions choose levels independently. +In the map, actual selected parents replace their children: L1 is tinted cyan, L2 orange, L3 pink, L4 purple; +L0 retains the original cluster-ID colors. The status reports selected levels/triangles for the diagnostic +cut. Ordinary GPU mode reports active LOD items; it does not synchronously read GPU triangle counts. + +At the coarsest complete forest, opaque architecture changes from 3,738,878 to 2,172,290 triangles, +Ivy from 5,065,558 to 4,455,592. These are offline lower bounds, not expected counts at every camera position. +Shadow/contact passes retain full source geometry. Temporal motion guides use independent immutable indices +and the same LOD cut, including previous object transforms; they do not borrow a completed GPU frame's buffer. + +`ADAENGINE_SPONZA_PROFILE_MESHLET_LOD=1` compares GPU LOD Off / 2 / 8 with the same two camera paths and +three alternating rounds. It fixes the new 2048/1024 shadow quality with 80-metre sun coverage, disables DLSS, local/flying lights, +Hi-Z and the map during timing, and records the explicit LOD/shadow settings. Profiling fixes the SceneView +to 1120×400 logical points so maximizing the native window cannot change the measured resolution. +Historical profiles keep +LOD off and 1024/512 shadows. `ADAENGINE_SPONZA_SHADOW_QUALITY=baseline` selects old startup shadows. + +[LOD, shadows and final validation](https://github.com/AdaEngine/Ada/blob/main/Documentation/Performance/MeshletLODAndShadows-2026-11-03.md). diff --git a/Examples/Technical/Sponza/SponzaDemo.swift b/Examples/Technical/Sponza/SponzaDemo.swift new file mode 100644 index 0000000..f83d61d --- /dev/null +++ b/Examples/Technical/Sponza/SponzaDemo.swift @@ -0,0 +1,371 @@ +import AdaEngine +import Foundation +import Math +import Observation +import SponzaCamera + +struct CameraRoutes: Decodable { + struct Pose: Decodable { + var position: [Float] + var rotation: [Float] + + var transform: Transform { + Transform(rotation: Quat(x: rotation[0], y: rotation[1], z: rotation[2], w: rotation[3]), position: [position[0], position[1], position[2]]) + } + } + struct Route: Decodable { var id: String; var start: Pose } + var routes: [Route] +} + +@MainActor +@Observable +final class SponzaSession: Resource { + var status = "Загружаем Sponza + Ivy…" + var hiZ = false + var requestedView: Int? + var camera: Entity? + var poses: [CameraRoutes.Pose] = [] + var preset = SponzaRenderPreset.fullRaster + var lightCount = 12 + var requestedPreset: SponzaRenderPreset? + var requestedLightCount: Int? + var pathHighQuality = true + var requestedQuality: Bool? + var dlssAvailable = false + var dlssEnabled = true + var requestedDLSS: Bool? + var dlssStatus = "Проверяем DLSS…" + var flyingLightEnabled = true + var meshletMode = MeshletVisibilityMode3D.gpu + var meshletMapEnabled = false + var requestedMeshletMode: MeshletVisibilityMode3D? + var requestedMeshletMap: Bool? + var meshletLODError: Float = 2 + var requestedMeshletLODError: Float? + var highResolutionShadows = true + var requestedShadowQuality: Bool? + var meshletStatus = "Загружаем подготовленные мешлеты…" + @ObservationIgnored var meshletStatistics: Render3DMeshletStatistics? + @ObservationIgnored var flightSeconds = 0.0 + @ObservationIgnored var temporalStatistics: Render3DTemporalStatistics? + var rendererStatus = "Готовим освещение…" + @ObservationIgnored var fullGeometry: Entity? + @ObservationIgnored var lodGeometry: Entity? + @ObservationIgnored var sun: Entity? + @ObservationIgnored var lighting = SponzaLighting() + @ObservationIgnored var pathStatistics: Render3DPathTracingStatistics? + @ObservationIgnored var nextStatusUpdate = ContinuousClock.now + var isLoaded = false + @ObservationIgnored let warmupFrames = SponzaWarmupFrames() + @ObservationIgnored var flight = SponzaFlyCamera() + @ObservationIgnored var cancelledLook = false + @ObservationIgnored var profiler: SponzaDemoProfiler? + var loadTask: Task? + @ObservationIgnored weak var runtime: AppWorlds? + + func move(to index: Int) { + guard poses.indices.contains(index), let camera else { return } + let transform = poses[index].transform + camera.components[Transform.self] = transform + camera.components[GlobalTransform.self] = GlobalTransform(matrix: transform.matrix) + flight.setPose(transform) + } +} + +@main +struct SponzaDemo: App { + var body: some AppScene { + WindowGroup { SponzaView() } + .window(with: UIWindow.Configuration(title: "Sponza + Ivy · AdaEngine", frame: Rect(x: 0, y: 0, + width: ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_PROFILE_STUTTER"] == "1" ? 2560 : 1280, + height: ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_PROFILE_STUTTER"] == "1" ? 1400 : 800))) + } +} + +private struct SponzaView: View { + @State private var session = SponzaSession() + + var body: some View { + VStack(spacing: 8) { + HStack(spacing: 16) { + Button("Атриум") { session.requestedView = 0 } + Button("Коридор") { session.requestedView = 1 } + Button("Галерея") { session.requestedView = 2 } + Button("Снаружи") { session.requestedView = 3 } + Button(session.hiZ ? "Hi-Z: вкл." : "Hi-Z: выкл.") { session.hiZ.toggle() } + } + .disabled(!session.isLoaded) + Text(session.status) + HStack(spacing: 16) { + Button("Полная сцена · растр") { session.requestedPreset = .fullRaster } + Button("PT LOD · растр") { session.requestedPreset = .lodRaster } + Button("PT LOD · path tracing") { session.requestedPreset = .pathTracing } + Button("Локальный свет: \(session.lightCount)") { session.requestedLightCount = session.lightCount == 0 ? 4 : session.lightCount == 4 ? 12 : 0 } + Button(session.meshletMapEnabled ? "Карта: 1:1" : session.dlssEnabled && session.dlssAvailable + ? (session.pathHighQuality ? "DLSS: Quality" : "DLSS: Performance") + : (session.pathHighQuality ? "PT: качество ½" : "PT: предпросмотр ¼")) { session.requestedQuality = !session.pathHighQuality } + } + .disabled(!session.isLoaded) + HStack(spacing: 16) { + Button(session.dlssEnabled && session.dlssAvailable && !session.meshletMapEnabled ? "DLSS: вкл." : "DLSS: выкл.") { session.requestedDLSS = !session.dlssEnabled } + Button(session.flyingLightEnabled ? "Летающий свет: вкл." : "Летающий свет: выкл.") { session.flyingLightEnabled.toggle() } + Button(session.highResolutionShadows ? "Тени: высокие · 80 м" : "Тени: базовые · 30 м") { session.requestedShadowQuality = !session.highResolutionShadows } + SponzaDLSSStatus(session: session) + } + .disabled(!session.isLoaded) + SponzaRendererStatus(session: session) + HStack(spacing: 16) { + Button("Мешлеты: \(session.meshletMode.rawValue)") { + session.requestedMeshletMode = session.meshletMode == .disabled ? .cpuReference : session.meshletMode == .cpuReference ? .gpu : .disabled + } + Button(session.meshletMapEnabled ? "Карта мешлетов: вкл." : "Карта мешлетов: выкл.") { session.requestedMeshletMap = !session.meshletMapEnabled } + Button(session.meshletLODError > 0 ? "LOD: \(Int(session.meshletLODError)) px" : "LOD: выкл.") { + session.requestedMeshletLODError = session.meshletLODError == 0 ? 1 : session.meshletLODError >= 8 ? 0 : session.meshletLODError * 2 + } + } + .disabled(!session.isLoaded) + SponzaMeshletStatus(session: session) + if session.meshletMapEnabled && session.meshletLODError > 0 { + Text("L0 — исходные кластеры · L1 — голубой · L2 — оранжевый · L3 — розовый · L4 — фиолетовый") + } + Text("WASD — движение · Q/E — вниз/вверх · правая кнопка мыши — осмотр · Shift — быстрее") + SceneView(useSceneCameras: true, make: { app in + app.addPlugin(TransformPlugin()).addPlugin(InputPlugin()).addPlugin(RenderWorldPlugin()) + .addPlugin(CameraPlugin()).addPlugin(AssetsPlugin()).addPlugin(VisibilityPlugin()) + .addPlugin(ScenePlugin()).addPlugin(Core3DPlugin(pathTracingPresentation: true)).addPlugin(UpscalePlugin()).addPlugin(SponzaPlugin()) + app.insertResource(session) + session.runtime = app + }, updateContent: { _, _ in }, onFrameRendered: { texture in + session.warmupFrames.didPublish() + session.profiler?.didPresent(width: texture.width, height: texture.height) + }) + .frameCaptureLabel("SponzaDemo") + // Keep measured GPU work independent of native-window resizing/maximization. + .frame(width: session.profiler?.isRunning == true && session.profiler?.usesFixedViewport == true ? 1120 : nil, + height: session.profiler?.isRunning == true && session.profiler?.usesFixedViewport == true ? 400 : nil) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .padding(10) + .background(Color(0.06, 0.07, 0.09, 1)) + .foregroundColor(.white) + } +} + +private struct SponzaPlugin: Plugin { + func setup(in app: AppWorlds) { + app.addSystem(SponzaStartupSystem.self, on: .startup) + app.addSystem(SponzaControlsSystem.self, on: .preUpdate) + } +} + +@System +@MainActor +func SponzaStartup(_ context: WorldUpdateContext, _ resource: Res) { + let session = resource.wrappedValue + guard let app = session.runtime else { return } + // Keep the window rendering and accepting input while the asset actor decodes the 4K textures. + // The session owns this task for the lifetime of this single-window demo; it is not detached. + session.loadTask = Task { @MainActor in + do { + let environment = ProcessInfo.processInfo.environment + let repository = (environment["ADAENGINE_ROOT"] ?? environment["ADAENGINE_SHADER_SOURCE_ROOT"]).map { URL(fileURLWithPath: $0) } + ?? URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true) + let root = ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_ASSETS"].map { URL(fileURLWithPath: $0, isDirectory: true) } + ?? repository.appendingPathComponent(".build-codex/benchmark-assets/intel-sponza/assets") + let routes = try JSONDecoder().decode(CameraRoutes.self, from: Data(contentsOf: Bundle.module.bundleURL.appendingPathComponent("Assets/intel-sponza-routes.json"))) + guard routes.routes.count == 3, routes.routes.allSatisfy({ $0.start.position.count == 3 && $0.start.rotation.count == 4 }) else { + throw CocoaError(.fileReadCorruptFile) + } + session.poses = routes.routes.map(\.start) + let outside = SponzaFlyCamera.looking(from: [-32, 25, -35], at: [0, 4, 0]) + session.poses.append(CameraRoutes.Pose(position: [outside.position.x, outside.position.y, outside.position.z], + rotation: [outside.rotation.x, outside.rotation.y, outside.rotation.z, outside.rotation.w])) + var environment = Environment3D(antiAliasing: .none) + environment.skybox.isEnabled = false + environment.shadows.isEnabled = true + environment.shadowCascadeCount = 3 + environment.shadows.resolution = 2048 + environment.ambientOcclusion.isEnabled = false + environment.screenSpaceReflection.isEnabled = false + environment.exposureEV = 3 + environment.gpuOcclusionEnabled = session.hiZ + let environmentRoot = try await app.withWorldAccess { + session.camera = app.main.spawn("Camera", bundle: Camera3D( + camera: Camera(), perspectiveProjection: PerspectiveProjection(near: 0.05, far: 200, fieldOfView: .degrees(60)), + transform: routes.routes[0].start.transform, environment: environment + )) + session.move(to: 0) + session.camera?.components[MouseCaptureComponent.self] = MouseCaptureComponent() + let lighting = app.main.spawn("Lighting") { Transform() } + let sun = app.main.spawn("Sun") { + DirectionalLightComponent(intensity: 3, castShadows: true) + Transform(rotation: Quat.euler([-0.4, 0.5, 0])) + } + lighting.addChild(sun) + session.sun = sun + session.lighting.spawn(in: app.main, parent: lighting) + let root = app.main.spawn("Environment") { Transform() } + session.fullGeometry = app.main.spawn("Full Sponza + Ivy") { Transform(); Visibility.hidden } + session.lodGeometry = app.main.spawn("PT lighting LOD") { Transform(); Visibility.hidden } + if let full = session.fullGeometry { root.addChild(full) } + if let lod = session.lodGeometry { root.addChild(lod) } + return root + } + _ = environmentRoot + for (filename, isLOD) in [("benchmark-pbr.gltf", false), ("lighting-pt.gltf", true)] { + for (folder, label) in [("main_sponza", "Sponza"), ("pkg_b_ivy", "Ivy")] { + try Task.checkCancellation() + session.status = "Загружаем \(label)\(isLOD ? " · PT LOD" : "")…" + let file = root.appendingPathComponent(folder).appendingPathComponent(filename) + guard let model = try await AssetsManager.load(ModelAsset3D.self, at: file.path).asset else { throw CocoaError(.fileReadCorruptFile) } + try await app.withWorldAccess { + let parent = isLOD ? session.lodGeometry : session.fullGeometry + parent?.addChild(model.instantiate(in: app.main)) + } + } + } + try await app.withWorldAccess { + session.pathStatistics = app.getSubworldBuilder(by: .renderWorld)?.getResource(Render3DPathTracingStatistics.self) + session.temporalStatistics = app.getSubworldBuilder(by: .renderWorld)?.getResource(Render3DTemporalStatistics.self) + session.meshletStatistics = app.getSubworldBuilder(by: .renderWorld)?.getResource(Render3DMeshletStatistics.self) + session.meshletMode = ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_MESHLETS"].flatMap(MeshletVisibilityMode3D.init(rawValue:)) ?? .gpu + session.meshletMapEnabled = ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_MESHLET_MAP"] == "1" + if let value = ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_MESHLET_LOD"].flatMap(Float.init), value.isFinite { + session.meshletLODError = min(max(value, 0), 8) + } + session.highResolutionShadows = ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_SHADOW_QUALITY"] != "baseline" + session.dlssAvailable = app.getSubworldBuilder(by: .renderWorld)?.getResource(RenderDeviceHandler.self)?.renderDevice.supportsTemporalUpscaling ?? false + let requested = ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_RENDER_PRESET"].flatMap(SponzaRenderPreset.init(rawValue:)) ?? .fullRaster + session.apply(preset: requested, lights: 12) + session.move(to: 2) + } + try await session.warmupRaster(app: app) + session.isLoaded = true + session.status = "Sponza + Ivy · полная сцена / отдельный PT LOD" + FileHandle.standardOutput.write(Data("SPONZA_DEMO_READY\n".utf8)) + if let output = ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_PROFILE_OUTPUT"] { + let profiler = SponzaDemoProfiler(output: URL(fileURLWithPath: output)) + session.profiler = profiler + try await profiler.run(session: session, app: app) + } + } catch { + session.status = session.isLoaded ? "Замер остановлен; подробности в журнале" : "Не удалось завершить загрузку: \(error)" + FileHandle.standardError.write(Data("SPONZA_DEMO_ERROR \(error)\n".utf8)) + } + } +} + +private struct SponzaRendererStatus: View { + let session: SponzaSession + + var body: some View { Text(session.rendererStatus) } +} + +private struct SponzaDLSSStatus: View { + let session: SponzaSession + var body: some View { Text(session.dlssStatus) } +} + +private struct SponzaMeshletStatus: View { + let session: SponzaSession + var body: some View { Text(session.meshletStatus) } +} + +@PlainSystem(dependencies: [.after(InputEventParseSystem.self), .before(CameraSystem.self)]) +struct SponzaControlsSystem { + @Res private var session + @Res private var input + @Res private var time + // Declare writes performed through the session's camera ID. + @Query, Ref, Ref, Ref, Ref> private var cameraAccess + @Query, Ref, PointLightComponent> private var lightAccess + @Query> private var visibilityAccess + @Query> private var sunAccess + + init(world _: World) {} + + @MainActor + func update(context _: UpdateContext) { + _ = cameraAccess + _ = visibilityAccess + _ = lightAccess + _ = sunAccess + guard session.isLoaded, let camera = session.camera, + let transform = camera.components[Transform.self] else { return } + session.refreshPathStatus() + if let profiler = session.profiler, profiler.isRunning { + if let next = profiler.cameraPose(), next != transform { + camera.components[Transform.self] = next + camera.components[GlobalTransform.self] = GlobalTransform(matrix: next.matrix) + profiler.didMoveCamera() + } + return + } + if session.isLoaded, session.requestedPreset != nil || session.requestedLightCount != nil || session.requestedQuality != nil + || session.requestedDLSS != nil || session.requestedMeshletMode != nil || session.requestedMeshletMap != nil + || session.requestedMeshletLODError != nil || session.requestedShadowQuality != nil { + if let quality = session.requestedQuality { session.pathHighQuality = quality } + if let dlss = session.requestedDLSS { session.dlssEnabled = dlss } + if let quality = session.requestedShadowQuality { session.highResolutionShadows = quality } + if let error = session.requestedMeshletLODError { + session.meshletLODError = error + session.hiZ = false + if error > 0 && session.meshletMode == .disabled { session.meshletMode = .gpu } + if session.preset == .pathTracing { session.requestedPreset = .fullRaster } + } + if session.requestedPreset == .pathTracing { session.meshletMapEnabled = false } + if let mode = session.requestedMeshletMode { + session.meshletMode = mode + session.hiZ = false + if session.preset == .pathTracing { session.requestedPreset = .fullRaster } + } + if let map = session.requestedMeshletMap { + session.meshletMapEnabled = map + if map { session.hiZ = false; session.requestedPreset = .fullRaster } + } + session.apply(preset: session.requestedPreset ?? session.preset, lights: session.requestedLightCount ?? session.lightCount) + session.requestedPreset = nil + session.requestedLightCount = nil + session.requestedQuality = nil + session.requestedDLSS = nil + session.requestedMeshletMode = nil + session.requestedMeshletMap = nil + session.requestedMeshletLODError = nil + session.requestedShadowQuality = nil + } + session.flightSeconds += max(0, Double(time.deltaTime)) + session.lighting.updateFlight(enabled: session.flyingLightEnabled, seconds: session.flightSeconds) + if let index = session.requestedView { + session.move(to: index) + session.requestedView = nil + } + let rightDown = input.isMouseButtonPressed(.right) + if !rightDown { session.cancelledLook = false } + if input.isKeyPressed(.escape) { session.cancelledLook = true } + if var capture = camera.components[MouseCaptureComponent.self] { + let requested = rightDown && !session.cancelledLook + if requested && capture.isCaptured { session.flight.look(delta: input.getMouseDelta()) } + if capture.isRequested != requested { + capture.isRequested = requested + camera.components[MouseCaptureComponent.self] = capture + } + } + var movement = Vector3.zero + if input.isKeyPressed(.w) { movement.z += 1 } + if input.isKeyPressed(.s) { movement.z -= 1 } + if input.isKeyPressed(.d) { movement.x += 1 } + if input.isKeyPressed(.a) { movement.x -= 1 } + if input.isKeyPressed(.e) { movement.y += 1 } + if input.isKeyPressed(.q) { movement.y -= 1 } + session.flight.move(local: movement, seconds: Double(time.deltaTime), fast: input.isKeyPressed(.shift)) + let next = session.flight.transform + if next != transform { + camera.components[Transform.self] = next + camera.components[GlobalTransform.self] = GlobalTransform(matrix: next.matrix) + } + if var environment = camera.components[Environment3D.self], environment.gpuOcclusionEnabled != session.hiZ { + environment.gpuOcclusionEnabled = session.hiZ + camera.components[Environment3D.self] = environment + } + } +} diff --git a/Examples/Technical/Sponza/SponzaDemoProfiler.swift b/Examples/Technical/Sponza/SponzaDemoProfiler.swift new file mode 100644 index 0000000..9ea0fde --- /dev/null +++ b/Examples/Technical/Sponza/SponzaDemoProfiler.swift @@ -0,0 +1,357 @@ +import AdaEngine +import Foundation +import SponzaCamera + +/// Opt-in measurements of actual SceneView publication, with no image readback in the interval. +@MainActor +final class SponzaDemoProfiler { + struct Measurement: Codable { + var view: String + var shadows: Bool + var round: Int + var width: Int + var height: Int + var publishedIntervalsMilliseconds: [Double] + var graphRecords: [RenderGraphFrameRecord] + var motion: String? + var cameraUpdates: Int? + var preset: String? + var localLightCount: Int? + var lightStatistics: [String: Render3DLightStatistics.Snapshot]? + var pathTracing: [String: PathTracing3DStatus]? + var pathFramesBefore: [String: Int]? + var pathSettings: PathTracing3D? + var meshlets: [String: Meshlet3DCount]? + var meshletGPUIndexCount: Int? + var meshletLODPixelError: Float? + var shadowCascadeResolution: Int? + var localShadowResolution: Int? + var shadowDistance: Float? + } + + let output: URL + let usesFixedViewport = ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_PROFILE_STUTTER"] != "1" + private(set) var isRunning = false + var recording = false + var last: ContinuousClock.Instant? + var intervals: [Double] = [] + var size = [0, 0] + var sizeChanged = false + var motion = SponzaCameraMotion.stationary + var motionOrigin = Transform() + var motionStart: ContinuousClock.Instant? + var cameraUpdates = 0 + + init(output: URL) { self.output = output } + + func cameraPose() -> Transform? { + guard motion != .stationary, let motionStart else { return nil } + let elapsed = motionStart.duration(to: .now).components + let seconds = Double(elapsed.seconds) + Double(elapsed.attoseconds) / 1e18 + return motion.pose(from: motionOrigin, seconds: seconds) + } + + func didMoveCamera() { + if recording { cameraUpdates += 1 } + } + + func didPresent(width: Int, height: Int) { + guard recording else { return } + if size[0] > 0, size != [width, height] { sizeChanged = true } + let now = ContinuousClock.now + if let last { + PerformanceStallProbe.end("SceneView.PublishGap", since: last, count: width * height) + let elapsed = last.duration(to: now).components + intervals.append(Double(elapsed.seconds) * 1000 + Double(elapsed.attoseconds) / 1e15) + } + last = now + size = [width, height] + } + + func run(session: SponzaSession, app: AppWorlds) async throws { + guard let render = app.getSubworldBuilder(by: .renderWorld), let diagnostics = render.getResource(RenderGraphDiagnostics.self) else { return } + isRunning = true + defer { isRunning = false; recording = false; motionStart = nil } + if !usesFixedViewport { + try await runStutter(session: session, app: app, diagnostics: diagnostics) + return + } + // Keep historical profiles comparable; the new LOD profile explicitly records its quality settings. + session.meshletLODError = 0 + session.highResolutionShadows = false + if ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_PROFILE_MESHLETS"] == "1" + || ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_PROFILE_MESHLET_LOD"] == "1" { + try await runMeshlets(session: session, app: app, diagnostics: diagnostics) + return + } + session.meshletMode = .disabled + session.meshletMapEnabled = false + if let lighting = ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_PROFILE_LIGHTING"], + ["1", "lodRaster", "pathTracing"].contains(lighting) { + try await runLighting(session: session, app: app, diagnostics: diagnostics) + return + } + try await app.withWorldAccess { + session.dlssEnabled = false + session.flyingLightEnabled = false + session.apply(preset: .fullRaster, lights: 0) + } + var measurements: [Measurement] = [] + let requestedMotion = ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_PROFILE_MOTION"] + let selectedMotion = requestedMotion.flatMap(SponzaCameraMotion.init(rawValue:)) + let motionRun = requestedMotion == "1" || selectedMotion != nil + let motions = selectedMotion.map { [$0] } ?? SponzaCameraMotion.allCases + let scenarios = motionRun ? motions.map { (2, $0) } : [0, 2, 3].map { ($0, .stationary) } + for (view, mode) in scenarios { + for round in 0..<3 { + for shadows in round.isMultiple(of: 2) ? [true, false] : [false, true] { + try await app.withWorldAccess { + session.move(to: view) + motion = mode + motionOrigin = session.flight.transform + motionStart = .now + if let camera = session.camera, var environment = camera.components[Environment3D.self] { + environment.shadows.isEnabled = shadows + camera.components[Environment3D.self] = environment + } + diagnostics.configure(isEnabled: true, capacity: 64) + diagnostics.clear() + } + session.status = "Измеряем сцену…" + try await Task.sleep(for: .seconds(1)) + intervals.removeAll(keepingCapacity: true) + intervals.reserveCapacity(1024) + last = nil + cameraUpdates = 0 + recording = true + try await Task.sleep(for: .seconds(motionRun ? 6 : 2)) + recording = false + let records = try await app.withWorldAccess { diagnostics.records } + let name = ["atrium", "corridor", "gallery", "outside"][view] + measurements.append(Measurement(view: name, shadows: shadows, round: round, + width: size[0], height: size[1], publishedIntervalsMilliseconds: intervals, graphRecords: records, + motion: mode.rawValue, cameraUpdates: cameraUpdates, preset: session.preset.rawValue, + localLightCount: session.lightCount)) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true) + try encoder.encode(measurements).write(to: output, options: .atomic) + } + } + } + try await app.withWorldAccess { + diagnostics.configure(isEnabled: false) + if let camera = session.camera, var environment = camera.components[Environment3D.self] { + environment.shadows.isEnabled = true + camera.components[Environment3D.self] = environment + } + session.move(to: 3) + } + session.status = "Sponza + Ivy · полная сцена / отдельный PT LOD" + FileHandle.standardOutput.write(Data("SPONZA_PROFILE_READY \(output.path)\n".utf8)) + } + + private func runLighting(session: SponzaSession, app: AppWorlds, diagnostics: RenderGraphDiagnostics) async throws { + let render = app.getSubworldBuilder(by: .renderWorld) + guard let tracing = render?.getResource(Render3DPathTracingStatistics.self), + let lights = render?.getResource(Render3DLightStatistics.self) else { throw CocoaError(.featureUnsupported) } + let selected = ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_PROFILE_MOTION"].flatMap(SponzaCameraMotion.init(rawValue:)) ?? .rotation + session.pathHighQuality = false + session.dlssEnabled = false + session.flyingLightEnabled = false + var measurements: [Measurement] = [] + // Group modes so the cold CPU BVH/texture atlas is built once, outside measured intervals. + let requested = ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_PROFILE_LIGHTING"] + let presets = requested.flatMap(SponzaRenderPreset.init(rawValue:)).map { [$0] } ?? [.lodRaster, .pathTracing] + for preset in presets { + for round in 0..<3 { + for count in round.isMultiple(of: 2) ? [0, 4, 12] : [12, 4, 0] { + motionStart = nil + try await app.withWorldAccess { + session.move(to: 2) + session.apply(preset: preset, lights: count) + } + if preset == .pathTracing { + let deadline = ContinuousClock.now + .seconds(180) + while true { + let states = tracing.snapshots.values + if let failure = states.compactMap(\.failure).first { + throw CocoaError(.featureUnsupported, userInfo: [NSLocalizedDescriptionKey: failure]) + } + if !states.isEmpty && states.allSatisfy({ $0.encodedFrames > 0 && $0.triangles > 0 && $0.lights == count + 1 }) { break } + guard ContinuousClock.now < deadline else { throw CocoaError(.executableRuntimeMismatch) } + try await Task.sleep(for: .milliseconds(50)) + } + } + try await app.withWorldAccess { + motion = selected + motionOrigin = session.flight.transform + motionStart = .now + diagnostics.configure(isEnabled: true, capacity: 64) + diagnostics.clear() + } + session.status = "Замер света: \(preset.rawValue) · \(count) источников" + try await Task.sleep(for: .seconds(1)) + let before = tracing.snapshots.mapValues(\.encodedFrames) + intervals.removeAll(keepingCapacity: true) + last = nil + cameraUpdates = 0 + recording = true + try await Task.sleep(for: .seconds(6)) + recording = false + let states = tracing.snapshots + guard !intervals.isEmpty else { throw CocoaError(.fileReadCorruptFile) } + if preset == .pathTracing { + guard !states.isEmpty && states.allSatisfy({ key, value in + value.failure == nil && value.encodedFrames > (before[key] ?? 0) && value.lights == count + 1 + }) else { + struct Failure: Encodable { + var before: [String: Int] + var after: [String: PathTracing3DStatus] + } + let data = try JSONEncoder().encode(Failure(before: before, after: states)) + try data.write(to: output.appendingPathExtension("failure.json"), options: .atomic) + let reason = "PT frame progress validation failed: \(String(decoding: data, as: UTF8.self))" + throw CocoaError(.featureUnsupported, userInfo: [NSLocalizedDescriptionKey: reason]) + } + } + let records = try await app.withWorldAccess { diagnostics.records } + measurements.append(Measurement(view: "gallery", shadows: true, round: round, + width: size[0], height: size[1], publishedIntervalsMilliseconds: intervals, graphRecords: records, + motion: selected.rawValue, cameraUpdates: cameraUpdates, preset: preset.rawValue, localLightCount: count, + lightStatistics: lights.snapshots, pathTracing: states, pathFramesBefore: before, + pathSettings: session.camera?.components[Environment3D.self]?.pathTracing)) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true) + try encoder.encode(measurements).write(to: output, options: .atomic) + } + } + } + motionStart = nil + try await app.withWorldAccess { + diagnostics.configure(isEnabled: false) + session.pathHighQuality = true + session.dlssEnabled = true + session.apply(preset: .pathTracing, lights: 12) + session.move(to: 2) + } + session.status = "Sponza + Ivy · полная сцена / отдельный PT LOD" + FileHandle.standardOutput.write(Data("SPONZA_PROFILE_READY \(output.path)\n".utf8)) + } + + private func runMeshlets(session: SponzaSession, app: AppWorlds, diagnostics: RenderGraphDiagnostics) async throws { + let render = app.getSubworldBuilder(by: .renderWorld) + guard let statistics = render?.getResource(Render3DMeshletStatistics.self) else { throw CocoaError(.featureUnsupported) } + session.dlssEnabled = false + session.meshletMapEnabled = false + session.flyingLightEnabled = false + session.hiZ = false + let lodRun = ProcessInfo.processInfo.environment["ADAENGINE_SPONZA_PROFILE_MESHLET_LOD"] == "1" + session.highResolutionShadows = lodRun + var measurements: [Measurement] = [] + var measuredSize: [Int]? + // The outside overview primes all eligible geometry uploads and material variants before timing. + try await app.withWorldAccess { + session.meshletMode = .gpu + session.meshletLODError = lodRun ? 2 : 0 + session.apply(preset: .fullRaster, lights: 0) + session.move(to: 3) + } + session.status = "Прогрев полной геометрии и GPU мешлетов…" + try await Task.sleep(for: .seconds(3)) + try await app.withWorldAccess { + session.move(to: 2) + motion = .rotation + motionOrigin = session.flight.transform + motionStart = .now + } + // A complete turn warms the partially clipped parts that actually enter the compute path. + try await Task.sleep(for: .seconds(6)) + for view in [2, 3] { + for round in 0..<3 { + let cases: [(mode: MeshletVisibilityMode3D, error: Float)] = lodRun + ? [(.gpu, 0), (.gpu, 2), (.gpu, 8)] : [(.disabled, 0), (.cpuReference, 0), (.gpu, 0)] + for sample in round.isMultiple(of: 2) ? cases : Array(cases.reversed()) { + let mode = sample.mode + try await app.withWorldAccess { + session.meshletMode = mode + session.meshletLODError = sample.error + session.apply(preset: .fullRaster, lights: 0) + session.move(to: view) + motion = view == 2 ? .rotation : .stationary + motionOrigin = session.flight.transform + motionStart = .now + diagnostics.configure(isEnabled: true, capacity: 32) + diagnostics.clear() + } + session.status = "Замер мешлетов · \(mode.rawValue) · LOD \(Int(sample.error)) px · \(view == 2 ? "галерея" : "снаружи") · \(round + 1)/3" + try await Task.sleep(for: .seconds(1)) + intervals.removeAll(keepingCapacity: true) + last = nil + cameraUpdates = 0 + size = [0, 0] + sizeChanged = false + recording = true + try await Task.sleep(for: .seconds(4)) + recording = false + guard !intervals.isEmpty, !sizeChanged, measuredSize == nil || measuredSize == size else { + throw CocoaError(.featureUnsupported, userInfo: [NSLocalizedDescriptionKey: "Benchmark requires a constant render size and published frames"]) + } + measuredSize = size + // Freeze frame updates only after timing, so statistics and arguments describe one completed frame. + let captured = try await app.withExclusiveWorldAccess { + let snapshot = statistics.snapshots + let actualGPUOrFullyVisible = snapshot.values.contains { + $0.gpuDraws > 0 || ($0.candidateMeshlets > 0 && $0.fullyVisibleItems > 0 && $0.fullyVisibleItems == $0.eligibleItems + && $0.fallbackReasons["processorUnavailableOrBudget"] == nil) + } + if mode == .gpu && !actualGPUOrFullyVisible { + throw CocoaError(.featureUnsupported, userInfo: [NSLocalizedDescriptionKey: "No actual GPU meshlet draws in benchmark"]) + } + if lodRun && sample.error > 0 && !snapshot.values.contains(where: { $0.lodGPUItems > 0 }) { + throw CocoaError(.featureUnsupported, userInfo: [NSLocalizedDescriptionKey: "No actual adaptive LOD draws in benchmark"]) + } + var submittedIndices: Int? + // Explicit benchmark readback AFTER the measured interval; never in ordinary frames. + if mode == .gpu, let camera = session.camera, + let output = render?.getResource(Meshlet3DState.self)?.output(for: camera.id) { + let bytes = try await output.arguments.readData() + var total = 0 + for draw in 0...stride + guard offset + 4 <= bytes.count else { throw CocoaError(.fileReadCorruptFile) } + let word = UInt32(bytes[offset]) | UInt32(bytes[offset + 1]) << 8 | UInt32(bytes[offset + 2]) << 16 | UInt32(bytes[offset + 3]) << 24 + total += Int(word) + } + submittedIndices = total + } + return (snapshot, submittedIndices, diagnostics.records) + } + measurements.append(Measurement(view: view == 2 ? "gallery" : "outside", shadows: true, round: round, + width: size[0], height: size[1], publishedIntervalsMilliseconds: intervals, graphRecords: captured.2, + motion: motion.rawValue, cameraUpdates: cameraUpdates, preset: session.preset.rawValue, localLightCount: 0, + meshlets: captured.0, meshletGPUIndexCount: captured.1, meshletLODPixelError: sample.error, + shadowCascadeResolution: session.highResolutionShadows ? 2048 : 1024, + localShadowResolution: session.highResolutionShadows ? 1024 : 512, + shadowDistance: session.highResolutionShadows ? 80 : 30)) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true) + try encoder.encode(measurements).write(to: output, options: .atomic) + } + } + } + motionStart = nil + try await app.withWorldAccess { + diagnostics.configure(isEnabled: false) + session.meshletMode = .gpu + session.meshletLODError = lodRun ? 2 : 0 + session.meshletMapEnabled = true + session.apply(preset: .fullRaster, lights: 0) + session.move(to: 2) + } + session.status = "Sponza + Ivy · мешлеты\(lodRun ? " + LOD" : "") · полный raster asset" + FileHandle.standardOutput.write(Data("SPONZA_PROFILE_READY \(output.path)\n".utf8)) + } +} diff --git a/Examples/Technical/Sponza/SponzaLighting.swift b/Examples/Technical/Sponza/SponzaLighting.swift new file mode 100644 index 0000000..a9f0ba8 --- /dev/null +++ b/Examples/Technical/Sponza/SponzaLighting.swift @@ -0,0 +1,134 @@ +import AdaEngine +import Foundation +import Math +import SponzaCamera + +enum SponzaRenderPreset: String, CaseIterable { + case fullRaster + case lodRaster + case pathTracing + + var usesLOD: Bool { self != .fullRaster } +} + +@MainActor +struct SponzaLighting { + private var lamps: [Entity] = [] + private var flyingLamp: Entity? + + mutating func spawn(in world: World, parent: Entity) { + let spots = world.spawn("Gallery spotlights") { Transform() } + let points = world.spawn("Gallery fill lights") { Transform() } + let moving = world.spawn("Flying test light") { Transform() } + parent.addChild(spots) + parent.addChild(points) + parent.addChild(moving) + let lamp = world.spawn("Moving point light") { + PointLightComponent(radiance: [0.85, 0.93, 1], intensity: 25, castShadows: true, range: 10) + Transform(position: SponzaLightMotion.position(seconds: 0)) + } + moving.addChild(lamp) + flyingLamp = lamp + for (index, x) in [Float(-9), -3, 3, 9].enumerated() { + let position = Vector3(x, 8.4, 5.35) + let lamp = world.spawn("Spot \(index + 1)") { + SpotLightComponent(radiance: [1, 0.92, 0.82], intensity: 8, castShadows: true, + range: 9, innerConeAngle: 25, outerConeAngle: 42) + SponzaFlyCamera.looking(from: position, at: [x, 6.4, 5.35]) + } + spots.addChild(lamp) + lamps.append(lamp) + } + for z in [Float(-5.35), 5.35] { + for (index, x) in [Float(-9), -3, 3, 9].enumerated() { + let lamp = world.spawn("Fill \(lamps.count - 3)") { + PointLightComponent(radiance: index.isMultiple(of: 2) ? [1, 0.9, 0.8] : [0.8, 0.9, 1], + intensity: 2, castShadows: false, range: 8) + Transform(position: [x, 7.6, z]) + } + points.addChild(lamp) + lamps.append(lamp) + } + } + } + + func setCount(_ count: Int) { + for (index, lamp) in lamps.enumerated() { + lamp.components[Visibility.self] = index < count ? .visible : .hidden + } + } + + func updateFlight(enabled: Bool, seconds: Double) { + guard let flyingLamp else { return } + let visibility: Visibility = enabled ? .visible : .hidden + if flyingLamp.components[Visibility.self] != visibility { flyingLamp.components[Visibility.self] = visibility } + guard enabled else { return } + let pose = Transform(position: SponzaLightMotion.position(seconds: seconds)) + flyingLamp.components[Transform.self] = pose + flyingLamp.components[GlobalTransform.self] = GlobalTransform(matrix: pose.matrix) + } +} + +extension SponzaSession { + func apply(preset: SponzaRenderPreset, lights: Int) { + fullGeometry?.components[Visibility.self] = preset.usesLOD ? .hidden : .visible + lodGeometry?.components[Visibility.self] = preset.usesLOD ? .visible : .hidden + lighting.setCount(lights) + lighting.updateFlight(enabled: flyingLightEnabled, seconds: flightSeconds) + if let sun, var light = sun.components[DirectionalLightComponent.self] { + light.shadowDistance = highResolutionShadows ? 80 : 30 + sun.components[DirectionalLightComponent.self] = light + } + if let camera, var component = camera.components[Camera.self] { + component.temporalUpscaling = dlssEnabled && dlssAvailable && !meshletMapEnabled + ? .init(renderScale: pathHighQuality ? 2.0 / 3.0 : 0.5, method: .automatic) : nil + camera.components[Camera.self] = component + } + if let camera, var environment = camera.components[Environment3D.self] { + environment.pathTracing = PathTracing3D(isEnabled: preset == .pathTracing, + samplesPerPixel: 1, maximumBounces: 2, + resolutionScale: dlssEnabled && dlssAvailable ? 1 : (pathHighQuality ? 0.5 : 0.25), historyLimit: 128) + environment.pathTracing.textureTileSize = 512 + environment.debugView = meshletMapEnabled ? .meshlets : .none + environment.meshVisibility.meshlets = preset == .pathTracing ? .disabled : meshletMode + environment.meshVisibility.meshletLODPixelError = meshletLODError + environment.shadows.isEnabled = true + environment.shadows.resolution = highResolutionShadows ? 2048 : 1024 + environment.shadows.splitLambda = highResolutionShadows ? 0.85 : 0.65 + environment.localShadows = LocalShadowSettings3D(isEnabled: true, maximumLights: 4, resolution: highResolutionShadows ? 1024 : 512) + camera.components[Environment3D.self] = environment + } + self.preset = preset + self.lightCount = lights + rendererStatus = preset == .pathTracing ? "Path tracing · 1 spp · 2 отскока · подготовка BVH…" + : "\(preset.usesLOD ? "PT LOD" : "Полная геометрия") · растр · \(lights) локальных источников" + } + + func refreshPathStatus() { + guard ContinuousClock.now >= nextStatusUpdate else { return } + nextStatusUpdate = .now + .seconds(1) + if preset == .pathTracing && !meshletMapEnabled { + meshletStatus = "Мешлеты — растровый путь; PT использует свой BVH" + } else if let status = meshletStatistics?.snapshots.values.first { + let levels = status.selectedLODLevels.keys.sorted().map { "L\($0): \(status.selectedLODLevels[$0] ?? 0)" }.joined(separator: " / ") + let lod = meshletLODError > 0 + ? (meshletMapEnabled ? "\n\(levels.isEmpty ? "LOD: нет данных" : levels) · \(status.lodSelectedTriangles) треуг." + : " · GPU LOD: \(status.lodGPUItems)") : "" + meshletStatus = meshletMapEnabled + ? "Кластеры \(status.candidateMeshlets) · серый = без карты (\(status.fallbackItems))\(lod)" + : "Кластеры \(status.candidateMeshlets) · GPU \(status.gpuDraws) · целиком \(status.fullyVisibleItems) · fallback \(status.fallbackItems)\(lod)" + } + if meshletMapEnabled { dlssStatus = "DLSS: отключён для карты мешлетов" } + else if dlssEnabled && dlssAvailable, let status = temporalStatistics?.snapshots.values.first { + dlssStatus = "DLSS · \(status.inputWidth)×\(status.inputHeight) → \(status.outputWidth)×\(status.outputHeight)" + + " · кадры \(status.encodedFrames) · fallback \(status.fallbackFrames)" + } else { dlssStatus = dlssAvailable ? "DLSS: выкл." : "DLSS недоступен на текущем устройстве/сборке" } + guard preset == .pathTracing, let status = pathStatistics?.snapshots.values.first else { return } + if let failure = status.failure { + rendererStatus = "Path tracing недоступен: \(failure)" + } else { + rendererStatus = "Path tracing · \(status.triangles) треуг. · \(status.lights) источников · \(status.width)×\(status.height)" + + " · кадр \(status.encodedFrames) · накопление \(status.accumulatedFrames)" + } + } +} diff --git a/Examples/Technical/Sponza/SponzaStutterProfiler.swift b/Examples/Technical/Sponza/SponzaStutterProfiler.swift new file mode 100644 index 0000000..9ca17ab --- /dev/null +++ b/Examples/Technical/Sponza/SponzaStutterProfiler.swift @@ -0,0 +1,72 @@ +import AdaEngine +import Foundation +import SponzaCamera + +extension SponzaDemoProfiler { + struct StutterPhase: Codable { + var name: String + var input: String + var width: Int + var height: Int + var sizeChanged: Bool + var publishedIntervalsMilliseconds: [Double] + var graphRecords: [RenderGraphFrameRecord] + var meshlets: [String: Meshlet3DCount] + } + + func runStutter(session: SponzaSession, app: AppWorlds, diagnostics: RenderGraphDiagnostics) async throws { + let render = app.getSubworldBuilder(by: .renderWorld) + let statistics = render?.getResource(Render3DMeshletStatistics.self) + var captured: [StutterPhase] = [] + session.status = "Sponza + Ivy · запись задержек кадров" + let cases: [(String, SponzaCameraMotion, Double)] = [ + ("stationary", .stationary, 6), ("rotation-first", .rotation, 12), ("rotation-repeat", .rotation, 12), + ("translation", .translation, 12), ("LOD-off", .rotation, 6), ("LOD-on", .rotation, 6), + ("DLSS-off", .rotation, 6), ("DLSS-on", .rotation, 6), + ("shadows-baseline", .stationary, 4), ("shadows-high", .stationary, 4), + ("lights-off", .stationary, 4), ("lights-on", .stationary, 4) + ] + for (name, movement, seconds) in cases { + try await app.withWorldAccess { + motionStart = nil + session.meshletMode = .gpu + session.meshletLODError = name == "LOD-off" ? 0 : 2 + session.meshletMapEnabled = false + session.flyingLightEnabled = false + session.dlssEnabled = name != "DLSS-off" + session.highResolutionShadows = name != "shadows-baseline" + session.apply(preset: .fullRaster, lights: name == "lights-off" ? 0 : 12) + session.move(to: 2) + motion = movement + motionOrigin = session.flight.transform + motionStart = .now + diagnostics.configure(isEnabled: true, capacity: 4096) + diagnostics.clear() + } + intervals.removeAll(keepingCapacity: true) + last = nil + size = [0, 0] + sizeChanged = false + cameraUpdates = 0 + PerformanceStallProbe.mark("begin:\(name)") + recording = true + try await Task.sleep(for: .seconds(seconds)) + recording = false + PerformanceStallProbe.mark("end:\(name)") + let data = try await app.withExclusiveWorldAccess { (diagnostics.records, statistics?.snapshots ?? [:]) } + captured.append(.init(name: name, input: movement.rawValue, width: size[0], height: size[1], sizeChanged: sizeChanged, + publishedIntervalsMilliseconds: intervals, graphRecords: data.0, meshlets: data.1)) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true) + try encoder.encode(captured).write(to: output, options: .atomic) + } + try await app.withWorldAccess { + diagnostics.configure(isEnabled: false) + motionStart = nil + session.move(to: 2) + } + session.status = "Sponza + Ivy · запись задержек завершена" + print("SPONZA_STUTTER_READY \(output.path)") + } +} diff --git a/Examples/Technical/Sponza/SponzaWarmup.swift b/Examples/Technical/Sponza/SponzaWarmup.swift new file mode 100644 index 0000000..2999ef0 --- /dev/null +++ b/Examples/Technical/Sponza/SponzaWarmup.swift @@ -0,0 +1,111 @@ +import AdaEngine +import Foundation + +enum SponzaWarmupError: Error, CustomStringConvertible { + case alreadyWaiting + case timedOut(String) + + var description: String { + switch self { + case .alreadyWaiting: "Прогрев уже ожидает кадры" + case .timedOut(let stage): "Не получены готовые кадры прогрева: \(stage)" + } + } +} + +/// A demo-local barrier driven by actual SceneView publications, not elapsed warmup time. +@MainActor +final class SponzaWarmupFrames { + private struct Waiter { + let id: UUID + let target: Int + let condition: @MainActor () -> Bool + let continuation: CheckedContinuation + let deadline: Task + } + private var publications = 0 + private var waiter: Waiter? + + func didPublish() { + publications += 1 + guard let waiter, publications >= waiter.target, waiter.condition() else { return } + finish(waiter.id) + } + + func wait(stage: String, frames: Int = 6, timeout: Duration = .seconds(60), + condition: @escaping @MainActor () -> Bool = { true }) async throws { + guard waiter == nil else { throw SponzaWarmupError.alreadyWaiting } + let id = UUID() + try Task.checkCancellation() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let deadline = Task { @MainActor [weak self] in + do { try await Task.sleep(for: timeout) } catch { return } + self?.finish(id, error: SponzaWarmupError.timedOut(stage)) + } + waiter = Waiter(id: id, target: publications + frames, condition: condition, continuation: continuation, deadline: deadline) + } + } onCancel: { + Task { @MainActor [weak self] in self?.finish(id, error: CancellationError()) } + } + } + + private func finish(_ id: UUID, error: (any Error)? = nil) { + guard let pending = waiter, pending.id == id else { return } + waiter = nil + pending.deadline.cancel() + if let error { pending.continuation.resume(throwing: error) } else { pending.continuation.resume() } + } +} + +extension SponzaSession { + /// Render both depth-format pipeline variants while controls remain in the loading state. + func warmupRaster(app: AppWorlds) async throws { + let selectedPreset = preset + let selectedDLSS = dlssEnabled + let selectedMap = meshletMapEnabled + let selectedMeshlets = meshletMode + let selectedLODError = meshletLODError + let selectedFlight = flyingLightEnabled + let selectedLights = lightCount + @MainActor func restoreSelection() { + dlssEnabled = selectedDLSS + meshletMapEnabled = selectedMap + meshletMode = selectedMeshlets + meshletLODError = selectedLODError + flyingLightEnabled = selectedFlight + apply(preset: selectedPreset, lights: selectedLights) + } + do { + let variants = dlssAvailable ? [false, true] : [false] + for temporal in variants { + try Task.checkCancellation() + let stage = temporal ? "растр · DLSS вкл." : "растр · DLSS выкл." + status = "Прогреваем \(stage)…" + try await app.withWorldAccess { + dlssEnabled = temporal + meshletMapEnabled = false + // Prime the exact leaf stream as well as the selected hierarchy below. Otherwise + // turning LOD off first uploads Ivy's large v1 stream during camera movement. + meshletMode = selectedMeshlets == .gpu ? .gpu : .disabled + meshletLODError = 0 + flyingLightEnabled = false + apply(preset: .fullRaster, lights: selectedLights) + } + // Six publications exceed the three in-flight slots. The DLSS stage additionally + // requires encoded temporal frames; an empty/fallback-only image cannot prove warmup. + try await warmupFrames.wait(stage: stage) { [weak self] in + guard let snapshots = self?.temporalStatistics?.snapshots else { return true } + return temporal ? snapshots.values.contains { $0.encodedFrames >= 3 && $0.fallbackFrames == 0 } : snapshots.isEmpty + } + FileHandle.standardOutput.write(Data("SPONZA_WARMUP_READY \(temporal ? "temporal" : "native")\n".utf8)) + } + status = "Готовим выбранный режим…" + try await app.withWorldAccess { restoreSelection() } + try await warmupFrames.wait(stage: "выбранный режим") + } catch { + try? await app.withWorldAccess { restoreSelection() } + throw error + } + } +} diff --git a/Examples/Technical/Sponza/Tests/SponzaCameraTests.swift b/Examples/Technical/Sponza/Tests/SponzaCameraTests.swift new file mode 100644 index 0000000..0ef7dbd --- /dev/null +++ b/Examples/Technical/Sponza/Tests/SponzaCameraTests.swift @@ -0,0 +1,66 @@ +import AdaTransform +import Testing +@testable import SponzaCamera + +@Suite("Sponza fly camera") +struct SponzaCameraTests { + @Test func yawTurnsHorizontallyAndPitchVerticallyWithoutRoll() { + let yaw = Transform(rotation: SponzaFlyCamera.rotation(yaw: .pi / 2, pitch: 0)).matrix + #expect(abs(yaw.z.x - 1) < 0.00001 && abs(yaw.z.y) < 0.00001 && abs(yaw.z.z) < 0.00001) + let pitch = Transform(rotation: SponzaFlyCamera.rotation(yaw: 0, pitch: .pi / 4)).matrix + #expect(pitch.z.y < -0.7 && pitch.z.z > 0.7 && abs(pitch.z.x) < 0.00001) + let mixed = Transform(rotation: SponzaFlyCamera.rotation(yaw: 1.2, pitch: 0.6)).matrix + #expect(abs(mixed.x.y) < 0.00001, "Camera right stays horizontal; there is no bank") + } + + @Test func resetsPreservePoseAndMouseMotionDoesNotJumpAxes() { + let pose = SponzaFlyCamera.looking(from: [-32, 25, -35], at: [0, 4, 0]) + var camera = SponzaFlyCamera() + camera.setPose(pose) + camera.look(delta: .zero) + #expect(camera.transform == pose) + camera.look(delta: [1, 0]) + #expect(abs(camera.transform.matrix.z.y - pose.matrix.z.y) < 0.00001) + camera.look(delta: [0, 10000]) + #expect(camera.pitch <= 1.55) + } + + @Test func movementHasConstantDiagonalSpeedAndUsesCameraForward() { + var straight = SponzaFlyCamera(), diagonal = SponzaFlyCamera() + straight.move(local: [0, 0, 1], seconds: 0.05, fast: false) + diagonal.move(local: [1, 0, 1], seconds: 0.05, fast: false) + #expect(abs(straight.transform.position.length - diagonal.transform.position.length) < 0.00001) + var turned = SponzaFlyCamera() + turned.setPose(Transform(rotation: SponzaFlyCamera.rotation(yaw: .pi / 2, pitch: 0))) + turned.move(local: [0, 0, 1], seconds: 0.05, fast: false) + #expect(abs(turned.transform.position.x - 0.15) < 0.00001 && abs(turned.transform.position.z) < 0.00001) + } + + @Test func galleryMotionIsPeriodicAndKeepsTranslationInsideTheCorridor() { + let origin = SponzaFlyCamera.looking(from: [-9.594356, 6.921101, 5.35278], at: [0, 6.921101, 5.35278]) + for mode in SponzaCameraMotion.allCases { + #expect(mode.pose(from: origin, seconds: 0) == origin) + #expect(mode.pose(from: origin, seconds: 6) == origin) + #expect(mode.pose(from: origin, seconds: .nan) == origin) + } + let midpoint = SponzaCameraMotion.translation.pose(from: origin, seconds: 3) + #expect(abs(midpoint.position.x - origin.position.x - 3) < 0.00001) + #expect(midpoint.position.y == origin.position.y && midpoint.position.z == origin.position.z) + #expect(midpoint.rotation == origin.rotation) + let turned = SponzaCameraMotion.rotation.pose(from: origin, seconds: 1.5) + #expect(turned.position == origin.position) + #expect(abs(turned.matrix.x.y) < 0.00001) + #expect(turned.rotation != origin.rotation) + } + + @Test func flyingLightStaysInGalleryAndCompletesItsOrbit() { + let start = SponzaLightMotion.position(seconds: 0) + #expect((SponzaLightMotion.position(seconds: 12) - start).length < 0.0001) + #expect(SponzaLightMotion.position(seconds: .nan) == start) + #expect((SponzaLightMotion.position(seconds: 3) - start).length > 8) + for step in 0...120 { + let p = SponzaLightMotion.position(seconds: Double(step) / 10) + #expect(abs(p.x) <= 8 && p.y >= 6.8 && p.y <= 8 && p.z >= 4.85 && p.z <= 5.85) + } + } +} diff --git a/Examples/Technical/SpriteInstancingValidation/.gitignore b/Examples/Technical/SpriteInstancingValidation/.gitignore new file mode 100644 index 0000000..fd14c91 --- /dev/null +++ b/Examples/Technical/SpriteInstancingValidation/.gitignore @@ -0,0 +1,6 @@ +.build/ +.swiftpm/ +dist/ +*.xcodeproj/ +__pycache__/ +Package.resolved diff --git a/Examples/Technical/SpriteInstancingValidation/Package.swift b/Examples/Technical/SpriteInstancingValidation/Package.swift new file mode 100644 index 0000000..1dd3ce9 --- /dev/null +++ b/Examples/Technical/SpriteInstancingValidation/Package.swift @@ -0,0 +1,44 @@ +// swift-tools-version: 6.2 +import Foundation +import PackageDescription + +let package = Package( + name: "SpriteInstancingValidation", + platforms: [.macOS(.v15), .iOS(.v18)], + products: [.executable(name: "SpriteInstancingValidation", targets: ["SpriteInstancingValidation"])], + dependencies: [ + .package(url: "https://github.com/AdaEngine/AdaEngine.git", revision: "a624ccded0a050fddc24ccfbb8079dfb8032805c") + ], + targets: [ + .executableTarget( + name: "SpriteInstancingValidation", + dependencies: [ + .product(name: "AdaApp", package: "adaengine"), + .product(name: "AdaAssets", package: "adaengine"), + .product(name: "AdaCorePipelines", package: "adaengine"), + .product(name: "AdaECS", package: "adaengine"), + .product(name: "AdaInput", package: "adaengine"), + .product(name: "AdaPlatform", package: "adaengine"), + .product(name: "AdaRender", package: "adaengine"), + .product(name: "AdaSprite", package: "adaengine"), + .product(name: "AdaText", package: "adaengine"), + .product(name: "AdaTransform", package: "adaengine"), + .product(name: "AdaUI", package: "adaengine"), + .product(name: "AdaUtils", package: "adaengine"), + .product(name: "Math", package: "adaengine") + ], + path: ".", + exclude: ["README.md", "project.yml"] + (FileManager.default.fileExists(atPath: "dist") ? ["dist"] : []), + sources: [ + "SpriteValidationApp.swift", + "SpriteValidationFixture.swift" + ], + swiftSettings: [.enableUpcomingFeature("MemberImportVisibility"), .strictMemorySafety()], + linkerSettings: [.unsafeFlags([ + "-Xclang-linker", "-mexec-model=reactor", + "-Xlinker", "--export-if-defined=main", + "-Xlinker", "--export-if-defined=__main_argc_argv" + ], .when(platforms: [.wasi]))] + ) + ] +) diff --git a/Examples/Technical/SpriteInstancingValidation/README.md b/Examples/Technical/SpriteInstancingValidation/README.md new file mode 100644 index 0000000..6fbe97f --- /dev/null +++ b/Examples/Technical/SpriteInstancingValidation/README.md @@ -0,0 +1,39 @@ +# Sprite instancing validation + +Separate QA app using production SpriteRenderSystem / SpriteDrawPass / shaders. +The same 640x360 GPU fixture covers stretch/fit/fill, sliced/tiled atlas regions, +flips, reflected/rotated transforms, anchors, alpha and ordered texture changes. +It compares readback pixels for five expanded and five instanced frames, with a +1200-sprite contiguous run. A PASS requires identical nonempty pixels and active +instancing. It reports backend, quads and draw count; no backend fallback counts as proof. + +iOS, from this demo directory: + +```sh +xcodegen generate --spec project.yml +``` + +Build and launch the scheme with `ADAENGINE_DISABLE_SWAN=1` in the host environment. +On iOS the visible PASS also writes `Documents/sprite-validation.json` in the QA +app's container, allowing a physical device result to be retrieved without relying +on launch success alone. + +Browser, from the AdaExamples root (set `ADAENGINE_ROOT` to the engine checkout for Tint preparation): + +```sh +python3 "$ADAENGINE_ROOT/script/ensure_tint.py" +ADAENGINE_WEB_EXPORT=1 swift package --package-path Examples/Technical/SpriteInstancingValidation --allow-writing-to-package-directory export-web \ + --product SpriteInstancingValidation --output dist/SpriteInstancingValidation \ + --scratch-path /tmp/ada-sprite-web-validation \ + --swift-sdk swift-6.3.2-RELEASE_wasm --debug +``` + +Use compatible Swan/Dawn; local development overrides are described in +[Tools/Tint](https://github.com/AdaEngine/Ada/blob/main/Tools/Tint/README.md). Serve the output over HTTP and inspect the +console `[SpriteValidation] PASS`. The canvas presents the validated GPU readback +image with a green border; failures log FAIL and show a red background. Known color +probes reject a blank frame even when both rendering modes produce equal bytes. + +Recorded proof: macOS native WebGPU, actual WASM WebGPU, iPhone 17 Pro Simulator, +and physical iPad8,1 / iPadOS 26.5. Rendering correctness is verified; this app +provides no browser/mobile performance benchmark or Editor UI validation. diff --git a/Examples/Technical/SpriteInstancingValidation/SpriteValidationApp.swift b/Examples/Technical/SpriteInstancingValidation/SpriteValidationApp.swift new file mode 100644 index 0000000..5ac1fc6 --- /dev/null +++ b/Examples/Technical/SpriteInstancingValidation/SpriteValidationApp.swift @@ -0,0 +1,152 @@ +import AdaApp +import AdaCorePipelines +import AdaECS +import AdaRender +import AdaSprite +import AdaText +import AdaTransform +import AdaUtils +import Foundation +import Math + +#if os(iOS) + import SwiftUI + import UIKit + + /// Separate QA application; no validation hooks are installed in Studio. + @main + struct SpriteValidationApp: SwiftUI.App { + var body: some SwiftUI.Scene { WindowGroup { ValidationView() } } + } + + private struct ValidationView: SwiftUI.View { + @State private var status = "Running Metal A/B readback…" + @State private var image: UIImage? + @State private var passed = false + var body: some SwiftUI.View { + VStack(spacing: 20) { + Text("Sprite instancing").font(.title) + Text(status).foregroundStyle(passed ? .green : .primary).accessibilityIdentifier("validation-status") + if let image { SwiftUI.Image(uiImage: image).resizable().interpolation(.none).aspectRatio(contentMode: .fit) } + } + .padding() + .task { + do { + unsafe RenderEngine.configurations.preferredBackend = .metal + let host = AppWorlds(main: World()) + host.addPlugin(RenderWorldPlugin()) + try await host.build() + let result = try await SpriteValidationFixture.run() + let bytes = result.image.data as CFData + guard let provider = CGDataProvider(data: bytes), + let cgImage = CGImage( + width: 640, + height: 360, + bitsPerComponent: 8, + bitsPerPixel: 32, + bytesPerRow: 640 * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: result.image.format == .bgra8 + ? [.byteOrder32Little, CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue)] + : [.byteOrder32Big, CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)], + provider: provider, + decode: nil, + shouldInterpolate: false, + intent: .defaultIntent + ) + else { return } + let report: [String: Any] = [ + "passed": true, "backend": String(describing: result.backend), "frames": 10, + "quads": result.quads, "draws": result.draws, "pixels": "identical", "controlPixels": "passed", + ] + let documents = try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + try JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted, .sortedKeys]) + .write(to: documents.appendingPathComponent("sprite-validation.json"), options: .atomic) + image = UIImage(cgImage: cgImage) + status = "PASS · Metal · identical pixels\n10 frames · \(result.quads) quads · \(result.draws) draws" + passed = true + } catch { + status = "FAIL: \(error)" + print("[SpriteValidation] FAIL: \(error)") + } + } + } + } +#else + import AdaAssets + import AdaInput + import AdaPlatform + import AdaUI + + @main + struct SpriteValidationApp: AdaApp.App { + var body: some AppScene { + EmptyWindow().transformAppWorlds { world in + world.insertPlugin(ValidationPlugin(), after: MainSchedulerPlugin.self) + } + .window(with: UIWindow.Configuration(title: "Sprite instancing validation", frame: Rect(x: 80, y: 80, width: 960, height: 540))) + } + } + + private struct ValidationPlugin: Plugin { + func setup(in app: AppWorlds) { + app.addPlugin(TransformPlugin()) + app.addPlugin(AppPlatformPlugin()) + app.addPlugin(InputPlugin()) + app.addPlugin(RenderWorldPlugin()) + app.addPlugin(CameraPlugin()) + app.addPlugin(AssetsPlugin()) + app.addPlugin(VisibilityPlugin()) + app.addPlugin(SpritePlugin()) + app.addPlugin(Mesh2DPlugin()) + app.addPlugin(TextPlugin()) + app.addPlugin(WindowPlugin()) + app.addPlugin(Core2DPlugin()) + app.addPlugin(UpscalePlugin()) + app.addPlugin(UIPlugin()) + app.addSystem(StartupSpriteValidationSystem.self, on: .startup) + } + } + + @System + @MainActor + func StartupSpriteValidation(_ context: WorldUpdateContext) async { + do { + let result = try await SpriteValidationFixture.run() + context.world.spawn { + Sprite(texture: Texture2D(image: result.image), size: [640, 360]) + Transform() + } + var camera = Camera() + camera.projection = .custom(ValidationProjection()) + camera.backgroundColor = .green + context.world.spawn { + camera + Transform() + GlobalViewUniform() + VisibleEntities() + Visibility.visible + CameraRenderGraph(subgraphLabel: .main2D, inputSlot: Main2DRenderNode.InputNode.view) + } + } catch { + print("[SpriteValidation] FAIL: \(error)") + var camera = Camera() + camera.backgroundColor = .red + context.world.spawn { + camera + Transform() + GlobalViewUniform() + VisibleEntities() + Visibility.visible + CameraRenderGraph(subgraphLabel: .main2D, inputSlot: Main2DRenderNode.InputNode.view) + } + } + } + + private struct ValidationProjection: CameraProjection { + var near: Float { -1 } + var far: Float { 1000 } + func makeClipView() -> Transform3D { Transform3D(scale: [Float(2) / 680, Float(2) / 400, 0.001]) } + mutating func updateView(width: Float, height: Float) {} + } +#endif diff --git a/Examples/Technical/SpriteInstancingValidation/SpriteValidationFixture.swift b/Examples/Technical/SpriteInstancingValidation/SpriteValidationFixture.swift new file mode 100644 index 0000000..bf3050b --- /dev/null +++ b/Examples/Technical/SpriteInstancingValidation/SpriteValidationFixture.swift @@ -0,0 +1,154 @@ +import AdaCorePipelines +import AdaECS +@_spi(Internal) import AdaRender +import AdaSprite +import AdaTransform +import AdaUtils +import Foundation +import Math + +/// Shared GPU readback fixture for native/mobile Metal and browser WebGPU. +@MainActor +enum SpriteValidationFixture { + struct Result { + let image: Image + let backend: RenderBackendType + let draws: Int + let quads: Int + } + + enum Failure: Error { + case unsupportedBackend, missingResource, missingImage, unequalPixels, emptyImage, missingInstancing + } + + static func run() async throws -> Result { + let device = unsafe RenderEngine.shared.renderDevice + guard device.supportsInstancedVertexInputs else { throw Failure.unsupportedBackend } + Camera.registerComponent() + let world = World() + world.insertResource(SpriteDrawPass()) + world.insertResource(SpriteBatches()) + world.insertResource(AdditionalExtractedSprites()) + world.insertResource(RenderDeviceHandler(renderDevice: device)) + world.insertResource(RenderPipelines(configurator: SpriteRenderPipeline())) + world.addSystem(SpriteRenderSystem.self, on: .update) + let view = world.spawn { Camera() } + let target = RenderTexture(size: [640, 360], scaleFactor: 1, format: .bgra8) + var pipelines = RenderPipelines(configurator: SpriteRenderPipeline()) + let pipeline = pipelines.pipeline(device: device) + var sprites: [Entity.ID: ExtractedSprite] = [:] + var items: [Transparent2DRenderItem] = [] + var atlasImage = Image(width: 32, height: 16, color: .green) + atlasImage.samplerDescription.magFilter = .nearest + atlasImage.samplerDescription.minFilter = .nearest + for y in 0..<16 { + for x in 0..<32 where x < 4 || x >= 28 || y < 4 || y >= 12 { + atlasImage.setPixel(in: [Float(x), Float(y)], color: y < 8 ? (x < 16 ? .red : .yellow) : (x < 16 ? .blue : .white)) + } + } + let atlas = TextureAtlas(from: atlasImage, size: [32, 16]) + guard let slice = atlas.textureSlice(in: RectInt(x: 0, y: 0, width: 32, height: 16)) else { throw Failure.missingImage } + let alphaTexture = Texture2D(image: Image(width: 2, height: 1, data: Data([255, 255, 255, 0, 255, 255, 255, 128]))) + func add( + texture: Texture2D, + position: Vector3, + size: Size, + mode: SpriteImageMode = .stretch, + anchor: SpriteAnchor = .center, + flipX: Bool = false, + flipY: Bool = false, + color: Color = .white, + scale: Vector3 = [1, 1, 1], + rotation: Quat = .identity + ) { + let id = sprites.count + let transform = Transform(rotation: rotation, scale: scale, position: position) + sprites[id] = ExtractedSprite( + entityId: id, + texture: texture, + size: size, + flipX: flipX, + flipY: flipY, + tintColor: color, + transform: transform, + worldTransform: transform.matrix, + anchor: anchor, + imageMode: mode + ) + items.append(.init(entity: id, drawPass: SpriteDrawPass(), renderPipeline: pipeline, sortKey: Float(id), batchRange: 0..<0)) + } + let modes: [SpriteImageMode] = [.stretch, .fit, .fill, .sliced(SpriteSliceBorder(4)), .sliced(SpriteSliceBorder(4)), .tiled()] + for (index, mode) in modes.enumerated() { + add( + texture: slice, + position: [Float(index) * 96 - 240, 112, 0], + size: [72, 72], + mode: mode, + flipX: index == 4, + flipY: index == 5 + ) + } + add(texture: .whiteTexture, position: [-280, 8, 0], size: [40, 40], anchor: .bottomLeft, color: .red) + add(texture: slice, position: [-170, 28, 0], size: [96, 48], scale: [-1, 1, 1], rotation: Quat(axis: [0, 0, 1], angle: 0.2)) + add(texture: .whiteTexture, position: [-32, 28, 0], size: [72, 48], color: .blue) + add(texture: alphaTexture, position: [-32, 28, 0], size: [72, 48]) + add(texture: .whiteTexture, position: [64, 28, 0], size: [72, 48], color: .red) + add(texture: alphaTexture, position: [64, 28, 0], size: [72, 48]) + // A long contiguous run exercises instance offsets and buffer reuse across frames. + for index in 0..<1200 { + add( + texture: .whiteTexture, + position: [Float(index % 120) * 5 - 298, Float(index / 120) * 5 - 120, 0], + size: [3, 3], + color: index % 2 == 0 ? .green : .yellow + ) + } + world.insertResource(ExtractedSprites(sprites: SparseSet(sprites))) + var reference: Data? + var resultImage: Image? + var draws = 0 + var quads = 0 + for mode in [SpriteRenderingMode.batched, .automatic] { + var data = SpriteDrawData.defaultValue + data.renderingMode = mode + world.insertResource(data) + for _ in 0..<5 { + world.insertResource(SortedRenderItems(items: RenderItems(items: items))) + await world.runScheduler(.update) + guard let prepared = world.getResource(SpriteDrawData.self), + let sorted = world.getResource(SortedRenderItems.self) + else { throw Failure.missingResource } + if mode == .automatic, !prepared.vertexBuffer.isEmpty { throw Failure.missingInstancing } + draws = sorted.items.items.filter { $0.batchRange?.isEmpty == false }.count + quads = world.getResource(SpriteBatches.self)?.batches.values.reduce(0) { $0 + $1.range.count } ?? 0 + let command = device.createCommandQueue().makeCommandBuffer() + let encoder = command.beginRenderPass( + RenderPassDescriptor(colorAttachments: [ + .init(texture: target, operation: OperationDescriptor(loadAction: .clear, storeAction: .store), clearColor: .black) + ]) + ) + let projection = Transform3D(scale: [Float(2) / 640, Float(2) / 360, 0.001]) + encoder.setVertexBuffer(GlobalViewUniform(projectionMatrix: projection, viewProjectionMatrix: projection), slot: GlobalBufferIndex.viewUniform) + encoder.setViewport(Rect(x: 0, y: 0, width: 640, height: 360)) + try sorted.items.render(with: encoder, world: world, view: view) + encoder.endRenderPass() + await withCheckedContinuation { (continuation: CheckedContinuation) in + command.addCompletedHandler { continuation.resume() } + command.commit() + } + guard let image = try await device.readImage(from: target) else { throw Failure.missingImage } + if let reference, image.data != reference { throw Failure.unequalPixels } + reference = image.data + resultImage = image + } + } + guard let image = resultImage else { throw Failure.missingImage } + guard image.getPixel(x: 50, y: 150) == .red, image.getPixel(x: 260, y: 150) == .blue, + image.getPixel(x: 356, y: 150) == .red, image.getPixel(x: 22, y: 300) == .green, + quads >= 1200, draws > 1 + else { throw Failure.emptyImage } + let backend = unsafe RenderEngine.shared.type + print("[SpriteValidation] PASS backend=\(backend) pixels=identical frames=10 quads=\(quads) draws=\(draws) instancing=true") + return Result(image: image, backend: backend, draws: draws, quads: quads) + } +} diff --git a/Examples/Technical/SpriteInstancingValidation/project.yml b/Examples/Technical/SpriteInstancingValidation/project.yml new file mode 100644 index 0000000..e302720 --- /dev/null +++ b/Examples/Technical/SpriteInstancingValidation/project.yml @@ -0,0 +1,45 @@ +name: SpriteInstancingValidation +options: + deploymentTarget: + iOS: '18.0' +packages: + AdaEngine: + url: https://github.com/AdaEngine/Ada.git + revision: a624ccded0a050fddc24ccfbb8079dfb8032805c +settings: + base: + SWIFT_VERSION: '6.2' + DEVELOPMENT_TEAM: 8PYCRS3EA3 + CODE_SIGN_STYLE: Automatic +targets: + SpriteInstancingValidation: + type: application + platform: iOS + sources: + - SpriteValidationApp.swift + - SpriteValidationFixture.swift + dependencies: + - package: AdaEngine + product: AdaApp + - package: AdaEngine + product: AdaCorePipelines + - package: AdaEngine + product: AdaECS + - package: AdaEngine + product: AdaRender + - package: AdaEngine + product: AdaSprite + - package: AdaEngine + product: AdaTransform + - package: AdaEngine + product: AdaUtils + - package: AdaEngine + product: Math + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: org.adaengine.SpriteInstancingValidation + GENERATE_INFOPLIST_FILE: YES + INFOPLIST_KEY_UILaunchScreen_Generation: YES + TARGETED_DEVICE_FAMILY: '1,2' + scheme: + testTargets: [] diff --git a/README.md b/README.md index 95756da..469b765 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ Official examples and complete sample games for [AdaEngine](https://github.com/A ## Repository layout - `Examples` contains focused projects that demonstrate one engine feature. +- [`Examples/Technical`](Examples/Technical/README.md) contains opt-in developer studies, + excluded from the public catalog and recommendations. - `Samples` contains complete, runnable games and larger reference projects. - `Assets/Previews` contains the images used by catalog cards. - `catalog.json` is the machine-readable catalog consumed by AdaEditor and the website. @@ -34,5 +36,8 @@ Keep every project self-contained. Do not add local package paths, build output, editor workspace state, or generated distribution bundles. Add a real preview under `Assets/Previews` and describe the project in `catalog.json`. +Technical studies use `Examples/Technical` and its separate README index instead. +Do not add them to `catalog.json`; catalog validation enforces this boundary. + All contributions must pass catalog validation. Swift samples are also built and tested in CI. diff --git a/scripts/test_technical_examples.py b/scripts/test_technical_examples.py new file mode 100644 index 0000000..e0f6b70 --- /dev/null +++ b/scripts/test_technical_examples.py @@ -0,0 +1,31 @@ +import contextlib +import io +import unittest + +from validate_catalog import load_catalog, validate_catalog + + +class TechnicalExamplesTests(unittest.TestCase): + def test_existing_catalog_still_validates(self): + validate_catalog(load_catalog()) + + def test_technical_project_cannot_enter_public_catalog(self): + for path in ["Examples/Technical/ProceduralSkeleton", "Examples/./Technical/ProceduralSkeleton", + "Examples/Other/../Technical/ProceduralSkeleton"]: + with self.subTest(path=path): + catalog = load_catalog() + catalog["items"].append({ + **catalog["items"][0], + "id": "procedural-skeleton", + "kind": "example", + "path": path, + }) + errors = io.StringIO() + with contextlib.redirect_stderr(errors), self.assertRaises(SystemExit) as raised: + validate_catalog(catalog) + self.assertEqual(raised.exception.code, 1) + self.assertIn("technical examples must remain outside the public catalog", errors.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/validate_catalog.py b/scripts/validate_catalog.py index 2f76558..578fb2d 100755 --- a/scripts/validate_catalog.py +++ b/scripts/validate_catalog.py @@ -84,7 +84,9 @@ def validate_item( fail(f"duplicate item path: {relative_path}") item_paths.add(relative_path) - project_path = ROOT / relative_path + project_path = (ROOT / relative_path).resolve() + if project_path.is_relative_to((ROOT / "Examples/Technical").resolve()): + fail(f"{item_id}: technical examples must remain outside the public catalog") if not project_path.is_dir(): fail(f"{item_id}: project directory does not exist: {relative_path}") if not (project_path / ".ada" / "project.json").is_file(): diff --git a/scripts/validate_technical_packages.py b/scripts/validate_technical_packages.py new file mode 100644 index 0000000..8523795 --- /dev/null +++ b/scripts/validate_technical_packages.py @@ -0,0 +1,58 @@ +"""Validate standalone technical SwiftPM manifests without downloading dependencies or building apps.""" +import argparse +import json +from pathlib import Path +import shutil +import subprocess + + +ROOT = Path(__file__).resolve().parents[1] + + +def manifest(path, swift): + result = subprocess.run([swift, "package", "--package-path", str(path), "dump-package"], + capture_output=True, text=True, check=True) + return json.loads(result.stdout) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--engine-root", type=Path, help="Also check products against a local engine manifest") + args = parser.parse_args() + swift = shutil.which("swift") + if not swift: + parser.error("Swift 6.2 or later is required") + products = None + if args.engine_root: + engine = manifest(args.engine_root.resolve(), swift) + products = {item["name"] for item in engine["products"]} + assert all(not (item.get("path") or "").startswith("Demos/") for item in engine["targets"]) + public = json.loads((ROOT / "catalog.json").read_text(encoding="utf-8")) + public_paths = {item["path"] for item in public["items"]} + paths = sorted((ROOT / "Examples/Technical").glob("*/Package.swift")) + for path in paths: + data = manifest(path.parent, swift) + assert path.parent.relative_to(ROOT).as_posix() not in public_paths + for dependency in data["dependencies"]: + assert "fileSystem" not in dependency, "Unset local dependency overrides when validating distribution manifests" + for source in dependency.get("sourceControl", []): + assert source["requirement"].get("revision") or source["requirement"].get("exact"), "Pin technical dependencies" + for target in data["targets"]: + folder = path.parent / target.get("path", ".") + assert folder.is_dir(), folder + for source in target.get("sources") or []: + assert source != "Package.swift", "Do not compile the package manifest as application code" + assert (folder / source).is_file(), source + for resource in target.get("resources", []): + assert (folder / resource["path"]).exists(), resource + if products: + for dependency in target["dependencies"]: + product = dependency.get("product") + if product and product[1].lower() == "adaengine": + assert product[0] in products, f"Missing engine product {product[0]}" + print(f"manifest ok: {path.parent.name}", flush=True) + print(f"Validated {len(paths)} technical packages; native builds and device execution are separate checks.") + + +if __name__ == "__main__": + main()