diff --git a/.prettierignore b/.prettierignore index e554bff..6c571fd 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,10 @@ # Xcode asset catalogs and app icon (Xcode owns these JSON files) **/*.xcassets/** -**/AppIcon.icon/** +**/*.icon/** # Deprecated apps -apps/__deprecated/ +apps/_deprecated/ + +# Generated files +**/*.gen.ts +generated/ diff --git a/apps/Derive/.gitignore b/apps/Derive/.gitignore deleted file mode 100644 index f5b322f..0000000 --- a/apps/Derive/.gitignore +++ /dev/null @@ -1,95 +0,0 @@ -# Xcode -# -# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore - -## User-specific files -xcuserdata/ - -## Xcode 8 and earlier -*.xcscmblueprint -*.xccheckout - -## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) -*.xcscmblueprint -*.xccheckout - -## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) -build/ -DerivedData/ -*.moved-aside -*.pbxuser -!default.pbxuser -*.mode1v3 -!default.mode1v3 -*.mode2v3 -!default.mode2v3 -*.perspectivev3 -!default.perspectivev3 - -## Obj-C/Swift specific -*.hmap - -## App packaging -*.ipa -*.dSYM.zip -*.dSYM - -## Playgrounds -timeline.xctimeline -playground.xcworkspace - -# Swift Package Manager -# -# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. -# Packages/ -# Package.pins -# Package.resolved -# *.xcodeproj -# -# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata -# hence it is not needed unless you have added a package configuration file to your project -# .swiftpm - -.build/ - -# CocoaPods -# -# We recommend against adding the Pods directory to your .gitignore. However -# you should judge for yourself, the pros and cons are mentioned at: -# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control -# -# Pods/ -# -# Add this line if you want to avoid checking in source code from the Xcode workspace -# *.xcworkspace - -# Carthage -# -# Add this line if you want to avoid checking in source code from Carthage dependencies. -# Carthage/Checkouts - -Carthage/Build/ - -# Accio dependency management -Dependencies/ -.accio/ - -# fastlane -# -# It is recommended to not store the screenshots in the git repo. -# Instead, use fastlane to re-generate the screenshots whenever they are needed. -# For more information about the recommended setup visit: -# https://docs.fastlane.tools/best-practices/source-control/#source-control - -fastlane/report.xml -fastlane/Preview.html -fastlane/screenshots/**/*.png -fastlane/test_output - -# Code Injection -# -# After new code Injection tools there's a generated folder /iOSInjectionProject -# https://github.com/johnno1962/injectionforxcode - -iOSInjectionProject/ - diff --git a/apps/Derive/Derive.xcodeproj/project.pbxproj b/apps/Derive/Derive.xcodeproj/project.pbxproj deleted file mode 100644 index 6a951aa..0000000 --- a/apps/Derive/Derive.xcodeproj/project.pbxproj +++ /dev/null @@ -1,370 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 77; - objects = { - -/* Begin PBXFileReference section */ - D3ACCE852F0CE7D6004E01DE /* Derive.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Derive.app; sourceTree = BUILT_PRODUCTS_DIR; }; -/* End PBXFileReference section */ - -/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - D3ACD0D12F0D39EF004E01DE /* Exceptions for "Derive" folder in "Derive" target */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Info.plist, - ); - target = D3ACCE842F0CE7D6004E01DE /* Derive */; - }; -/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ - -/* Begin PBXFileSystemSynchronizedRootGroup section */ - D3ACCE872F0CE7D6004E01DE /* Derive */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - D3ACD0D12F0D39EF004E01DE /* Exceptions for "Derive" folder in "Derive" target */, - ); - path = Derive; - sourceTree = ""; - }; -/* End PBXFileSystemSynchronizedRootGroup section */ - -/* Begin PBXFrameworksBuildPhase section */ - D3ACCE822F0CE7D6004E01DE /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - D3ACCE7C2F0CE7D6004E01DE = { - isa = PBXGroup; - children = ( - D3ACCE872F0CE7D6004E01DE /* Derive */, - D3ACCE862F0CE7D6004E01DE /* Products */, - ); - sourceTree = ""; - }; - D3ACCE862F0CE7D6004E01DE /* Products */ = { - isa = PBXGroup; - children = ( - D3ACCE852F0CE7D6004E01DE /* Derive.app */, - ); - name = Products; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - D3ACCE842F0CE7D6004E01DE /* Derive */ = { - isa = PBXNativeTarget; - buildConfigurationList = D3ACCE902F0CE7D7004E01DE /* Build configuration list for PBXNativeTarget "Derive" */; - buildPhases = ( - D3ACCE812F0CE7D6004E01DE /* Sources */, - D3ACCE822F0CE7D6004E01DE /* Frameworks */, - D3ACCE832F0CE7D6004E01DE /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - fileSystemSynchronizedGroups = ( - D3ACCE872F0CE7D6004E01DE /* Derive */, - ); - name = Derive; - packageProductDependencies = ( - ); - productName = Derive; - productReference = D3ACCE852F0CE7D6004E01DE /* Derive.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - D3ACCE7D2F0CE7D6004E01DE /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 2610; - LastUpgradeCheck = 2610; - TargetAttributes = { - D3ACCE842F0CE7D6004E01DE = { - CreatedOnToolsVersion = 26.1.1; - }; - }; - }; - buildConfigurationList = D3ACCE802F0CE7D6004E01DE /* Build configuration list for PBXProject "Derive" */; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = D3ACCE7C2F0CE7D6004E01DE; - minimizedProjectReferenceProxies = 1; - preferredProjectObjectVersion = 77; - productRefGroup = D3ACCE862F0CE7D6004E01DE /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - D3ACCE842F0CE7D6004E01DE /* Derive */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - D3ACCE832F0CE7D6004E01DE /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - D3ACCE812F0CE7D6004E01DE /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin XCBuildConfiguration section */ - D3ACCE8E2F0CE7D7004E01DE /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = 9RQRHCCMVL; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.1; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - D3ACCE8F2F0CE7D7004E01DE /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = 9RQRHCCMVL; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.1; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - SDKROOT = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - D3ACCE912F0CE7D7004E01DE /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; - DEVELOPMENT_TEAM = 9RQRHCCMVL; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = Derive/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = Derive; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.lifestyle"; - INFOPLIST_KEY_NSCameraUsageDescription = "Derive needs camera access to take photos for your grid."; - INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Derive needs access to save your completed grid to your photo library."; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 18.6; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 0.0.2; - PRODUCT_BUNDLE_IDENTIFIER = com.buildergroup.Derive; - PRODUCT_NAME = "$(TARGET_NAME)"; - STRING_CATALOG_GENERATE_SYMBOLS = YES; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SUPPORTS_MACCATALYST = NO; - SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; - SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; - }; - name = Debug; - }; - D3ACCE922F0CE7D7004E01DE /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; - DEVELOPMENT_TEAM = 9RQRHCCMVL; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = Derive/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = Derive; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.lifestyle"; - INFOPLIST_KEY_NSCameraUsageDescription = "Derive needs camera access to take photos for your grid."; - INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Derive needs access to save your completed grid to your photo library."; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 18.6; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 0.0.2; - PRODUCT_BUNDLE_IDENTIFIER = com.buildergroup.Derive; - PRODUCT_NAME = "$(TARGET_NAME)"; - STRING_CATALOG_GENERATE_SYMBOLS = YES; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SUPPORTS_MACCATALYST = NO; - SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; - SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - D3ACCE802F0CE7D6004E01DE /* Build configuration list for PBXProject "Derive" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - D3ACCE8E2F0CE7D7004E01DE /* Debug */, - D3ACCE8F2F0CE7D7004E01DE /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - D3ACCE902F0CE7D7004E01DE /* Build configuration list for PBXNativeTarget "Derive" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - D3ACCE912F0CE7D7004E01DE /* Debug */, - D3ACCE922F0CE7D7004E01DE /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = D3ACCE7D2F0CE7D6004E01DE /* Project object */; -} diff --git a/apps/Derive/Derive.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/apps/Derive/Derive.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a..0000000 --- a/apps/Derive/Derive.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/apps/Derive/Derive/AppIcon.icon/Assets/1-layer.svg b/apps/Derive/Derive/AppIcon.icon/Assets/1-layer.svg deleted file mode 100644 index 026b268..0000000 --- a/apps/Derive/Derive/AppIcon.icon/Assets/1-layer.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/Derive/Derive/AppIcon.icon/icon.json b/apps/Derive/Derive/AppIcon.icon/icon.json deleted file mode 100644 index 41b3779..0000000 --- a/apps/Derive/Derive/AppIcon.icon/icon.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "fill" : { - "solid" : "display-p3:0.95045,0.93751,0.91953,1.00000" - }, - "groups" : [ - { - "hidden" : false, - "layers" : [ - { - "blend-mode" : "normal", - "glass" : true, - "hidden" : false, - "image-name" : "1-layer.svg", - "name" : "1-layer" - } - ], - "shadow" : { - "kind" : "neutral", - "opacity" : 0.5 - }, - "specular" : true, - "translucency" : { - "enabled" : true, - "value" : 0.5 - } - } - ], - "supported-platforms" : { - "circles" : [ - "watchOS" - ], - "squares" : "shared" - } -} \ No newline at end of file diff --git a/apps/Derive/Derive/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/Derive/Derive/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index eb87897..0000000 --- a/apps/Derive/Derive/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/apps/Derive/Derive/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/Derive/Derive/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 2305880..0000000 --- a/apps/Derive/Derive/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "tinted" - } - ], - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/apps/Derive/Derive/Assets.xcassets/Contents.json b/apps/Derive/Derive/Assets.xcassets/Contents.json deleted file mode 100644 index 73c0059..0000000 --- a/apps/Derive/Derive/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/apps/Derive/Derive/Assets.xcassets/colors/Contents.json b/apps/Derive/Derive/Assets.xcassets/colors/Contents.json deleted file mode 100644 index 73c0059..0000000 --- a/apps/Derive/Derive/Assets.xcassets/colors/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/apps/Derive/Derive/Assets.xcassets/colors/background.colorset/Contents.json b/apps/Derive/Derive/Assets.xcassets/colors/background.colorset/Contents.json deleted file mode 100644 index 94d22bb..0000000 --- a/apps/Derive/Derive/Assets.xcassets/colors/background.colorset/Contents.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0xEA", - "green" : "0xEF", - "red" : "0xF3" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "light" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0xEA", - "green" : "0xEF", - "red" : "0xF3" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0x00", - "green" : "0x00", - "red" : "0x00" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/apps/Derive/Derive/Assets.xcassets/colors/card.colorset/Contents.json b/apps/Derive/Derive/Assets.xcassets/colors/card.colorset/Contents.json deleted file mode 100644 index 8492b0c..0000000 --- a/apps/Derive/Derive/Assets.xcassets/colors/card.colorset/Contents.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "1.000", - "green" : "1.000", - "red" : "1.000" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "light" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "1.000", - "green" : "1.000", - "red" : "1.000" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.118", - "green" : "0.110", - "red" : "0.110" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/apps/Derive/Derive/Assets.xcassets/colors/cta.colorset/Contents.json b/apps/Derive/Derive/Assets.xcassets/colors/cta.colorset/Contents.json deleted file mode 100644 index 9903aec..0000000 --- a/apps/Derive/Derive/Assets.xcassets/colors/cta.colorset/Contents.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.000", - "green" : "0.000", - "red" : "0.000" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "light" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.000", - "green" : "0.000", - "red" : "0.000" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "1.000", - "green" : "1.000", - "red" : "1.000" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/apps/Derive/Derive/Assets.xcassets/colors/ctaContent.colorset/Contents.json b/apps/Derive/Derive/Assets.xcassets/colors/ctaContent.colorset/Contents.json deleted file mode 100644 index 737e910..0000000 --- a/apps/Derive/Derive/Assets.xcassets/colors/ctaContent.colorset/Contents.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "1.000", - "green" : "1.000", - "red" : "1.000" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "light" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "1.000", - "green" : "1.000", - "red" : "1.000" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.000", - "green" : "0.000", - "red" : "0.000" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/apps/Derive/Derive/Assets.xcassets/icons/Contents.json b/apps/Derive/Derive/Assets.xcassets/icons/Contents.json deleted file mode 100644 index 73c0059..0000000 --- a/apps/Derive/Derive/Assets.xcassets/icons/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/apps/Derive/Derive/Assets.xcassets/icons/github.symbolset/Contents.json b/apps/Derive/Derive/Assets.xcassets/icons/github.symbolset/Contents.json deleted file mode 100644 index c1e81e1..0000000 --- a/apps/Derive/Derive/Assets.xcassets/icons/github.symbolset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - }, - "symbols" : [ - { - "filename" : "github.svg", - "idiom" : "universal" - } - ] -} diff --git a/apps/Derive/Derive/Assets.xcassets/icons/github.symbolset/github.svg b/apps/Derive/Derive/Assets.xcassets/icons/github.symbolset/github.svg deleted file mode 100644 index d6af429..0000000 --- a/apps/Derive/Derive/Assets.xcassets/icons/github.symbolset/github.svg +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - - Weight/Scale Variations - Ultralight - Thin - Light - Regular - Medium - Semibold - Bold - Heavy - Black - - - - - - - - - - - Design Variations - Symbols are supported in up to nine weights and three scales. - For optimal layout with text and other symbols, vertically align - symbols with the adjacent text. - - - - - - Margins - Leading and trailing margins on the left and right side of each symbol - can be adjusted by modifying the x-location of the margin guidelines. - Modifications are automatically applied proportionally to all - scales and weights. - - - - Exporting - Symbols should be outlined when exporting to ensure the - design is preserved when submitting to Xcode. - Template v.6.0 - Requires Xcode 16 or greater - Generated from square - Typeset at 100.0 points - Small - Medium - Large - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/apps/Derive/Derive/Assets.xcassets/icons/logo.symbolset/Contents.json b/apps/Derive/Derive/Assets.xcassets/icons/logo.symbolset/Contents.json deleted file mode 100644 index f1e059e..0000000 --- a/apps/Derive/Derive/Assets.xcassets/icons/logo.symbolset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - }, - "symbols" : [ - { - "filename" : "logo.svg", - "idiom" : "universal" - } - ] -} diff --git a/apps/Derive/Derive/Assets.xcassets/icons/logo.symbolset/logo.svg b/apps/Derive/Derive/Assets.xcassets/icons/logo.symbolset/logo.svg deleted file mode 100644 index c31ac0f..0000000 --- a/apps/Derive/Derive/Assets.xcassets/icons/logo.symbolset/logo.svg +++ /dev/null @@ -1,94 +0,0 @@ - - - - - - - - diff --git a/apps/Derive/Derive/Assets.xcassets/illustrations/Contents.json b/apps/Derive/Derive/Assets.xcassets/illustrations/Contents.json deleted file mode 100644 index 73c0059..0000000 --- a/apps/Derive/Derive/Assets.xcassets/illustrations/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/apps/Derive/Derive/ContentView.swift b/apps/Derive/Derive/ContentView.swift deleted file mode 100644 index 88d4f7a..0000000 --- a/apps/Derive/Derive/ContentView.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// ContentView.swift -// Derive -// -// Created by Benno on 06.01.26. -// - -import SwiftData -import SwiftUI - -enum AppTab: Hashable { - case derive, discover, settings -} - -struct ContentView: View { - @QuerySingleton private var player: Player - @State private var showSplash = true - @State private var selectedTab: AppTab = .derive - - var body: some View { - Group { - if showSplash { - SplashView() - } else if !player.hasCompletedOnboarding { - OnboardingView() - } else { - mainTabView - } - } - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - showSplash = false - } - } - } - - // MARK: - UI - - private var mainTabView: some View { - TabView(selection: $selectedTab) { - Tab(value: .derive) { - NavigationStack { - HomeView(selectedTab: $selectedTab) - } - } label: { - Label("Dérive", image: "logo") - } - - Tab(value: .discover) { - NavigationStack { - BrowseView(selectedTab: $selectedTab) - } - } label: { - Label("Discover", systemImage: "sparkle.magnifyingglass") - } - - Tab(value: .settings) { - NavigationStack { - SettingsView() - } - } label: { - Label("Settings", systemImage: "gearshape") - } - } - } -} - -#Preview { - ContentView() - .previewDataContainer() -} - -#Preview("Onboarded") { - ContentView() - .previewDataContainer { context in - let player = Player.instance(with: context) - player.onboardingCompletedAt = Date() - } -} diff --git a/apps/Derive/Derive/DeriveApp.swift b/apps/Derive/Derive/DeriveApp.swift deleted file mode 100644 index 41d8e76..0000000 --- a/apps/Derive/Derive/DeriveApp.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// DeriveApp.swift -// Derive -// -// Created by Benno on 06.01.26. -// - -import SwiftData -import SwiftUI - -@main -struct DeriveApp: App { - init() { - configureNavigationBarAppearance() - } - - var body: some Scene { - WindowGroup { - ContentView() - .modelContainer(DataContainer.shared.modelContainer) - } - } - - private func configureNavigationBarAppearance() { - let appearance = UINavigationBarAppearance() - appearance.configureWithDefaultBackground() - - // Variable fonts - need to use font descriptor for weight - let titleDescriptor = UIFontDescriptor(fontAttributes: [ - .family: "Erode Variable", - .traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold] - ]) - let titleFont = UIFont(descriptor: titleDescriptor, size: 18) - - let largeTitleDescriptor = UIFontDescriptor(fontAttributes: [ - .family: "Erode Variable", - .traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold] - ]) - let largeTitleFont = UIFont(descriptor: largeTitleDescriptor, size: 36) - - appearance.titleTextAttributes = [.font: titleFont] - appearance.largeTitleTextAttributes = [.font: largeTitleFont] - - UINavigationBar.appearance().standardAppearance = appearance - UINavigationBar.appearance().scrollEdgeAppearance = appearance - UINavigationBar.appearance().compactAppearance = appearance - } -} diff --git a/apps/Derive/Derive/Environment/ChallengeRegistry.swift b/apps/Derive/Derive/Environment/ChallengeRegistry.swift deleted file mode 100644 index 8ea5f67..0000000 --- a/apps/Derive/Derive/Environment/ChallengeRegistry.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// ChallengeRegistry.swift -// Derive -// -// Created by Benno on 06.01.26. -// - -import SwiftUI - -struct ChallengeRegistry { - static let shared = ChallengeRegistry() - private init() {} - - let all: [Challenge] = [ - Challenge(id: "yellow", prompt: "find things in yellow"), - Challenge(id: "red", prompt: "find things in red"), - Challenge(id: "blue", prompt: "find things in blue"), - Challenge(id: "green", prompt: "find things in green"), - Challenge(id: "orange", prompt: "find things in orange"), - Challenge(id: "pink", prompt: "find things in pink"), - Challenge(id: "purple", prompt: "find things in purple"), - Challenge(id: "brown", prompt: "find things in brown"), - ] - - func challenge(id: String) -> Challenge? { - all.first { $0.id == id } - } -} diff --git a/apps/Derive/Derive/Environment/Configs/AppConfig.swift b/apps/Derive/Derive/Environment/Configs/AppConfig.swift deleted file mode 100644 index d8f79b8..0000000 --- a/apps/Derive/Derive/Environment/Configs/AppConfig.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// AppConfig.swift -// Derive -// -// Created by Benno on 06.01.26. -// - -import Foundation - -enum AppConfig { - // MARK: - App Information - - static var appName: String { - Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") - as? String - ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") - as? String - ?? "Derive" - } - - static var bundleIdentifier: String { - Bundle.main.bundleIdentifier ?? "com.buildergroup.Derive" - } - - static var version: String { - Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" - } - - static var build: String { - Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1" - } - - // MARK: - External Links - - static var websiteURL: URL? { - URL(string: "https://builder.group/apps/derive") - } - - static var appStoreURL: URL? { - nil // TODO: Add App Store URL after release - } - - static var privacyPolicyURL: URL? { - URL(string: "https://builder.group/apps/derive/legal/privacy") - } - - static var githubURL: URL? { - URL(string: "https://github.com/builder-group/lab") - } - - // MARK: - Feedback & Support - - static var feedbackEmail: String { - "support@builder.group" - } - - static func mailtoURL(subject: String) -> URL? { - let fullSubject = "[Dérive] \(subject)" - let encodedSubject = - fullSubject.addingPercentEncoding( - withAllowedCharacters: .urlQueryAllowed - ) ?? fullSubject - return URL(string: "mailto:\(feedbackEmail)?subject=\(encodedSubject)") - } - - // MARK: - Image Configuration - - /// Maximum image dimension when storing photos - static let maxImageDimension: CGFloat = 1000 -} diff --git a/apps/Derive/Derive/Environment/DataContainer.swift b/apps/Derive/Derive/Environment/DataContainer.swift deleted file mode 100644 index 21cd864..0000000 --- a/apps/Derive/Derive/Environment/DataContainer.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// DataContainer.swift -// Derive -// -// Created by Benno on 06.01.26. -// - -import Foundation -import OSLog -import SwiftData -import SwiftUI - -@Observable -@MainActor -class DataContainer { - static let shared = DataContainer() - - let modelContainer: ModelContainer - - var modelContext: ModelContext { - modelContainer.mainContext - } - - init(isStoredInMemoryOnly: Bool = false) { - do { - modelContainer = try Self.createContainer( - isStoredInMemoryOnly: isStoredInMemoryOnly - ) - } catch { - guard !isStoredInMemoryOnly else { - fatalError("Failed to create in-memory container: \(error)") - } - Logger( - subsystem: AppConfig.bundleIdentifier, - category: "DataContainer" - ).error( - "Failed to create persistent container, using in-memory: \(error.localizedDescription)" - ) - modelContainer = try! Self.createContainer( - isStoredInMemoryOnly: true - ) - } - - DataContainer.ensureDefaults(in: modelContext) - } - - private static func createContainer( - isStoredInMemoryOnly: Bool - ) throws -> ModelContainer { - let schema = Schema(Self.schema()) - let configuration = ModelConfiguration( - "DeriveData", - schema: schema, - isStoredInMemoryOnly: isStoredInMemoryOnly, - allowsSave: true, - cloudKitDatabase: .none - ) - return try ModelContainer(for: schema, configurations: [configuration]) - } - - static func schema() -> [any PersistentModel.Type] { - [ - Player.self, - Derive.self, - ] - } - - static func ensureDefaults(in context: ModelContext) { - _ = Player.instance(with: context) - try? context.save() - } -} - -// MARK: - Preview Support - -extension View { - func previewDataContainer(seed: ((ModelContext) -> Void)? = nil) -> some View { - let container = DataContainer(isStoredInMemoryOnly: true) - if let seed = seed { - seed(container.modelContext) - try? container.modelContext.save() - } - return self.modelContainer(container.modelContainer) - } -} diff --git a/apps/Derive/Derive/Extensions/Color+App.swift b/apps/Derive/Derive/Extensions/Color+App.swift deleted file mode 100644 index 285ca16..0000000 --- a/apps/Derive/Derive/Extensions/Color+App.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// Color+App.swift -// Derive -// -// Created by Benno on 06.01.26. -// - -import SwiftUI - -extension Color { - static let appBackground = Color("background") - static let appCard = Color("card") - static let appCta = Color("cta") - static let appCtaContent = Color("ctaContent") -} diff --git a/apps/Derive/Derive/Features/Challenge/Models/Challenge.swift b/apps/Derive/Derive/Features/Challenge/Models/Challenge.swift deleted file mode 100644 index 805401e..0000000 --- a/apps/Derive/Derive/Features/Challenge/Models/Challenge.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// Challenge.swift -// Derive -// -// Created by Benno on 06.01.26. -// - -import SwiftUI - -struct Challenge: Identifiable, Codable, Hashable { - let id: String - let type: String - let prompt: String - - init(id: String, type: String = "color", prompt: String) { - self.id = id - self.type = type - self.prompt = prompt - } - - // MARK: - Display - - var title: String { - id.capitalized - } - - var color: Color { - switch id { - case "yellow": return .yellow - case "red": return .red - case "blue": return .blue - case "green": return .green - case "orange": return .orange - case "pink": return .pink - case "purple": return .purple - case "brown": return .brown - default: return .accentColor - } - } -} diff --git a/apps/Derive/Derive/Features/Derive/Models/Derive.swift b/apps/Derive/Derive/Features/Derive/Models/Derive.swift deleted file mode 100644 index 6c0d749..0000000 --- a/apps/Derive/Derive/Features/Derive/Models/Derive.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// Derive.swift -// Derive -// -// Created by Benno on 06.01.26. -// - -import Foundation -import SwiftData - -/// A player's instance of doing a challenge. -/// Created when the player joins a challenge, contains their photos and progress. -@Model -final class Derive { - var id: UUID - var challengeId: String - var startedAt: Date - var completedAt: Date? - - /// Photos stored as JSON-encoded Data for SwiftData compatibility - var photosData: Data? - - @Relationship - var player: Player? - - /// Access photos as [PhotoSlot] array - var photos: [PhotoSlot] { - get { - guard let data = photosData else { - return (0..<9).map { _ in PhotoSlot() } - } - return (try? JSONDecoder().decode([PhotoSlot].self, from: data)) - ?? (0..<9).map { _ in PhotoSlot() } - } - set { - photosData = try? JSONEncoder().encode(newValue) - } - } - - init( - id: UUID = UUID(), - challengeId: String, - player: Player - ) { - self.id = id - self.challengeId = challengeId - self.startedAt = Date() - self.photosData = try? JSONEncoder().encode((0..<9).map { _ in PhotoSlot() }) - self.player = player - } - - // MARK: - Challenge Reference - - /// The challenge template this derive is based on - var challenge: Challenge? { - ChallengeRegistry.shared.challenge(id: challengeId) - } - - var prompt: String { - challenge?.prompt ?? "Unknown challenge" - } - - // MARK: - State - - var isActive: Bool { - completedAt == nil - } - - // MARK: - Progress - - var filledCount: Int { - photos.filter { $0.isFilled }.count - } - - var isComplete: Bool { - filledCount == 9 - } -} diff --git a/apps/Derive/Derive/Features/Derive/Models/PhotoSlot.swift b/apps/Derive/Derive/Features/Derive/Models/PhotoSlot.swift deleted file mode 100644 index 34123a3..0000000 --- a/apps/Derive/Derive/Features/Derive/Models/PhotoSlot.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// PhotoSlot.swift -// Derive -// -// Created by Benno on 06.01.26. -// - -import Foundation - -/// A single slot in the 3×3 photo grid. -/// Stored as Codable array in Derive model. -struct PhotoSlot: Codable, Identifiable { - let id: UUID - var imageData: Data? - var capturedAt: Date? - - init(id: UUID = UUID(), imageData: Data? = nil, capturedAt: Date? = nil) { - self.id = id - self.imageData = imageData - self.capturedAt = capturedAt - } - - var isFilled: Bool { - imageData != nil - } -} diff --git a/apps/Derive/Derive/Features/Derive/Views/GridCellView.swift b/apps/Derive/Derive/Features/Derive/Views/GridCellView.swift deleted file mode 100644 index a9eecf4..0000000 --- a/apps/Derive/Derive/Features/Derive/Views/GridCellView.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// GridCellView.swift -// Derive -// -// Created by Benno on 06.01.26. -// - -import SwiftUI - -struct GridCellView: View { - let slot: PhotoSlot - var isSelected: Bool = false - let onTap: () -> Void - - var body: some View { - Button(action: onTap) { - ZStack { - if let data = slot.imageData, let image = UIImage(data: data) { - Image(uiImage: image) - .resizable() - .scaledToFill() - } else { - Color.appCard - Image(systemName: "plus") - .font(.title3) - .foregroundStyle(.tertiary) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .clipped() - .opacity(isSelected ? 0.5 : 1) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } -} - -#Preview { - HStack(spacing: 4) { - GridCellView(slot: PhotoSlot(), onTap: {}) - GridCellView(slot: PhotoSlot(), onTap: {}) - GridCellView(slot: PhotoSlot(), onTap: {}) - } - .frame(height: 120) - .padding() - .background(Color.appBackground) -} diff --git a/apps/Derive/Derive/Features/Derive/Views/GridView.swift b/apps/Derive/Derive/Features/Derive/Views/GridView.swift deleted file mode 100644 index 1fc448b..0000000 --- a/apps/Derive/Derive/Features/Derive/Views/GridView.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// GridView.swift -// Derive -// -// Created by Benno on 06.01.26. -// - -import SwiftUI - -struct GridView: View { - let photos: [PhotoSlot] - var selectedIndex: Int? - var spacing: CGFloat = 4 - let onSlotTap: (Int) -> Void - - var body: some View { - GeometryReader { geo in - let cellSize = (geo.size.width - spacing * 2) / 3 - - LazyVGrid( - columns: [ - GridItem(.fixed(cellSize), spacing: spacing), - GridItem(.fixed(cellSize), spacing: spacing), - GridItem(.fixed(cellSize), spacing: spacing), - ], - spacing: spacing - ) { - ForEach(Array(photos.enumerated()), id: \.element.id) { index, slot in - GridCellView(slot: slot, isSelected: selectedIndex == index) { - onSlotTap(index) - } - .frame(width: cellSize, height: cellSize) - } - } - } - .aspectRatio(1, contentMode: .fit) - } -} - -struct GridThumbnail: View { - let photos: [PhotoSlot] - var spacing: CGFloat = 2 - - var body: some View { - GeometryReader { geo in - let cellSize = (geo.size.width - spacing * 2) / 3 - - LazyVGrid( - columns: [ - GridItem(.fixed(cellSize), spacing: spacing), - GridItem(.fixed(cellSize), spacing: spacing), - GridItem(.fixed(cellSize), spacing: spacing), - ], - spacing: spacing - ) { - ForEach(photos) { slot in - ZStack { - if let data = slot.imageData, let image = UIImage(data: data) { - Image(uiImage: image) - .resizable() - .scaledToFill() - } else { - Color.appCard - } - } - .frame(width: cellSize, height: cellSize) - .clipped() - } - } - } - .aspectRatio(1, contentMode: .fit) - } -} - -#Preview { - GridView(photos: (0 ..< 9).map { _ in PhotoSlot() }, onSlotTap: { _ in }) - .padding() -} diff --git a/apps/Derive/Derive/Features/Player/Models/Player.swift b/apps/Derive/Derive/Features/Player/Models/Player.swift deleted file mode 100644 index f74fa0e..0000000 --- a/apps/Derive/Derive/Features/Player/Models/Player.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// Player.swift -// Derive -// -// Created by Benno on 06.01.26. -// - -import Foundation -import SwiftData - -/// Singleton model representing the player. -/// Owns all derives and tracks player state. -@Model -final class Player: SingletonModel { - var createdAt: Date - var onboardingCompletedAt: Date? - - @Relationship(deleteRule: .cascade, inverse: \Derive.player) - var derives: [Derive] = [] - - init(createdAt: Date = Date()) { - self.createdAt = createdAt - } - - static var `default`: Player { - Player() - } - - // MARK: - Onboarding - - var hasCompletedOnboarding: Bool { - onboardingCompletedAt != nil - } - - // MARK: - Derives - - /// The currently active derive (not completed, not expired) - var activeDerive: Derive? { - derives.first { $0.isActive } - } - - /// All completed derives, sorted by completion date (newest first) - var completedDerives: [Derive] { - derives - .filter { $0.completedAt != nil } - .sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) } - } - - /// Whether the player currently has an active derive - var hasActiveDerive: Bool { - activeDerive != nil - } -} diff --git a/apps/Derive/Derive/Fonts/Erode-Variable.ttf b/apps/Derive/Derive/Fonts/Erode-Variable.ttf deleted file mode 100644 index dabc6b0..0000000 Binary files a/apps/Derive/Derive/Fonts/Erode-Variable.ttf and /dev/null differ diff --git a/apps/Derive/Derive/Fonts/Font+Erode.swift b/apps/Derive/Derive/Fonts/Font+Erode.swift deleted file mode 100644 index 8544429..0000000 --- a/apps/Derive/Derive/Fonts/Font+Erode.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// Font+Erode.swift -// Derive -// -// Created by Benno on 06.01.26. -// - -import SwiftUI -import UIKit - -extension Font { - static func erode(_ size: CGFloat, weight: Font.Weight = .regular) -> Font { - .custom("Erode Variable", size: size).weight(weight) - } -} - -extension UIFont { - static func erode(_ size: CGFloat, weight: UIFont.Weight = .regular) -> UIFont { - let descriptor = UIFontDescriptor(fontAttributes: [ - .name: "Erode Variable", - .traits: [UIFontDescriptor.TraitKey.weight: weight] - ]) - return UIFont(descriptor: descriptor, size: size) - } -} diff --git a/apps/Derive/Derive/Info.plist b/apps/Derive/Derive/Info.plist deleted file mode 100644 index 7dbf353..0000000 --- a/apps/Derive/Derive/Info.plist +++ /dev/null @@ -1,16 +0,0 @@ - - - - - UIAppFonts - - Erode-Variable.ttf - - NSCameraUsageDescription - Take photos for your dérive challenge - NSPhotoLibraryUsageDescription - Select photos for your dérive and save completed grids - NSPhotoLibraryAddUsageDescription - Save your completed dérive grid to Photos - - diff --git a/apps/Derive/Derive/Lib/CameraPicker.swift b/apps/Derive/Derive/Lib/CameraPicker.swift deleted file mode 100644 index 421b35d..0000000 --- a/apps/Derive/Derive/Lib/CameraPicker.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// CameraPicker.swift -// Derive -// -// Created by Benno on 06.01.26. -// - -import SwiftUI -import UIKit - -struct CameraPicker: UIViewControllerRepresentable { - let onCapture: (UIImage?) -> Void - - func makeUIViewController(context: Context) -> UIImagePickerController { - let picker = UIImagePickerController() - picker.sourceType = .camera - picker.delegate = context.coordinator - return picker - } - - func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} - - func makeCoordinator() -> Coordinator { - Coordinator(onCapture: onCapture) - } - - class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { - let onCapture: (UIImage?) -> Void - - init(onCapture: @escaping (UIImage?) -> Void) { - self.onCapture = onCapture - } - - func imagePickerController( - _ picker: UIImagePickerController, - didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] - ) { - let image = info[.originalImage] as? UIImage - onCapture(image) - } - - func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { - onCapture(nil) - } - } -} diff --git a/apps/Derive/Derive/Lib/QuerySingleton.swift b/apps/Derive/Derive/Lib/QuerySingleton.swift deleted file mode 100644 index 2347975..0000000 --- a/apps/Derive/Derive/Lib/QuerySingleton.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// QuerySingleton.swift -// Derive -// -// Created by Benno on 06.01.26. -// - -import SwiftData -import SwiftUI - -/// Property wrapper for querying singleton SwiftData models. -/// Returns the first instance from the store, or falls back to `Model.default` if not found. -/// -/// ```swift -/// @QuerySingleton private var player: Player -/// ``` -@propertyWrapper -struct QuerySingleton: DynamicProperty { - @Query private var queried: [Model] - - var wrappedValue: Model { - queried.first ?? Model.default - } - - init() { - _queried = Query() - } -} diff --git a/apps/Derive/Derive/Lib/SingletonModel.swift b/apps/Derive/Derive/Lib/SingletonModel.swift deleted file mode 100644 index 678d826..0000000 --- a/apps/Derive/Derive/Lib/SingletonModel.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// SingletonModel.swift -// Derive -// -// Created by Benno on 06.01.26. -// - -import Foundation -import SwiftData - -/// Protocol for singleton SwiftData models that have a default instance. -protocol SingletonModel: PersistentModel { - /// Default instance used when creating new singleton instances. - static var `default`: Self { get } - - /// Fetches existing instance or creates one from `default` if not found. - static func instance(with modelContext: ModelContext) -> Self -} - -extension SingletonModel { - static func instance(with modelContext: ModelContext) -> Self { - let descriptor = FetchDescriptor() - if let result = try? modelContext.fetch(descriptor).first { - return result - } else { - let instance = Self.default - modelContext.insert(instance) - try? modelContext.save() - return instance - } - } -} diff --git a/apps/Derive/Derive/Routes/Browse/BrowseView.swift b/apps/Derive/Derive/Routes/Browse/BrowseView.swift deleted file mode 100644 index 20dd279..0000000 --- a/apps/Derive/Derive/Routes/Browse/BrowseView.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// BrowseView.swift -// Derive -// -// Created by Benno on 06.01.26. -// - -import SwiftUI - -struct BrowseView: View { - @Binding var selectedTab: AppTab - @State private var selectedChallenge: Challenge? - - private let challenges = ChallengeRegistry.shared.all - - // MARK: - UI - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - colorSection - - Spacer().frame(height: 32) - - soonSection - - Spacer().frame(height: 40) - } - .padding(.horizontal, 20) - } - .scrollIndicators(.hidden) - .background(Color.appBackground) - .navigationTitle("Discover") - .navigationDestination(item: $selectedChallenge) { challenge in - ChallengeDetailView(challenge: challenge, selectedTab: $selectedTab) - } - } - - private var colorSection: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Color") - .font(.erode(24, weight: .semibold)) - - LazyVGrid( - columns: [ - GridItem(.flexible(), spacing: 12), - GridItem(.flexible(), spacing: 12), - GridItem(.flexible(), spacing: 12) - ], - spacing: 12 - ) { - ForEach(challenges) { challenge in - challengeCard(challenge) - } - } - } - } - - private func challengeCard(_ challenge: Challenge) -> some View { - Button { - selectedChallenge = challenge - } label: { - VStack(spacing: 8) { - RoundedRectangle(cornerRadius: 12) - .fill(challenge.color) - .aspectRatio(1, contentMode: .fit) - - Text(challenge.title) - .font(.caption.weight(.medium)) - .foregroundStyle(.primary) - } - } - .buttonStyle(.plain) - } - - private var soonSection: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Soon") - .font(.erode(24, weight: .semibold)) - - Text("More ways to explore coming soon — textures, shapes, themes, and beyond.") - .font(.subheadline) - .foregroundStyle(.secondary) - } - } -} - -#Preview { - NavigationStack { - BrowseView(selectedTab: .constant(.discover)) - } - .previewDataContainer() -} diff --git a/apps/Derive/Derive/Routes/ChallengeDetail/ChallengeDetailView.swift b/apps/Derive/Derive/Routes/ChallengeDetail/ChallengeDetailView.swift deleted file mode 100644 index 6d7f9c0..0000000 --- a/apps/Derive/Derive/Routes/ChallengeDetail/ChallengeDetailView.swift +++ /dev/null @@ -1,152 +0,0 @@ -// -// ChallengeDetailView.swift -// Derive -// -// Created by Benno on 06.01.26. -// - -import SwiftData -import SwiftUI - -struct ChallengeDetailView: View { - let challenge: Challenge - @Binding var selectedTab: AppTab - @QuerySingleton private var player: Player - @Environment(\.modelContext) private var modelContext - @Environment(\.dismiss) private var dismiss - - @State private var showAbandonConfirm = false - - // MARK: - UI - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - colorPreview - - Spacer().frame(height: 24) - - promptLabel - - Spacer().frame(height: 32) - - infoSection - - Spacer().frame(height: 40) - } - .padding(.horizontal, 24) - } - .scrollIndicators(.hidden) - .background(Color.appBackground) - .navigationTitle(challenge.title) - .safeAreaInset(edge: .bottom) { - startButton - .padding(.horizontal, 24) - .padding(.bottom, 24) - .background(Color.appBackground) - } - } - - private var colorPreview: some View { - RoundedRectangle(cornerRadius: 16) - .fill(challenge.color) - .frame(height: 200) - } - - private var promptLabel: some View { - Text(challenge.prompt) - .font(.body) - .foregroundStyle(.secondary) - } - - private var infoSection: some View { - VStack(alignment: .leading, spacing: 16) { - infoRow(icon: "square.grid.3x3", title: "9 Photos", subtitle: "Fill a 3×3 grid") - infoRow(icon: "clock", title: "Take your time", subtitle: "No time limit") - infoRow(icon: "eye", title: "Look around", subtitle: "Find the color in your world") - } - } - - private func infoRow(icon: String, title: String, subtitle: String) -> some View { - HStack(spacing: 16) { - Image(systemName: icon) - .font(.title2) - .foregroundStyle(.secondary) - .frame(width: 32) - - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(.subheadline.weight(.medium)) - Text(subtitle) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - - private var startButton: some View { - Button { - if player.hasActiveDerive { - showAbandonConfirm = true - } else { - startDerive() - } - } label: { - Text("Start Dérive") - .font(.headline) - .foregroundStyle(Color.appCtaContent) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .background(Color.appCta) - .clipShape(Capsule()) - } - .alert("Abandon current dérive?", isPresented: $showAbandonConfirm) { - Button("Cancel", role: .cancel) {} - Button("Abandon & Start", role: .destructive) { - abandonAndStartDerive() - } - } message: { - Text("Your current progress will be lost.") - } - } - - // MARK: - Actions - - private func startDerive() { - let derive = Derive(challengeId: challenge.id, player: player) - modelContext.insert(derive) - try? modelContext.save() - dismiss() - selectedTab = .derive - } - - private func abandonAndStartDerive() { - if let activeDerive = player.activeDerive { - modelContext.delete(activeDerive) - } - startDerive() - } -} - -#Preview { - NavigationStack { - ChallengeDetailView( - challenge: Challenge(id: "yellow", prompt: "Find 9 things in yellow"), - selectedTab: .constant(.discover) - ) - } - .previewDataContainer() -} - -#Preview("Has Active") { - NavigationStack { - ChallengeDetailView( - challenge: Challenge(id: "blue", prompt: "Find 9 things in blue"), - selectedTab: .constant(.discover) - ) - } - .previewDataContainer { ctx in - let player = Player.instance(with: ctx) - ctx.insert(Derive(challengeId: "yellow", player: player)) - } -} diff --git a/apps/Derive/Derive/Routes/Completed/CompletedView.swift b/apps/Derive/Derive/Routes/Completed/CompletedView.swift deleted file mode 100644 index 26997e6..0000000 --- a/apps/Derive/Derive/Routes/Completed/CompletedView.swift +++ /dev/null @@ -1,219 +0,0 @@ -// -// CompletedView.swift -// Derive -// -// Created by Benno on 06.01.26. -// - -import Photos -import SwiftData -import SwiftUI - -struct CompletedView: View { - let derive: Derive - @Environment(\.modelContext) private var modelContext - @Environment(\.dismiss) private var dismiss - - @State private var showSaveSuccess = false - @State private var showSaveError = false - @State private var showDeleteConfirm = false - - private var durationText: String { - guard let completedAt = derive.completedAt else { return "—" } - let interval = completedAt.timeIntervalSince(derive.startedAt) - let formatter = DateComponentsFormatter() - formatter.allowedUnits = [.day, .hour, .minute] - formatter.unitsStyle = .abbreviated - formatter.maximumUnitCount = 2 - return formatter.string(from: interval) ?? "—" - } - - private var completedDateText: String { - guard let completedAt = derive.completedAt else { return "—" } - let formatter = DateFormatter() - formatter.dateStyle = .medium - return formatter.string(from: completedAt) - } - - // MARK: - UI - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - promptLabel - - Spacer().frame(height: 24) - - GridView(photos: derive.photos, onSlotTap: { _ in }) - - Spacer().frame(height: 24) - - statsRow - - Spacer().frame(height: 40) - } - .padding(.horizontal, 24) - } - .scrollIndicators(.hidden) - .background(Color.appBackground) - .navigationBarHidden(false) - .navigationTitle(derive.challenge?.title ?? "Dérive") - .navigationBarTitleDisplayMode(.large) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Menu { - Button { - saveToLibrary() - } label: { - Label("Save to Photos", systemImage: "square.and.arrow.down") - } - - Button(role: .destructive) { - showDeleteConfirm = true - } label: { - Label("Delete", systemImage: "trash") - } - } label: { - Image(systemName: "ellipsis.circle") - } - } - } - .alert("Saved!", isPresented: $showSaveSuccess) { - Button("OK") {} - } message: { - Text("Your dérive has been saved to Photos.") - } - .alert("Error", isPresented: $showSaveError) { - Button("OK") {} - } message: { - Text("Could not save to Photos. Please check permissions in Settings.") - } - .alert("Delete Dérive?", isPresented: $showDeleteConfirm) { - Button("Cancel", role: .cancel) {} - Button("Delete", role: .destructive) { - deleteDerive() - } - } message: { - Text("This will permanently delete this dérive and all its photos.") - } - } - - private var promptLabel: some View { - Text(derive.prompt) - .font(.body) - .foregroundStyle(.secondary) - } - - private var statsRow: some View { - HStack(spacing: 32) { - VStack(alignment: .leading, spacing: 4) { - Text("Completed") - .font(.caption) - .foregroundStyle(.secondary) - Text(completedDateText) - .font(.subheadline.weight(.medium)) - } - - VStack(alignment: .leading, spacing: 4) { - Text("Duration") - .font(.caption) - .foregroundStyle(.secondary) - Text(durationText) - .font(.subheadline.weight(.medium)) - } - - Spacer() - } - } - - // MARK: - Actions - - private func deleteDerive() { - modelContext.delete(derive) - try? modelContext.save() - dismiss() - } - - private func saveToLibrary() { - let image = createGridImage(from: derive.photos) - - PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in - guard status == .authorized || status == .limited else { - DispatchQueue.main.async { showSaveError = true } - return - } - - PHPhotoLibrary.shared().performChanges { - PHAssetCreationRequest.creationRequestForAsset(from: image) - } completionHandler: { success, _ in - DispatchQueue.main.async { - if success { - showSaveSuccess = true - } else { - showSaveError = true - } - } - } - } - } - - private func createGridImage(from photos: [PhotoSlot]) -> UIImage { - let cellSize: CGFloat = 400 - let spacing: CGFloat = 4 - let gridSize = cellSize * 3 + spacing * 2 - - let renderer = UIGraphicsImageRenderer( - size: CGSize(width: gridSize, height: gridSize) - ) - - return renderer.image { ctx in - UIColor.systemBackground.setFill() - ctx.fill( - CGRect( - origin: .zero, - size: CGSize(width: gridSize, height: gridSize) - ) - ) - - for (index, slot) in photos.enumerated() { - let row = index / 3 - let col = index % 3 - let x = CGFloat(col) * (cellSize + spacing) - let y = CGFloat(row) * (cellSize + spacing) - let rect = CGRect(x: x, y: y, width: cellSize, height: cellSize) - - if let data = slot.imageData, let image = UIImage(data: data) { - image.draw(in: rect) - } else { - UIColor.secondarySystemBackground.setFill() - ctx.fill(rect) - } - } - - // Watermark - if let logo = UIImage(named: "logo") { - let maxSize: CGFloat = 48 - let padding: CGFloat = 16 - let aspectRatio = logo.size.width / logo.size.height - let watermarkWidth = aspectRatio >= 1 ? maxSize : maxSize * aspectRatio - let watermarkHeight = aspectRatio >= 1 ? maxSize / aspectRatio : maxSize - let watermarkRect = CGRect( - x: gridSize - watermarkWidth - padding, - y: gridSize - watermarkHeight - padding, - width: watermarkWidth, - height: watermarkHeight - ) - logo.withTintColor(.white.withAlphaComponent(0.5), renderingMode: .alwaysOriginal) - .draw(in: watermarkRect) - } - } - } -} - -// MARK: - Preview - -#Preview { - NavigationStack { - CompletedView(derive: Derive(challengeId: "yellow", player: Player())) - } -} diff --git a/apps/Derive/Derive/Routes/Home/HomeView.swift b/apps/Derive/Derive/Routes/Home/HomeView.swift deleted file mode 100644 index d55f7ff..0000000 --- a/apps/Derive/Derive/Routes/Home/HomeView.swift +++ /dev/null @@ -1,439 +0,0 @@ -// -// HomeView.swift -// Derive -// -// Created by Benno on 06.01.26. -// - -import PhotosUI -import SwiftData -import SwiftUI - -struct HomeView: View { - @Binding var selectedTab: AppTab - @QuerySingleton private var player: Player - @Environment(\.modelContext) private var modelContext - - @State private var selectedSlotIndex: Int? - @State private var showCamera = false - @State private var selectedPhotos: [PhotosPickerItem] = [] - @State private var isProcessing = false - @State private var selectedCompletedDerive: Derive? - - private var selectedSlotIsEmpty: Bool { - guard let index = selectedSlotIndex, - let derive = player.activeDerive - else { return true } - return derive.photos[index].imageData == nil - } - - private var emptySlotCount: Int { - player.activeDerive?.photos.filter { $0.imageData == nil }.count ?? 0 - } - - private var navigationTitle: String { - player.activeDerive?.challenge?.title ?? "Dérive" - } - - // MARK: - UI - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - Spacer().frame(height: 60) - - titleLabel - - if let derive = player.activeDerive { - Spacer().frame(height: 4) - promptLabel(derive) - Spacer().frame(height: 24) - deriveContent(derive) - } else { - emptyStateContent - } - - if !player.completedDerives.isEmpty { - historySection - } - - Spacer().frame(height: 40) - } - .padding(.horizontal, 24) - } - .scrollIndicators(.hidden) - .background(Color.appBackground) - .navigationBarHidden(true) - .navigationDestination(item: $selectedCompletedDerive) { derive in - CompletedView(derive: derive) - } - .overlay { processingOverlay } - } - - private var titleLabel: some View { - HStack(spacing: 8) { - if let color = player.activeDerive?.challenge?.color { - RoundedRectangle(cornerRadius: 4) - .fill(color) - .frame(width: 20, height: 20) - } - - Text(navigationTitle) - .font(.erode(36, weight: .bold)) - - if let derive = player.activeDerive { - Text("(\(derive.filledCount)/9)") - .font(.erode(24, weight: .medium)) - .foregroundStyle(.secondary) - } - } - } - - private func promptLabel(_ derive: Derive) -> some View { - Text(derive.prompt) - .font(.body) - .foregroundStyle(.secondary) - } - - @ViewBuilder - private func deriveContent(_ derive: Derive) -> some View { - photoGrid(derive) - .overlay { photoActionOverlay(derive) } - - Spacer().frame(height: 24) - - completeRow(derive) - .fullScreenCover(isPresented: $showCamera) { - CameraPicker { image in - if let image, let derive = player.activeDerive { - saveCameraPhoto(image, to: derive) - } - showCamera = false - } - .ignoresSafeArea() - } - .onChange(of: selectedPhotos) { _, items in - guard !items.isEmpty, let derive = player.activeDerive else { - return - } - Task { await loadSelectedPhotos(items, for: derive) } - } - } - - @ViewBuilder - private func photoActionOverlay(_ derive: Derive) -> some View { - if selectedSlotIndex != nil { - ZStack(alignment: .bottomTrailing) { - Color.black.opacity(0.01) - .onTapGesture { selectedSlotIndex = nil } - - VStack(alignment: .trailing, spacing: 8) { - if selectedSlotIsEmpty { - PhotosPicker( - selection: $selectedPhotos, - maxSelectionCount: emptySlotCount, - matching: .images - ) { - actionButton("Select Photos") - } - } else { - PhotosPicker( - selection: $selectedPhotos, - maxSelectionCount: 1, - matching: .images - ) { - actionButton("Replace Photo") - } - } - - Button { - showCamera = true - } label: { - actionButton("Camera") - } - - Button { - selectedSlotIndex = nil - } label: { - actionButtonSecondary("Cancel") - } - } - .padding(16) - .background { - Circle() - .fill(Color.appBackground) - .frame(width: 300, height: 300) - .blur(radius: 30) - .offset(x: 40, y: 40) - } - } - .clipShape(Rectangle()) - } - } - - private func actionButton(_ title: String) -> some View { - Text(title) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.primary) - .padding(.horizontal, 20) - .padding(.vertical, 10) - .background(Color.appCard) - .clipShape(Capsule()) - } - - private func actionButtonSecondary(_ title: String) -> some View { - Text(title) - .font(.subheadline) - .foregroundStyle(.secondary) - .padding(.horizontal, 20) - .padding(.vertical, 10) - } - - private func photoGrid(_ derive: Derive) -> some View { - GridView(photos: derive.photos, selectedIndex: selectedSlotIndex) { index in - selectedSlotIndex = index - } - } - - private func completeRow(_ derive: Derive) -> some View { - let canComplete = derive.filledCount > 0 - - return HStack { - Spacer() - Button { - completeDerive(derive) - } label: { - Text("Complete") - .font(.headline) - .foregroundStyle(Color.appCtaContent) - .padding(.horizontal, 32) - .padding(.vertical, 14) - .background(canComplete ? Color.appCta : Color.gray) - .clipShape(Capsule()) - } - .disabled(!canComplete) - } - } - - @ViewBuilder - private var processingOverlay: some View { - if isProcessing { - ZStack { - Color.black.opacity(0.3).ignoresSafeArea() - ProgressView() - .tint(.white) - .scaleEffect(1.5) - } - } - } - - private var emptyStateContent: some View { - VStack(spacing: 16) { - Spacer().frame(height: 100) - Image(systemName: "square.grid.3x3") - .font(.system(size: 48)) - .foregroundStyle(.secondary) - Text("No Active Dérive") - .font(.erode(24, weight: .semibold)) - Text("Pick a color and start exploring.") - .font(.body) - .foregroundStyle(.secondary) - - Spacer().frame(height: 8) - - Button { - selectedTab = .discover - } label: { - Text("Discover") - .font(.headline) - .foregroundStyle(Color.appCtaContent) - .padding(.horizontal, 32) - .padding(.vertical, 14) - .background(Color.appCta) - .clipShape(Capsule()) - } - } - .frame(maxWidth: .infinity) - .padding(.bottom, 40) - } - - private var historySection: some View { - VStack(alignment: .leading, spacing: 16) { - Spacer().frame(height: 24) - - Text("History") - .font(.erode(24, weight: .semibold)) - - LazyVGrid( - columns: [ - GridItem(.flexible(), spacing: 8), - GridItem(.flexible(), spacing: 8), - GridItem(.flexible(), spacing: 8), - ], - spacing: 8 - ) { - ForEach(player.completedDerives) { derive in - historyItem(derive) - } - } - } - } - - private func historyItem(_ derive: Derive) -> some View { - Button { - selectedCompletedDerive = derive - } label: { - GridThumbnail(photos: derive.photos) - .clipShape(RoundedRectangle(cornerRadius: 4)) - } - .buttonStyle(.plain) - } - - // MARK: - Actions - - private func saveCameraPhoto(_ image: UIImage, to derive: Derive) { - guard let index = selectedSlotIndex, - let data = processImage(image) - else { return } - - var photos = derive.photos - photos[index] = PhotoSlot( - id: photos[index].id, - imageData: data, - capturedAt: Date() - ) - derive.photos = photos - try? modelContext.save() - selectedSlotIndex = nil - } - - @MainActor - private func loadSelectedPhotos( - _ items: [PhotosPickerItem], - for derive: Derive - ) async { - isProcessing = true - defer { - isProcessing = false - selectedPhotos = [] - selectedSlotIndex = nil - } - - guard let tappedIndex = selectedSlotIndex else { return } - let tappedSlotIsEmpty = derive.photos[tappedIndex].imageData == nil - - var photos = derive.photos - - if tappedSlotIsEmpty { - // Fill empty slots starting from tapped, then others - var emptyIndices = derive.photos.enumerated() - .filter { $0.element.imageData == nil } - .map { $0.offset } - .sorted { a, b in - // Prioritize tapped index first - if a == tappedIndex { return true } - if b == tappedIndex { return false } - return a < b - } - - for item in items { - guard !emptyIndices.isEmpty else { break } - - if let data = try? await item.loadTransferable(type: Data.self), - let image = UIImage(data: data), - let processed = processImage(image) - { - let index = emptyIndices.removeFirst() - photos[index] = PhotoSlot( - id: photos[index].id, - imageData: processed, - capturedAt: Date() - ) - } - } - } else { - // Replace single photo at tapped index - if let item = items.first, - let data = try? await item.loadTransferable(type: Data.self), - let image = UIImage(data: data), - let processed = processImage(image) - { - photos[tappedIndex] = PhotoSlot( - id: photos[tappedIndex].id, - imageData: processed, - capturedAt: Date() - ) - } - } - - derive.photos = photos - try? modelContext.save() - } - - private func completeDerive(_ derive: Derive) { - derive.completedAt = Date() - try? modelContext.save() - selectedCompletedDerive = derive - } - - // MARK: - Image Processing - - private func processImage(_ image: UIImage) -> Data? { - let size = image.size - let shortSide = min(size.width, size.height) - let cropRect = CGRect( - x: (size.width - shortSide) / 2, - y: (size.height - shortSide) / 2, - width: shortSide, - height: shortSide - ) - - guard let cgImage = image.cgImage?.cropping(to: cropRect) else { - return nil - } - - let cropped = UIImage( - cgImage: cgImage, - scale: image.scale, - orientation: image.imageOrientation - ) - let maxDim = AppConfig.maxImageDimension - let finalSize = shortSide > maxDim ? maxDim : shortSide - - let renderer = UIGraphicsImageRenderer( - size: CGSize(width: finalSize, height: finalSize) - ) - let resized = renderer.image { _ in - cropped.draw( - in: CGRect( - origin: .zero, - size: CGSize(width: finalSize, height: finalSize) - ) - ) - } - - return resized.jpegData(compressionQuality: 0.8) - } -} - -// MARK: - Preview - -#Preview("Empty") { - NavigationStack { - HomeView(selectedTab: .constant(.derive)) - } - .previewDataContainer { ctx in - Player.instance(with: ctx).onboardingCompletedAt = Date() - } -} - -#Preview("Active") { - NavigationStack { - HomeView(selectedTab: .constant(.derive)) - } - .previewDataContainer { ctx in - let player = Player.instance(with: ctx) - player.onboardingCompletedAt = Date() - ctx.insert(Derive(challengeId: "yellow", player: player)) - } -} diff --git a/apps/Derive/Derive/Routes/Onboarding/OnboardingView.swift b/apps/Derive/Derive/Routes/Onboarding/OnboardingView.swift deleted file mode 100644 index 68a08e9..0000000 --- a/apps/Derive/Derive/Routes/Onboarding/OnboardingView.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// OnboardingView.swift -// Derive -// -// Created by Benno on 06.01.26. -// - -import SwiftUI - -struct OnboardingView: View { - var body: some View { - NavigationStack { - OnboardingWelcomeView() - } - } -} - -#Preview { - OnboardingView() - .previewDataContainer() -} diff --git a/apps/Derive/Derive/Routes/Onboarding/Views/OnboardingHowItWorksView.swift b/apps/Derive/Derive/Routes/Onboarding/Views/OnboardingHowItWorksView.swift deleted file mode 100644 index ee80414..0000000 --- a/apps/Derive/Derive/Routes/Onboarding/Views/OnboardingHowItWorksView.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// OnboardingHowItWorksView.swift -// Derive -// -// Created by Benno on 06.01.26. -// - -import SwiftUI - -struct OnboardingHowItWorksView: View { - var body: some View { - VStack(alignment: .leading, spacing: 0) { - // Title - Text("How it works") - .font(.erode(36, weight: .bold)) - - Spacer().frame(height: 8) - - // Subtitle - Text("Three simple steps") - .font(.body) - .foregroundStyle(.secondary) - - Spacer().frame(height: 40) - - // Content - VStack(spacing: 24) { - stepRow(number: 1, title: "Pick a color", description: "Choose from yellow, red, blue, and more") - stepRow(number: 2, title: "Notice 9 things", description: "Look around and capture what you notice") - stepRow(number: 3, title: "Complete your grid", description: "Save or share your finished dérive") - } - - Spacer() - - // CTA - NavigationLink { - OnboardingPickChallengeView() - } label: { - Text("Choose a Color") - .font(.headline) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .background(Color.appCta) - .foregroundStyle(Color.appCtaContent) - .clipShape(Capsule()) - } - } - .padding(.horizontal, 24) - .padding(.top, 60) - .padding(.bottom, 24) - .background(Color.appBackground) - .navigationBarHidden(true) - } - - private func stepRow(number: Int, title: String, description: String) -> some View { - HStack(alignment: .top, spacing: 16) { - Text("\(number)") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(Color.appCtaContent) - .frame(width: 28, height: 28) - .background(Circle().fill(Color.appCta)) - - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.subheadline.weight(.semibold)) - - Text(description) - .font(.subheadline) - .foregroundStyle(.secondary) - } - - Spacer() - } - } -} - -#Preview { - NavigationStack { - OnboardingHowItWorksView() - } -} diff --git a/apps/Derive/Derive/Routes/Onboarding/Views/OnboardingPickChallengeView.swift b/apps/Derive/Derive/Routes/Onboarding/Views/OnboardingPickChallengeView.swift deleted file mode 100644 index cd2f193..0000000 --- a/apps/Derive/Derive/Routes/Onboarding/Views/OnboardingPickChallengeView.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// OnboardingPickChallengeView.swift -// Derive -// -// Created by Benno on 06.01.26. -// - -import SwiftData -import SwiftUI - -struct OnboardingPickChallengeView: View { - @QuerySingleton private var player: Player - @Environment(\.modelContext) private var modelContext - - private let challenges = ChallengeRegistry.shared.all - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - // Title - Text("Pick a color") - .font(.erode(36, weight: .bold)) - - Spacer().frame(height: 8) - - // Subtitle - Text("Start your first dérive") - .font(.body) - .foregroundStyle(.secondary) - - Spacer().frame(height: 32) - - // Content (color grid) - LazyVGrid( - columns: [ - GridItem(.flexible(), spacing: 12), - GridItem(.flexible(), spacing: 12), - GridItem(.flexible(), spacing: 12), - ], - spacing: 12 - ) { - ForEach(challenges) { challenge in - Button { - startDerive(challenge) - } label: { - VStack(spacing: 8) { - RoundedRectangle(cornerRadius: 12) - .fill(challenge.color) - .aspectRatio(1, contentMode: .fit) - - Text(challenge.title) - .font(.caption.weight(.medium)) - .foregroundStyle(.primary) - } - } - .buttonStyle(.plain) - } - } - - Spacer() - } - .padding(.horizontal, 24) - .padding(.top, 60) - .padding(.bottom, 24) - .background(Color.appBackground) - .navigationBarHidden(true) - } - - private func startDerive(_ challenge: Challenge) { - let derive = Derive(challengeId: challenge.id, player: player) - modelContext.insert(derive) - player.onboardingCompletedAt = Date() - try? modelContext.save() - } -} - -#Preview { - NavigationStack { - OnboardingPickChallengeView() - } - .previewDataContainer() -} diff --git a/apps/Derive/Derive/Routes/Onboarding/Views/OnboardingWelcomeView.swift b/apps/Derive/Derive/Routes/Onboarding/Views/OnboardingWelcomeView.swift deleted file mode 100644 index 1cf8ab1..0000000 --- a/apps/Derive/Derive/Routes/Onboarding/Views/OnboardingWelcomeView.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// OnboardingWelcomeView.swift -// Derive -// -// Created by Benno on 06.01.26. -// - -import SwiftUI - -struct OnboardingWelcomeView: View { - var body: some View { - VStack(alignment: .leading, spacing: 0) { - Spacer().frame(height: 60) - - // Title - Text("Dérive") - .font(.erode(36, weight: .bold)) - - Spacer().frame(height: 8) - - // Subtitle - Text("A reason to look up from your phone") - .font(.body) - .foregroundStyle(.secondary) - - Spacer() - - // CTA - NavigationLink { - OnboardingHowItWorksView() - } label: { - Text("Get Started") - .font(.headline) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .background(Color.appCta) - .foregroundStyle(Color.appCtaContent) - .clipShape(Capsule()) - } - } - .padding(.horizontal, 24) - .padding(.bottom, 24) - .background(Color.appBackground) - .navigationBarHidden(true) - } -} - -#Preview { - NavigationStack { - OnboardingWelcomeView() - } -} diff --git a/apps/Derive/Derive/Routes/Settings/SettingsView.swift b/apps/Derive/Derive/Routes/Settings/SettingsView.swift deleted file mode 100644 index 03977d5..0000000 --- a/apps/Derive/Derive/Routes/Settings/SettingsView.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// SettingsView.swift -// Derive -// -// Created by Benno on 06.01.26. -// - -import SwiftUI - -struct SettingsView: View { - - // MARK: - UI - - var body: some View { - Form { - appSection - } - .scrollContentBackground(.hidden) - .background(Color.appBackground) - .navigationTitle("Settings") - } - - private var appSection: some View { - Section("APP") { - NavigationLink { - SettingsAboutView() - } label: { - Label("About", systemImage: "info.circle") - } - - NavigationLink { - SettingsCreditsView() - } label: { - Label("Credits", systemImage: "heart") - } - } - } -} - -#Preview { - NavigationStack { - SettingsView() - } -} diff --git a/apps/Derive/Derive/Routes/SettingsAbout/AboutLogoView.swift b/apps/Derive/Derive/Routes/SettingsAbout/AboutLogoView.swift deleted file mode 100644 index b30da04..0000000 --- a/apps/Derive/Derive/Routes/SettingsAbout/AboutLogoView.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// AboutLogoView.swift -// Derive -// -// Created by Benno on 06.01.26. -// - -import SwiftUI - -struct AboutLogoView: View { - var body: some View { - Image("logo") - .font(.system(size: 48)) - .foregroundStyle(.primary) - } -} - -#Preview { - AboutLogoView() - .padding() - .background(Color.appBackground) -} diff --git a/apps/Derive/Derive/Routes/SettingsAbout/SettingsAboutView.swift b/apps/Derive/Derive/Routes/SettingsAbout/SettingsAboutView.swift deleted file mode 100644 index 1c0f1fa..0000000 --- a/apps/Derive/Derive/Routes/SettingsAbout/SettingsAboutView.swift +++ /dev/null @@ -1,278 +0,0 @@ -// -// SettingsAboutView.swift -// Derive -// -// Created by Benno on 06.01.26. -// - -import SwiftUI - -struct SettingsAboutView: View { - private enum FeedbackSubject { - static let general = "Dérive Feedback" - static let feature = "Feature Request" - static let bug = "Bug Report" - } - - // MARK: - UI - - var body: some View { - ScrollView { - VStack(spacing: 24) { - headerSection - feedbackSection - linksSection - privacySection - versionSection - } - } - .background(Color.appBackground) - .navigationTitle("About") - .navigationBarTitleDisplayMode(.inline) - } - - private var headerSection: some View { - VStack(spacing: 16) { - AboutLogoView() - - VStack(spacing: 4) { - Text("A reason to look up from your phone") - .font(.subheadline) - .multilineTextAlignment(.center) - .foregroundStyle(.primary) - - Text("We'd love to hear your feedback!") - .font(.subheadline) - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) - } - .padding(.horizontal) - } - .padding(.top, 8) - } - - private var feedbackSection: some View { - SectionContainerView { - ActionRowView( - icon: "envelope.fill", - iconColor: .blue, - title: "Feedback", - action: { openMail(subject: FeedbackSubject.general) } - ) - - SectionDivider() - - ActionRowView( - icon: "gift.fill", - iconColor: .pink, - title: "Request a Feature", - action: { openMail(subject: FeedbackSubject.feature) } - ) - - SectionDivider() - - ActionRowView( - icon: "ladybug.fill", - iconColor: .red, - title: "Report a Bug", - action: { openMail(subject: FeedbackSubject.bug) } - ) - } - } - - private var linksSection: some View { - SectionContainerView { - if let url = AppConfig.appStoreURL { - LinkRowView( - icon: "apple.logo", - iconColor: .primary, - title: "App Store", - url: url - ) - - SectionDivider() - } - - if let url = AppConfig.websiteURL { - LinkRowView( - icon: "safari.fill", - iconColor: .blue, - title: "Website", - url: url - ) - - SectionDivider() - } - - if let url = AppConfig.githubURL { - LinkRowView( - icon: "github", - iconColor: .primary, - title: "GitHub", - url: url, - isSystemIcon: false - ) - } - } - } - - @ViewBuilder - private var privacySection: some View { - if let privacyURL = AppConfig.privacyPolicyURL { - SectionContainerView { - LinkRowView( - icon: "hand.raised.fill", - iconColor: .blue, - title: "Privacy Policy", - url: privacyURL - ) - } - } - } - - private var versionSection: some View { - VStack(spacing: 4) { - Text("Version \(AppConfig.version) (\(AppConfig.build))") - .font(.caption) - .foregroundStyle(.secondary) - - Text("© 2025 builder.group") - .font(.caption2) - .foregroundStyle(.tertiary) - } - .padding(.top, 8) - .padding(.bottom, 24) - } - - // MARK: - Actions - - private func openMail(subject: String) { - guard let url = AppConfig.mailtoURL(subject: subject) else { return } - UIApplication.shared.open(url) - } -} - -// MARK: - Helper Views - -private struct SectionContainerView: View { - @ViewBuilder let content: Content - - var body: some View { - VStack(spacing: 0) { - content - } - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color.appCard) - ) - .padding(.horizontal) - } -} - -private struct SectionDivider: View { - var body: some View { - Divider() - .padding(.leading, 56) - } -} - -private struct ActionRowView: View { - let icon: String - let iconColor: Color - let title: String - let action: () -> Void - var isSystemIcon: Bool = true - - var body: some View { - Button(action: action) { - HStack(spacing: 12) { - Group { - if isSystemIcon { - Image(systemName: icon) - .font(.title3) - .foregroundStyle(iconColor) - } else { - Image(icon) - .font(.title3) - .foregroundStyle(iconColor) - } - } - .frame(width: 32, height: 32) - - Text(title) - .font(.body) - .foregroundStyle(.primary) - - Spacer() - - Image(systemName: "chevron.right") - .font(.caption) - .foregroundStyle(.tertiary) - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } -} - -private struct LinkRowView: View { - let icon: String - let iconColor: Color - let title: String - var subtitle: String? = nil - let url: URL - var isSystemIcon: Bool = true - - var body: some View { - Link(destination: url) { - HStack(spacing: 12) { - Group { - if isSystemIcon { - Image(systemName: icon) - .font(.title3) - .foregroundStyle(iconColor) - } else { - Image(icon) - .font(.title3) - .foregroundStyle(iconColor) - } - } - .frame(width: 32, height: 32) - - if let subtitle = subtitle { - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.body) - .foregroundStyle(.primary) - - Text(subtitle) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } else { - Text(title) - .font(.body) - .foregroundStyle(.primary) - } - - Spacer() - - Image(systemName: "arrow.up.forward") - .font(.caption) - .foregroundStyle(.tertiary) - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } -} - -#Preview { - NavigationStack { - SettingsAboutView() - } -} diff --git a/apps/Derive/Derive/Routes/SettingsCredits/SettingsCreditsView.swift b/apps/Derive/Derive/Routes/SettingsCredits/SettingsCreditsView.swift deleted file mode 100644 index f75c92a..0000000 --- a/apps/Derive/Derive/Routes/SettingsCredits/SettingsCreditsView.swift +++ /dev/null @@ -1,139 +0,0 @@ -// -// SettingsCreditsView.swift -// Derive -// -// Created by Benno on 06.01.26. -// - -import SwiftUI - -struct SettingsCreditsView: View { - var body: some View { - ScrollView { - VStack(spacing: 24) { - headerSection - creditsSection - } - } - .background(Color.appBackground) - .navigationTitle("Credits") - .navigationBarTitleDisplayMode(.inline) - } - - private var headerSection: some View { - VStack(spacing: 16) { - Image("logo") - .font(.system(size: 48)) - .foregroundStyle(.primary) - - VStack(spacing: 4) { - Text("Dérive is inspired by amazing people") - .font(.subheadline) - .multilineTextAlignment(.center) - .foregroundStyle(.primary) - - Text("We're grateful for their work") - .font(.subheadline) - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) - } - .padding(.horizontal) - } - .padding(.top, 8) - } - - private var creditsSection: some View { - SectionContainerView { - if let debordURL = URL(string: "https://en.wikipedia.org/wiki/D%C3%A9rive") { - LinkRowView( - icon: "book.fill", - iconColor: .orange, - title: "Guy Debord", - subtitle: "Theory of the Dérive (1956)", - url: debordURL - ) - - SectionDivider() - } - - if let tweetURL = URL(string: "https://x.com/malisauskasLT/status/2008123520727867451") { - LinkRowView( - icon: "sparkles", - iconColor: .yellow, - title: "@malisauskasLT", - subtitle: "Color hunting inspiration", - url: tweetURL - ) - } - } - } -} - -// MARK: - Helper Views - -private struct SectionContainerView: View { - @ViewBuilder let content: Content - - var body: some View { - VStack(spacing: 0) { - content - } - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color.appCard) - ) - .padding(.horizontal) - } -} - -private struct SectionDivider: View { - var body: some View { - Divider() - .padding(.leading, 56) - } -} - -private struct LinkRowView: View { - let icon: String - let iconColor: Color - let title: String - let subtitle: String - let url: URL - - var body: some View { - Link(destination: url) { - HStack(spacing: 12) { - Image(systemName: icon) - .font(.title3) - .foregroundStyle(iconColor) - .frame(width: 32, height: 32) - - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(.body) - .foregroundStyle(.primary) - - Text(subtitle) - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - Image(systemName: "arrow.up.forward") - .font(.caption) - .foregroundStyle(.tertiary) - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } -} - -#Preview { - NavigationStack { - SettingsCreditsView() - } -} diff --git a/apps/Derive/Derive/Routes/Splash/SplashView.swift b/apps/Derive/Derive/Routes/Splash/SplashView.swift deleted file mode 100644 index 0459a42..0000000 --- a/apps/Derive/Derive/Routes/Splash/SplashView.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// SplashView.swift -// Derive -// -// Created by Benno on 06.01.26. -// - -import SwiftUI - -struct SplashView: View { - var body: some View { - Image("logo") - .resizable() - .scaledToFit() - .frame(width: 80, height: 80) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background { Color.appBackground.ignoresSafeArea() } - } -} - -#Preview { - SplashView() -} diff --git a/apps/Derive/Derive/Views/BannerView.swift b/apps/Derive/Derive/Views/BannerView.swift deleted file mode 100644 index d5b751b..0000000 --- a/apps/Derive/Derive/Views/BannerView.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// BannerView.swift -// Derive -// -// Created by Benno on 06.01.26. -// - -import SwiftUI - -struct BannerView: View { - let icon: String - let message: Text - let style: Style - - init(icon: String, message: String, style: Style) { - self.icon = icon - self.message = Text(message) - self.style = style - } - - init(icon: String, message: Text, style: Style) { - self.icon = icon - self.message = message - self.style = style - } - - enum Style { - case success - case warning - case info - case error - - var color: Color { - switch self { - case .success: return .green - case .warning: return .yellow - case .info: return .blue - case .error: return .red - } - } - } - - var body: some View { - HStack(alignment: .top, spacing: 10) { - Image(systemName: icon) - .font(.callout) - .foregroundStyle(style.color) - - message - .font(.footnote) - .foregroundStyle(.primary) - .fixedSize(horizontal: false, vertical: true) - - Spacer(minLength: 0) - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(style.color.opacity(0.12)) - ) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .stroke(style.color.opacity(0.3), lineWidth: 1) - ) - } -} - -#Preview { - VStack(spacing: 12) { - BannerView( - icon: "checkmark.circle.fill", - message: "Operation completed successfully.", - style: .success - ) - - BannerView( - icon: "exclamationmark.triangle.fill", - message: "Complete your current dérive to start a new one.", - style: .warning - ) - - BannerView( - icon: "info.circle.fill", - message: "This is helpful information.", - style: .info - ) - - BannerView( - icon: "xmark.circle.fill", - message: "An error occurred.", - style: .error - ) - } - .padding() - .background(Color.appBackground) -} diff --git a/apps/Derive/README.md b/apps/Derive/README.md deleted file mode 100644 index 918ac12..0000000 --- a/apps/Derive/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# Dérive - -**Dérive** (French: [de.ʁiv], "drift") — a journey through a landscape where you drop your everyday routine and let curiosity guide you. The concept originates from Guy Debord's "Theory of the Dérive" (1956). - -Dérive is a native iOS app that gives you a reason to look up from your phone. City streets or forest trails. Your neighborhood or a new country. - -## Inspiration - -- [Berlin color hunt tweet](https://x.com/malisauskasLT/status/2008123520727867451) — organic interest in the concept - -## POC: Color Grid - -The first challenge to validate the Dérive concept — one prompt, nine photos, one grid. - -- **Pick a Color** — Yellow, red, blue, and more -- **Fill the Grid** — Capture or select photos for each cell -- **Save & Share** — Export completed grid as a single image -- **History** — View past completed grids - -## Vision - -If validated, Dérive expands into more exploration challenges — textures, shapes, sound walks, route randomizers, and beyond. diff --git a/apps/midimarble/.gitignore b/apps/midimarble/.gitignore new file mode 100644 index 0000000..7e6ced4 --- /dev/null +++ b/apps/midimarble/.gitignore @@ -0,0 +1,2 @@ +.tanstack +.output \ No newline at end of file diff --git a/apps/midimarble/README.md b/apps/midimarble/README.md new file mode 100644 index 0000000..1d5d76f --- /dev/null +++ b/apps/midimarble/README.md @@ -0,0 +1 @@ +# `@repo/midimarble` diff --git a/apps/midimarble/docs/architecture.md b/apps/midimarble/docs/architecture.md new file mode 100644 index 0000000..d397303 --- /dev/null +++ b/apps/midimarble/docs/architecture.md @@ -0,0 +1,388 @@ +# Architecture + +## Purpose + +This document is the Midimarble-specific source of truth for: + +- plugin ownership +- plugin dependencies +- state layering +- where ECS stops and plain UI begins + +For stable naming and boundary choices, see `decisions.md`. + +The current architecture is intentionally optimized for the narrow prototype: + +- one marble +- editable straight tracks +- direct manipulation +- physics-backed playback with a timeline UI + +## Plugin Graph + +The engine now uses eight plugins: + +- `Core` +- `Midi` +- `Transport` +- `Audio` +- `Physics` +- `Render` +- `Trajectory` +- `Scene` + +Dependency graph: + +- `Core` has no app-specific dependencies +- `Midi` depends on `Default` only +- `Transport` depends on `Midi` +- `Audio` depends on `Midi` and `Transport` +- `Physics` depends on `Core`, `Midi`, and `Transport` +- `Render` depends on `Core` and `Physics` +- `Trajectory` depends on `Core`, `Midi`, `Transport`, `Audio`, `Physics`, and `Render` +- `Scene` depends on `Core`, `Midi`, `Physics`, `Render`, and `Trajectory` + +This graph is intentionally one-way and acyclic. + +`Scene` is the app-specific composition root for Midimarble entities. + +`Physics`, `Render`, and `Trajectory` stay scene-agnostic. `Scene` is the only plugin allowed to know all of them. + +The engine should prefer ECSify-native change tracking over ad-hoc diff caches: + +- `Added(...)` +- `Changed(...)` +- `Removed(...)` +- `app.wasResourceAdded(...)` +- `app.wasResourceChanged(...)` + +Manual signature maps or shadow sync resources are a last resort, not the default. + +## What Lives Outside ECS + +The timeline UI stays in React. + +That is intentional: + +- timeline controls are editor presentation +- the UI reads engine state and calls runtime methods +- the timeline does not need its own ECS plugin for the current scope +- a small React-side `TimelineCx` may own zoom, scroll, and viewport layout only + +Playback state now lives across `Midi`, `Transport`, `Audio`, and `Physics`. + +That split is intentional: + +- `Midi` owns the imported song and first selected track +- `Transport` owns play/pause and the current playhead tick +- `Audio` owns sound output for the selected track +- `Physics` owns live steps, buffered steps, checkpoint restore, world replacement, and rebuild state +- the timeline UI reads the relevant state but still stays in React + +Midimarble uses this generic schedule: + +- `First` +- `PreUpdate` +- `Update` +- `PostUpdate` +- `Last` +- `Flush` + +`Flush` exists specifically so ECSify change tracking stays visible through late-frame systems. + +## State Layers + +### Authored state + +This is the scene document. + +Current examples: + +- `AuthoredTransformMixin` +- `StraightTrackMixin` +- `LinearElementMixin` + +Authored state is what editing mutates. + +### Live runtime state + +This is the state used by simulation and rendering. + +Current examples: + +- `PositionMixin` +- `RotationMixin` +- `ScaleMixin` +- `RigidBodyMixin` +- `ColliderMixin` +- `MeshMixin` +- Rapier worlds and checkpoint data + +For static scene entities, live transforms are derived from authored transforms. + +For dynamic entities like the marble, live transforms are driven by physics. + +### Transient interaction state + +This exists only while editing. + +Current examples: + +- selected entity id +- drag mode +- drag offsets +- manipulation handles + +This state lives in `Scene` because the current editor interaction is entirely scene-specific. +Note selection remains in `Midi`, and `Scene` clears its own entity selection whenever note selection becomes active. + +## Plugin Ownership + +### `Core` + +Owns only shared runtime primitives: + +- `PositionMixin` +- `RotationMixin` +- `ScaleMixin` +- `spawnBundle()` + +`Core` should stay small and domain-neutral. + +### `Physics` + +Owns only physics semantics and physics-specific runtime control. + +Responsibilities: + +- Rapier lifecycle +- rigid body and collider components +- live world stepping +- checkpoint and preload buffering +- following the transport tick playhead by mapping ticks to steps +- generic simulation invalidation and resync + +Public physics sync contract: + +- `markSimulationDirty()` +- `requestSimulationSync()` + +Important boundary: + +- `Physics` does not know scene editing +- callers only tell physics that simulation is stale or should rebuild + +### `Midi` + +Owns imported song data for the editor. + +Responsibilities: + +- `midiSong` +- `selectedTrackId` +- `selectedNoteId` +- MIDI import and parse errors +- first-track-only selection for the current slice + +Important boundary: + +- `Midi` does not own playback +- it is the source of song timing, not the source of the playhead + +### `Transport` + +Owns the shared playback playhead. + +Responsibilities: + +- `transport` +- play/pause +- tick-based seek/reset controls +- advancing the tick playhead from song BPM and ticks-per-beat + +Important boundary: + +- `Transport` does not own buffering or world restore +- it follows `Midi` for song timing, and `Physics` follows it for simulation state + +### `Audio` + +Owns only sound output. + +Responsibilities: + +- `audioState` +- `audioConfig` +- simple built-in synthesis for the selected track +- note previews on step controls and note clicks +- transport-driven note playback while running + +Important boundary: + +- `Audio` does not own playhead state +- it follows `Midi` and `Transport` +- it intentionally does not implement a heavy scheduler or SoundFont pipeline yet + +### `Render` + +Owns only rendering semantics. + +Responsibilities: + +- viewport lifecycle +- `MeshMixin` +- mounting Three objects +- syncing live transforms to Three objects +- preview camera state and follow-camera behavior +- disposing orphaned render objects + +`Render` owns preview as viewport state, not authored scene state. + +`Render` does not know scene semantics. + +### `Trajectory` + +Owns only trajectory visualization. + +Responsibilities: + +- `TrajectorySourceTag` +- past and future line objects +- note marker objects and picking +- `trajectoryProjection` as the shared note-anchor seam +- simulation-derived path rendering +- note selection and seek interactions routed through shared engine state +- clipping the visible future to the imported song horizon + +`Trajectory` defines what a trajectory source is and queries only `TrajectorySourceTag`. + +It does not need to know what a marble is. + +Trajectory refresh should be keyed off ECSify resource/component change tracking, not a duplicated shadow sync resource. + +For the current slice, trajectory is now the first real authoring surface: + +- it shows the full solved past +- it shows the currently buffered future +- it renders selected-track MIDI note markers on that path +- clicking a marker pauses if needed, seeks the shared playhead, and selects the note +- timeline note selection and trajectory marker selection meet at shared `selectedNoteId` +- note anchors are exposed through `trajectoryProjection`, not through Three marker objects + +### `Scene` + +`Scene` is the app-specific composition root. + +Responsibilities: + +- authored scene components +- initial scene seeding +- direct manipulation state and systems +- entity bundle factories +- authored-to-runtime sync for scene-authored track meshes and collider descriptors +- note-bound platform creation and runtime sync + +`Scene` is allowed to attach mixins owned by other plugins when it creates entities: + +- `MeshMixin` +- `RigidBodyMixin` +- `ColliderMixin` + +It may also attach tags owned by extension plugins that this app wires in: + +- `TrajectorySourceTag` + +That is composition, not ownership leakage. + +`Scene` does not define what those mixins mean. It only decides that a Midimarble entity uses them. + +`Scene` also owns note-bound world entities that reference MIDI note ids. +The note data itself still stays in `Midi`. + +## Current Entity Model + +### Straight track + +A straight track is composed from: + +- authored placement via `AuthoredTransformMixin` +- authored track shape via `StraightTrackMixin` +- generic linear editing data via `LinearElementMixin` +- render data via `MeshMixin` +- physics setup via `RigidBodyMixin` and `ColliderMixin` + +The track mesh and collider descriptors are updated inside `Scene` when authored track data changes. + +That sync is driven by ECSify `Added(...)` and `Changed(...)` queries rather than plugin-local signature caches. + +### Marble + +The marble is composed from: + +- app identity via `MarbleTag` +- trajectory source capability via `TrajectorySourceTag` +- render data via `MeshMixin` +- physics setup via `RigidBodyMixin` and `ColliderMixin` + +The marble is not modeled as authored transform state after spawn. Its live position comes from physics. + +### Note platform + +The first note-bound platform is composed from: + +- note identity via `NoteBindingMixin` +- authored local shape via `NotePlatformMixin` +- live transform via `PositionMixin` and `RotationMixin` +- render data via `MeshMixin` +- physics setup via `RigidBodyMixin` and `ColliderMixin` + +It is intentionally not a `LinearElementMixin` and not a free-move scene element in this slice. + +Its world placement is derived from `trajectoryProjection`: + +- `Midi` owns the note +- `Trajectory` solves and exposes the note anchor +- `Scene` owns the entity that binds to that note id + +If the anchor is unresolved, the platform hides its mesh and clears its colliders. +If upstream path changes move the anchor, the platform reflows to the new solved position without losing its authored local properties. + +### Pegboard + +The pegboard is currently simple environment geometry with authored placement and a render object. + +## Simulation Sync Model + +When authored scene data changes: + +1. `Scene` mutates authored components. +2. `Scene` calls `markSimulationDirty()`. +3. `Physics` treats the buffered simulation as invalid from step `0`. +4. The UI shows rebuild progress from `0` up to the current playhead. +5. On commit, `Scene` calls `requestSimulationSync()`. +6. `Physics` rebuilds exactly to the current transport playhead and swaps the rebuilt world in. + +This is intentionally honest. + +The past is not shown as still-valid after an authored edit, because it is not guaranteed to be valid. + +## Why This Shape + +This architecture is deliberately simple for the prototype: + +- one app-specific composition root instead of extra scene sub-plugins +- owner plugins stay pure +- timeline UI stays outside ECS +- state layers remain explicit +- plugin dependencies stay one-way + +The goal is not maximal abstraction. + +The goal is that another engineer can answer these questions quickly: + +1. Where is the authored scene document? +2. Where does physics invalidation live? +3. Who creates a straight track entity? +4. Who owns the meaning of each mixin? + +If those answers stop being obvious, the architecture needs another pass. diff --git a/apps/midimarble/docs/conventions/ecs.md b/apps/midimarble/docs/conventions/ecs.md new file mode 100644 index 0000000..15a1746 --- /dev/null +++ b/apps/midimarble/docs/conventions/ecs.md @@ -0,0 +1,350 @@ +# ECS And ECSify Conventions + +See also [../architecture.md](../architecture.md) for the Midimarble-specific domain map and plugin ownership. + +Primary ECSify reference: [../../../../../community/packages/ecsify/README.md](../../../../../community/packages/ecsify/README.md). + +## Purpose + +This document captures generic rules for building with ECS and ECSify. + +It should stay reusable across projects. Do not put app-specific plugin names, scene element names, or product behavior here unless they illustrate a general rule. + +## Core ECS Rules + +### Components store data, not behavior + +Components should describe state. They should not hide domain behavior behind methods or class instances. + +Good: + +- transforms +- authored shape data +- rigid body descriptors +- selection state + +Bad: + +- components that execute behavior +- components that own hidden side effects +- components that mix unrelated concerns because they are convenient today + +Zero-data components should use a marker-style name such as `*Marker` or `*Tag`. + +Reserve `*Mixin` for components that actually carry state. + +### Systems own behavior + +Systems should: + +- read components and resources +- compute derived results +- write updated components or resources + +Prefer small systems with clear ownership over large systems that mix document edits, rendering, simulation, and UI policy. + +### Model by capability + +Do not model everything as one-off entity types first. + +Instead, ask: + +1. What capability is generic? +2. What data is element-specific? +3. What system derives runtime behavior from that data? + +This usually leads to cleaner composition and better extension paths. + +## State Layers + +Every new piece of state should fit one layer clearly. + +### Authored state + +This is the document or user-authored source of truth. + +Examples: + +- authored transforms +- scene element parameters +- editor-created notes or markers + +Rules: + +- keep it explicit +- keep it serializable +- do not let simulation overwrite it + +### Live runtime state + +This is the current state used for rendering, physics, playback, or other runtime computation. + +Examples: + +- live transforms +- buffered simulation state +- render objects + +Rules: + +- it may be derived from authored state +- it may be driven by simulation +- do not confuse it with the document + +### Transient interaction state + +This exists only while the user is interacting. + +Examples: + +- active drag mode +- pointer-down offsets +- temporary handles +- hover state + +Rules: + +- keep it near the interaction owner +- do not persist it as authored state +- do not let it leak into unrelated domains + +## ECSify Conventions + +### Prefer the app approach by default + +ECSify's app approach is the default for most product code because it gives: + +- plugin boundaries +- typed components and resources +- explicit systems +- clearer ownership + +Use the raw approach only when performance pressure is real and measured. + +### Use plugins for runtime domains + +A plugin is a good fit when a concern owns: + +- its own components or resources +- systems with runtime behavior +- app extensions that expose a domain boundary + +Good examples: + +- scene derivation +- simulation +- rendering +- editor interaction + +Do not turn `Core` into a general utility dump. + +- shared ECS primitives or app-level scheduling types belong in `Core` or shared engine types +- plain math or string helpers belong in the nearest plugin `lib/` or a plain engine utility module + +Bad examples: + +- a plugin that mostly stores React view state +- a plugin that exists only to avoid passing props + +### Use `updateComponent` and `updateResource` for tracked changes + +When working through the app API, prefer `app.updateComponent()` and `app.updateResource()` over direct mutation so change tracking stays explicit. + +If direct mutation is necessary in a hot path, mark the change explicitly with ECSify's change-tracking API. + +Before inventing local signature maps or shadow sync resources, check whether ECSify already gives you the signal you need: + +- `Added(...)` +- `Changed(...)` +- `Removed(...)` +- `app.wasResourceAdded(...)` +- `app.wasResourceChanged(...)` + +Prefer those first. Add manual caches only when there is a measured need that ECSify's built-in change tracking does not cover cleanly. + +### Keep resources intentional + +Resources are for global or singleton state, not a place to dump anything that does not fit. + +Good resource candidates: + +- config +- simulation transport +- registries +- UI context state that is truly singleton inside a domain + +Bad resource candidates: + +- per-entity state that belongs in components +- hidden cross-domain coordination that should be an app extension or explicit system boundary + +### Query for the data you need + +Prefer narrow queries over broad scans. + +Ask for: + +- the specific components a system actually needs +- the narrowest useful filter + +This keeps system intent obvious and reduces accidental coupling. + +## Plugin Boundary Rules + +Before creating a plugin, ask: + +1. Does this concern own runtime state or systems? +2. Can it be understood in isolation? +3. Would another project plausibly reuse the same boundary? +4. Is it better modeled as UI/context instead? + +If the concern is mostly presentation state, React/context is often the better boundary. + +### Keep plugin ownership separate from composition + +A plugin should own the meaning and behavior of its own mixins and resources. + +That does not mean the same plugin must be the one that initially attaches those mixins to entities. + +An app-specific composition root may assemble entities from multiple domains: + +- scene-authored mixins +- render mixins +- physics mixins +- debug or derived capability markers + +That is composition, not ownership leakage. + +### Prefer one app-specific composition root over speculative sub-plugins + +For a narrow prototype, default to fewer plugin boundaries. + +If one top-level app plugin can honestly own: + +- authored state +- transient interaction state +- entity seeding +- entity composition + +then keep it together until reuse pressure is real. + +Do not create extra plugins only because the split feels architecturally neat. + +### Keep plugin dependencies acyclic + +Dependencies should flow from generic domains toward app-specific domains. + +Good pattern: + +- shared primitives at the bottom +- reusable owner plugins in the middle +- one app-specific composition root at the top + +Bad pattern: + +- cycles between scene, physics, render, and editor concerns +- plugins that need each other to explain what they mean + +If two plugins start depending on each other, the boundary is usually wrong. + +## Plugin File Layout + +Keep plugin structure predictable. + +Default shape: + +- `*-plugin.ts` for plugin declaration and setup +- `systems.ts` for actual ECS systems only +- `types.ts` for public plugin types +- `lib/` for support logic used by systems or plugin setup + +Rules: + +- do not hide non-system helpers inside `systems.ts` +- move domain actions, math helpers, signatures, and setup utilities into `lib/` +- `systems.ts` should contain system exports only; even private helper functions should move to `lib/` once they stop being trivial +- if a plugin grows many systems, split `systems.ts` into a `systems/` folder with one file per domain or system group +- prefer naming helper files after the domain they support or the specific action they implement + +## System Set Guidance + +`ecsify` itself only needs this lean default schedule: + +- `First` +- `Update` +- `Last` +- `Flush` + +An app may extend that with additional generic phases when it has a real need. + +For Midimarble, prefer a small generic schedule over domain-specific set names: + +- `First` +- `PreUpdate` +- `Update` +- `PostUpdate` +- `Last` +- `Flush` + +Rules: + +- change-tracking consumers must run before `Flush` +- do not assume `Last` is safe for `Added(...)` or `Changed(...)` unless `Flush` is a separate final phase +- reserve domain-specific set names such as `Render` only when a truly reusable engine-wide pipeline exists + +## UI Versus ECS + +Put a concern in UI/context when it is mostly: + +- layout state +- selected tab or view +- zoom or panel state +- presentation-layer composition + +Put a concern in ECS when it is mostly: + +- simulation +- scene document derivation +- render sync +- editor interaction with world entities + +UI can consume ECS state freely. That does not mean UI state should move into ECS. + +## Interaction Modeling Rules + +For editor interactions: + +- store authored edits in authored components +- store drag bookkeeping in transient interaction state +- preserve pointer continuity +- avoid jumps on pointer down +- make handle and tool config explicit + +If a behavior is generic across multiple element types, model that capability generically instead of hardcoding one element type into the interaction layer. + +Even when authored and transient interaction state live under the same top-level plugin, keep them clearly separated in names, files, and resources. + +## Testing Rules + +Test the seams where regressions are expensive. + +Prefer extracting pure helpers for: + +- interaction math +- view model assembly +- dependency collection +- domain derivation + +Then test those helpers directly instead of standing up the whole runtime unless integration coverage is specifically needed. + +## Extension Checklist + +Before shipping a new ECS feature, answer: + +1. Is this authored, live, or transient state? +2. Which domain owns it? +3. Does it need ECS, or would UI/context be simpler? +4. Are change-tracked updates explicit? +5. Can another engineer understand the ownership without knowing the implementation history? + +If ownership is unclear, the design usually needs another pass. diff --git a/apps/midimarble/docs/decisions.md b/apps/midimarble/docs/decisions.md new file mode 100644 index 0000000..dbf2dd9 --- /dev/null +++ b/apps/midimarble/docs/decisions.md @@ -0,0 +1,246 @@ +# Decisions + +## Purpose + +This document records the current Midimarble architecture and product decisions that should stay stable across implementation steps. + +Use it for naming, ownership, and UX-policy decisions that would otherwise get re-litigated in code review. + +For system structure and plugin ownership details, see `architecture.md`. +For the broader target editor experience, see `ux.md`. + +## Decision: Shared Playback Domain Is Named `Transport` + +The shared playback/playhead owner is named `Transport`. + +Why: + +- it describes shared playback intent, not UI +- it can later serve both physics and MIDI +- it avoids pulling playhead ownership back into `Physics` + +Rejected names: + +- `Timeline` + - sounds like UI/editor presentation +- `Simulation` + - sounds physics-owned +- `Frame` + - too ambiguous with render frames and video frames + +## Decision: Use `playhead` For The Generic Concept + +Use this vocabulary consistently: + +- `playhead` = the generic current-position concept +- `tick` = the current concrete unit in the shared transport slice +- `step` = the physics-facing derived unit used by simulation + +So today the state is: + +- `transport.playheadTick` + +Not: + +- `currentFrame` +- `timelineFrame` +- `simulationFrame` + +## Decision: `Physics` Follows `Transport` + +The dependency direction is: + +- `Transport` is generic +- `Physics` depends on `Transport` + +Not the other way around. + +Why: + +- `Transport` should remain reusable as the shared playback/playhead domain +- `Physics` still owns physics-specific buffering, restore, preload, and rebuild behavior +- reversing the dependency would turn `Transport` into a physics playback wrapper + +## Decision: Buffering Stays In `Physics` + +The following remain physics-owned: + +- `bufferedStep` +- checkpoint store +- preload world +- world restore +- simulation rebuild and sync + +Why: + +- these are still Rapier and simulation invariants +- they are not generic transport concerns + +## Decision: Current Transport Slice Is Tick-First + +The current implementation uses MIDI ticks as the concrete playhead unit. + +That means: + +- timeline controls are tick-based today +- the timeline UI still displays `Step` and `Preloaded` as physics debugging state +- `Physics` derives step targets from the transport tick playhead + +This is the first real cross-domain transport slice, not a placeholder. + +## Decision: Step Is Derived From Tick + +The current time model is: + +- `Transport` owns `playheadTick` +- `Physics` derives `liveStep` and `bufferedStep` from that tick-based playhead + +The conversion is deterministic for the currently loaded song: + +- `ticksPerSecond = (ticksPerBeat * bpm) / 60` +- `step = floor(seconds / fixedTimeStepSeconds)` + +That means tick and step are related, but not identical. + +## Decision: First MIDI Slice Is First-Track-Only + +The current engine MIDI slice selects the first parsed track with notes. + +That means: + +- there is no multi-track UI yet +- there is no track switching UI yet +- the selected track is established at import time + +This is intentional scope control for the prototype. + +## Decision: Timeline Length Comes From The Imported Song + +Once a MIDI file is loaded: + +- the timeline width comes from `midiSong.totalTicks` +- beat markers come from `midiSong.ticksPerBeat` +- the red playhead is positioned from `transport.playheadTick` + +The preload region is still physics-derived and remains a separate concept from song length. + +## Decision: One Shared Playhead In The UX + +Even though the engine now exposes both transport ticks and physics steps, the intended UX is still one shared playhead. + +The user should experience: + +- one current position in time +- one red playhead +- one trajectory timeline relationship + +Later, physics step and MIDI tick may both exist internally, but the UX should still feel like one coherent time model. + +## Decision: The Timeline Remains React UI + +The timeline stays outside ECS for now. + +Why: + +- it is presentation and interaction UI +- it reads engine state and calls runtime actions +- it does not yet need its own plugin boundary + +The timeline may use a small React-side `TimelineCx` for viewport behavior such as: + +- zoom +- scroll position +- container width +- tick-to-pixel conversion + +That context is view-state only. +It must not become a second owner of transport, MIDI, or physics domain state. + +## Decision: Trajectory Is A Real Authoring Surface + +Trajectory is not just debug output. + +It is intended to become: + +- the note marker surface +- the place where users see upcoming musical events +- the primary 3D mechanism for placing note-bound platforms + +This decision should guide future trajectory and MIDI work. + +## Decision: First Audio Slice Uses A Simple Built-In Synth + +Midimarble should use a simple Web Audio synth first. + +That means: + +- no SoundFont loading yet +- no heavy event scheduler yet +- selected-track-only playback for now +- step controls and note clicks preview notes +- drag scrubbing stays silent + +Signal's heavier `Player` + `SoundFontSynth` stack remains a useful reference, but it is intentionally not the first Midimarble audio implementation. + +## Decision: Note-Bound Elements Are Parametric By Default + +Platforms created from note moments should be bound to that note chain by default. + +That means: + +- upstream path changes can reposition them +- their own authored properties, such as rotation, should remain stable where possible + +Free elements are a separate category and should remain freely movable. + +## Decision: MIDI Notes Stay Resource Data + +Imported MIDI notes stay in `Midi` resource data. + +That means: + +- notes are not ECS entities in the current architecture +- `selectedNoteId` is the shared note-selection seam +- note-bound world objects become ECS entities and reference `noteId` + +Why: + +- imported notes are authored song data, not world objects +- the engine does not need per-note ECS lifecycle yet +- note-bound platforms are the actual world entities that physics and rendering care about + +## Decision: First Note-Bound Element Family Is `NotePlatform` + +The first note-bound scene object family is named `NotePlatform`. + +That means: + +- the current slice uses one simple flat pad type +- one `NotePlatform` per note is a policy for now +- the family name leaves room for more note-bound element types later without renaming the current object + +This is intentionally broader than a one-off name like `MusicPad`, but still concrete enough to describe the first real bound platform type. + +## Decision: The Viewport Mode Is Named `Preview` + +The temporary follow-camera mode is named `Preview`. + +Why: + +- it describes a viewport presentation mode, not transport +- it avoids over-promising polished output implied by `Cinematic` +- it can be enabled while paused, stepped, or playing + +This is intentionally separate from play/pause. + +## Decision: Preview Camera Is Global Viewport State + +The preview camera is global editor state owned by `Render`. + +That means: + +- it is not an ECS scene entity +- it is not tied to the currently selected element +- its controls replace the right-hand inspector while preview is active + +This keeps the first preview slice simple and avoids inventing authored camera shots too early. diff --git a/apps/midimarble/project-spec.md b/apps/midimarble/docs/project-spec.md similarity index 100% rename from apps/midimarble/project-spec.md rename to apps/midimarble/docs/project-spec.md diff --git a/apps/midimarble/docs/ux.md b/apps/midimarble/docs/ux.md new file mode 100644 index 0000000..bffb8b6 --- /dev/null +++ b/apps/midimarble/docs/ux.md @@ -0,0 +1,322 @@ +# Midimarble UX + +## Purpose + +This document describes the intended Midimarble editor experience from the user's point of view. + +It is not the low-level engine design. + +It exists to answer: + +- what the editor should feel like +- what the core authoring loop is +- how time, trajectory, and note placement should behave +- which elements are parametric and which are freeform + +For engine ownership and plugin boundaries, see `architecture.md`. +For current naming and boundary choices, see `decisions.md`. + +## Product Framing + +Midimarble is a music-first marble editor. + +The user is not drawing a static scene and then asking it to make sound later. +The user is building a marble run directly against musical time. + +The core experience should feel like: + +- import a song +- watch the marble path through time +- place note-driven platforms where notes happen +- shape the path +- see the future update immediately + +The trajectory is not just debug visualization. +It is the main authoring surface. + +## Current Implemented Slice + +The current editor now supports: + +- importing one MIDI file +- auto-selecting the first parsed track with notes +- a shared tick-first transport playhead +- simple built-in sound playback for the selected track +- a timeline whose length comes from the imported song +- tick navigation with reset, back one tick, play/pause, and forward one tick +- note markers rendered on the marble trajectory for the selected track +- clicking a note marker to pause if needed, seek, and select that note +- clicking a timeline note to select the same note and jump there +- a simple read-only inspector for the current selected note, straight track, or marble +- creating or reselecting one `NotePlatform` per note from the selected note inspector +- inspector-first editing for the selected note platform's rotation and length +- placed vs unplaced note state reflected in the timeline and trajectory +- a global `Preview` mode with a soft follow camera +- hiding authoring overlays while preview is active +- a pinned preview camera inspector while preview is active + +This is the first proof of note-bound geometry, not the finished platform toolset. + +## Core UX Model + +### One shared playhead + +The editor should have one shared source of truth for playback position. + +Conceptually, that playhead represents: + +- where the marble is in the run +- where the song is in musical time +- where the red timeline playhead is + +The user should never feel like there is: + +- a physics time +- a MIDI time +- a separate UI time + +There is one playhead. + +Internally that playhead may map between simulation step and MIDI tick, but the UX should present it as one coherent position. + +That same playhead should also drive what the user hears. + +### Trajectory as authoring UI + +The trajectory should become the note-placement surface. + +The line and note markers are not secondary overlays. They are how the user understands: + +- where the marble came from +- where it is going next +- where a note will occur +- where a new note-bound platform can be added + +### Parametric by default + +When a platform is created from a note marker, it should be treated as part of the note chain by default. + +That means: + +- it belongs to a specific note or note moment +- it follows the updated future trajectory when upstream changes alter the marble path +- its authored adjustments, such as rotation, remain meaningful while its solved position may move + +This is different from free scene elements. + +Free elements are not bound to a note moment and should remain directly movable by the user. + +## Main Authoring Loop + +The intended workflow is: + +1. The user imports a MIDI track. +2. The editor enters build mode for the first parsed track with notes. +3. The user drags the marble start position to define tick `0`. +4. The user advances through time either: + - one tick at a time + - or by pressing play +5. The editor shows note markers on the marble trajectory for the selected track. +6. The user clicks a note marker to select that musical moment and jump the playhead there. +7. The inspector lets the user create or reselect that note's `NotePlatform`. +8. The user adjusts that platform's local authored settings. +9. The user continues forward to the next note and repeats the process. + +This loop should feel incremental and musical, not batch-generated. + +The user should be able to build the run note by note. + +## Timeline UX + +The timeline should visualize the same playhead the 3D viewport uses. + +Its job is to make time legible and controllable, not to become a second editor. + +The timeline viewport itself should support: + +- seeking from the ruler as well as the note body +- zooming in and out without changing the underlying playhead model +- expanding to the available panel width even when the song is short + +### Timeline controls + +The intended core controls are: + +- reset to tick `0` +- back one tick +- play / pause +- forward one tick + +Later, higher-level navigation can be added, such as: + +- jump to previous note +- jump to next note +- jump to selected note + +But the baseline editing flow should already work with the four core controls above. + +### Timeline meaning + +The red playhead indicates the current shared playback position. + +The timeline should eventually make clear: + +- past note moments +- current playhead position +- simulated future that is already known +- future that is not yet computed + +The user should be able to trust that the timeline and the 3D note markers refer to the same moments. + +Selecting a note from either surface should select the same note everywhere: + +- click a timeline note: the 3D marker becomes selected +- click a 3D marker: the matching timeline note becomes selected +- the shared selection should still point at one note, not two parallel UI-local selections + +The right sidebar should also follow that same current target: + +- selected note +- selected straight track +- selected marble + +While preview mode is active, the sidebar should temporarily switch from selection inspection to preview camera settings. + +## Trajectory And Marker UX + +### Past and future + +The trajectory should be split conceptually into: + +- past +- future + +The past should be shown completely for the currently known run. +It tells the user what has already been solved or traversed. + +The future should be shown only as far as the engine has currently simulated ahead. +It should also never imply musical time beyond the imported song length. +That future is the actionable space where upcoming note markers can be clicked. + +In preview mode, authoring overlays should disappear: + +- trajectory lines +- note markers +- manipulation handles + +The scene itself should remain visible. + +### Note markers + +Note markers should show where the marble is expected to be when each note occurs. + +Their job is to support authoring, not only display. + +Marker interactions should evolve like this: + +- current slice: click selects the note and seeks there +- current platform flow: the inspector creates or selects the note-bound platform for that note +- later slice: marker interaction may become a more direct creation shortcut +- selected: show that the note is the current editing target + +The user should not need to mentally translate from a MIDI list into 3D space. +The marker is the bridge between time and geometry. + +## Platform Editing UX + +### Note-bound platforms + +Platforms created from note markers should be note-bound by default. + +That means: + +- the note moment remains their anchor in the chain +- their solved world position may change when upstream path changes +- their user-authored properties should remain stable where possible + +Example: + +- the user places a platform at note 7 +- then later rotates the platform at note 3 +- the marble path after note 3 changes +- note 7's platform shifts to the new solved position on the updated path +- note 7's own rotation and other local authored settings remain intact unless the user changes them + +This is the parametric behavior that makes Midimarble feel like a real build system rather than a loose physics toy. + +### Free elements + +The editor should also support elements that are not bound to note markers. + +These are used for: + +- visual composition +- optional path shaping outside note moments +- future decorative or auxiliary gameplay elements + +Free elements should remain freely movable and should not be automatically repositioned by note-chain updates. + +The UX needs to make this distinction legible: + +- note-bound elements belong to the musical chain +- free elements belong to the scene + +## Editing Upstream And Reflowing The Future + +One of the most important UX goals is that the user can go back and improve earlier decisions without manually rebuilding everything later in the song. + +Example: + +- the user decides the first placed platform looks ugly +- they rotate it to improve the angle +- the marble path changes +- all later note-bound platform placements update to the new solved future path + +This reflow is not a side effect. It is a core promise of the editor. + +The user should come to expect: + +- upstream changes reshape downstream note-bound placements +- the future updates immediately +- the timeline and note markers remain coherent after the change + +## UX Principles + +The editor should follow these principles: + +### 1. One time model + +There is one playhead, not competing clocks. + +### 2. Trajectory is primary + +The line and note markers are part of the editor, not debug leftovers. + +### 3. Build incrementally + +Users should be able to work one note at a time. + +### 4. Parametric where it matters + +Note-bound platforms should update with the chain. + +### 5. Freeform where it matters + +Not every scene element needs to belong to a note. + +### 6. Immediate feedback + +Dragging, rotating, stepping, and playing should make the future understandable right away. + +## Immediate Next UX Milestones + +The staged UX evolution should be: + +1. Shared tick-first playhead with imported song length and first-track selection. +2. Note markers rendered on the trajectory for the selected track. +3. Clickable note markers on the trajectory. +4. Note-bound platform creation from markers. +5. Downstream reflow of later note-bound platforms after upstream edits. +6. Free non-note elements alongside the note chain. + +This keeps the product moving toward the full editor experience without pretending the final interaction model is already complete. diff --git a/apps/midimarble/eslint.config.js b/apps/midimarble/eslint.config.js new file mode 100644 index 0000000..5970b21 --- /dev/null +++ b/apps/midimarble/eslint.config.js @@ -0,0 +1,12 @@ +import reactInternal from '@blgc/config/eslint/react-internal'; + +/** + * @see https://eslint.org/docs/latest/use/configure/configuration-files + * @type {import("eslint").Linter.Config} + */ +export default [ + ...reactInternal, + { + ignores: ['build/**', 'dist/**', 'node_modules/**'] + } +]; diff --git a/apps/midimarble/package.json b/apps/midimarble/package.json new file mode 100644 index 0000000..1f6609b --- /dev/null +++ b/apps/midimarble/package.json @@ -0,0 +1,57 @@ +{ + "name": "@repo/midimarble", + "version": "0.0.1", + "private": true, + "description": "", + "keywords": [], + "bugs": { + "url": "https://github.com/builder-group/lab/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/builder-group/lab.git" + }, + "license": "AGPL-3.0-or-later", + "author": "@bennobuilder", + "type": "module", + "scripts": { + "build": "pnpm typecheck && vite build", + "clean": "shx rm -rf dist && shx rm -rf .tanstack && shx rm -rf .turbo && shx rm -rf node_modules", + "format": "prettier --write \"**/*.{ts,tsx,md,json,js,jsx}\"", + "install:clean": "pnpm run clean && pnpm install", + "lint": "eslint . --fix", + "start:dev": "vite dev", + "test": "vitest run", + "typecheck": "tsc --noEmit", + "update:latest": "pnpm update --latest" + }, + "dependencies": { + "@dimforge/rapier3d-compat": "^0.19.3", + "@tanstack/react-devtools": "^0.9.13", + "@tanstack/react-router": "^1.166.7", + "@tanstack/react-router-devtools": "^1.166.7", + "@tanstack/react-start": "^1.166.8", + "ecsify": "^0.0.19", + "feature-react": "^0.0.67", + "feature-state": "^0.0.65", + "lucide-react": "^0.577.0", + "react": "19.2.4", + "react-dom": "19.2.4", + "react-resizable-panels": "^4.7.2", + "three": "^0.183.2", + "tone": "^15.1.22" + }, + "devDependencies": { + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.2.1", + "@tanstack/devtools-vite": "^0.5.5", + "@types/node": "^25.5.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@types/three": "^0.183.1", + "@vitejs/plugin-react": "^5.1.4", + "nitro": "3.0.260311-beta", + "tailwindcss": "^4.2.1", + "vite-tsconfig-paths": "^6.1.1" + } +} diff --git a/apps/midimarble/public/favicon.ico b/apps/midimarble/public/favicon.ico new file mode 100644 index 0000000..a11777c Binary files /dev/null and b/apps/midimarble/public/favicon.ico differ diff --git a/apps/midimarble/public/logo192.png b/apps/midimarble/public/logo192.png new file mode 100644 index 0000000..fc44b0a Binary files /dev/null and b/apps/midimarble/public/logo192.png differ diff --git a/apps/midimarble/public/logo512.png b/apps/midimarble/public/logo512.png new file mode 100644 index 0000000..a4e47a6 Binary files /dev/null and b/apps/midimarble/public/logo512.png differ diff --git a/apps/midimarble/public/manifest.json b/apps/midimarble/public/manifest.json new file mode 100644 index 0000000..4d9581b --- /dev/null +++ b/apps/midimarble/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "TanStack App", + "name": "Create TanStack App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/apps/midimarble/public/midis/ode-to-joy.mid b/apps/midimarble/public/midis/ode-to-joy.mid new file mode 100644 index 0000000..d7e50c2 Binary files /dev/null and b/apps/midimarble/public/midis/ode-to-joy.mid differ diff --git a/apps/midimarble/public/midis/simple.mid b/apps/midimarble/public/midis/simple.mid new file mode 100644 index 0000000..da31b4a Binary files /dev/null and b/apps/midimarble/public/midis/simple.mid differ diff --git a/apps/midimarble/public/robots.txt b/apps/midimarble/public/robots.txt new file mode 100644 index 0000000..e9e57dc --- /dev/null +++ b/apps/midimarble/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/apps/midimarble/public/textures/pegboard-normals.jpg b/apps/midimarble/public/textures/pegboard-normals.jpg new file mode 100644 index 0000000..1410b9c Binary files /dev/null and b/apps/midimarble/public/textures/pegboard-normals.jpg differ diff --git a/apps/midimarble/src/hooks/index.ts b/apps/midimarble/src/hooks/index.ts new file mode 100644 index 0000000..3b6a7d2 --- /dev/null +++ b/apps/midimarble/src/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-memo-cleanup'; diff --git a/apps/midimarble/src/hooks/use-memo-cleanup.ts b/apps/midimarble/src/hooks/use-memo-cleanup.ts new file mode 100644 index 0000000..643310e --- /dev/null +++ b/apps/midimarble/src/hooks/use-memo-cleanup.ts @@ -0,0 +1,52 @@ +import React from 'react'; + +// Registry to handle cleanup when component gets garbage collected +const registry = new FinalizationRegistry((cleanupRef: React.RefObject<(() => void) | null>) => { + cleanupRef.current?.(); // cleanup on unmount +}); + +/** + * A version of useMemo that allows cleanup using FinalizationRegistry. + * This ensures proper cleanup even in React Strict Mode where components might mount/unmount multiple times. + * + * @see https://stackoverflow.com/questions/66446642/react-usememo-memory-clean + * + * @example + * ```ts + * const editor = useMemoCleanup(() => { + * const content = createState(initialValue); + * const unlisten = content.listen(() => {}); + * return [{ content }, unlisten]; + * }, [initialValue]); + * ``` + */ +export function useMemoCleanup( + factory: () => [T, () => void], + deps: React.DependencyList = [] +): T { + const cleanupRef = React.useRef<(() => void) | null>(null); // Holds cleanup function + const valueRef = React.useRef(undefined); // Tracks latest value after cleanup + const unmountRef = React.useRef(false); // GC-triggering candidate, once true triggers registry + + // Register cleanup only once per component instance + if (!unmountRef.current) { + unmountRef.current = true; + registry.register(unmountRef, cleanupRef); + } + + const value = React.useMemo(() => { + // Clean up previous value before creating new one + cleanupRef.current?.(); + cleanupRef.current = null; + + // Create new value and store its cleanup + const [returned, cleanup] = factory(); + cleanupRef.current = cleanup; + valueRef.current = returned; // Track latest value for access after cleanup + + return returned; + }, deps); + + // Return latest value from ref in case previous was cleaned up + return valueRef.current ?? value; +} diff --git a/apps/midimarble/src/modules/editor/EditorCx.tsx b/apps/midimarble/src/modules/editor/EditorCx.tsx new file mode 100644 index 0000000..7b8d910 --- /dev/null +++ b/apps/midimarble/src/modules/editor/EditorCx.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { Runtime } from '@/modules/engine'; +import { projectRepository, type TProjectRecord } from '@/modules/persistence'; + +export class EditorCx { + public readonly runtime: Runtime; + private _container: HTMLDivElement | null = null; + + constructor(record: TProjectRecord | null) { + this.runtime = new Runtime(record); + } + + public readonly setContainer = (container: HTMLDivElement | null): void => { + if (this._container === container) { + return; + } + this._container = container; + this.runtime.setContainer(container); + }; + + public unmount(): void { + this.runtime.unmount(); + this._container = null; + } +} + +// MARK: - React Context + +const ReactEditorCx = React.createContext(null); + +export const EditorCxProvider: React.FC<{ + children: React.ReactNode; + projectId?: string; +}> = ({ children, projectId }) => { + const [cx, setCx] = React.useState(null); + + React.useEffect(() => { + let createdCx: EditorCx | null = null; + let active = true; + + void (async () => { + let record: TProjectRecord | null = null; + if (projectId != null) { + record = (await projectRepository.getProject(projectId)) ?? null; + } + if (!active) return; + createdCx = new EditorCx(record); + setCx(createdCx); + })(); + + return () => { + active = false; + createdCx?.unmount(); + setCx(null); + }; + }, [projectId]); + + if (cx == null) { + return ( +
+

Loading…

+
+ ); + } + + return {children}; +}; + +export function useEditorCx(): EditorCx { + const cx = React.useContext(ReactEditorCx); + if (cx == null) { + throw new Error('useEditorCx must be used within EditorCxProvider'); + } + return cx; +} diff --git a/apps/midimarble/src/modules/editor/components/Editor.tsx b/apps/midimarble/src/modules/editor/components/Editor.tsx new file mode 100644 index 0000000..ab6fa1b --- /dev/null +++ b/apps/midimarble/src/modules/editor/components/Editor.tsx @@ -0,0 +1,353 @@ +import { Link } from '@tanstack/react-router'; +import { ArrowLeft, ChevronDown, FileUp, Plus, Save } from 'lucide-react'; +import React from 'react'; +import { Group, Panel, Separator } from 'react-resizable-panels'; +import { useResource } from '@/modules/engine'; +import { projectRepository } from '@/modules/persistence'; +import { EditorCxProvider, useEditorCx } from '../EditorCx'; +import { canCreateStraightTrack } from '../lib/scene-ui'; +import { PreviewCameraInspector } from './PreviewCameraInspector'; +import { SelectionInspector } from './SelectionInspector'; +import { Timeline } from './Timeline'; + +export const Editor: React.FC<{ projectId?: string }> = ({ projectId }) => { + return ( + + + + ); +}; + +const TrajectorySection: React.FC = () => { + const runtime = useEditorCx().runtime; + const app = runtime.app; + const config = useResource(app, 'trajectoryConfig'); + const update = (patch: Partial) => runtime.updateTrajectoryConfig(patch); + + return ( +
+

Trajectory

+ +
+ + +
+
+ ); +}; + +const AudioSection: React.FC = () => { + const runtime = useEditorCx().runtime; + const app = runtime.app; + const settings = useResource(app, 'audioSettings'); + const state = useResource(app, 'audioState'); + const update = (patch: Partial) => runtime.updateAudioSettings(patch); + + const status = !settings.enabled + ? 'Muted' + : state.isEnabled && state.context != null + ? 'Ready' + : 'Waiting for gesture'; + + return ( +
+

Audio

+ + +

{status}

+
+ ); +}; + +const InnerEditor: React.FC<{ projectId?: string }> = ({ projectId }) => { + const cx = useEditorCx(); + const app = cx.runtime.app; + const [isAddMenuOpen, setIsAddMenuOpen] = React.useState(false); + const [isProjectMenuOpen, setIsProjectMenuOpen] = React.useState(false); + const [isImporting, setIsImporting] = React.useState(false); + const [isSaving, setIsSaving] = React.useState(false); + const midiSong = useResource(app, 'midiSong'); + const selectedTrackId = useResource(app, 'selectedTrackId'); + const midiImportError = useResource(app, 'midiImportError'); + const previewConfig = useResource(app, 'previewConfig'); + const simulationSync = useResource(app, 'simulationSync'); + const fileInputRef = React.useRef(null); + const selectedTrack = React.useMemo( + () => midiSong?.tracks.find((track) => track.id === selectedTrackId) ?? null, + [midiSong, selectedTrackId] + ); + const projectLabel = midiSong?.name ?? 'Untitled'; + // Only show the "no MIDI" splash when there's no project context + const needsMidiStart = projectId == null && (midiSong == null || selectedTrack == null); + const canAddStraightTrack = React.useMemo( + () => canCreateStraightTrack(previewConfig.enabled, simulationSync.mode), + [previewConfig.enabled, simulationSync.mode] + ); + + const openMidiPicker = React.useCallback(() => { + if (!isImporting) { + fileInputRef.current?.click(); + } + }, [isImporting]); + + const handleMidiFileChange = React.useCallback( + async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (file == null) { + return; + } + + setIsImporting(true); + try { + await cx.runtime.loadMidiFile(file); + } finally { + setIsImporting(false); + } + }, + [cx.runtime] + ); + + const handleCreateStraightTrack = React.useCallback(() => { + if (!canAddStraightTrack) { + return; + } + + setIsAddMenuOpen(false); + void cx.runtime.createStraightTrack(); + }, [canAddStraightTrack, cx.runtime]); + + const handleSave = React.useCallback(async () => { + if (projectId == null || isSaving) return; + setIsSaving(true); + try { + const existing = await projectRepository.getProject(projectId); + if (existing == null) return; + const snapshot = cx.runtime.extractSnapshot(); + await projectRepository.saveProject({ + ...snapshot, + id: existing.id, + name: existing.name, + createdAt: existing.createdAt, + updatedAt: Date.now() + }); + } finally { + setIsSaving(false); + } + }, [projectId, isSaving, cx.runtime]); + + React.useEffect(() => { + if (!canAddStraightTrack) { + setIsAddMenuOpen(false); + } + }, [canAddStraightTrack]); + + return ( +
+ + + {needsMidiStart ? ( +
+
+

+ Midimarble +

+

+ Open a MIDI file to start shaping the marble path. +

+

+ Import a single MIDI track first. Then the timeline, note markers, and note-bound + platform workflow become available in the scene. +

+ +
+ +
+ + {midiImportError != null ? ( +

{midiImportError}

+ ) : selectedTrack == null && midiSong != null ? ( +

+ The imported MIDI file does not contain a playable note track yet. +

+ ) : null} +
+
+ ) : ( + + + + +
+
+
+
+ {/* Project name button — opens settings dropdown */} +
+ + + {isProjectMenuOpen ? ( +
+
+ setIsProjectMenuOpen(false)} + > + + Back to Projects + +
+
+
+ + +
+
+ ) : null} +
+ + {/* Save */} + {projectId != null ? ( + + ) : null} + + {/* Add scene element */} +
+ + + {isAddMenuOpen ? ( +
+ +
+ ) : null} +
+
+
+
+
+ + + + + + +
+
+ + + + + + +
+ )} +
+ ); +}; diff --git a/apps/midimarble/src/modules/editor/components/LazyEditor.tsx b/apps/midimarble/src/modules/editor/components/LazyEditor.tsx new file mode 100644 index 0000000..1ae1767 --- /dev/null +++ b/apps/midimarble/src/modules/editor/components/LazyEditor.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +const EditorComponent = React.lazy(async () => { + const mod = await import('./Editor'); + return { default: mod.Editor }; +}); + +export const LazyEditor: React.FC = () => { + const [isMounted, setIsMounted] = React.useState(false); + + React.useEffect(() => { + setIsMounted(true); + }, []); + + if (!isMounted) { + return ; + } + + return ( + }> + + + ); +}; + +const Fallback: React.FC = () => { + return ( +
+

Loading editor...

+
+ ); +}; diff --git a/apps/midimarble/src/modules/editor/components/PreviewCameraInspector.tsx b/apps/midimarble/src/modules/editor/components/PreviewCameraInspector.tsx new file mode 100644 index 0000000..c8ab124 --- /dev/null +++ b/apps/midimarble/src/modules/editor/components/PreviewCameraInspector.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { useResource } from '@/modules/engine'; +import { renderConfig } from '@/modules/engine/plugins/render/config'; +import { useEditorCx } from '../EditorCx'; + +export const PreviewCameraInspector: React.FC = () => { + const runtime = useEditorCx().runtime; + const config = useResource(runtime.app, 'previewConfig'); + + return ( +
+

+ Preview Camera +

+ +
+
+
+

Preview Camera

+

Follow Marble

+
+ + runtime.updatePreviewConfig({ fov: value })} + /> + runtime.updatePreviewConfig({ distance: value })} + /> + runtime.updatePreviewConfig({ height: value })} + /> + runtime.updatePreviewConfig({ lookAhead: value })} + /> + runtime.updatePreviewConfig({ smoothing: value })} + /> +
+
+
+ ); +}; + +const SliderField: React.FC<{ + label: string; + value: number; + min: number; + max: number; + step: number; + onChange: (value: number) => void; +}> = ({ label, value, min, max, step, onChange }) => ( + +); diff --git a/apps/midimarble/src/modules/editor/components/SelectionInspector.tsx b/apps/midimarble/src/modules/editor/components/SelectionInspector.tsx new file mode 100644 index 0000000..82b2827 --- /dev/null +++ b/apps/midimarble/src/modules/editor/components/SelectionInspector.tsx @@ -0,0 +1,523 @@ +import { Entity, With } from 'ecsify'; +import { Trash2 } from 'lucide-react'; +import React from 'react'; +import { useQueryComponents, useResource } from '@/modules/engine'; +import type { TMidiLookup, TMidiSong } from '@/modules/engine/plugins/midi'; +import { sceneConfig } from '@/modules/engine/plugins/scene/config'; +import { useEditorCx } from '../EditorCx'; +import { deriveInspectorPathState, type TInspectorTarget } from '../lib/inspector-target'; +import { canDeleteStraightTrack } from '../lib/scene-ui'; +import { + deriveSceneSelectionInspectorTarget, + deriveSelectedNoteInspectorTarget +} from '../lib/selection-inspector-target'; + +export const SelectionInspector: React.FC = () => { + const runtime = useEditorCx().runtime; + const app = runtime.app; + const midiSong = useResource(app, 'midiSong'); + const midiLookup = useResource(app, 'midiLookup'); + const selectedTrackId = useResource(app, 'selectedTrackId'); + const selectedNoteId = useResource(app, 'selectedNoteId'); + const selectedNoteIds = useResource(app, 'selectedNoteIds'); + const sceneSelection = useResource(app, 'sceneSelection'); + const simulationSync = useResource(app, 'simulationSync'); + const deleteEntityId = + sceneSelection.entityId != null && + app.hasComponent(sceneSelection.entityId, app.c.StraightTrackMixin) + ? sceneSelection.entityId + : null; + const canDelete = React.useMemo( + () => deleteEntityId != null && canDeleteStraightTrack(simulationSync.mode), + [deleteEntityId, simulationSync.mode] + ); + const handleDelete = React.useCallback(() => { + if (canDelete && deleteEntityId != null) { + runtime.deleteStraightTrack(deleteEntityId); + } + }, [canDelete, deleteEntityId, runtime]); + + return ( +
+
+

Inspector

+ {deleteEntityId != null ? ( + + ) : null} +
+ +
+ {selectedNoteId != null ? ( + + void runtime.createOrSelectNotePlatform(noteId) + } + /> + ) : sceneSelection.entityId != null ? ( + + ) : ( + + )} +
+
+ ); +}; + +const SelectedNoteInspectorPanel: React.FC<{ + midiSong: TMidiSong | null; + midiLookup: TMidiLookup; + selectedTrackId: number | null; + selectedNoteId: number; + selectedNoteCount: number; + onCreateOrSelectNotePlatform: (noteId: number) => void; +}> = ({ + midiSong, + midiLookup, + selectedTrackId, + selectedNoteId, + selectedNoteCount, + onCreateOrSelectNotePlatform +}) => { + const app = useEditorCx().runtime.app; + const fixedTimeStepSeconds = useResource(app, 'fixedTimeStepSeconds'); + const trajectoryProjection = useResource(app, 'trajectoryProjection'); + const notePlatforms = useQueryComponents(app, { + components: [Entity, app.c.NoteBindingMixin] as const, + queryOrFilter: With(app.c.NotePlatformMixin), + watchComponents: [app.c.NoteBindingMixin, app.c.NotePlatformMixin] + }); + + const target = React.useMemo(() => { + return deriveSelectedNoteInspectorTarget({ + midiSong, + midiLookup, + selectedTrackId, + selectedNoteId, + fixedTimeStepSeconds, + trajectoryProjection, + notePlatformByNoteId: new Map(notePlatforms.map(([eid, binding]) => [binding.noteId, eid])) + }); + }, [ + fixedTimeStepSeconds, + midiLookup, + midiSong, + notePlatforms, + selectedNoteId, + selectedTrackId, + trajectoryProjection + ]); + + if (target.kind === 'empty') { + return ; + } + + return ( + + ); +}; + +const SceneSelectionInspectorPanel: React.FC<{ + midiSong: TMidiSong | null; + midiLookup: TMidiLookup; + sceneSelectionEntityId: number; +}> = ({ midiSong, midiLookup, sceneSelectionEntityId }) => { + const runtime = useEditorCx().runtime; + const app = runtime.app; + const fixedTimeStepSeconds = useResource(app, 'fixedTimeStepSeconds'); + const trajectoryProjection = useResource(app, 'trajectoryProjection'); + const tracks = useQueryComponents(app, { + components: [ + Entity, + app.c.AuthoredTransformMixin, + app.c.LinearElementMixin, + app.c.StraightTrackMixin + ] as const, + queryOrFilter: With(app.c.StraightTrackMixin), + watchComponents: [ + app.c.AuthoredTransformMixin, + app.c.LinearElementMixin, + app.c.StraightTrackMixin + ] + }); + const marbles = useQueryComponents(app, { + components: [Entity, app.c.PositionMixin, app.c.MarblePhysicsMixin] as const, + queryOrFilter: With(app.c.MarbleTag), + watchComponents: [app.c.MarbleTag, app.c.PositionMixin, app.c.MarblePhysicsMixin] + }); + const notePlatforms = useQueryComponents(app, { + components: [Entity, app.c.NoteBindingMixin, app.c.NotePlatformMixin] as const, + queryOrFilter: With(app.c.NotePlatformMixin), + watchComponents: [app.c.NoteBindingMixin, app.c.NotePlatformMixin] + }); + + const target = React.useMemo(() => { + return deriveSceneSelectionInspectorTarget({ + midiSong, + midiLookup, + sceneSelectionEntityId, + fixedTimeStepSeconds, + trajectoryProjection, + tracks, + marbles, + notePlatforms + }); + }, [ + fixedTimeStepSeconds, + marbles, + midiLookup, + midiSong, + notePlatforms, + sceneSelectionEntityId, + tracks, + trajectoryProjection + ]); + + if (target.kind === 'empty') { + return ; + } + if (target.kind === 'note-platform') { + return ( + + runtime.updateNotePlatform(target.entityId, { + offsetY: clamp( + value, + sceneConfig.notePlatform.limits.offsetY.min, + sceneConfig.notePlatform.limits.offsetY.max + ) + }) + } + onPushChange={(value) => + runtime.updateNotePlatform(target.entityId, { + offsetZ: clamp( + value, + sceneConfig.notePlatform.limits.offsetZ.min, + sceneConfig.notePlatform.limits.offsetZ.max + ) + }) + } + onRotationChange={(value) => + runtime.updateNotePlatform(target.entityId, { + rotationX: clamp( + value, + sceneConfig.notePlatform.limits.rotationX.min, + sceneConfig.notePlatform.limits.rotationX.max + ) + }) + } + onBounceChange={(value) => + runtime.updateNotePlatform(target.entityId, { + bounce: clamp( + value, + sceneConfig.notePlatform.limits.bounce.min, + sceneConfig.notePlatform.limits.bounce.max + ) + }) + } + onCommit={() => runtime.commitSceneEdit()} + /> + ); + } + if (target.kind === 'straight-track') { + return ; + } + if (target.kind === 'marble') { + return ( + + runtime.updateMarblePhysics(target.entityId, { + bounce: clamp( + value, + sceneConfig.marble.physics.limits.bounce.min, + sceneConfig.marble.physics.limits.bounce.max + ) + }) + } + onCommit={() => runtime.commitSceneEdit()} + /> + ); + } + + return ; +}; + +const EmptyState: React.FC<{ message: string }> = ({ message }) => ( +
+

{message}

+
+); + +const NoteInspector: React.FC<{ + target: Extract; + selectedNoteCount: number; + onCreateOrSelectNotePlatform: (noteId: number) => void; +}> = ({ target, selectedNoteCount, onCreateOrSelectNotePlatform }) => ( +
+ + {selectedNoteCount > 1 ? ( + + ) : null} + + + + + + + {target.position != null ? : null} + {selectedNoteCount === 1 ? ( +
+ + {target.notePlatformEntityId == null && target.position == null ? ( +

+ This note must be within the solved trajectory horizon before a note platform can be + created. +

+ ) : null} +
+ ) : null} +
+); + +const NotePlatformInspector: React.FC<{ + target: Extract; + onLiftChange: (value: number) => void; + onPushChange: (value: number) => void; + onRotationChange: (value: number) => void; + onBounceChange: (value: number) => void; + onCommit: () => void; +}> = ({ target, onLiftChange, onPushChange, onRotationChange, onBounceChange, onCommit }) => ( +
+ + + + + + {target.position != null ? : null} + + + + + + + + +
+); + +const StraightTrackInspector: React.FC<{ + target: Extract; +}> = ({ target }) => ( +
+ + + + + + + + +
+); + +const MarbleInspector: React.FC<{ + target: Extract; + onBounceChange: (value: number) => void; + onCommit: () => void; +}> = ({ target, onBounceChange, onCommit }) => ( +
+ + + + +
+); + +const InspectorTitle: React.FC<{ + title: string; + subtitle: string; +}> = ({ title, subtitle }) => ( +
+
+

{title}

+

{subtitle}

+
+
+); + +const InspectorField: React.FC<{ + label: string; + value: string | number; + mono?: boolean; +}> = ({ label, value, mono = false }) => ( +
+ {label} + + {value} + +
+); + +const InspectorColorField: React.FC<{ + label: string; + value: string; +}> = ({ label, value }) => ( +
+ {label} + + + {value} + +
+); + +const Vec3Field: React.FC<{ + label: string; + value: { x: number; y: number; z: number }; +}> = ({ label, value }) => ( + +); + +const MarbleVelocityField: React.FC<{ + entityId: number; +}> = ({ entityId }) => { + const app = useEditorCx().runtime.app; + useResource(app, 'liveStep'); + + const velocity = app.r.rigidBodies.get(entityId)?.linvel(); + if (velocity == null) { + return null; + } + + return ; +}; + +const PathStateField: React.FC<{ + step: number; + position: { x: number; y: number; z: number } | null; +}> = ({ step, position }) => { + const app = useEditorCx().runtime.app; + const liveStep = useResource(app, 'liveStep'); + const bufferedStep = useResource(app, 'bufferedStep'); + const pathState = React.useMemo( + () => deriveInspectorPathState(step, position, liveStep, bufferedStep), + [bufferedStep, liveStep, position, step] + ); + + return ; +}; + +function capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +const SliderField: React.FC<{ + label: string; + value: number; + min: number; + max: number; + step: number; + onChange: (value: number) => void; + onCommit?: () => void; +}> = ({ label, value, min, max, step, onChange, onCommit }) => ( + +); + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} diff --git a/apps/midimarble/src/modules/editor/components/Timeline.tsx b/apps/midimarble/src/modules/editor/components/Timeline.tsx new file mode 100644 index 0000000..112022d --- /dev/null +++ b/apps/midimarble/src/modules/editor/components/Timeline.tsx @@ -0,0 +1,755 @@ +import { Entity, With } from 'ecsify'; +import React from 'react'; +import { useMemoCleanup } from '@/hooks'; +import { useQueryComponents, useResource } from '@/modules/engine'; +import { getTrackInstrumentId } from '@/modules/engine/plugins/audio'; +import { audioConfig } from '@/modules/engine/plugins/audio/config'; +import { + clampMidiTick, + findTrackById, + getTrackNotesOverlappingTickWindow, + stepToTick, + type TMidiNote +} from '@/modules/engine/plugins/midi'; +import { isNotePlatformAdjusted } from '@/modules/engine/plugins/scene/lib/note-platform'; +import { useEditorCx } from '../EditorCx'; +import { + buildDrawnTimelineNote, + buildMovedTimelineNotes, + buildResizedTimelineNote, + getSnappedMoveDeltaTick, + getSnappedTimelineTick, + type TTimelineEditableNote +} from '../lib/timeline-editing'; +import { + buildNoteRows, + getNoteName, + MIN_ROLL_HEIGHT, + NOTE_ROW_HEIGHT, + ZOOM_STEP_FACTOR +} from '../lib/timeline-layout'; +import { + TimelineCx, + useTimelineState, + type TTimelineInteractionState +} from './timeline/TimelineCx'; +import { TimelineHeader } from './timeline/TimelineHeader'; +import { + TimelineRoll, + type TTimelineDraftNote, + type TTimelineGridPointerInput, + type TTimelineNotePointerInput +} from './timeline/TimelineRoll'; + +const TimelineEmptyState: React.FC<{ message: string }> = ({ message }) => ( +
+

{message}

+
+); + +const POINTER_DRAG_THRESHOLD_PX = 4; +const TIMELINE_SNAP_THRESHOLD_PX = 8; +const TIMELINE_VIEWPORT_OVERSCAN_SCREENS = 0.5; + +export const Timeline: React.FC<{ className?: string }> = ({ className }) => { + const cx = useEditorCx(); + const app = cx.runtime.app; + const timelineCx = useMemoCleanup(() => { + const nextTimelineCx = new TimelineCx(); + return [nextTimelineCx, () => nextTimelineCx.unmount()]; + }, []); + + const isReady = useResource(app, 'isReady'); + const midiSong = useResource(app, 'midiSong'); + const midiLookup = useResource(app, 'midiLookup'); + const selectedTrackId = useResource(app, 'selectedTrackId'); + const midiImportError = useResource(app, 'midiImportError'); + const selectedNoteId = useResource(app, 'selectedNoteId'); + const selectedNoteIds = useResource(app, 'selectedNoteIds'); + const audioSettings = useResource(app, 'audioSettings'); + const audioPlaybackFeedback = useResource(app, 'audioPlaybackFeedback'); + const transport = useResource(app, 'transport'); + const previewConfig = useResource(app, 'previewConfig'); + const liveStep = useResource(app, 'liveStep'); + const bufferedStep = useResource(app, 'bufferedStep'); + const simulationSync = useResource(app, 'simulationSync'); + const sceneEditState = useResource(app, 'sceneEditState'); + const fixedTimeStepSeconds = useResource(app, 'fixedTimeStepSeconds'); + const notePlatforms = useQueryComponents(app, { + components: [Entity, app.c.NoteBindingMixin, app.c.NotePlatformMixin] as const, + queryOrFilter: With(app.c.NotePlatformMixin), + watchComponents: [app.c.NoteBindingMixin, app.c.NotePlatformMixin] + }); + + const containerWidth = useTimelineState(timelineCx.$containerWidth); + const scrollLeft = useTimelineState(timelineCx.$scrollLeft); + const pixelsPerBeat = useTimelineState(timelineCx.$pixelsPerBeat); + const keyboardMode = useTimelineState(timelineCx.$keyboardMode); + const interactionState = useTimelineState(timelineCx.$interactionState); + + const selectedTrack = React.useMemo( + () => findTrackById(midiSong, selectedTrackId, midiLookup), + [midiLookup, midiSong, selectedTrackId] + ); + const canControlPlayback = + isReady && midiSong != null && selectedTrack != null && midiSong.totalTicks > 0; + const canEditNotes = + midiSong != null && + selectedTrack != null && + simulationSync.mode === 'idle' && + !sceneEditState.pending; + + const pixelsPerTick = timelineCx.getPixelsPerTick(midiSong); + const playheadTick = + midiSong == null ? 0 : clampMidiTick(transport.playheadTick, midiSong.totalTicks); + const bufferedTick = + midiSong == null + ? 0 + : Math.min(stepToTick(bufferedStep, midiSong, fixedTimeStepSeconds), midiSong.totalTicks); + const hasPendingFuture = sceneEditState.pending || simulationSync.mode !== 'idle'; + const visibleBufferedTick = hasPendingFuture ? 0 : bufferedTick; + const preloadedSteps = Math.max(0, bufferedStep - liveStep); + const preloadedLabel = + sceneEditState.pending || simulationSync.mode === 'dirty' + ? 'Preloaded Pending' + : simulationSync.mode === 'rebuilding' + ? 'Preloaded Recomputing' + : `Preloaded ${preloadedSteps}`; + const playheadPx = playheadTick * pixelsPerTick; + const bufferedPx = visibleBufferedTick * pixelsPerTick; + const draftNotes = React.useMemo( + () => buildDraftTimelineNotes(interactionState, midiSong?.ticksPerBeat ?? 480), + [interactionState, midiSong?.ticksPerBeat] + ); + const noteRows = React.useMemo( + () => + buildNoteRows( + selectedTrack?.notes ?? [], + draftNotes.map((note) => note.noteNumber), + keyboardMode + ), + [draftNotes, keyboardMode, selectedTrack] + ); + const selectedNote = React.useMemo( + () => selectedTrack?.notes.find((note) => note.id === selectedNoteId) ?? null, + [selectedNoteId, selectedTrack] + ); + const selectedNoteLabel = + selectedNoteIds.size > 1 + ? `${selectedNoteIds.size} selected` + : selectedNote == null + ? null + : `${getNoteName(selectedNote.noteNumber)} @ ${Math.round(selectedNote.tick)}`; + const selectedTrackInstrumentId = getTrackInstrumentId( + audioSettings.trackInstrumentIds, + selectedTrack?.id ?? null + ); + const trackOptions = React.useMemo( + () => midiSong?.tracks.map((t) => ({ id: t.id, name: t.name })) ?? [], + [midiSong] + ); + const placedNoteIds = React.useMemo( + () => new Set(notePlatforms.map(([, binding]) => binding.noteId)), + [notePlatforms] + ); + const adjustedNoteIds = React.useMemo( + () => + new Set( + notePlatforms.flatMap(([, binding, platform]) => + isNotePlatformAdjusted(platform) ? [binding.noteId] : [] + ) + ), + [notePlatforms] + ); + const contentHeight = Math.max(noteRows.length * NOTE_ROW_HEIGHT, MIN_ROLL_HEIGHT); + const timelineWidth = + midiSong == null ? Math.max(containerWidth, 1) : timelineCx.getTimelineWidth(midiSong); + const overscanTicks = + pixelsPerTick <= 0 ? 0 : (containerWidth * TIMELINE_VIEWPORT_OVERSCAN_SCREENS) / pixelsPerTick; + const visibleTickStart = Math.max( + 0, + scrollLeft / Math.max(pixelsPerTick, 0.0001) - overscanTicks + ); + const visibleTickEnd = Math.max( + 0, + (scrollLeft + containerWidth) / Math.max(pixelsPerTick, 0.0001) + overscanTicks + ); + const visibleNotes = React.useMemo( + () => + selectedTrack == null + ? [] + : getTrackNotesOverlappingTickWindow( + midiLookup, + selectedTrack.id, + visibleTickStart, + visibleTickEnd, + selectedTrack + ), + [midiLookup, selectedTrack, visibleTickEnd, visibleTickStart] + ); + + const [isRulerDragging, setIsRulerDragging] = React.useState(false); + const [isImporting, setIsImporting] = React.useState(false); + const fileInputRef = React.useRef(null); + + React.useEffect(() => { + const scrollContainer = timelineCx.scrollContainerRef.current; + if (scrollContainer == null) { + return; + } + + const syncContainerWidth = () => { + timelineCx.setContainerWidth(scrollContainer.clientWidth); + timelineCx.setScrollLeft(scrollContainer.scrollLeft); + if (midiSong == null) { + scrollContainer.scrollLeft = 0; + return; + } + + timelineCx.syncViewport(midiSong, scrollContainer.scrollLeft, pixelsPerBeat); + }; + + syncContainerWidth(); + const observer = new ResizeObserver(syncContainerWidth); + observer.observe(scrollContainer); + + return () => { + observer.disconnect(); + }; + }, [midiSong, pixelsPerBeat, timelineCx]); + + const seekFromClientX = React.useCallback( + (clientX: number) => { + if (!canControlPlayback || midiSong == null) { + return; + } + + cx.runtime.seekToTick(timelineCx.getTickAtClientX(midiSong, clientX)); + }, + [canControlPlayback, cx.runtime, midiSong, timelineCx] + ); + + const handleRulerPointerDown = React.useCallback( + (event: React.PointerEvent) => { + if (!canControlPlayback) { + return; + } + + event.currentTarget.setPointerCapture(event.pointerId); + setIsRulerDragging(true); + seekFromClientX(event.clientX); + }, + [canControlPlayback, seekFromClientX] + ); + + const handleRulerPointerMove = React.useCallback( + (event: React.PointerEvent) => { + if (!isRulerDragging) { + return; + } + + seekFromClientX(event.clientX); + }, + [isRulerDragging, seekFromClientX] + ); + + const handleRulerPointerUp = React.useCallback(() => { + setIsRulerDragging(false); + }, []); + + const handleWheel = React.useCallback( + (event: React.WheelEvent) => { + if (midiSong == null || (!event.ctrlKey && !event.metaKey)) { + return; + } + + event.preventDefault(); + timelineCx.zoomAtClientX( + midiSong, + event.clientX, + event.deltaY > 0 ? 1 / ZOOM_STEP_FACTOR : ZOOM_STEP_FACTOR + ); + }, + [midiSong, timelineCx] + ); + + const openMidiPicker = React.useCallback(() => { + if (!isImporting) { + fileInputRef.current?.click(); + } + }, [isImporting]); + + const handleMidiFileChange = React.useCallback( + async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (file == null) { + return; + } + + setIsImporting(true); + try { + await cx.runtime.loadMidiFile(file); + } finally { + setIsImporting(false); + } + }, + [cx.runtime] + ); + + const handleZoomIn = React.useCallback(() => { + if (midiSong != null) { + timelineCx.zoomIn(midiSong); + } + }, [midiSong, timelineCx]); + + const handleZoomOut = React.useCallback(() => { + if (midiSong != null) { + timelineCx.zoomOut(midiSong); + } + }, [midiSong, timelineCx]); + + const handleNoteSelection = React.useCallback( + (noteId: number, additive: boolean) => { + if (!additive) { + cx.runtime.selectNotes([noteId], noteId); + return; + } + + const nextSelectedNoteIds = new Set(selectedNoteIds); + if (nextSelectedNoteIds.has(noteId)) { + nextSelectedNoteIds.delete(noteId); + } else { + nextSelectedNoteIds.add(noteId); + } + const nextNoteIds = Array.from(nextSelectedNoteIds); + const nextPrimaryNoteId = + nextNoteIds.length === 0 + ? null + : selectedNoteId != null && nextSelectedNoteIds.has(selectedNoteId) + ? selectedNoteId + : noteId; + cx.runtime.selectNotes(nextNoteIds, nextPrimaryNoteId); + }, + [cx.runtime, selectedNoteId, selectedNoteIds] + ); + + const handleGridPointerDown = React.useCallback( + (input: TTimelineGridPointerInput) => { + if (!canEditNotes) { + return; + } + + const anchorTick = getSnappedTimelineTick( + input.tick, + midiSong?.ticksPerBeat ?? 0, + pixelsPerTick, + TIMELINE_SNAP_THRESHOLD_PX + ); + + timelineCx.setInteractionState({ + mode: 'drawing', + pointerId: input.pointerId, + anchorTick, + currentTick: anchorTick, + noteNumber: input.noteNumber, + didDrag: false, + pointerDownClient: { x: input.clientX, y: input.clientY } + }); + }, + [canEditNotes, midiSong?.ticksPerBeat, pixelsPerTick, timelineCx] + ); + + const handleGridPointerMove = React.useCallback( + (input: TTimelineGridPointerInput) => { + const currentInteractionState = timelineCx.$interactionState.get(); + if ( + currentInteractionState.mode === 'idle' || + currentInteractionState.pointerId !== input.pointerId + ) { + return; + } + + if (currentInteractionState.mode === 'drawing') { + const currentTick = getSnappedTimelineTick( + input.tick, + midiSong?.ticksPerBeat ?? 0, + pixelsPerTick, + TIMELINE_SNAP_THRESHOLD_PX + ); + const didDrag = + currentInteractionState.didDrag || + Math.abs(input.clientX - currentInteractionState.pointerDownClient.x) > + POINTER_DRAG_THRESHOLD_PX || + Math.abs(input.clientY - currentInteractionState.pointerDownClient.y) > + POINTER_DRAG_THRESHOLD_PX; + if (didDrag && !currentInteractionState.didDrag && transport.mode === 'running') { + cx.runtime.pause(); + } + + timelineCx.setInteractionState({ + ...currentInteractionState, + currentTick, + didDrag + }); + return; + } + + if (currentInteractionState.mode === 'moving') { + const snappedDeltaTick = getSnappedMoveDeltaTick( + currentInteractionState.clickedNote.tick, + input.tick - currentInteractionState.anchorTick, + midiSong?.ticksPerBeat ?? 0, + pixelsPerTick, + TIMELINE_SNAP_THRESHOLD_PX + ); + const didDrag = + currentInteractionState.didDrag || + Math.abs(input.clientX - currentInteractionState.pointerDownClient.x) > + POINTER_DRAG_THRESHOLD_PX || + Math.abs(input.clientY - currentInteractionState.pointerDownClient.y) > + POINTER_DRAG_THRESHOLD_PX; + if (didDrag && !currentInteractionState.didDrag && transport.mode === 'running') { + cx.runtime.pause(); + } + + timelineCx.setInteractionState({ + ...currentInteractionState, + currentTick: currentInteractionState.anchorTick + snappedDeltaTick, + currentNoteNumber: input.noteNumber, + didDrag + }); + return; + } + + const currentTick = getSnappedTimelineTick( + input.tick, + midiSong?.ticksPerBeat ?? 0, + pixelsPerTick, + TIMELINE_SNAP_THRESHOLD_PX + ); + timelineCx.setInteractionState({ + ...currentInteractionState, + currentTick + }); + }, + [cx.runtime, midiSong?.ticksPerBeat, pixelsPerTick, timelineCx, transport.mode] + ); + + const handleGridPointerUp = React.useCallback(() => { + const currentInteractionState = timelineCx.$interactionState.get(); + if (currentInteractionState.mode === 'idle') { + return; + } + + if (currentInteractionState.mode === 'drawing') { + if (!currentInteractionState.didDrag) { + cx.runtime.clearNoteSelection(); + } else if (midiSong != null) { + const draftNote = buildDrawnTimelineNote( + currentInteractionState.noteNumber, + currentInteractionState.anchorTick, + currentInteractionState.currentTick, + midiSong.ticksPerBeat + ); + cx.runtime.createNote(draftNote); + } + timelineCx.clearInteractionState(); + return; + } + + if (currentInteractionState.mode === 'moving') { + if (!currentInteractionState.didDrag) { + cx.runtime.selectNote( + currentInteractionState.clickedNote.id, + currentInteractionState.clickedNote.tick + ); + timelineCx.clearInteractionState(); + return; + } + + cx.runtime.moveSelectedNotes( + currentInteractionState.currentTick - currentInteractionState.anchorTick, + currentInteractionState.currentNoteNumber - currentInteractionState.anchorNoteNumber + ); + timelineCx.clearInteractionState(); + return; + } + + if ( + Math.round(currentInteractionState.currentTick - currentInteractionState.anchorTick) === 0 + ) { + timelineCx.clearInteractionState(); + return; + } + + cx.runtime.resizePrimarySelectedNote( + currentInteractionState.mode === 'resizing-start' ? 'start' : 'end', + currentInteractionState.currentTick - currentInteractionState.anchorTick + ); + timelineCx.clearInteractionState(); + }, [cx.runtime, midiSong, timelineCx, transport.mode]); + + const handleNotePointerDown = React.useCallback( + (input: TTimelineNotePointerInput) => { + handleNoteSelection(input.note.id, input.additive); + if (input.additive || !canEditNotes || selectedTrack == null) { + return; + } + + const selectedNotes = + input.edge === 'body' && selectedNoteIds.has(input.note.id) && selectedNoteIds.size > 0 + ? selectedTrack.notes.filter((note) => selectedNoteIds.has(note.id)) + : [input.note]; + + if (input.edge !== 'body' && selectedNotes.length === 1) { + if (transport.mode === 'running') { + cx.runtime.pause(); + } + + timelineCx.setInteractionState({ + mode: input.edge === 'start' ? 'resizing-start' : 'resizing-end', + pointerId: input.pointerId, + anchorTick: + input.edge === 'start' ? input.note.tick : input.note.tick + input.note.durationTicks, + currentTick: input.tick, + note: toEditableTimelineNote(input.note) + }); + return; + } + + timelineCx.setInteractionState({ + mode: 'moving', + pointerId: input.pointerId, + anchorTick: input.tick, + currentTick: input.tick, + anchorNoteNumber: input.noteNumber, + currentNoteNumber: input.noteNumber, + didDrag: false, + pointerDownClient: { x: input.clientX, y: input.clientY }, + clickedNote: toEditableTimelineNote(input.note), + notes: selectedNotes.map((note) => toEditableTimelineNote(note)) + }); + }, + [ + canEditNotes, + cx.runtime, + handleNoteSelection, + selectedNoteIds, + selectedTrack, + timelineCx, + transport.mode + ] + ); + + const handlePianoKeyPointerDown = React.useCallback( + (noteNumber: number) => { + if (selectedTrack == null) { + return; + } + + if (transport.mode === 'running') { + cx.runtime.pause(); + } + + cx.runtime.previewMidiNote(noteNumber); + }, + [cx.runtime, selectedTrack, transport.mode] + ); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + timelineCx.clearInteractionState(); + return; + } + + if ( + selectedTrack != null && + (event.metaKey || event.ctrlKey) && + event.key.toLowerCase() === 'a' + ) { + event.preventDefault(); + cx.runtime.selectAllTrackNotes(selectedTrack.id); + return; + } + + if (event.key === 'Delete' || event.key === 'Backspace') { + event.preventDefault(); + cx.runtime.deleteSelectedNotes(); + } + }, + [cx.runtime, selectedTrack, timelineCx] + ); + + const emptyStateMessage = + midiSong == null + ? 'Open a MIDI file to set the song length, beat ruler, and note lanes.' + : selectedTrack == null + ? 'The imported MIDI file does not contain a playable note track yet.' + : null; + + return ( +
+ + + 0} + isImporting={isImporting} + importLabel={isImporting ? 'Importing…' : 'Open MIDI'} + importError={midiImportError} + mode={transport.mode} + previewEnabled={previewConfig.enabled} + trackName={selectedTrack?.name ?? null} + bpm={midiSong?.bpm ?? null} + playheadTick={playheadTick} + liveStep={liveStep} + preloadedLabel={preloadedLabel} + selectedNoteLabel={selectedNoteLabel} + keyboardMode={keyboardMode} + trackId={selectedTrackId} + trackOptions={trackOptions} + instrumentId={selectedTrackInstrumentId} + instrumentOptions={audioConfig.instrumentOptions} + onOpenMidi={openMidiPicker} + onSetTrack={(trackId) => cx.runtime.setSelectedTrack(trackId)} + onSetInstrument={(instrumentId) => { + if (selectedTrack == null) { + return; + } + + cx.runtime.setTrackInstrument(selectedTrack.id, instrumentId); + }} + onSetKeyboardMode={(mode) => timelineCx.setKeyboardMode(mode)} + onStepBackwardTick={() => cx.runtime.stepBackwardTick()} + onStepForwardTick={() => cx.runtime.stepForwardTick()} + onPlay={() => cx.runtime.run()} + onPause={() => cx.runtime.pause()} + onReset={() => cx.runtime.reset()} + onTogglePreview={() => cx.runtime.togglePreview()} + onZoomOut={handleZoomOut} + onZoomIn={handleZoomIn} + /> + + {emptyStateMessage != null || midiSong == null ? ( + + ) : ( +
timelineCx.setScrollLeft(event.currentTarget.scrollLeft)} + onWheel={handleWheel} + > + +
+ )} +
+ ); +}; + +function buildDraftTimelineNotes( + interactionState: TTimelineInteractionState, + defaultDurationTicks: number +): TTimelineDraftNote[] { + if (interactionState.mode === 'idle') { + return []; + } + + if (interactionState.mode === 'drawing') { + if (!interactionState.didDrag) { + return []; + } + + const note = buildDrawnTimelineNote( + interactionState.noteNumber, + interactionState.anchorTick, + interactionState.currentTick, + defaultDurationTicks + ); + return [ + { + id: -1, + sourceId: null, + velocity: 100, + ...note + } + ]; + } + + if (interactionState.mode === 'moving') { + return buildMovedTimelineNotes( + interactionState.notes, + interactionState.currentTick - interactionState.anchorTick, + interactionState.currentNoteNumber - interactionState.anchorNoteNumber + ).map((note) => ({ + ...note, + sourceId: note.id + })); + } + + const resizedNote = buildResizedTimelineNote( + interactionState.note, + interactionState.mode === 'resizing-start' ? 'start' : 'end', + interactionState.currentTick - interactionState.anchorTick + ); + + return [ + { + ...resizedNote, + sourceId: interactionState.note.id + } + ]; +} + +function toEditableTimelineNote( + note: Pick +): TTimelineEditableNote { + return { + id: note.id, + tick: note.tick, + durationTicks: note.durationTicks, + noteNumber: note.noteNumber, + velocity: note.velocity + }; +} diff --git a/apps/midimarble/src/modules/editor/components/index.ts b/apps/midimarble/src/modules/editor/components/index.ts new file mode 100644 index 0000000..1d79c54 --- /dev/null +++ b/apps/midimarble/src/modules/editor/components/index.ts @@ -0,0 +1,2 @@ +export * from './Editor'; +export * from './LazyEditor'; diff --git a/apps/midimarble/src/modules/editor/components/timeline/TimelineCx.ts b/apps/midimarble/src/modules/editor/components/timeline/TimelineCx.ts new file mode 100644 index 0000000..298b87a --- /dev/null +++ b/apps/midimarble/src/modules/editor/components/timeline/TimelineCx.ts @@ -0,0 +1,224 @@ +import { createState } from 'feature-state'; +import React from 'react'; +import { clampMidiTick, type TMidiSong } from '@/modules/engine/plugins/midi'; +import type { TTimelineEditableNote } from '../../lib/timeline-editing'; +import { + DEFAULT_PIXELS_PER_BEAT, + getPixelsPerTick, + MAX_PIXELS_PER_BEAT, + MIN_PIXELS_PER_BEAT, + PIANO_WIDTH, + ZOOM_STEP_FACTOR +} from '../../lib/timeline-layout'; + +export type TTimelineKeyboardMode = 'adaptive' | 'full88'; + +export type TTimelineInteractionState = + | { mode: 'idle' } + | { + mode: 'drawing'; + pointerId: number; + anchorTick: number; + currentTick: number; + noteNumber: number; + didDrag: boolean; + pointerDownClient: { x: number; y: number }; + } + | { + mode: 'moving'; + pointerId: number; + anchorTick: number; + currentTick: number; + anchorNoteNumber: number; + currentNoteNumber: number; + didDrag: boolean; + pointerDownClient: { x: number; y: number }; + clickedNote: TTimelineEditableNote; + notes: TTimelineEditableNote[]; + } + | { + mode: 'resizing-start' | 'resizing-end'; + pointerId: number; + anchorTick: number; + currentTick: number; + note: TTimelineEditableNote; + }; + +export class TimelineCx { + public readonly scrollContainerRef = React.createRef(); + public readonly $containerWidth = createState(0); + public readonly $scrollLeft = createState(0); + public readonly $pixelsPerBeat = createState(DEFAULT_PIXELS_PER_BEAT); + public readonly $keyboardMode = createState('adaptive'); + public readonly $interactionState = createState({ mode: 'idle' }); + + public unmount(): void { + // No-op for now. Keep symmetry with other local Cx helpers. + } + + public setContainerWidth(width: number): void { + const nextWidth = Math.max(0, width); + if (this.$containerWidth.get() !== nextWidth) { + this.$containerWidth.set(nextWidth); + } + } + + public setScrollLeft(scrollLeft: number): void { + const nextScrollLeft = Math.max(0, scrollLeft); + if (this.$scrollLeft.get() !== nextScrollLeft) { + this.$scrollLeft.set(nextScrollLeft); + } + } + + public getPixelsPerTick(song: Pick | null): number { + return getPixelsPerTick(song, this.$pixelsPerBeat.get()); + } + + public getSongWidth(song: Pick): number { + return Math.max(song.totalTicks * this.getPixelsPerTick(song), 1); + } + + public getInnerWidth(song: Pick): number { + return Math.max(PIANO_WIDTH + this.getSongWidth(song), this.$containerWidth.get()); + } + + public getTimelineWidth(song: Pick): number { + return Math.max(this.getInnerWidth(song) - PIANO_WIDTH, 1); + } + + public getZoomRatio(): number { + return this.$pixelsPerBeat.get() / DEFAULT_PIXELS_PER_BEAT; + } + + public setKeyboardMode(mode: TTimelineKeyboardMode): void { + if (this.$keyboardMode.get() !== mode) { + this.$keyboardMode.set(mode); + } + } + + public setInteractionState(state: TTimelineInteractionState): void { + this.$interactionState.set(state); + } + + public clearInteractionState(): void { + if (this.$interactionState.get().mode !== 'idle') { + this.$interactionState.set({ mode: 'idle' }); + } + } + + public getTickAtClientX( + song: Pick, + clientX: number + ): number { + const scrollContainer = this.scrollContainerRef.current; + const pixelsPerTick = this.getPixelsPerTick(song); + if (scrollContainer == null || pixelsPerTick <= 0) { + return 0; + } + + const rect = scrollContainer.getBoundingClientRect(); + const notePx = clientX - rect.left + scrollContainer.scrollLeft - PIANO_WIDTH; + return clampMidiTick(notePx / pixelsPerTick, song.totalTicks); + } + + public zoomIn(song: Pick): void { + this.zoomAtClientX(song, this.getViewportCenterX(), ZOOM_STEP_FACTOR); + } + + public zoomOut(song: Pick): void { + this.zoomAtClientX(song, this.getViewportCenterX(), 1 / ZOOM_STEP_FACTOR); + } + + public zoomAtClientX( + song: Pick, + clientX: number, + factor: number + ): void { + const scrollContainer = this.scrollContainerRef.current; + if (scrollContainer == null) { + return; + } + + const currentPixelsPerBeat = this.$pixelsPerBeat.get(); + const nextPixelsPerBeat = clampPixelsPerBeat(currentPixelsPerBeat * factor); + if (nextPixelsPerBeat === currentPixelsPerBeat) { + return; + } + + const rect = scrollContainer.getBoundingClientRect(); + const noteViewportX = Math.max(0, clientX - rect.left - PIANO_WIDTH); + const currentPixelsPerTick = getPixelsPerTick(song, currentPixelsPerBeat); + if (currentPixelsPerTick <= 0) { + return; + } + + const tickAtPointer = clampMidiTick( + (scrollContainer.scrollLeft + noteViewportX) / currentPixelsPerTick, + song.totalTicks + ); + const nextPixelsPerTick = getPixelsPerTick(song, nextPixelsPerBeat); + const nextScrollLeft = tickAtPointer * nextPixelsPerTick - noteViewportX; + this.syncViewport(song, nextScrollLeft, nextPixelsPerBeat); + } + + public syncViewport( + song: Pick, + scrollLeft: number, + pixelsPerBeat = this.$pixelsPerBeat.get() + ): void { + const nextPixelsPerBeat = clampPixelsPerBeat(pixelsPerBeat); + const nextScrollLeft = this.clampScrollLeft(song, scrollLeft, nextPixelsPerBeat); + + this.$pixelsPerBeat.set(nextPixelsPerBeat); + this.setScrollLeft(nextScrollLeft); + + const scrollContainer = this.scrollContainerRef.current; + if (scrollContainer != null) { + scrollContainer.scrollLeft = nextScrollLeft; + } + } + + private getViewportCenterX(): number { + const scrollContainer = this.scrollContainerRef.current; + if (scrollContainer == null) { + return 0; + } + + const rect = scrollContainer.getBoundingClientRect(); + return rect.left + rect.width / 2; + } + + private clampScrollLeft( + song: Pick, + scrollLeft: number, + pixelsPerBeat = this.$pixelsPerBeat.get() + ): number { + const maxScrollLeft = Math.max( + 0, + Math.max( + PIANO_WIDTH + Math.max(song.totalTicks * getPixelsPerTick(song, pixelsPerBeat), 1), + this.$containerWidth.get() + ) - this.$containerWidth.get() + ); + return Math.max(0, Math.min(maxScrollLeft, scrollLeft)); + } +} + +function clampPixelsPerBeat(pixelsPerBeat: number): number { + if (!Number.isFinite(pixelsPerBeat)) { + return DEFAULT_PIXELS_PER_BEAT; + } + + return Math.max(MIN_PIXELS_PER_BEAT, Math.min(MAX_PIXELS_PER_BEAT, pixelsPerBeat)); +} + +export function useTimelineState(state: { + get(): T; + listen(listener: () => void): () => void; +}): T { + return React.useSyncExternalStore( + (onStoreChange) => state.listen(onStoreChange), + () => state.get(), + () => state.get() + ); +} diff --git a/apps/midimarble/src/modules/editor/components/timeline/TimelineHeader.test.tsx b/apps/midimarble/src/modules/editor/components/timeline/TimelineHeader.test.tsx new file mode 100644 index 0000000..f6170ae --- /dev/null +++ b/apps/midimarble/src/modules/editor/components/timeline/TimelineHeader.test.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { TimelineHeader } from './TimelineHeader'; + +describe('TimelineHeader', () => { + it('renders the selected instrument and routes changes through the callback', () => { + const onSetInstrument = vi.fn(); + + const tree = TimelineHeader({ + canControlPlayback: true, + canEditNotes: true, + canZoom: true, + isImporting: false, + importLabel: 'Open MIDI', + importError: null, + mode: 'paused', + previewEnabled: false, + trackName: 'Lead', + bpm: 120, + playheadTick: 0, + liveStep: 0, + preloadedLabel: 'Preloaded 0', + selectedNoteLabel: null, + keyboardMode: 'adaptive', + trackId: 1, + trackOptions: [{ id: 1, name: 'Lead' }], + instrumentId: 'bell', + instrumentOptions: [ + { id: 'classic', label: 'Classic' }, + { id: 'bell', label: 'Bell' }, + { id: 'lead', label: 'Lead' } + ], + onOpenMidi: vi.fn(), + onSetTrack: vi.fn(), + onSetInstrument, + onSetKeyboardMode: vi.fn(), + onStepBackwardTick: vi.fn(), + onStepForwardTick: vi.fn(), + onPlay: vi.fn(), + onPause: vi.fn(), + onReset: vi.fn(), + onTogglePreview: vi.fn(), + onZoomOut: vi.fn(), + onZoomIn: vi.fn() + }) as React.ReactNode; + + const select = findElement(tree, 'select'); + expect(select).not.toBeNull(); + expect(select?.props['value']).toBe('bell'); + + select?.props['onChange']?.({ target: { value: 'lead' } }); + expect(onSetInstrument).toHaveBeenCalledWith('lead'); + }); + + it('hides the instrument control when no playable track is selected', () => { + const tree = TimelineHeader({ + canControlPlayback: false, + canEditNotes: false, + canZoom: false, + isImporting: false, + importLabel: 'Open MIDI', + importError: null, + mode: 'paused', + previewEnabled: false, + trackName: null, + bpm: null, + playheadTick: 0, + liveStep: 0, + preloadedLabel: 'Preloaded 0', + selectedNoteLabel: null, + keyboardMode: 'adaptive', + trackId: null, + trackOptions: [], + instrumentId: null, + instrumentOptions: [], + onOpenMidi: vi.fn(), + onSetTrack: vi.fn(), + onSetInstrument: vi.fn(), + onSetKeyboardMode: vi.fn(), + onStepBackwardTick: vi.fn(), + onStepForwardTick: vi.fn(), + onPlay: vi.fn(), + onPause: vi.fn(), + onReset: vi.fn(), + onTogglePreview: vi.fn(), + onZoomOut: vi.fn(), + onZoomIn: vi.fn() + }) as React.ReactNode; + + expect(findElement(tree, 'select')).toBeNull(); + }); +}); + +type TInspectableElement = React.ReactElement<{ + children?: React.ReactNode; + value?: string; + onChange?: (event: { target: { value: string } }) => void; +}>; + +function findElement(node: React.ReactNode, type: string): TInspectableElement | null { + if (!React.isValidElement(node)) { + if (Array.isArray(node)) { + for (const child of node) { + const match = findElement(child, type); + if (match != null) { + return match; + } + } + } + return null; + } + + if (node.type === type) { + return node as TInspectableElement; + } + + const element = node as React.ReactElement<{ children?: React.ReactNode }>; + return findElement(element.props.children, type); +} diff --git a/apps/midimarble/src/modules/editor/components/timeline/TimelineHeader.tsx b/apps/midimarble/src/modules/editor/components/timeline/TimelineHeader.tsx new file mode 100644 index 0000000..6316c6b --- /dev/null +++ b/apps/midimarble/src/modules/editor/components/timeline/TimelineHeader.tsx @@ -0,0 +1,347 @@ +import { + Eye, + FileUp, + Pause, + Play, + SkipBack, + SkipForward, + Square, + ZoomIn, + ZoomOut +} from 'lucide-react'; +import React from 'react'; +import type { TAudioInstrumentId, TAudioInstrumentOption } from '@/modules/engine/plugins/audio'; + +const TICK_REPEAT_INITIAL_DELAY_MS = 260; +const TICK_REPEAT_INTERVAL_MS = 70; + +const TimelineIconButton: React.FC<{ + disabled: boolean; + icon: React.ComponentType<{ size?: number; strokeWidth?: number; className?: string }>; + title: string; + onClick: () => void; + repeatOnHold?: boolean; + pressed?: boolean; +}> = ({ disabled, icon: Icon, title, onClick, repeatOnHold = false, pressed = false }) => { + const timeoutRef = React.useRef(null); + const intervalRef = React.useRef(null); + const suppressResetRef = React.useRef(null); + const suppressClickRef = React.useRef(false); + + const clearRepeat = React.useCallback(() => { + if (timeoutRef.current != null) { + window.clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + if (intervalRef.current != null) { + window.clearInterval(intervalRef.current); + intervalRef.current = null; + } + if (suppressResetRef.current != null) { + window.clearTimeout(suppressResetRef.current); + suppressResetRef.current = null; + } + }, []); + + React.useEffect(() => clearRepeat, [clearRepeat]); + + const handlePointerDown = React.useCallback( + (event: React.PointerEvent) => { + if (!repeatOnHold || disabled || event.button !== 0) { + return; + } + + suppressClickRef.current = true; + event.currentTarget.setPointerCapture(event.pointerId); + onClick(); + + timeoutRef.current = window.setTimeout(() => { + intervalRef.current = window.setInterval(() => { + onClick(); + }, TICK_REPEAT_INTERVAL_MS); + }, TICK_REPEAT_INITIAL_DELAY_MS); + }, + [disabled, onClick, repeatOnHold] + ); + + const handleClick = React.useCallback(() => { + if (suppressClickRef.current) { + suppressClickRef.current = false; + return; + } + + onClick(); + }, [onClick]); + + const stopRepeat = React.useCallback(() => { + clearRepeat(); + suppressResetRef.current = window.setTimeout(() => { + suppressClickRef.current = false; + suppressResetRef.current = null; + }, 0); + }, [clearRepeat]); + + return ( + + ); +}; + +const TimelineOpenMidiButton: React.FC<{ + disabled: boolean; + label: string; + onClick: () => void; +}> = ({ disabled, label, onClick }) => ( + +); + +export const TimelineHeader: React.FC<{ + canControlPlayback: boolean; + canEditNotes: boolean; + canZoom: boolean; + isImporting: boolean; + importLabel: string; + importError: string | null; + mode: 'paused' | 'running'; + previewEnabled: boolean; + trackName: string | null; + bpm: number | null; + playheadTick: number; + liveStep: number; + preloadedLabel: string; + selectedNoteLabel: string | null; + keyboardMode: 'adaptive' | 'full88'; + trackId: number | null; + trackOptions: readonly { id: number; name: string }[]; + instrumentId: TAudioInstrumentId | null; + instrumentOptions: readonly TAudioInstrumentOption[]; + onOpenMidi: () => void; + onSetTrack: (trackId: number) => void; + onSetInstrument: (instrumentId: TAudioInstrumentId) => void; + onSetKeyboardMode: (mode: 'adaptive' | 'full88') => void; + onStepBackwardTick: () => void; + onStepForwardTick: () => void; + onPlay: () => void; + onPause: () => void; + onReset: () => void; + onTogglePreview: () => void; + onZoomOut: () => void; + onZoomIn: () => void; +}> = ({ + canControlPlayback, + canEditNotes, + canZoom, + isImporting, + importLabel, + importError, + mode, + previewEnabled, + trackName, + bpm, + playheadTick, + liveStep, + preloadedLabel, + selectedNoteLabel, + keyboardMode, + trackId, + trackOptions, + instrumentId, + instrumentOptions, + onOpenMidi, + onSetTrack, + onSetInstrument, + onSetKeyboardMode, + onStepBackwardTick, + onStepForwardTick, + onPlay, + onPause, + onReset, + onTogglePreview, + onZoomOut, + onZoomIn +}) => ( +
+
+

Timeline

+ + {importError != null ? ( + + {importError} + + ) : null} + + {bpm != null ? ( + + BPM {bpm} + + ) : null} + + + Tick {Math.round(playheadTick)} + + + + Step {liveStep} + + + + {preloadedLabel} + + + {selectedNoteLabel != null ? ( + + Note {selectedNoteLabel} + + ) : null} + +
+ + + {trackId != null ? ( + + ) : null} + + {instrumentId != null ? ( + + ) : null} + +
+ + +
+ +
+ + + + + + + + + + + {mode === 'running' ? ( + + ) : ( + + )} + + +
+
+
+
+); diff --git a/apps/midimarble/src/modules/editor/components/timeline/TimelineRoll.tsx b/apps/midimarble/src/modules/editor/components/timeline/TimelineRoll.tsx new file mode 100644 index 0000000..dbc2630 --- /dev/null +++ b/apps/midimarble/src/modules/editor/components/timeline/TimelineRoll.tsx @@ -0,0 +1,646 @@ +import React from 'react'; +import type { TMidiNote } from '@/modules/engine/plugins/midi'; +import { + buildBeatTicks, + getNoteName, + isBlackKey, + MIN_ROLL_HEIGHT, + NOTE_ROW_HEIGHT, + PIANO_WIDTH, + RULER_HEIGHT +} from '../../lib/timeline-layout'; + +export interface TTimelineDraftNote extends Pick< + TMidiNote, + 'id' | 'tick' | 'durationTicks' | 'noteNumber' | 'velocity' +> { + sourceId: number | null; +} + +export interface TTimelineGridPointerInput { + pointerId: number; + tick: number; + noteNumber: number; + clientX: number; + clientY: number; +} + +export interface TTimelineNotePointerInput extends TTimelineGridPointerInput { + note: Pick; + edge: 'body' | 'start' | 'end'; + additive: boolean; +} + +const PianoColumn = React.memo(function PianoColumn({ + noteRows, + contentHeight, + activeNoteNumbers, + onKeyPointerDown +}: { + noteRows: number[]; + contentHeight: number; + activeNoteNumbers: Set; + onKeyPointerDown: (noteNumber: number) => void; +}) { + return ( +
+
+ +
+
+ {noteRows.map((noteNumber, index) => ( + + ))} +
+
+
+ ); +}); + +const PianoKeyRow = React.memo(function PianoKeyRow({ + noteNumber, + top, + isActive, + onPointerDown +}: { + noteNumber: number; + top: number; + isActive: boolean; + onPointerDown: (noteNumber: number) => void; +}) { + const blackKey = isBlackKey(noteNumber); + const cNote = noteNumber % 12 === 0; + + return ( + + ); +}); + +const TimelineRuler: React.FC<{ + ticksPerBeat: number; + bufferedPx: number; + playheadPx: number; + pixelsPerTick: number; + beatTicks: { majorBeats: number[]; minorBeats: number[] }; + onPointerDown: (event: React.PointerEvent) => void; + onPointerMove: (event: React.PointerEvent) => void; + onPointerUp: () => void; +}> = ({ + ticksPerBeat, + bufferedPx, + playheadPx, + pixelsPerTick, + beatTicks, + onPointerDown, + onPointerMove, + onPointerUp +}) => { + return ( +
+
+ + {beatTicks.majorBeats.map((beat) => ( +
+
+ + {`B${beat}`} + +
+ ))} + + {beatTicks.minorBeats.map((beat) => ( +
+
+
+ ))} + +
+
+
+
+
+ ); +}; + +const PianoRollGrid = React.memo(function PianoRollGrid({ + noteRows, + ticksPerBeat, + pixelsPerTick, + contentHeight, + beatTicks, + notes, + draftNotes, + selectedNoteId, + selectedNoteIds, + hiddenNoteIds, + activeNoteIds, + placedNoteIds, + adjustedNoteIds, + onNotePointerDown +}: { + noteRows: number[]; + ticksPerBeat: number; + pixelsPerTick: number; + contentHeight: number; + beatTicks: { majorBeats: number[]; minorBeats: number[] }; + notes: Array>; + draftNotes: TTimelineDraftNote[]; + selectedNoteId: number | null; + selectedNoteIds: Set; + hiddenNoteIds: Set; + activeNoteIds: Set; + placedNoteIds: Set; + adjustedNoteIds: Set; + onNotePointerDown: ( + event: React.PointerEvent, + note: Pick + ) => void; +}) { + const noteIndexByNumber = React.useMemo( + () => new Map(noteRows.map((noteNumber, index) => [noteNumber, index])), + [noteRows] + ); + + return ( +
+ {noteRows.map((noteNumber, index) => ( +
+ ))} + + {beatTicks.majorBeats.map((beat) => ( +
+ ))} + + {beatTicks.minorBeats.map((beat) => ( +
+ ))} + + {notes.map((note) => { + if (hiddenNoteIds.has(note.id)) { + return null; + } + + const noteRow = noteIndexByNumber.get(note.noteNumber); + if (noteRow == null) { + return null; + } + + const isAdjusted = adjustedNoteIds.has(note.id); + const isPlaced = placedNoteIds.has(note.id); + const isPrimarySelected = note.id === selectedNoteId; + const isSelected = selectedNoteIds.has(note.id); + const isActive = activeNoteIds.has(note.id); + + return ( + + ); + })} + + {draftNotes.map((note) => { + const noteRow = noteIndexByNumber.get(note.noteNumber); + if (noteRow == null) { + return null; + } + + return ( +
+ ); + })} +
+ ); +}); + +export const TimelineRoll: React.FC<{ + timelineWidth: number; + totalTicks: number; + ticksPerBeat: number; + pixelsPerTick: number; + bufferedPx: number; + playheadPx: number; + contentHeight: number; + noteRows: number[]; + notes: Array>; + draftNotes: TTimelineDraftNote[]; + selectedNoteId: number | null; + selectedNoteIds: Set; + activeNoteIds: Set; + activeNoteNumbers: Set; + onPianoKeyPointerDown: (noteNumber: number) => void; + placedNoteIds: Set; + adjustedNoteIds: Set; + canScrub: boolean; + canEditNotes: boolean; + isRulerDragging: boolean; + onRulerPointerDown: (event: React.PointerEvent) => void; + onRulerPointerMove: (event: React.PointerEvent) => void; + onRulerPointerUp: () => void; + onGridPointerDown: (input: TTimelineGridPointerInput) => void; + onGridPointerMove: (input: TTimelineGridPointerInput) => void; + onGridPointerUp: () => void; + onNotePointerDown: (input: TTimelineNotePointerInput) => void; + onKeyDown: (event: React.KeyboardEvent) => void; +}> = ({ + timelineWidth, + totalTicks, + ticksPerBeat, + pixelsPerTick, + bufferedPx, + playheadPx, + contentHeight, + noteRows, + notes, + draftNotes, + selectedNoteId, + selectedNoteIds, + activeNoteIds, + activeNoteNumbers, + onPianoKeyPointerDown, + placedNoteIds, + adjustedNoteIds, + canScrub, + canEditNotes, + isRulerDragging, + onRulerPointerDown, + onRulerPointerMove, + onRulerPointerUp, + onGridPointerDown, + onGridPointerMove, + onGridPointerUp, + onNotePointerDown, + onKeyDown +}) => { + const playheadLineRef = React.useRef(null); + const gridSurfaceRef = React.useRef(null); + const hiddenNoteIds = React.useMemo( + () => new Set(draftNotes.flatMap((note) => (note.sourceId == null ? [] : [note.sourceId]))), + [draftNotes] + ); + const renderedTimelineWidth = Math.max(timelineWidth, Math.max(totalTicks * pixelsPerTick, 1)); + const renderedTotalTicks = Math.max( + totalTicks, + Math.ceil(renderedTimelineWidth / Math.max(pixelsPerTick, 0.0001)) + ); + const beatTicks = React.useMemo( + () => buildBeatTicks(renderedTotalTicks, ticksPerBeat), + [renderedTotalTicks, ticksPerBeat] + ); + + React.useEffect(() => { + if (playheadLineRef.current != null) { + playheadLineRef.current.style.left = `${playheadPx}px`; + } + }, [playheadPx]); + + const getGridPointerInput = React.useCallback( + (pointerId: number, clientX: number, clientY: number): TTimelineGridPointerInput | null => { + const surface = gridSurfaceRef.current; + if (surface == null) { + return null; + } + + const rect = surface.getBoundingClientRect(); + const noteViewportX = Math.max(0, clientX - rect.left); + const tick = noteViewportX / Math.max(pixelsPerTick, 0.0001); + const noteRowIndex = Math.max( + 0, + Math.min(noteRows.length - 1, Math.floor((clientY - rect.top) / NOTE_ROW_HEIGHT)) + ); + const noteNumber = noteRows[noteRowIndex] ?? noteRows[noteRows.length - 1] ?? 60; + + return { + pointerId, + tick, + noteNumber, + clientX, + clientY + }; + }, + [noteRows, pixelsPerTick] + ); + + const handleGridPointerDown = React.useCallback( + (event: React.PointerEvent) => { + if (!canEditNotes) { + return; + } + + gridSurfaceRef.current?.focus(); + gridSurfaceRef.current?.setPointerCapture(event.pointerId); + const input = getGridPointerInput(event.pointerId, event.clientX, event.clientY); + if (input != null) { + onGridPointerDown(input); + } + }, + [canEditNotes, getGridPointerInput, onGridPointerDown] + ); + + const handleGridPointerMove = React.useCallback( + (event: React.PointerEvent) => { + const input = getGridPointerInput(event.pointerId, event.clientX, event.clientY); + if (input != null) { + onGridPointerMove(input); + } + }, + [getGridPointerInput, onGridPointerMove] + ); + + const handleNoteButtonPointerDown = React.useCallback( + ( + event: React.PointerEvent, + note: Pick + ) => { + event.preventDefault(); + event.stopPropagation(); + gridSurfaceRef.current?.focus(); + + const input = getGridPointerInput(event.pointerId, event.clientX, event.clientY); + if (input == null) { + return; + } + + const additive = event.metaKey || event.ctrlKey; + const rect = event.currentTarget.getBoundingClientRect(); + const edgeSize = Math.min(12, Math.max(6, rect.width * 0.35)); + const canResize = canEditNotes && !additive; + const edge = + !canEditNotes || !canResize + ? 'body' + : event.clientX - rect.left <= edgeSize + ? 'start' + : rect.right - event.clientX <= edgeSize + ? 'end' + : 'body'; + + if (canEditNotes && !additive) { + gridSurfaceRef.current?.setPointerCapture(event.pointerId); + } + + onNotePointerDown({ + ...input, + note, + edge, + additive + }); + }, + [canEditNotes, getGridPointerInput, onNotePointerDown] + ); + + return ( +
+
+ + +
+ + +
+ +
+ +
+
+
+
+
+
+ ); +}; diff --git a/apps/midimarble/src/modules/editor/index.ts b/apps/midimarble/src/modules/editor/index.ts new file mode 100644 index 0000000..c0ed301 --- /dev/null +++ b/apps/midimarble/src/modules/editor/index.ts @@ -0,0 +1,2 @@ +export * from './components'; +export * from './EditorCx'; diff --git a/apps/midimarble/src/modules/editor/lib/inspector-target.test.ts b/apps/midimarble/src/modules/editor/lib/inspector-target.test.ts new file mode 100644 index 0000000..e1f8748 --- /dev/null +++ b/apps/midimarble/src/modules/editor/lib/inspector-target.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { + buildEmptyInspectorTarget, + buildNoteInspectorTarget, + buildStraightTrackInspectorTarget, + deriveInspectorPathState +} from './inspector-target'; + +const SONG = { + bpm: 120, + ticksPerBeat: 480 +} as const; + +describe('inspector target helpers', () => { + it('builds a note inspector target with derived step', () => { + expect( + buildNoteInspectorTarget( + SONG, + 'Lead', + { + id: 1, + tick: 8, + durationTicks: 120, + noteNumber: 60, + velocity: 100, + channel: 0 + }, + 1 / 240, + { x: 1, y: 2, z: 3 }, + null + ) + ).toMatchObject({ + kind: 'note', + noteName: 'C4', + step: 2, + trackName: 'Lead', + position: { x: 1, y: 2, z: 3 } + }); + }); + + it('derives inspector path state from playback progress', () => { + expect(deriveInspectorPathState(2, { x: 1, y: 2, z: 3 }, 1, 3)).toBe('future'); + expect(deriveInspectorPathState(1, { x: 1, y: 2, z: 3 }, 1, 3)).toBe('past'); + expect(deriveInspectorPathState(5, { x: 1, y: 2, z: 3 }, 1, 3)).toBe('unresolved'); + expect(deriveInspectorPathState(2, null, 1, 3)).toBe('unresolved'); + }); + + it('builds an empty inspector target', () => { + expect(buildEmptyInspectorTarget()).toEqual({ + kind: 'empty', + message: 'Select a note, straight track, or marble to inspect it.' + }); + }); + + it('builds a straight-track inspector target', () => { + expect( + buildStraightTrackInspectorTarget(12, { + position: { x: -7.25, y: 10, z: 4 }, + rotation: { x: 0.3, y: 0, z: 0 }, + length: 14, + width: 1.5, + channelWidth: 1.3, + channelDepth: 0.2, + color: '#2a5e92' + }) + ).toMatchObject({ + kind: 'straight-track', + entityId: 12, + position: { x: -7.25, y: 10, z: 4 }, + length: 14 + }); + }); +}); diff --git a/apps/midimarble/src/modules/editor/lib/inspector-target.ts b/apps/midimarble/src/modules/editor/lib/inspector-target.ts new file mode 100644 index 0000000..6e43b5a --- /dev/null +++ b/apps/midimarble/src/modules/editor/lib/inspector-target.ts @@ -0,0 +1,187 @@ +import type { TVec3 } from '@/modules/engine'; +import { tickToStep, type TMidiNote, type TMidiSong } from '@/modules/engine/plugins/midi'; +import { getNoteName } from './timeline-layout'; + +export type TInspectorTarget = + | TEmptyInspectorTarget + | TNoteInspectorTarget + | TNotePlatformInspectorTarget + | TStraightTrackInspectorTarget + | TMarbleInspectorTarget; + +export interface TEmptyInspectorTarget { + kind: 'empty'; + message: string; +} + +export interface TNoteInspectorTarget { + kind: 'note'; + title: string; + noteId: number; + noteName: string; + noteNumber: number; + tick: number; + step: number; + durationTicks: number; + velocity: number; + channel: number; + trackName: string; + position: TVec3 | null; + notePlatformEntityId: number | null; +} + +export interface TNotePlatformInspectorTarget { + kind: 'note-platform'; + title: string; + entityId: number; + noteId: number; + noteName: string; + tick: number; + step: number; + position: TVec3 | null; + offsetY: number; + offsetZ: number; + rotationX: number; + length: number; + width: number; + thickness: number; + bounce: number; + color: string; +} + +export interface TStraightTrackInspectorTarget { + kind: 'straight-track'; + title: string; + entityId: number; + position: TVec3; + rotation: TVec3; + length: number; + width: number; + channelWidth: number; + channelDepth: number; + color: string; +} + +export interface TMarbleInspectorTarget { + kind: 'marble'; + title: string; + entityId: number; + position: TVec3; + bounce: number; +} + +export type TInspectorPathState = 'past' | 'future' | 'unresolved'; + +export function buildNoteInspectorTarget( + song: Pick, + trackName: string, + note: TMidiNote, + fixedTimeStepSeconds: number, + position: TVec3 | null, + notePlatformEntityId: number | null +): TNoteInspectorTarget { + const step = tickToStep(note.tick, song, fixedTimeStepSeconds); + return { + kind: 'note', + title: 'Selected Note', + noteId: note.id, + noteName: getNoteName(note.noteNumber), + noteNumber: note.noteNumber, + tick: note.tick, + step, + durationTicks: note.durationTicks, + velocity: note.velocity, + channel: note.channel, + trackName, + position, + notePlatformEntityId + }; +} + +export function buildNotePlatformInspectorTarget( + trackName: string, + note: TMidiNote, + entityId: number, + step: number, + position: TVec3 | null, + platform: { + offsetY: number; + offsetZ: number; + rotationX: number; + length: number; + width: number; + thickness: number; + bounce: number; + color: string; + } +): TNotePlatformInspectorTarget { + return { + kind: 'note-platform', + title: 'Note Platform', + entityId, + noteId: note.id, + noteName: `${getNoteName(note.noteNumber)} · ${trackName}`, + tick: note.tick, + step, + position, + offsetY: platform.offsetY, + offsetZ: platform.offsetZ, + rotationX: platform.rotationX, + length: platform.length, + width: platform.width, + thickness: platform.thickness, + bounce: platform.bounce, + color: platform.color + }; +} + +export function buildStraightTrackInspectorTarget( + entityId: number, + track: { + position: TVec3; + rotation: TVec3; + length: number; + width: number; + channelWidth: number; + channelDepth: number; + color: string; + } +): TStraightTrackInspectorTarget { + return { + kind: 'straight-track', + title: 'Straight Track', + entityId, + position: track.position, + rotation: track.rotation, + length: track.length, + width: track.width, + channelWidth: track.channelWidth, + channelDepth: track.channelDepth, + color: track.color + }; +} + +export function buildEmptyInspectorTarget(): TEmptyInspectorTarget { + return { + kind: 'empty', + message: 'Select a note, straight track, or marble to inspect it.' + }; +} + +export function deriveInspectorPathState( + step: number, + position: TVec3 | null, + liveStep: number, + bufferedStep: number +): TInspectorPathState { + if (position == null) { + return 'unresolved'; + } + if (step <= liveStep) { + return 'past'; + } + if (step <= bufferedStep) { + return 'future'; + } + return 'unresolved'; +} diff --git a/apps/midimarble/src/modules/editor/lib/scene-ui.test.ts b/apps/midimarble/src/modules/editor/lib/scene-ui.test.ts new file mode 100644 index 0000000..8dbf3c1 --- /dev/null +++ b/apps/midimarble/src/modules/editor/lib/scene-ui.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { + canCreateStraightTrack, + canDeleteStraightTrack, + getInspectorDeleteEntityId +} from './scene-ui'; + +describe('scene UI helpers', () => { + it('allows creating a straight track only when preview is off and simulation is idle', () => { + expect(canCreateStraightTrack(false, 'idle')).toBe(true); + expect(canCreateStraightTrack(true, 'idle')).toBe(false); + expect(canCreateStraightTrack(false, 'dirty')).toBe(false); + expect(canCreateStraightTrack(false, 'rebuilding')).toBe(false); + }); + + it('allows deleting a straight track only when simulation is idle', () => { + expect(canDeleteStraightTrack('idle')).toBe(true); + expect(canDeleteStraightTrack('dirty')).toBe(false); + expect(canDeleteStraightTrack('rebuilding')).toBe(false); + }); + + it('returns a delete entity id only for straight-track inspector targets', () => { + expect( + getInspectorDeleteEntityId({ + kind: 'straight-track', + title: 'Straight Track', + entityId: 14, + position: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0, z: 0 }, + length: 14, + width: 1.5, + channelWidth: 1.3, + channelDepth: 0.2, + color: '#2a5e92' + }) + ).toBe(14); + expect( + getInspectorDeleteEntityId({ + kind: 'marble', + title: 'Marble', + entityId: 3, + position: { x: 0, y: 0, z: 0 }, + bounce: 0.4 + }) + ).toBeNull(); + }); +}); diff --git a/apps/midimarble/src/modules/editor/lib/scene-ui.ts b/apps/midimarble/src/modules/editor/lib/scene-ui.ts new file mode 100644 index 0000000..6766746 --- /dev/null +++ b/apps/midimarble/src/modules/editor/lib/scene-ui.ts @@ -0,0 +1,17 @@ +import type { TSimulationSync } from '@/modules/engine/plugins/physics'; +import type { TInspectorTarget } from './inspector-target'; + +export function canCreateStraightTrack( + previewEnabled: boolean, + simulationSyncMode: TSimulationSync['mode'] +): boolean { + return !previewEnabled && simulationSyncMode === 'idle'; +} + +export function canDeleteStraightTrack(simulationSyncMode: TSimulationSync['mode']): boolean { + return simulationSyncMode === 'idle'; +} + +export function getInspectorDeleteEntityId(target: TInspectorTarget): number | null { + return target.kind === 'straight-track' ? target.entityId : null; +} diff --git a/apps/midimarble/src/modules/editor/lib/selection-inspector-target.ts b/apps/midimarble/src/modules/editor/lib/selection-inspector-target.ts new file mode 100644 index 0000000..a759f5d --- /dev/null +++ b/apps/midimarble/src/modules/editor/lib/selection-inspector-target.ts @@ -0,0 +1,165 @@ +import type { TVec3 } from '@/modules/engine'; +import { + findNoteById, + findTrackById, + tickToStep, + type TMidiLookup, + type TMidiSong +} from '@/modules/engine/plugins/midi'; +import { + buildEmptyInspectorTarget, + buildNoteInspectorTarget, + buildNotePlatformInspectorTarget, + buildStraightTrackInspectorTarget, + type TEmptyInspectorTarget, + type TInspectorTarget, + type TNoteInspectorTarget +} from './inspector-target'; + +type TStraightTrackEntry = readonly [ + number, + { position: TVec3; rotation: TVec3 }, + { length: number }, + { width: number; channelWidth: number; channelDepth: number; color: string } +]; + +type TMarbleEntry = readonly [number, TVec3, { bounce: number }]; + +type TNotePlatformEntry = readonly [ + number, + { noteId: number }, + { + offsetY: number; + offsetZ: number; + rotationX: number; + length: number; + width: number; + thickness: number; + bounce: number; + color: string; + } +]; + +interface TTrajectoryProjectionLike { + noteAnchorsById: Map< + number, + { + tick: number; + step: number; + position: TVec3; + } + >; +} + +export interface TSelectedNoteInspectorTargetInput { + midiSong: TMidiSong | null; + midiLookup: TMidiLookup; + selectedTrackId: number | null; + selectedNoteId: number | null; + fixedTimeStepSeconds: number; + trajectoryProjection: TTrajectoryProjectionLike; + notePlatformByNoteId: Map; +} + +export interface TSceneSelectionInspectorTargetInput { + midiSong: TMidiSong | null; + midiLookup: TMidiLookup; + sceneSelectionEntityId: number | null; + fixedTimeStepSeconds: number; + trajectoryProjection: TTrajectoryProjectionLike; + tracks: TStraightTrackEntry[]; + marbles: TMarbleEntry[]; + notePlatforms: TNotePlatformEntry[]; +} + +export function deriveSelectedNoteInspectorTarget( + input: TSelectedNoteInspectorTargetInput +): TNoteInspectorTarget | TEmptyInspectorTarget { + const { midiSong, midiLookup, selectedTrackId, selectedNoteId, fixedTimeStepSeconds } = input; + const selectedTrack = findTrackById(midiSong, selectedTrackId, midiLookup); + const selectedNoteMatch = findNoteById(midiSong, selectedNoteId, midiLookup); + + if ( + midiSong == null || + selectedTrack == null || + selectedNoteMatch == null || + selectedNoteMatch.track.id !== selectedTrack.id + ) { + return buildEmptyInspectorTarget(); + } + + const anchor = input.trajectoryProjection.noteAnchorsById.get(selectedNoteMatch.note.id) ?? null; + return buildNoteInspectorTarget( + midiSong, + selectedTrack.name, + selectedNoteMatch.note, + fixedTimeStepSeconds, + anchor?.position ?? null, + input.notePlatformByNoteId.get(selectedNoteMatch.note.id) ?? null + ); +} + +export function deriveSceneSelectionInspectorTarget( + input: TSceneSelectionInspectorTargetInput +): TInspectorTarget { + const { + midiSong, + midiLookup, + sceneSelectionEntityId, + fixedTimeStepSeconds, + trajectoryProjection, + tracks, + marbles, + notePlatforms + } = input; + + if (sceneSelectionEntityId == null) { + return buildEmptyInspectorTarget(); + } + + const selectedNotePlatform = notePlatforms.find(([eid]) => eid === sceneSelectionEntityId); + if (selectedNotePlatform != null) { + const [eid, binding, platform] = selectedNotePlatform; + const noteMatch = findNoteById(midiSong, binding.noteId, midiLookup); + if (noteMatch != null && midiSong != null) { + const anchor = trajectoryProjection.noteAnchorsById.get(binding.noteId); + const step = anchor?.step ?? tickToStep(noteMatch.note.tick, midiSong, fixedTimeStepSeconds); + return buildNotePlatformInspectorTarget( + noteMatch.track.name, + noteMatch.note, + eid, + step, + anchor?.position ?? null, + platform + ); + } + } + + const selectedTrackEntity = tracks.find(([eid]) => eid === sceneSelectionEntityId); + if (selectedTrackEntity != null) { + const [eid, transform, linear, track] = selectedTrackEntity; + return buildStraightTrackInspectorTarget(eid, { + position: transform.position, + rotation: transform.rotation, + length: linear.length, + width: track.width, + channelWidth: track.channelWidth, + channelDepth: track.channelDepth, + color: track.color + }); + } + + const selectedMarble = marbles.find(([eid]) => eid === sceneSelectionEntityId); + if (selectedMarble != null) { + const [eid, position, marblePhysics] = selectedMarble; + return { + kind: 'marble', + title: 'Marble', + entityId: eid, + position, + bounce: marblePhysics.bounce + }; + } + + return buildEmptyInspectorTarget(); +} diff --git a/apps/midimarble/src/modules/editor/lib/timeline-editing.test.ts b/apps/midimarble/src/modules/editor/lib/timeline-editing.test.ts new file mode 100644 index 0000000..ae7fcb0 --- /dev/null +++ b/apps/midimarble/src/modules/editor/lib/timeline-editing.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { + buildDrawnTimelineNote, + buildMovedTimelineNotes, + buildResizedTimelineNote, + getSnappedMoveDeltaTick, + getSnappedTimelineTick +} from './timeline-editing'; + +describe('timeline editing helpers', () => { + it('moves note groups while clamping tick and pitch deltas', () => { + expect( + buildMovedTimelineNotes( + [ + { id: 1, tick: 10, durationTicks: 120, noteNumber: 60, velocity: 100 }, + { id: 2, tick: 20, durationTicks: 120, noteNumber: 64, velocity: 100 } + ], + -50, + 80 + ) + ).toEqual([ + { id: 1, tick: 0, durationTicks: 120, noteNumber: 123, velocity: 100 }, + { id: 2, tick: 10, durationTicks: 120, noteNumber: 127, velocity: 100 } + ]); + }); + + it('resizes a note from either edge while keeping duration positive', () => { + const note = { id: 1, tick: 100, durationTicks: 80, noteNumber: 60, velocity: 100 }; + + expect(buildResizedTimelineNote(note, 'start', 120)).toEqual({ + ...note, + tick: 179, + durationTicks: 1 + }); + expect(buildResizedTimelineNote(note, 'end', -100)).toEqual({ + ...note, + durationTicks: 1 + }); + }); + + it('creates a default-length note for tiny draw gestures', () => { + expect(buildDrawnTimelineNote(72, 100, 101, 480)).toEqual({ + tick: 100, + durationTicks: 480, + noteNumber: 72 + }); + }); + + it('snaps ticks to the nearest beat when they are within the pixel threshold', () => { + expect(getSnappedTimelineTick(479, 480, 0.5, 2)).toBe(480); + expect(getSnappedTimelineTick(470, 480, 0.5, 2)).toBe(470); + }); + + it('snaps move deltas using the dragged note tick as the reference', () => { + expect(getSnappedMoveDeltaTick(240, 237, 480, 1, 6)).toBe(240); + expect(getSnappedMoveDeltaTick(240, 220, 480, 1, 6)).toBe(220); + }); +}); diff --git a/apps/midimarble/src/modules/editor/lib/timeline-editing.ts b/apps/midimarble/src/modules/editor/lib/timeline-editing.ts new file mode 100644 index 0000000..08abe78 --- /dev/null +++ b/apps/midimarble/src/modules/editor/lib/timeline-editing.ts @@ -0,0 +1,125 @@ +import type { TMidiNote } from '@/modules/engine/plugins/midi'; + +export interface TTimelineEditableNote extends Pick< + TMidiNote, + 'id' | 'tick' | 'durationTicks' | 'noteNumber' | 'velocity' +> {} + +export function getSnappedTimelineTick( + tick: number, + ticksPerBeat: number, + pixelsPerTick: number, + thresholdPx: number +): number { + const normalizedTick = Math.max(0, Math.round(tick)); + if (ticksPerBeat <= 0 || pixelsPerTick <= 0 || thresholdPx <= 0) { + return normalizedTick; + } + + const snappedTick = Math.round(normalizedTick / ticksPerBeat) * ticksPerBeat; + return Math.abs((normalizedTick - snappedTick) * pixelsPerTick) <= thresholdPx + ? snappedTick + : normalizedTick; +} + +export function getSnappedMoveDeltaTick( + referenceTick: number, + deltaTick: number, + ticksPerBeat: number, + pixelsPerTick: number, + thresholdPx: number +): number { + const normalizedReferenceTick = Math.max(0, Math.round(referenceTick)); + const normalizedDeltaTick = Math.round(deltaTick); + const snappedTargetTick = getSnappedTimelineTick( + normalizedReferenceTick + normalizedDeltaTick, + ticksPerBeat, + pixelsPerTick, + thresholdPx + ); + return snappedTargetTick - normalizedReferenceTick; +} + +export function getClampedMoveDeltaTick(notes: TTimelineEditableNote[], deltaTick: number): number { + const roundedDeltaTick = Math.round(deltaTick); + const minTick = Math.min(...notes.map((note) => note.tick)); + return Math.max(-minTick, roundedDeltaTick); +} + +export function getClampedMoveDeltaNoteNumber( + notes: TTimelineEditableNote[], + deltaNoteNumber: number +): number { + const roundedDeltaNoteNumber = Math.round(deltaNoteNumber); + const minNoteNumber = Math.min(...notes.map((note) => note.noteNumber)); + const maxNoteNumber = Math.max(...notes.map((note) => note.noteNumber)); + return Math.max(-minNoteNumber, Math.min(127 - maxNoteNumber, roundedDeltaNoteNumber)); +} + +export function buildMovedTimelineNotes( + notes: TTimelineEditableNote[], + deltaTick: number, + deltaNoteNumber: number +): TTimelineEditableNote[] { + const clampedDeltaTick = getClampedMoveDeltaTick(notes, deltaTick); + const clampedDeltaNoteNumber = getClampedMoveDeltaNoteNumber(notes, deltaNoteNumber); + + return notes.map((note) => ({ + ...note, + tick: Math.max(0, Math.round(note.tick + clampedDeltaTick)), + noteNumber: clampNoteNumber(note.noteNumber + clampedDeltaNoteNumber) + })); +} + +export function buildResizedTimelineNote( + note: TTimelineEditableNote, + edge: 'start' | 'end', + deltaTick: number +): TTimelineEditableNote { + const roundedDelta = Math.round(deltaTick); + const noteEndTick = note.tick + note.durationTicks; + + if (edge === 'start') { + const nextTick = Math.max(0, Math.min(noteEndTick - 1, note.tick + roundedDelta)); + return { + ...note, + tick: nextTick, + durationTicks: noteEndTick - nextTick + }; + } + + return { + ...note, + durationTicks: Math.max(1, note.durationTicks + roundedDelta) + }; +} + +export function buildDrawnTimelineNote( + noteNumber: number, + anchorTick: number, + currentTick: number, + defaultDurationTicks: number +): Pick { + const normalizedAnchorTick = Math.max(0, Math.round(anchorTick)); + const normalizedCurrentTick = Math.max(0, Math.round(currentTick)); + const startTick = Math.min(normalizedAnchorTick, normalizedCurrentTick); + const endTick = Math.max(normalizedAnchorTick, normalizedCurrentTick); + const durationTicks = Math.max( + 1, + endTick - startTick < 2 ? Math.round(defaultDurationTicks) : endTick - startTick + ); + + return { + tick: startTick, + durationTicks, + noteNumber: clampNoteNumber(noteNumber) + }; +} + +function clampNoteNumber(noteNumber: number): number { + if (!Number.isFinite(noteNumber)) { + return 60; + } + + return Math.max(0, Math.min(127, Math.round(noteNumber))); +} diff --git a/apps/midimarble/src/modules/editor/lib/timeline-layout.ts b/apps/midimarble/src/modules/editor/lib/timeline-layout.ts new file mode 100644 index 0000000..c0e54db --- /dev/null +++ b/apps/midimarble/src/modules/editor/lib/timeline-layout.ts @@ -0,0 +1,101 @@ +import type { TMidiNote, TMidiSong } from '@/modules/engine/plugins/midi'; + +export const RULER_HEIGHT = 28; +export const PIANO_WIDTH = 72; +export const NOTE_ROW_HEIGHT = 18; +export const MIN_NOTE_RANGE = 12; +export const MIN_ROLL_HEIGHT = 220; +export const DEFAULT_PIXELS_PER_BEAT = 48; +export const MIN_PIXELS_PER_BEAT = 20; +export const MAX_PIXELS_PER_BEAT = 320; +export const ZOOM_STEP_FACTOR = 1.25; +export const FULL_PIANO_LOW_NOTE = 21; +export const FULL_PIANO_HIGH_NOTE = 108; + +const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; + +export function getPixelsPerTick( + song: Pick | null, + pixelsPerBeat = DEFAULT_PIXELS_PER_BEAT +): number { + if (song == null) { + return 0; + } + + return pixelsPerBeat / Math.max(1, song.ticksPerBeat); +} + +export function buildBeatTicks( + totalTicks: number, + ticksPerBeat: number +): { + majorBeats: number[]; + minorBeats: number[]; +} { + const totalBeats = Math.max(1, Math.ceil(totalTicks / Math.max(1, ticksPerBeat))); + const beatStride = + totalBeats > 512 + ? 32 + : totalBeats > 256 + ? 16 + : totalBeats > 128 + ? 8 + : totalBeats > 64 + ? 4 + : totalBeats > 32 + ? 2 + : 1; + + return { + majorBeats: Array.from( + { length: Math.ceil(totalBeats / beatStride) + 1 }, + (_, index) => index * beatStride + ).filter((beat) => beat * ticksPerBeat <= totalTicks), + minorBeats: + beatStride === 1 + ? Array.from({ length: totalBeats }, (_, index) => index + 0.5).filter( + (beat) => beat * ticksPerBeat <= totalTicks + ) + : [] + }; +} + +export function buildNoteRows( + notes: Pick[], + extraNoteNumbers: number[] = [], + keyboardMode: 'adaptive' | 'full88' = 'adaptive' +): number[] { + if (keyboardMode === 'full88') { + return Array.from( + { length: FULL_PIANO_HIGH_NOTE - FULL_PIANO_LOW_NOTE + 1 }, + (_, index) => FULL_PIANO_HIGH_NOTE - index + ); + } + + const allNoteNumbers = [ + ...notes.map((note) => note.noteNumber), + ...extraNoteNumbers.filter((noteNumber) => Number.isFinite(noteNumber)) + ]; + + if (allNoteNumbers.length === 0) { + return Array.from({ length: MIN_NOTE_RANGE }, (_, index) => 71 - index); + } + + const minNote = Math.min(...allNoteNumbers); + const maxNote = Math.max(...allNoteNumbers); + const range = maxNote - minNote + 1; + const padding = Math.max(0, MIN_NOTE_RANGE - range); + const low = Math.max(0, minNote - Math.floor(padding / 2) - 1); + const high = Math.min(127, maxNote + Math.ceil(padding / 2) + 1); + + return Array.from({ length: high - low + 1 }, (_, index) => high - index); +} + +export function getNoteName(noteNumber: number): string { + const octave = Math.floor(noteNumber / 12) - 1; + return `${NOTE_NAMES[noteNumber % 12]}${octave}`; +} + +export function isBlackKey(noteNumber: number): boolean { + return [1, 3, 6, 8, 10].includes(noteNumber % 12); +} diff --git a/apps/midimarble/src/modules/engine/Runtime.test.ts b/apps/midimarble/src/modules/engine/Runtime.test.ts new file mode 100644 index 0000000..72f4aa4 --- /dev/null +++ b/apps/midimarble/src/modules/engine/Runtime.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Runtime } from './Runtime'; + +describe('Runtime scene edit lifecycle', () => { + it('clears pending scene edits after loading a new MIDI file', async () => { + const runtime = createRuntimeHarness(); + + await Runtime.prototype.loadMidiFile.call( + runtime, + new File([new Uint8Array([0x4d, 0x54, 0x68, 0x64])], 'demo.mid', { type: 'audio/midi' }) + ); + + expect(runtime._app.loadMidiFile).toHaveBeenCalledOnce(); + expect(runtime._app.clearTrackInstruments).toHaveBeenCalledOnce(); + expect(runtime._app.resetTransport).toHaveBeenCalledOnce(); + expect(runtime._app.setSceneEditPending).toHaveBeenCalledWith(false); + expect(runtime._flushImmediateUpdate).toHaveBeenCalledOnce(); + }); + + it('clears pending scene edits after clearing the current MIDI song', () => { + const runtime = createRuntimeHarness(); + + Runtime.prototype.clearMidiSong.call(runtime); + + expect(runtime._app.clearMidiSong).toHaveBeenCalledOnce(); + expect(runtime._app.clearTrackInstruments).toHaveBeenCalledOnce(); + expect(runtime._app.resetTransport).toHaveBeenCalledOnce(); + expect(runtime._app.setSceneEditPending).toHaveBeenCalledWith(false); + expect(runtime._flushImmediateUpdate).toHaveBeenCalledOnce(); + }); + + it('updates the selected track instrument and previews it immediately', () => { + const runtime = createRuntimeHarness({ + _app: { + r: { + selectedTrackId: 7, + simulationSync: { + mode: 'idle' + } + } + } + }); + + Runtime.prototype.setTrackInstrument.call(runtime, 7, 'lead'); + + expect(runtime._app.setTrackInstrument).toHaveBeenCalledWith(7, 'lead'); + expect(runtime._app.previewTrackInstrument).toHaveBeenCalledWith(7); + expect(runtime._flushImmediateUpdate).toHaveBeenCalledOnce(); + }); + + it('does not create a straight track while simulation sync is active', () => { + const runtime = createRuntimeHarness({ + _app: { + setSimulationResumeWhenReady: vi.fn(() => true), + r: { + simulationSync: { + mode: 'dirty', + requested: false, + resumeWhenReady: true + } + } + } + }); + + const entityId = Runtime.prototype.createStraightTrack.call(runtime); + + expect(entityId).toBeNull(); + expect(runtime._app.createStraightTrack).not.toHaveBeenCalled(); + expect(runtime._app.setSimulationResumeWhenReady).toHaveBeenCalledWith(false); + expect(runtime._flushImmediateUpdate).not.toHaveBeenCalled(); + }); + + it('does not delete a straight track while simulation sync is active', () => { + const runtime = createRuntimeHarness({ + _app: { + setSimulationResumeWhenReady: vi.fn(() => true), + r: { + simulationSync: { + mode: 'dirty', + requested: false, + resumeWhenReady: true + } + } + } + }); + + Runtime.prototype.deleteStraightTrack.call(runtime, 41); + + expect(runtime._app.deleteStraightTrack).not.toHaveBeenCalled(); + expect(runtime._app.setSimulationResumeWhenReady).toHaveBeenCalledWith(false); + expect(runtime._flushImmediateUpdate).not.toHaveBeenCalled(); + }); +}); + +function createRuntimeHarness( + overrides: { + _app?: Record; + _flushImmediateUpdate?: ReturnType; + } = {} +): any { + const harness: any = { + _app: { + loadMidiFile: vi.fn().mockResolvedValue(undefined), + clearMidiSong: vi.fn(), + clearTrackInstruments: vi.fn(), + resetTransport: vi.fn(), + createStraightTrack: vi.fn(() => 41), + deleteStraightTrack: vi.fn(() => true), + setTrackInstrument: vi.fn(), + previewTrackInstrument: vi.fn(), + setSceneEditPending: vi.fn(), + setSimulationResumeWhenReady: vi.fn(() => false), + r: { + selectedTrackId: null, + simulationSync: { + mode: 'idle' + } + }, + ...overrides._app + }, + _flushImmediateUpdate: overrides._flushImmediateUpdate ?? vi.fn() + }; + + harness._runImmediateCommand = (command: () => unknown) => { + const result = command(); + harness._flushImmediateUpdate(); + return result; + }; + harness._runSimulationCommand = (fallback: unknown, command: () => unknown) => { + if (harness._app.setSimulationResumeWhenReady(false)) { + return fallback; + } + return harness._runImmediateCommand(command); + }; + + return harness; +} diff --git a/apps/midimarble/src/modules/engine/Runtime.ts b/apps/midimarble/src/modules/engine/Runtime.ts new file mode 100644 index 0000000..91ab3f4 --- /dev/null +++ b/apps/midimarble/src/modules/engine/Runtime.ts @@ -0,0 +1,495 @@ +import { + createApp, + createDefaultPlugin, + Entity, + With, + type TApp, + type TAppContext, + type TDefaultPlugin +} from 'ecsify'; +import type { + TMarbleSnapshot, + TNotePlatformSnapshot, + TProjectRecord, + TStraightTrackSnapshot +} from '@/modules/persistence/types'; +import { + createAudioPlugin, + createCorePlugin, + createMidiPlugin, + createPhysicsPlugin, + createRenderPlugin, + createScenePlugin, + createTrajectoryPlugin, + createTransportPlugin, + type TAudioInstrumentId, + type TAudioPlugin, + type TCorePlugin, + type TMidiCreateNoteInput, + type TMidiPlugin, + type TPhysicsPlugin, + type TRenderPlugin, + type TScenePlugin, + type TTrajectoryPlugin, + type TTransportPlugin +} from './plugins'; +import { sceneConfig } from './plugins/scene/config'; +import { ENGINE_SYSTEM_SETS } from './types'; + +export class Runtime { + private readonly _app: TRuntimeApp; + private _frameId: number | null = null; + private _lastTime = 0; + private _isMounted = true; + + constructor(record?: TProjectRecord | null) { + this._app = createApp({ + plugins: [ + createDefaultPlugin(), + createCorePlugin(), + createMidiPlugin( + record != null + ? { song: record.midiSong, selectedTrackId: record.selectedTrackId } + : undefined + ), + createTransportPlugin(), + createAudioPlugin(record != null ? { audioSettings: record.audioSettings } : undefined), + createPhysicsPlugin(), + createRenderPlugin(), + createTrajectoryPlugin( + record != null ? { trajectoryConfig: record.trajectoryConfig } : undefined + ), + createScenePlugin( + record != null + ? { + marble: record.marble, + straightTracks: record.straightTracks, + notePlatforms: record.notePlatforms + } + : undefined + ) + ] as const, + systemSets: [...ENGINE_SYSTEM_SETS] + }); + + if (record != null) { + this._app.seekToTick(record.playheadTick); + } + } + + public get app(): TRuntimeApp { + return this._app; + } + + public run(): void { + const app = this._app; + void app.resumeAudio(); + if (app.setSimulationResumeWhenReady(true)) { + return; + } + + app.run(); + } + + public pause(): void { + this._app.setSimulationResumeWhenReady(false); + this._runImmediateCommand(() => { + this._app.pause(); + }); + } + + public reset(): void { + this._runSimulationCommand(undefined, () => { + this._app.resetTransport(); + }); + } + + public async loadMidiFile(file: File): Promise { + await this._app.loadMidiFile(file); + this._runImmediateCommand(() => { + this._app.clearTrackInstruments(); + this._app.resetTransport(); + this._app.setSceneEditPending(false); + }); + } + + public clearMidiSong(): void { + this._runImmediateCommand(() => { + this._app.clearMidiSong(); + this._app.clearTrackInstruments(); + this._app.resetTransport(); + this._app.setSceneEditPending(false); + }); + } + + public setPreviewEnabled(enabled: boolean): void { + this._runImmediateCommand(() => { + this._app.setPreviewEnabled(enabled); + }); + } + + public togglePreview(): void { + this._runImmediateCommand(() => { + this._app.togglePreview(); + }); + } + + public updatePreviewConfig(patch: Partial): void { + this._runImmediateCommand(() => { + this._app.updatePreviewConfig(patch); + }); + } + + public updateTrajectoryConfig(patch: Partial): void { + this._runImmediateCommand(() => { + this._app.updateTrajectoryConfig(patch); + }); + } + + public updateAudioSettings(patch: Partial): void { + this._runImmediateCommand(() => { + this._app.updateAudioSettings(patch); + }); + } + + public setSelectedTrack(trackId: number): void { + this._runImmediateCommand(() => { + this._app.updateResource('selectedTrackId', trackId); + this._app.clearNoteSelection(); + }); + } + + public setTrackInstrument(trackId: number, instrumentId: TAudioInstrumentId): void { + this._runImmediateCommand(() => { + this._app.setTrackInstrument(trackId, instrumentId); + }); + if (this._app.r.selectedTrackId === trackId) { + void this._app.previewTrackInstrument(trackId); + } + } + + public selectNote(noteId: number, tick: number): void { + const didSelect = this._runTransportEditCommand(false, () => { + this._app.seekToTick(tick); + this._app.selectNote(noteId); + return true; + }); + if (didSelect) { + void this._app.previewNote(noteId); + } + } + + public selectNotes(noteIds: number[], primaryNoteId: number | null): void { + this._runImmediateCommand(() => { + this._app.selectNotes(noteIds, primaryNoteId); + }); + } + + public selectAllTrackNotes(trackId?: number): void { + this._runImmediateCommand(() => { + this._app.selectAllTrackNotes(trackId); + }); + } + + public clearNoteSelection(): void { + this._runImmediateCommand(() => { + this._app.clearNoteSelection(); + }); + } + + public previewNotesAtTick(tick: number): void { + void this._app.previewNotesAtTick(tick); + } + + public previewNote(noteId: number): void { + void this._app.previewNote(noteId); + } + + public previewMidiNote(noteNumber: number): void { + void this._app.previewMidiNote(noteNumber); + } + + public createNote(input: TMidiCreateNoteInput): number | null { + return this._runTransportEditCommand(null, () => this._app.createNote(input)); + } + + public moveSelectedNotes(deltaTick: number, deltaNoteNumber: number): boolean { + return this._runTransportEditCommand(false, () => + this._app.moveSelectedNotes(deltaTick, deltaNoteNumber) + ); + } + + public resizePrimarySelectedNote(edge: 'start' | 'end', deltaTick: number): boolean { + return this._runTransportEditCommand(false, () => + this._app.resizePrimarySelectedNote(edge, deltaTick) + ); + } + + public deleteSelectedNotes(): number { + return this._runTransportEditCommand(0, () => this._app.deleteSelectedNotes()); + } + + public createOrSelectNotePlatform(noteId: number): number | null { + return this._runSimulationCommand(null, () => this._app.createOrSelectNotePlatform(noteId)); + } + + public createStraightTrack(): number | null { + return this._runSimulationCommand(null, () => this._app.createStraightTrack()); + } + + public deleteStraightTrack(entityId: number): void { + void this._runSimulationCommand(false, () => this._app.deleteStraightTrack(entityId)); + } + + public updateNotePlatform( + entityId: number, + patch: Partial + ): void { + this._runDeferredSceneEdit(() => this._app.updateNotePlatform(entityId, patch)); + } + + public updateMarblePhysics( + entityId: number, + patch: Partial + ): void { + this._runDeferredSceneEdit(() => this._app.updateMarblePhysics(entityId, patch)); + } + + public commitSceneEdit(): void { + if (!this._app.r.sceneEditState.pending) { + return; + } + + this._runImmediateCommand(() => { + this._app.markSimulationDirty(); + this._app.requestSimulationSync(); + this._app.setSceneEditPending(false); + }); + } + + public extractSnapshot(): TProjectRecord { + const app = this._app; + + // Marble + let marble: TMarbleSnapshot = { + position: { ...sceneConfig.marble.spawn.position }, + rotation: { ...sceneConfig.marble.spawn.rotation }, + bounce: sceneConfig.marble.physics.defaults.bounce + }; + for (const [, , , marblePhysics] of app.queryComponents( + [Entity, app.c.PositionMixin, app.c.RotationMixin, app.c.MarblePhysicsMixin] as const, + With(app.c.MarbleTag) + )) { + marble = { + position: { ...sceneConfig.marble.spawn.position }, + rotation: { ...sceneConfig.marble.spawn.rotation }, + bounce: marblePhysics.bounce + }; + break; + } + + // Note platforms + const notePlatforms: TNotePlatformSnapshot[] = []; + for (const [, binding, platform] of app.queryComponents( + [Entity, app.c.NoteBindingMixin, app.c.NotePlatformMixin] as const, + With(app.c.NotePlatformMixin) + )) { + notePlatforms.push({ + noteId: binding.noteId, + offsetY: platform.offsetY, + offsetZ: platform.offsetZ, + rotationX: platform.rotationX, + length: platform.length, + width: platform.width, + thickness: platform.thickness, + bounce: platform.bounce, + color: platform.color + }); + } + + // Straight tracks + const straightTracks: TStraightTrackSnapshot[] = []; + for (const [, transform, track, linear] of app.queryComponents( + [ + Entity, + app.c.AuthoredTransformMixin, + app.c.StraightTrackMixin, + app.c.LinearElementMixin + ] as const, + With(app.c.StraightTrackMixin) + )) { + straightTracks.push({ + position: { ...transform.position }, + rotation: { ...transform.rotation }, + scale: { ...transform.scale }, + length: linear.length, + height: track.height, + width: track.width, + channelWidth: track.channelWidth, + channelDepth: track.channelDepth, + color: track.color + }); + } + + const { midiSong, selectedTrackId, transport, audioSettings, trajectoryConfig } = app.r; + + return { + id: '', + name: midiSong?.name ?? 'Untitled', + createdAt: 0, + updatedAt: Date.now(), + midiFileName: midiSong?.name ?? null, + midiSong: midiSong ?? null, + selectedTrackId, + playheadTick: transport.playheadTick, + audioSettings: { ...audioSettings }, + trajectoryConfig: { ...trajectoryConfig }, + marble, + notePlatforms, + straightTracks + }; + } + + public seekToTick(tick: number): void { + this._runSimulationCommand(undefined, () => { + this._app.seekToTick(tick); + }); + } + + public stepBackwardTick(): void { + if (this._app.setSimulationResumeWhenReady(false)) { + return; + } + + const wasPaused = this._app.r.transport.mode === 'paused'; + const prevTick = this._app.r.transport.playheadTick; + this._runImmediateCommand(() => { + this._app.stepBackwardTick(); + }); + if (wasPaused && this._app.r.transport.playheadTick !== prevTick) { + void this._app.previewNotesAtTick(this._app.r.transport.playheadTick); + } + } + + public stepForwardTick(): void { + if (this._app.setSimulationResumeWhenReady(false)) { + return; + } + + const wasPaused = this._app.r.transport.mode === 'paused'; + const prevTick = this._app.r.transport.playheadTick; + this._runImmediateCommand(() => { + this._app.stepForwardTick(); + }); + if (wasPaused && this._app.r.transport.playheadTick !== prevTick) { + void this._app.previewNotesAtTick(this._app.r.transport.playheadTick); + } + } + + public start(): void { + if (!this._isMounted || this._frameId != null) { + return; + } + this._lastTime = performance.now(); + this._frameId = window.requestAnimationFrame(this._loop); + } + + public stop(): void { + if (this._frameId == null) { + return; + } + window.cancelAnimationFrame(this._frameId); + this._frameId = null; + } + + public setContainer(container: HTMLDivElement | null): void { + this._app.setRenderContainer(container); + if (container == null) { + this.stop(); + return; + } + this.start(); + } + + public unmount(): void { + if (!this._isMounted) { + return; + } + this._isMounted = false; + this.stop(); + this._app.r.preloadWorld?.free(); + this._app.r.world?.free(); + if (this._app.r.simulationSync.mode === 'rebuilding') { + this._app.r.simulationSync.world.free(); + } + this._app.disposeScene(); + this._app.disposeTrajectory(); + this._app.disposeAudio(); + this._app.setRenderContainer(null); + this._app.disposeRender(); + this._app.flush(); + } + + private readonly _loop = (time: number): void => { + if (!this._isMounted) { + return; + } + const dt = Math.max(0, (time - this._lastTime) / 1000); + this._lastTime = time; + this._app.update(dt); + this._frameId = window.requestAnimationFrame(this._loop); + }; + + private _flushImmediateUpdate(): void { + this._app.update(0); + } + + private _runImmediateCommand(command: () => T): T { + const result = command(); + this._flushImmediateUpdate(); + return result; + } + + private _runSimulationCommand(fallback: T, command: () => T): T { + if (this._app.setSimulationResumeWhenReady(false)) { + return fallback; + } + + return this._runImmediateCommand(command); + } + + private _runTransportEditCommand(fallback: T, command: () => T): T { + if (this._app.setSimulationResumeWhenReady(false)) { + return fallback; + } + + this._app.pause(); + return this._runImmediateCommand(command); + } + + private _runDeferredSceneEdit(command: () => boolean): void { + if (!command()) { + return; + } + + this._runImmediateCommand(() => { + this._app.setSceneEditPending(true); + }); + } +} + +export type TRuntimeApp = TApp< + TAppContext< + [ + TDefaultPlugin, + TCorePlugin, + TMidiPlugin, + TTransportPlugin, + TAudioPlugin, + TPhysicsPlugin, + TRenderPlugin, + TTrajectoryPlugin, + TScenePlugin + ] + > +>; diff --git a/apps/midimarble/src/modules/engine/hooks/index.ts b/apps/midimarble/src/modules/engine/hooks/index.ts new file mode 100644 index 0000000..ca94fa6 --- /dev/null +++ b/apps/midimarble/src/modules/engine/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './use-query-components'; +export * from './use-resource'; diff --git a/apps/midimarble/src/modules/engine/hooks/use-query-components.ts b/apps/midimarble/src/modules/engine/hooks/use-query-components.ts new file mode 100644 index 0000000..70d7cf0 --- /dev/null +++ b/apps/midimarble/src/modules/engine/hooks/use-query-components.ts @@ -0,0 +1,58 @@ +import { + Entity, + type TApp, + type TComponentDataTuple, + type TComponentRef, + type TEntity, + type TQuery, + type TQueryFilter +} from 'ecsify'; +import React from 'react'; + +export function useQueryComponents( + app: TApp, + options: TUseQueryComponentsFactoryValue, + deps: React.DependencyList = [] +): TComponentDataTuple[] { + const { components, queryOrFilter, watchComponents } = options; + const [values, setValues] = React.useState[]>(() => + app.queryComponents(components, queryOrFilter) + ); + + React.useEffect(() => { + const watched = + watchComponents ?? + (components.filter((component) => component !== Entity) as TComponentRef[]); + + const sync = () => { + setValues(app.queryComponents(components, queryOrFilter)); + }; + + const unbinds: Array<() => void> = []; + for (const component of watched) { + unbinds.push( + app._componentRegistry.onAdd(component, sync), + app._componentRegistry.onChange(component, sync), + app._componentRegistry.onRemove(component, sync) + ); + } + + sync(); + + return () => { + for (const unbind of unbinds) { + unbind(); + } + }; + }, [app, ...deps]); + + return values; +} + +interface TUseQueryComponentsFactoryValue< + GComponents extends readonly (TComponentRef | TEntity)[] +> { + components: GComponents; + queryOrFilter?: TQuery | TQueryFilter; + watchComponents?: TComponentRef[]; +} diff --git a/apps/midimarble/src/modules/engine/hooks/use-resource.ts b/apps/midimarble/src/modules/engine/hooks/use-resource.ts new file mode 100644 index 0000000..bf22711 --- /dev/null +++ b/apps/midimarble/src/modules/engine/hooks/use-resource.ts @@ -0,0 +1,24 @@ +import type { TApp, TAppContext } from 'ecsify'; +import React from 'react'; + +export function useResource< + GAppContext extends TAppContext, + GKey extends keyof GAppContext['resources'] +>(app: TApp, key: GKey): GAppContext['resources'][GKey] { + const [, forceRender] = React.useReducer((count) => count + 1, 0); + + React.useEffect(() => { + const unbinds: Array<() => void> = [ + app._resourceRegistry.onAdd(key, forceRender), + app._resourceRegistry.onChange(key, forceRender) + ]; + + return () => { + for (const unbind of unbinds) { + unbind(); + } + }; + }, [app, key]); + + return app.r[key]; +} diff --git a/apps/midimarble/src/modules/engine/index.ts b/apps/midimarble/src/modules/engine/index.ts new file mode 100644 index 0000000..4e97cfa --- /dev/null +++ b/apps/midimarble/src/modules/engine/index.ts @@ -0,0 +1,4 @@ +export * from './hooks'; +export * from './plugins'; +export * from './Runtime'; +export * from './types'; diff --git a/apps/midimarble/src/modules/engine/plugins/audio/audio-plugin.test.ts b/apps/midimarble/src/modules/engine/plugins/audio/audio-plugin.test.ts new file mode 100644 index 0000000..9f9c708 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/audio/audio-plugin.test.ts @@ -0,0 +1,377 @@ +import { createApp, createDefaultPlugin } from 'ecsify'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ENGINE_SYSTEM_SETS } from '../../types'; +import { createMidiPlugin } from '../midi'; +import { createTransportPlugin } from '../transport'; +import { createAudioPlugin } from './audio-plugin'; + +const toneState = vi.hoisted(() => ({ + players: [] as Array<{ + voiceName: string; + options: unknown; + connect: ReturnType; + triggerAttackRelease: ReturnType; + releaseAll: ReturnType; + dispose: ReturnType; + }>, + rawContext: null as unknown +})); + +vi.mock('./lib/tone-runtime', () => { + class FakePolySynth { + public readonly voiceName: string; + public readonly options: unknown; + public connect = vi.fn(() => this); + public triggerAttackRelease = vi.fn(); + public releaseAll = vi.fn(); + public dispose = vi.fn(); + + constructor(voice?: { name?: string }, options?: unknown) { + this.voiceName = voice?.name ?? 'unknown'; + this.options = options; + toneState.players.push(this); + } + } + + class FakeSynth {} + class FakeFMSynth {} + class FakeAMSynth {} + class FakeMonoSynth {} + + return { + PolySynth: FakePolySynth, + Synth: FakeSynth, + FMSynth: FakeFMSynth, + AMSynth: FakeAMSynth, + MonoSynth: FakeMonoSynth, + getContext: vi.fn(() => ({ rawContext: toneState.rawContext })), + setContext: vi.fn((context: { rawContext?: unknown }) => { + toneState.rawContext = 'rawContext' in context ? context.rawContext : context; + }) + }; +}); + +const SONG = { + name: 'Demo', + bpm: 120, + ticksPerBeat: 480, + totalTicks: 960, + tracks: [ + { + id: 0, + name: 'Lead', + notes: [ + { id: 1, tick: 0, durationTicks: 120, noteNumber: 60, velocity: 100, channel: 0 }, + { id: 2, tick: 8, durationTicks: 120, noteNumber: 62, velocity: 90, channel: 0 } + ] + } + ] +} as const; + +describe('audio plugin', () => { + afterEach(() => { + toneState.players.length = 0; + toneState.rawContext = null; + Reflect.deleteProperty(globalThis, 'AudioContext'); + vi.useRealTimers(); + }); + + it('creates and resumes the audio context once across concurrent calls', async () => { + const deferred = createDeferred(); + const resume = vi.fn(() => deferred.promise); + let createCount = 0; + + class FakeAudioContext { + public readonly destination = {}; + public readonly currentTime = 0; + public readonly state = 'running'; + + constructor() { + createCount += 1; + } + + public createGain = vi.fn(() => new FakeGainNode()); + public resume = resume; + public close = vi.fn(async () => undefined); + } + + Object.defineProperty(globalThis, 'AudioContext', { + value: FakeAudioContext, + configurable: true, + writable: true + }); + + const app = createAudioHarness(); + + const first = app.resumeAudio(); + const second = app.resumeAudio(); + deferred.resolve(); + await Promise.all([first, second]); + + expect(app.r.audioState.context).not.toBeNull(); + expect(createCount).toBe(1); + expect(resume).toHaveBeenCalledOnce(); + }); + + it('keeps only the latest preview request after async audio init', async () => { + const deferred = createDeferred(); + + class FakeAudioContext { + public readonly destination = {}; + public readonly currentTime = 0; + public readonly state = 'running'; + public createGain = vi.fn(() => new FakeGainNode()); + public resume = vi.fn(() => deferred.promise); + public close = vi.fn(async () => undefined); + } + + Object.defineProperty(globalThis, 'AudioContext', { + value: FakeAudioContext, + configurable: true, + writable: true + }); + + const app = createAudioHarness(); + app.updateResource('midiSong', SONG as never); + app.updateResource('selectedTrackId', 0); + + const first = app.previewNotesAtTick(0); + const second = app.previewNotesAtTick(8); + deferred.resolve(); + await Promise.all([first, second]); + + expect(app.r.audioState.lastProcessedTick).toBe(8); + expect([...app.r.audioState.activeVoices.keys()]).toEqual([2]); + expect(app.r.audioPlaybackFeedback.activeNoteIds).toEqual(new Set([2])); + expect(app.r.audioPlaybackFeedback.activeNoteNumbers).toEqual(new Set([62])); + }); + + it('uses the bell preset by default and track overrides for preview', async () => { + Object.defineProperty(globalThis, 'AudioContext', { + value: createFakeAudioContextClass(), + configurable: true, + writable: true + }); + + const app = createAudioHarness(); + app.updateResource('midiSong', SONG as never); + app.updateResource('selectedTrackId', 0); + + await app.previewMidiNote(65); + expect(toneState.players.at(-1)?.voiceName).toBe('FakeFMSynth'); + + app.setTrackInstrument(0, 'lead'); + await app.previewMidiNote(65); + expect(toneState.players.at(-1)?.voiceName).toBe('FakeMonoSynth'); + }); + + it('stops ringing voices when the track instrument changes', async () => { + Object.defineProperty(globalThis, 'AudioContext', { + value: createFakeAudioContextClass(), + configurable: true, + writable: true + }); + + const app = createAudioHarness(); + app.updateResource('midiSong', SONG as never); + app.updateResource('selectedTrackId', 0); + + await app.previewMidiNote(65); + const player = toneState.players.at(-1); + + app.setTrackInstrument(0, 'lead'); + + expect(player?.dispose).toHaveBeenCalledOnce(); + expect(app.r.audioState.activeVoices.size).toBe(0); + expect(app.r.audioPlaybackFeedback.activeNoteNumbers).toEqual(new Set()); + expect(app.r.audioSettings.trackInstrumentIds).toEqual({ 0: 'lead' }); + }); + + it('uses the selected preset during playback after transport advances', async () => { + Object.defineProperty(globalThis, 'AudioContext', { + value: createFakeAudioContextClass(), + configurable: true, + writable: true + }); + + const app = createAudioHarness(); + app.updateResource('midiSong', SONG as never); + app.updateResource('selectedTrackId', 0); + app.setTrackInstrument(0, 'lead'); + + await app.resumeAudio(); + app.update(0); + app.updateResource('transport', { mode: 'running', playheadTick: 8 } as never); + app.update(0); + + const player = toneState.players.at(-1); + expect(player?.voiceName).toBe('FakeMonoSynth'); + expect(player?.triggerAttackRelease).toHaveBeenCalled(); + }); + + it('previews a selected note by id with its own playback feedback', async () => { + Object.defineProperty(globalThis, 'AudioContext', { + value: createFakeAudioContextClass(), + configurable: true, + writable: true + }); + + const app = createAudioHarness(); + app.updateResource('midiSong', SONG as never); + app.updateResource('selectedTrackId', 0); + + await app.previewNote(2); + + expect(app.r.audioState.lastProcessedTick).toBe(8); + expect([...app.r.audioState.activeVoices.keys()]).toEqual([2]); + expect(app.r.audioPlaybackFeedback.activeNoteIds).toEqual(new Set([2])); + expect(app.r.audioPlaybackFeedback.activeNoteNumbers).toEqual(new Set([62])); + }); + + it('expires playback feedback after the tap window', async () => { + vi.useFakeTimers(); + + Object.defineProperty(globalThis, 'AudioContext', { + value: createFakeAudioContextClass(), + configurable: true, + writable: true + }); + + const app = createAudioHarness(); + app.updateResource('midiSong', SONG as never); + app.updateResource('selectedTrackId', 0); + + await app.previewNotesAtTick(0); + expect(app.r.audioPlaybackFeedback.activeNoteIds).toEqual(new Set([1])); + + vi.setSystemTime(Date.now() + 121); + app.update(0); + + expect(app.r.audioPlaybackFeedback.activeNoteIds).toEqual(new Set()); + expect(app.r.audioPlaybackFeedback.activeNoteNumbers).toEqual(new Set()); + }); + + it('resets audio state on dispose so the graph can be rebuilt later', async () => { + let createCount = 0; + + class FakeAudioContext { + public readonly destination = {}; + public readonly currentTime = 0; + public readonly state = 'running'; + + constructor() { + createCount += 1; + } + + public createGain = vi.fn(() => new FakeGainNode()); + public resume = vi.fn(async () => undefined); + public close = vi.fn(async () => undefined); + } + + Object.defineProperty(globalThis, 'AudioContext', { + value: FakeAudioContext, + configurable: true, + writable: true + }); + + const app = createAudioHarness(); + + await app.resumeAudio(); + const firstContext = app.r.audioState.context; + app.disposeAudio(); + + expect(app.r.audioState).toMatchObject({ + context: null, + masterGain: null, + isEnabled: false, + lastProcessedTick: 0, + lastMode: 'paused' + }); + + await app.resumeAudio(); + + expect(app.r.audioState.context).not.toBeNull(); + expect(app.r.audioState.context).not.toBe(firstContext); + expect(createCount).toBe(2); + }); + + it('ignores an in-flight resume after dispose', async () => { + const deferred = createDeferred(); + + class FakeAudioContext { + public readonly destination = {}; + public readonly currentTime = 0; + public readonly state = 'running'; + public createGain = vi.fn(() => new FakeGainNode()); + public resume = vi.fn(() => deferred.promise); + public close = vi.fn(async () => undefined); + } + + Object.defineProperty(globalThis, 'AudioContext', { + value: FakeAudioContext, + configurable: true, + writable: true + }); + + const app = createAudioHarness(); + + const pendingResume = app.resumeAudio(); + app.disposeAudio(); + deferred.resolve(); + await pendingResume; + + expect(app.r.audioState).toMatchObject({ + context: null, + masterGain: null, + isEnabled: false, + lastProcessedTick: 0, + lastMode: 'paused' + }); + }); +}); + +function createAudioHarness() { + return createApp({ + plugins: [ + createDefaultPlugin(), + createMidiPlugin(), + createTransportPlugin(), + createAudioPlugin() + ] as const, + systemSets: [...ENGINE_SYSTEM_SETS] + }); +} + +function createFakeAudioContextClass() { + return class FakeAudioContext { + public readonly destination = {}; + public readonly currentTime = 0; + public readonly state = 'running'; + public createGain = vi.fn(() => new FakeGainNode()); + public resume = vi.fn(async () => undefined); + public close = vi.fn(async () => undefined); + }; +} + +class FakeGainNode { + public readonly gain = { + value: 0, + setValueAtTime: vi.fn(), + cancelScheduledValues: vi.fn(), + linearRampToValueAtTime: vi.fn() + }; + + public connect = vi.fn(); + public disconnect = vi.fn(); +} + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve; + reject = nextReject; + }); + + return { promise, resolve, reject }; +} diff --git a/apps/midimarble/src/modules/engine/plugins/audio/audio-plugin.ts b/apps/midimarble/src/modules/engine/plugins/audio/audio-plugin.ts new file mode 100644 index 0000000..35baf09 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/audio/audio-plugin.ts @@ -0,0 +1,303 @@ +import { findNoteById, findTrackById } from '../midi'; +import { audioConfig } from './config'; +import { getTrackInstrumentId } from './lib/instruments'; +import { getSelectedTrackNotesAtTick } from './lib/playback'; +import { + disposeAudioGraph, + ensureAudioGraph, + previewMidiKeyNote, + previewSelectedTrackNote, + previewTrackNotesAtTick, + stopAllVoices +} from './lib/synth'; +import { syncAudioPlaybackSystem } from './systems'; +import type { + TAudioApp, + TAudioInstrumentId, + TAudioPlugin, + TAudioSettings, + TAudioState +} from './types'; + +export function createAudioPlugin(options?: { + audioSettings?: Partial; +}): TAudioPlugin { + let resumePromise: Promise | null = null; + let previewRequestId = 0; + let audioSessionId = 0; + + return { + // Audio owns note playback and simple built-in synthesis for the selected MIDI track. + name: 'Audio', + deps: ['Default', 'Midi', 'Transport'], + resources: { + audioState: createInitialAudioState(), + audioSettings: { + enabled: audioConfig.defaults.enabled, + masterVolume: audioConfig.defaults.masterVolume, + trackInstrumentIds: {}, + ...options?.audioSettings + }, + audioPlaybackFeedback: { + activeNoteIds: new Set(), + activeNoteNumbers: new Set(), + expiresAtMs: 0 + } + }, + appExtensions: { + async resumeAudio(this: TAudioApp): Promise { + if (!this.r.audioSettings.enabled) { + return; + } + + if (resumePromise != null) { + await resumePromise; + return; + } + + const sessionId = audioSessionId; + resumePromise = (async () => { + const nextState = await ensureAudioGraph( + this.r.audioState, + this.r.audioSettings.masterVolume + ); + if (sessionId !== audioSessionId) { + disposeAudioGraph(nextState); + return; + } + if (nextState !== this.r.audioState) { + this.updateResource('audioState', nextState); + } + })(); + + try { + await resumePromise; + } finally { + resumePromise = null; + } + }, + async previewNotesAtTick(this: TAudioApp, tick: number): Promise { + if (!this.r.audioSettings.enabled) { + return; + } + + const requestId = ++previewRequestId; + await this.resumeAudio(); + if (requestId !== previewRequestId) { + return; + } + const { audioState, midiSong, midiLookup, selectedTrackId, transport } = this.r; + if (!audioState.isEnabled || midiSong == null || selectedTrackId == null) { + return; + } + + const notes = getSelectedTrackNotesAtTick(midiSong, midiLookup, selectedTrackId, tick); + const instrumentId = getTrackInstrumentId( + this.r.audioSettings.trackInstrumentIds, + selectedTrackId + ); + stopAllVoices(audioState); + previewTrackNotesAtTick(audioState, midiSong, notes, instrumentId); + this.updateResource('audioPlaybackFeedback', createPlaybackFeedback(notes)); + this.updateResource('audioState', { + ...audioState, + lastProcessedTick: tick, + lastMode: transport.mode + }); + }, + async previewNote(this: TAudioApp, noteId: number): Promise { + if (!this.r.audioSettings.enabled) { + return; + } + + const requestId = ++previewRequestId; + await this.resumeAudio(); + if (requestId !== previewRequestId) { + return; + } + + const { audioState, midiSong, midiLookup, selectedTrackId, transport } = this.r; + if (!audioState.isEnabled || midiSong == null || selectedTrackId == null) { + return; + } + + const noteMatch = findNoteById(midiSong, noteId, midiLookup); + if (noteMatch == null || noteMatch.track.id !== selectedTrackId) { + return; + } + + const instrumentId = getTrackInstrumentId( + this.r.audioSettings.trackInstrumentIds, + noteMatch.track.id + ); + stopAllVoices(audioState); + previewSelectedTrackNote(audioState, midiSong, noteMatch.note, instrumentId); + this.updateResource('audioPlaybackFeedback', createPlaybackFeedback([noteMatch.note])); + this.updateResource('audioState', { + ...audioState, + lastProcessedTick: noteMatch.note.tick, + lastMode: transport.mode + }); + }, + async previewMidiNote(this: TAudioApp, noteNumber: number): Promise { + if (!this.r.audioSettings.enabled) { + return; + } + + const requestId = ++previewRequestId; + await this.resumeAudio(); + if (requestId !== previewRequestId) { + return; + } + + const { audioState, midiSong, midiLookup, selectedTrackId, transport } = this.r; + if (!audioState.isEnabled || midiSong == null || selectedTrackId == null) { + return; + } + + const track = findTrackById(midiSong, selectedTrackId, midiLookup); + if (track == null) { + return; + } + + const clampedNoteNumber = Math.max(0, Math.min(127, Math.round(noteNumber))); + const instrumentId = getTrackInstrumentId( + this.r.audioSettings.trackInstrumentIds, + selectedTrackId + ); + stopAllVoices(audioState); + previewMidiKeyNote( + audioState, + midiSong, + clampedNoteNumber, + { velocity: 100 }, + instrumentId + ); + this.updateResource('audioPlaybackFeedback', { + activeNoteIds: new Set(), + activeNoteNumbers: new Set([clampedNoteNumber]), + expiresAtMs: Date.now() + 120 + }); + this.updateResource('audioState', { + ...audioState, + lastProcessedTick: transport.playheadTick, + lastMode: transport.mode + }); + }, + async previewTrackInstrument(this: TAudioApp, trackId: number): Promise { + if (!this.r.audioSettings.enabled) { + return; + } + + const requestId = ++previewRequestId; + await this.resumeAudio(); + if (requestId !== previewRequestId) { + return; + } + + const { audioState, midiSong, midiLookup } = this.r; + if (!audioState.isEnabled || midiSong == null) { + return; + } + + const track = findTrackById(midiSong, trackId, midiLookup); + const previewNote = track?.notes[0]; + if (track == null || previewNote == null) { + return; + } + + const instrumentId = getTrackInstrumentId( + this.r.audioSettings.trackInstrumentIds, + track.id + ); + stopAllVoices(audioState); + previewMidiKeyNote( + audioState, + midiSong, + previewNote.noteNumber, + { velocity: previewNote.velocity }, + instrumentId + ); + this.updateResource('audioPlaybackFeedback', { + activeNoteIds: new Set(), + activeNoteNumbers: new Set([previewNote.noteNumber]), + expiresAtMs: Date.now() + 120 + }); + }, + updateAudioSettings(this: TAudioApp, patch: Partial): void { + this.updateResource('audioSettings', { + ...this.r.audioSettings, + ...patch + }); + }, + setTrackInstrument(this: TAudioApp, trackId: number, instrumentId: TAudioInstrumentId): void { + if (this.r.audioSettings.trackInstrumentIds[trackId] === instrumentId) { + return; + } + + stopAllVoices(this.r.audioState); + this.updateResource('audioSettings', { + ...this.r.audioSettings, + trackInstrumentIds: { + ...this.r.audioSettings.trackInstrumentIds, + [trackId]: instrumentId + } + }); + this.updateResource('audioPlaybackFeedback', createEmptyPlaybackFeedback()); + }, + clearTrackInstruments(this: TAudioApp): void { + if (Object.keys(this.r.audioSettings.trackInstrumentIds).length === 0) { + return; + } + + stopAllVoices(this.r.audioState); + this.updateResource('audioSettings', { + ...this.r.audioSettings, + trackInstrumentIds: {} + }); + this.updateResource('audioPlaybackFeedback', createEmptyPlaybackFeedback()); + }, + disposeAudio(this: TAudioApp): void { + audioSessionId += 1; + disposeAudioGraph(this.r.audioState); + resumePromise = null; + previewRequestId += 1; + this.updateResource('audioState', createInitialAudioState()); + this.updateResource('audioPlaybackFeedback', createEmptyPlaybackFeedback()); + } + }, + setup(app: TAudioApp) { + app.addSystem(syncAudioPlaybackSystem, { set: 'PostUpdate' }); + } + }; +} + +function createInitialAudioState(): TAudioState { + return { + context: null, + masterGain: null, + isEnabled: false, + lastProcessedTick: 0, + lastMode: 'paused' as const, + activeVoices: new Map(), + instrumentPlayers: {} + }; +} + +function createPlaybackFeedback( + notes: Array<{ id: number; noteNumber: number }> +): TAudioApp['r']['audioPlaybackFeedback'] { + return { + activeNoteIds: new Set(notes.map((note) => note.id)), + activeNoteNumbers: new Set(notes.map((note) => note.noteNumber)), + expiresAtMs: Date.now() + 120 + }; +} + +function createEmptyPlaybackFeedback(): TAudioApp['r']['audioPlaybackFeedback'] { + return { + activeNoteIds: new Set(), + activeNoteNumbers: new Set(), + expiresAtMs: 0 + }; +} diff --git a/apps/midimarble/src/modules/engine/plugins/audio/config.ts b/apps/midimarble/src/modules/engine/plugins/audio/config.ts new file mode 100644 index 0000000..b7cdb77 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/audio/config.ts @@ -0,0 +1,20 @@ +export const audioConfig = { + defaults: { + enabled: true, + masterVolume: 0.32 + }, + defaultInstrumentId: 'bell', + instrumentOptions: [ + { id: 'classic', label: 'Classic' }, + { id: 'bell', label: 'Bell' }, + { id: 'xylophone', label: 'Xylophone' }, + { id: 'warm', label: 'Warm' }, + { id: 'pluck', label: 'Pluck' }, + { id: 'lead', label: 'Lead' } + ], + playback: { + maxNoteSeconds: 2.5, + previewMaxNoteSeconds: 0.9, + voiceCleanupPaddingSeconds: 1.6 + } +} as const; diff --git a/apps/midimarble/src/modules/engine/plugins/audio/index.ts b/apps/midimarble/src/modules/engine/plugins/audio/index.ts new file mode 100644 index 0000000..099a8aa --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/audio/index.ts @@ -0,0 +1,4 @@ +export * from './audio-plugin'; +export * from './lib/instruments'; +export * from './lib/playback'; +export * from './types'; diff --git a/apps/midimarble/src/modules/engine/plugins/audio/lib/instruments.test.ts b/apps/midimarble/src/modules/engine/plugins/audio/lib/instruments.test.ts new file mode 100644 index 0000000..9e767a6 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/audio/lib/instruments.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { audioConfig } from '../config'; +import { getTrackInstrumentId, isAudioInstrumentId } from './instruments'; + +describe('audio instrument helpers', () => { + it('falls back to the bell preset when a track has no override', () => { + expect(getTrackInstrumentId({}, 4)).toBe(audioConfig.defaultInstrumentId); + }); + + it('returns the explicit track override when present', () => { + expect(getTrackInstrumentId({ 4: 'lead' }, 4)).toBe('lead'); + }); + + it('recognizes instrument ids from the catalog', () => { + expect(audioConfig.instrumentOptions.map((option) => option.id)).toEqual([ + 'classic', + 'bell', + 'xylophone', + 'warm', + 'pluck', + 'lead' + ]); + expect(isAudioInstrumentId('warm')).toBe(true); + expect(isAudioInstrumentId('xylophone')).toBe(true); + expect(isAudioInstrumentId('nope')).toBe(false); + }); +}); diff --git a/apps/midimarble/src/modules/engine/plugins/audio/lib/instruments.ts b/apps/midimarble/src/modules/engine/plugins/audio/lib/instruments.ts new file mode 100644 index 0000000..e29271b --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/audio/lib/instruments.ts @@ -0,0 +1,73 @@ +import { audioConfig } from '../config'; +import type { TAudioInstrumentId, TAudioInstrumentPlayer, TAudioSettings } from '../types'; +import { AMSynth, FMSynth, MonoSynth, PolySynth, Synth } from './tone-runtime'; + +export function getTrackInstrumentId( + trackInstrumentIds: TAudioSettings['trackInstrumentIds'], + trackId: number | null | undefined +): TAudioInstrumentId | null { + if (trackId == null) { + return null; + } + + return trackInstrumentIds[trackId] ?? audioConfig.defaultInstrumentId; +} + +export function isAudioInstrumentId(value: string): value is TAudioInstrumentId { + return audioConfig.instrumentOptions.some((option) => option.id === value); +} + +export function createInstrumentPlayer(instrumentId: TAudioInstrumentId): TAudioInstrumentPlayer { + switch (instrumentId) { + case 'classic': + return new PolySynth(Synth, { + oscillator: { type: 'triangle' }, + envelope: { attack: 0.01, decay: 0.18, sustain: 0.35, release: 0.7 } + }); + case 'bell': + return new PolySynth(FMSynth, { + harmonicity: 3.01, + modulationIndex: 14, + oscillator: { type: 'sine' }, + envelope: { attack: 0.001, decay: 1.2, sustain: 0, release: 1.4 }, + modulation: { type: 'square' }, + modulationEnvelope: { attack: 0.002, decay: 0.3, sustain: 0, release: 1.2 } + }); + case 'xylophone': + return new PolySynth(FMSynth, { + harmonicity: 2.4, + modulationIndex: 10, + oscillator: { type: 'triangle' }, + envelope: { attack: 0.001, decay: 0.42, sustain: 0, release: 0.22 }, + modulation: { type: 'square' }, + modulationEnvelope: { attack: 0.001, decay: 0.14, sustain: 0, release: 0.12 } + }); + case 'warm': + return new PolySynth(AMSynth, { + harmonicity: 1.5, + oscillator: { type: 'sine' }, + envelope: { attack: 0.03, decay: 0.25, sustain: 0.45, release: 0.9 }, + modulation: { type: 'triangle' }, + modulationEnvelope: { attack: 0.02, decay: 0.2, sustain: 0.2, release: 0.8 } + }); + case 'pluck': + return new PolySynth(Synth, { + oscillator: { type: 'triangle8' }, + envelope: { attack: 0.001, decay: 0.12, sustain: 0, release: 0.14 } + }); + case 'lead': + return new PolySynth(MonoSynth, { + oscillator: { type: 'sawtooth' }, + filter: { Q: 2, type: 'lowpass', rolloff: -24 }, + envelope: { attack: 0.01, decay: 0.08, sustain: 0.6, release: 0.18 }, + filterEnvelope: { + attack: 0.01, + decay: 0.12, + sustain: 0.35, + release: 0.2, + baseFrequency: 300, + octaves: 3 + } + }); + } +} diff --git a/apps/midimarble/src/modules/engine/plugins/audio/lib/playback.test.ts b/apps/midimarble/src/modules/engine/plugins/audio/lib/playback.test.ts new file mode 100644 index 0000000..3397a90 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/audio/lib/playback.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { buildMidiLookup } from '../../midi'; +import { + getSelectedTrackNotesAtTick, + getSelectedTrackNotesInRange, + midiNoteToFrequency +} from './playback'; + +const SONG = { + name: 'Demo', + bpm: 120, + ticksPerBeat: 480, + totalTicks: 960, + tracks: [ + { + id: 0, + name: 'Lead', + notes: [ + { id: 1, tick: 0, durationTicks: 120, noteNumber: 60, velocity: 100, channel: 0 }, + { id: 2, tick: 8, durationTicks: 120, noteNumber: 62, velocity: 90, channel: 0 }, + { id: 3, tick: 12, durationTicks: 120, noteNumber: 64, velocity: 80, channel: 0 } + ] + }, + { + id: 1, + name: 'Bass', + notes: [{ id: 4, tick: 8, durationTicks: 120, noteNumber: 36, velocity: 110, channel: 1 }] + } + ] +} as const; + +describe('audio playback helpers', () => { + it('reads only the selected track in a tick range', () => { + const midiLookup = buildMidiLookup(SONG as never); + + expect( + getSelectedTrackNotesInRange(SONG as never, midiLookup, 0, 0, 10).map((note) => note.id) + ).toEqual([2]); + expect( + getSelectedTrackNotesInRange(SONG as never, midiLookup, 1, 0, 10).map((note) => note.id) + ).toEqual([4]); + }); + + it('reads notes exactly at the landed tick', () => { + const midiLookup = buildMidiLookup(SONG as never); + + expect( + getSelectedTrackNotesAtTick(SONG as never, midiLookup, 0, 8).map((note) => note.id) + ).toEqual([2]); + }); + + it('converts MIDI note numbers to frequencies', () => { + expect(midiNoteToFrequency(69)).toBeCloseTo(440, 6); + }); +}); diff --git a/apps/midimarble/src/modules/engine/plugins/audio/lib/playback.ts b/apps/midimarble/src/modules/engine/plugins/audio/lib/playback.ts new file mode 100644 index 0000000..4ada030 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/audio/lib/playback.ts @@ -0,0 +1,67 @@ +import { + clampMidiTick, + findTrackById, + getTicksPerSecond, + getTrackNotesAtRoundedTick, + getTrackNotesInTickRange, + tickToSeconds, + type TMidiLookup, + type TMidiNote, + type TMidiSong +} from '../../midi'; +import { audioConfig } from '../config'; + +export function getSelectedTrackNotesInRange( + song: TMidiSong | null, + midiLookup: TMidiLookup, + selectedTrackId: number | null, + startTick: number, + endTick: number +): TMidiNote[] { + if (song == null) { + return []; + } + + const track = findTrackById(song, selectedTrackId, midiLookup); + return getTrackNotesInTickRange(midiLookup, selectedTrackId, startTick, endTick, track); +} + +export function getSelectedTrackNotesAtTick( + song: TMidiSong | null, + midiLookup: TMidiLookup, + selectedTrackId: number | null, + tick: number +): TMidiNote[] { + if (song == null) { + return []; + } + + const track = findTrackById(song, selectedTrackId, midiLookup); + const roundedTick = Math.round(clampMidiTick(tick, song.totalTicks)); + return getTrackNotesAtRoundedTick(midiLookup, selectedTrackId, roundedTick, track); +} + +export function midiNoteToFrequency(noteNumber: number): number { + return 440 * Math.pow(2, (noteNumber - 69) / 12); +} + +export function getNoteDurationSeconds( + song: Pick, + note: Pick, + maxDurationSeconds: number +): number { + return Math.min(tickToSeconds(note.durationTicks, song), maxDurationSeconds); +} + +export function getPlaybackDelaySeconds( + song: Pick, + noteTick: number, + playheadTick: number +): number { + const ticksPerSecond = getTicksPerSecond(song); + if (ticksPerSecond <= 0) { + return 0; + } + + return Math.max(0, (noteTick - playheadTick) / ticksPerSecond); +} diff --git a/apps/midimarble/src/modules/engine/plugins/audio/lib/synth.test.ts b/apps/midimarble/src/modules/engine/plugins/audio/lib/synth.test.ts new file mode 100644 index 0000000..ea744d0 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/audio/lib/synth.test.ts @@ -0,0 +1,156 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { playTrackNotes, previewSelectedTrackNote, stopAllVoices } from './synth'; + +const toneState = vi.hoisted(() => ({ + players: [] as Array<{ + voiceName: string; + options: unknown; + connect: ReturnType; + triggerAttackRelease: ReturnType; + releaseAll: ReturnType; + dispose: ReturnType; + }>, + rawContext: null as unknown +})); + +vi.mock('./tone-runtime', () => { + class FakePolySynth { + public readonly voiceName: string; + public readonly options: unknown; + public connect = vi.fn(() => this); + public triggerAttackRelease = vi.fn(); + public releaseAll = vi.fn(); + public dispose = vi.fn(); + + constructor(voice?: { name?: string }, options?: unknown) { + this.voiceName = voice?.name ?? 'unknown'; + this.options = options; + toneState.players.push(this); + } + } + + class FakeSynth {} + class FakeFMSynth {} + class FakeAMSynth {} + class FakeMonoSynth {} + + return { + PolySynth: FakePolySynth, + Synth: FakeSynth, + FMSynth: FakeFMSynth, + AMSynth: FakeAMSynth, + MonoSynth: FakeMonoSynth, + getContext: vi.fn(() => ({ rawContext: toneState.rawContext })), + setContext: vi.fn((context: { rawContext?: unknown }) => { + toneState.rawContext = 'rawContext' in context ? context.rawContext : context; + }) + }; +}); + +describe('audio synth helpers', () => { + afterEach(() => { + toneState.players.length = 0; + toneState.rawContext = null; + vi.useRealTimers(); + }); + + it('preserves note offsets within a processed tick range', () => { + vi.useFakeTimers(); + + const state = createAudioState(); + + playTrackNotes( + state, + { bpm: 120, ticksPerBeat: 480 }, + [ + { id: 1, tick: 8, durationTicks: 120, noteNumber: 60, velocity: 100, channel: 0 }, + { id: 2, tick: 12, durationTicks: 120, noteNumber: 62, velocity: 90, channel: 0 } + ], + 8, + 'lead' + ); + + const player = toneState.players[0]; + expect(player?.voiceName).toBe('FakeMonoSynth'); + expect(player?.triggerAttackRelease.mock.calls[0]?.[2]).toBe(10); + expect(player?.triggerAttackRelease.mock.calls[1]?.[2]).toBeCloseTo(10 + 4 / 960, 6); + + stopAllVoices(state); + }); + + it('previews a selected note for its full duration instead of a fixed cap', () => { + const state = createAudioState(); + + previewSelectedTrackNote( + state, + { bpm: 120, ticksPerBeat: 480 }, + { + id: 1, + tick: 0, + durationTicks: 6000, + noteNumber: 60, + velocity: 100, + channel: 0 + }, + 'bell' + ); + + const player = toneState.players[0]; + expect(player?.voiceName).toBe('FakeFMSynth'); + expect(player?.triggerAttackRelease.mock.calls[0]?.[1]).toBeCloseTo(6.25, 6); + + stopAllVoices(state); + }); + + it('releases and disposes instantiated players when voices are stopped', () => { + const state = createAudioState(); + + playTrackNotes( + state, + { bpm: 120, ticksPerBeat: 480 }, + [{ id: 1, tick: 0, durationTicks: 120, noteNumber: 60, velocity: 100, channel: 0 }], + 0, + 'classic' + ); + + const player = toneState.players[0]; + stopAllVoices(state); + + expect(player?.releaseAll).toHaveBeenCalledOnce(); + expect(player?.dispose).toHaveBeenCalledOnce(); + expect(state.activeVoices.size).toBe(0); + expect(state.instrumentPlayers).toEqual({}); + }); +}); + +function createAudioState() { + const context = { + currentTime: 10, + state: 'running' + } as AudioContext; + const masterGain = new FakeGainNode() as unknown as GainNode; + + toneState.rawContext = context; + + return { + context, + masterGain, + isEnabled: true, + lastProcessedTick: 0, + lastMode: 'paused' as const, + activeVoices: new Map(), + instrumentPlayers: {} + }; +} + +class FakeGainNode { + public readonly gain = { + value: 0, + setValueAtTime: vi.fn(), + cancelScheduledValues: vi.fn(), + linearRampToValueAtTime: vi.fn() + }; + + public connect = vi.fn(); + public disconnect = vi.fn(); +} diff --git a/apps/midimarble/src/modules/engine/plugins/audio/lib/synth.ts b/apps/midimarble/src/modules/engine/plugins/audio/lib/synth.ts new file mode 100644 index 0000000..44c2867 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/audio/lib/synth.ts @@ -0,0 +1,259 @@ +import { getTicksPerSecond, type TMidiNote, type TMidiSong } from '../../midi'; +import { audioConfig } from '../config'; +import type { TActiveVoice, TAudioInstrumentId, TAudioState } from '../types'; +import { createInstrumentPlayer } from './instruments'; +import { getNoteDurationSeconds, getPlaybackDelaySeconds, midiNoteToFrequency } from './playback'; +import { getContext, setContext } from './tone-runtime'; + +export async function ensureAudioGraph( + state: TAudioState, + masterVolume: number +): Promise { + if ( + state.isEnabled && + state.context != null && + state.masterGain != null && + state.context.state !== 'closed' + ) { + syncToneContext(state.context); + syncMasterVolume(state, masterVolume); + return state; + } + + const AudioCtor = getAudioContextCtor(); + if (AudioCtor == null) { + return state; + } + + const reusableContext = + state.context != null && state.context.state !== 'closed' ? state.context : null; + const context = reusableContext ?? new AudioCtor(); + const masterGain = + reusableContext != null && state.masterGain != null ? state.masterGain : context.createGain(); + syncToneContext(context); + masterGain.gain.value = clampVolume(masterVolume); + if (reusableContext == null || state.masterGain == null) { + masterGain.connect(context.destination); + } + + await context.resume(); + + return { + ...state, + context, + masterGain, + isEnabled: true, + instrumentPlayers: reusableContext != null ? state.instrumentPlayers : {} + }; +} + +export function syncMasterVolume(state: TAudioState, masterVolume: number): void { + state.masterGain?.gain.setValueAtTime(clampVolume(masterVolume), state.context?.currentTime ?? 0); +} + +export function previewTrackNotesAtTick( + state: TAudioState, + song: Pick, + notes: TMidiNote[], + instrumentId: TAudioInstrumentId | null +): void { + previewTrackNotes(state, song, notes, audioConfig.playback.previewMaxNoteSeconds, instrumentId); +} + +export function previewSelectedTrackNote( + state: TAudioState, + song: Pick, + note: TMidiNote, + instrumentId: TAudioInstrumentId | null +): void { + previewTrackNotes(state, song, [note], Number.POSITIVE_INFINITY, instrumentId); +} + +export function previewMidiKeyNote( + state: TAudioState, + song: Pick, + noteNumber: number, + options: { + velocity: number; + }, + instrumentId: TAudioInstrumentId | null +): void { + const previewDurationTicks = Math.max( + 1, + Math.round(getTicksPerSecond(song) * audioConfig.playback.previewMaxNoteSeconds) + ); + previewTrackNotes( + state, + song, + [ + { + id: -1000 - noteNumber, + tick: 0, + durationTicks: previewDurationTicks, + noteNumber, + velocity: options.velocity, + channel: 0 + } + ], + audioConfig.playback.previewMaxNoteSeconds, + instrumentId + ); +} + +function previewTrackNotes( + state: TAudioState, + song: Pick, + notes: TMidiNote[], + maxDurationSeconds: number, + instrumentId: TAudioInstrumentId | null +): void { + for (const note of notes) { + playTrackNote(state, song, note, { + delaySeconds: 0, + maxDurationSeconds, + instrumentId + }); + } +} + +export function playTrackNotes( + state: TAudioState, + song: Pick, + notes: TMidiNote[], + startTick: number, + instrumentId: TAudioInstrumentId | null +): void { + for (const note of notes) { + playTrackNote(state, song, note, { + delaySeconds: getPlaybackDelaySeconds(song, note.tick, startTick), + maxDurationSeconds: audioConfig.playback.maxNoteSeconds, + instrumentId + }); + } +} + +export function stopAllVoices(state: TAudioState): void { + for (const voices of state.activeVoices.values()) { + for (const voice of voices) { + if (voice.cleanupId != null) { + globalThis.clearTimeout(voice.cleanupId); + } + } + } + + state.activeVoices.clear(); + const now = state.context?.currentTime ?? 0; + for (const instrument of Object.values(state.instrumentPlayers)) { + instrument?.releaseAll(now); + instrument?.dispose(); + } + state.instrumentPlayers = {}; +} + +export function disposeAudioGraph(state: TAudioState): void { + stopAllVoices(state); + state.masterGain?.disconnect(); + if (state.context != null) { + void state.context.close(); + } +} + +function playTrackNote( + state: TAudioState, + song: Pick, + note: TMidiNote, + options: { + delaySeconds: number; + maxDurationSeconds: number; + instrumentId: TAudioInstrumentId | null; + } +): void { + const context = state.context; + if (context == null || state.masterGain == null || options.instrumentId == null) { + return; + } + + const instrument = ensureInstrumentPlayer(state, options.instrumentId); + if (instrument == null) { + return; + } + + const startAt = context.currentTime + Math.max(0, options.delaySeconds); + const sustainSeconds = getNoteDurationSeconds(song, note, options.maxDurationSeconds); + instrument.triggerAttackRelease( + midiNoteToFrequency(note.noteNumber), + sustainSeconds, + startAt, + clampVelocity(note.velocity) + ); + + const voice: TActiveVoice = { + instrumentId: options.instrumentId, + noteNumber: note.noteNumber, + cleanupId: null + }; + + const voices = state.activeVoices.get(note.id) ?? []; + voices.push(voice); + state.activeVoices.set(note.id, voices); + + voice.cleanupId = globalThis.setTimeout( + () => { + const active = state.activeVoices.get(note.id); + if (active == null) { + return; + } + + const next = active.filter((entry) => entry !== voice); + if (next.length === 0) { + state.activeVoices.delete(note.id); + return; + } + + state.activeVoices.set(note.id, next); + }, + Math.ceil( + (options.delaySeconds + sustainSeconds + audioConfig.playback.voiceCleanupPaddingSeconds) * + 1000 + ) + ); +} + +function clampVolume(value: number): number { + return Math.max(0, Math.min(1, value)); +} + +function clampVelocity(value: number): number { + return Math.max(0.05, Math.min(1, value / 127)); +} + +function getAudioContextCtor(): (new () => AudioContext) | null { + const scope = globalThis as typeof globalThis & { + AudioContext?: new () => AudioContext; + webkitAudioContext?: new () => AudioContext; + }; + + return scope.AudioContext ?? scope.webkitAudioContext ?? null; +} + +function syncToneContext(context: AudioContext): void { + if (getContext().rawContext !== context) { + setContext(context); + } +} + +function ensureInstrumentPlayer(state: TAudioState, instrumentId: TAudioInstrumentId) { + const existing = state.instrumentPlayers[instrumentId]; + if (existing != null) { + return existing; + } + + if (state.masterGain == null) { + return null; + } + + const created = createInstrumentPlayer(instrumentId); + created.connect(state.masterGain); + state.instrumentPlayers[instrumentId] = created; + return created; +} diff --git a/apps/midimarble/src/modules/engine/plugins/audio/lib/tone-runtime.ts b/apps/midimarble/src/modules/engine/plugins/audio/lib/tone-runtime.ts new file mode 100644 index 0000000..afcc3ab --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/audio/lib/tone-runtime.ts @@ -0,0 +1 @@ +export { AMSynth, FMSynth, getContext, MonoSynth, PolySynth, setContext, Synth } from 'tone'; diff --git a/apps/midimarble/src/modules/engine/plugins/audio/systems.ts b/apps/midimarble/src/modules/engine/plugins/audio/systems.ts new file mode 100644 index 0000000..caef0d3 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/audio/systems.ts @@ -0,0 +1,143 @@ +import { getTrackInstrumentId } from './lib/instruments'; +import { getSelectedTrackNotesInRange } from './lib/playback'; +import { playTrackNotes, stopAllVoices, syncMasterVolume } from './lib/synth'; +import type { TAudioApp } from './types'; + +export function syncAudioPlaybackSystem(app: TAudioApp) { + const { + audioSettings, + audioState, + audioPlaybackFeedback, + midiSong, + midiLookup, + selectedTrackId, + transport + } = app.r; + + if ( + audioPlaybackFeedback.expiresAtMs > 0 && + audioPlaybackFeedback.expiresAtMs <= Date.now() && + (audioPlaybackFeedback.activeNoteIds.size > 0 || + audioPlaybackFeedback.activeNoteNumbers.size > 0) + ) { + app.updateResource('audioPlaybackFeedback', { + activeNoteIds: new Set(), + activeNoteNumbers: new Set(), + expiresAtMs: 0 + }); + } + + syncMasterVolume(audioState, audioSettings.masterVolume); + + if ( + !audioSettings.enabled || + !audioState.isEnabled || + audioState.context == null || + audioState.masterGain == null + ) { + if (audioState.activeVoices.size > 0) { + stopAllVoices(audioState); + } + syncAudioCursor(app, transport.playheadTick, transport.mode); + return; + } + + const didSongContextChange = + app.wasResourceChanged('midiSong') || app.wasResourceChanged('selectedTrackId'); + + if (midiSong == null || selectedTrackId == null) { + if (audioState.activeVoices.size > 0) { + stopAllVoices(audioState); + } + syncAudioCursor(app, 0, transport.mode); + return; + } + + if (didSongContextChange) { + stopAllVoices(audioState); + syncAudioCursor(app, transport.playheadTick, transport.mode); + return; + } + + if (transport.mode !== 'running') { + if (audioState.lastMode !== 'paused') { + stopAllVoices(audioState); + syncAudioCursor(app, transport.playheadTick, 'paused'); + } else if (audioState.lastProcessedTick !== transport.playheadTick) { + stopAllVoices(audioState); + syncAudioCursor(app, transport.playheadTick, 'paused'); + } + return; + } + + if (audioState.lastMode !== 'running') { + const notesAtCurrentTick = getSelectedTrackNotesInRange( + midiSong, + midiLookup, + selectedTrackId, + transport.playheadTick - 0.0001, + transport.playheadTick + ); + const instrumentId = getTrackInstrumentId(audioSettings.trackInstrumentIds, selectedTrackId); + syncPlaybackFeedback(app, notesAtCurrentTick); + playTrackNotes( + audioState, + midiSong, + notesAtCurrentTick, + transport.playheadTick - 0.0001, + instrumentId + ); + syncAudioCursor(app, transport.playheadTick, 'running'); + return; + } + + if (transport.playheadTick < audioState.lastProcessedTick) { + stopAllVoices(audioState); + syncAudioCursor(app, transport.playheadTick, 'running'); + return; + } + + const notes = getSelectedTrackNotesInRange( + midiSong, + midiLookup, + selectedTrackId, + audioState.lastProcessedTick, + transport.playheadTick + ); + const instrumentId = getTrackInstrumentId(audioSettings.trackInstrumentIds, selectedTrackId); + syncPlaybackFeedback(app, notes); + playTrackNotes(audioState, midiSong, notes, audioState.lastProcessedTick, instrumentId); + syncAudioCursor(app, transport.playheadTick, 'running'); +} + +function syncAudioCursor( + app: Pick, + lastProcessedTick: number, + lastMode: 'paused' | 'running' +): void { + const { audioState } = app.r; + if (audioState.lastProcessedTick === lastProcessedTick && audioState.lastMode === lastMode) { + return; + } + + app.updateResource('audioState', { + ...audioState, + lastProcessedTick, + lastMode + }); +} + +function syncPlaybackFeedback( + app: Pick, + notes: Array<{ id: number; noteNumber: number }> +): void { + if (notes.length === 0) { + return; + } + + app.updateResource('audioPlaybackFeedback', { + activeNoteIds: new Set(notes.map((note) => note.id)), + activeNoteNumbers: new Set(notes.map((note) => note.noteNumber)), + expiresAtMs: Date.now() + 120 + }); +} diff --git a/apps/midimarble/src/modules/engine/plugins/audio/types.ts b/apps/midimarble/src/modules/engine/plugins/audio/types.ts new file mode 100644 index 0000000..3f4819f --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/audio/types.ts @@ -0,0 +1,74 @@ +import type { TApp, TAppContext, TDefaultPlugin, TPlugin } from 'ecsify'; +import type { TEngineSystemSet } from '../../types'; +import type { TMidiPlugin } from '../midi'; +import type { TTransportPlugin } from '../transport'; + +export type TAudioPlugin = TPlugin< + { + name: 'Audio'; + resources: { + audioState: TAudioState; + audioSettings: TAudioSettings; + audioPlaybackFeedback: TAudioPlaybackFeedback; + }; + appExtensions: { + resumeAudio(): Promise; + previewNotesAtTick(tick: number): Promise; + previewNote(noteId: number): Promise; + previewMidiNote(noteNumber: number): Promise; + previewTrackInstrument(trackId: number): Promise; + updateAudioSettings(patch: Partial): void; + setTrackInstrument(trackId: number, instrumentId: TAudioInstrumentId): void; + clearTrackInstruments(): void; + disposeAudio(): void; + }; + systemSets: TEngineSystemSet; + }, + [TDefaultPlugin, TMidiPlugin, TTransportPlugin] +>; + +export type TAudioApp = TApp< + TAppContext<[TDefaultPlugin, TMidiPlugin, TTransportPlugin, TAudioPlugin]> +>; + +export interface TAudioState { + context: AudioContext | null; + masterGain: GainNode | null; + isEnabled: boolean; + lastProcessedTick: number; + lastMode: 'paused' | 'running'; + activeVoices: Map; + instrumentPlayers: Partial>; +} + +export type TAudioInstrumentId = 'classic' | 'bell' | 'xylophone' | 'warm' | 'pluck' | 'lead'; + +export interface TAudioSettings { + enabled: boolean; + masterVolume: number; + trackInstrumentIds: Record; +} + +export interface TAudioPlaybackFeedback { + activeNoteIds: Set; + activeNoteNumbers: Set; + expiresAtMs: number; +} + +export interface TAudioInstrumentOption { + id: TAudioInstrumentId; + label: string; +} + +export interface TAudioInstrumentPlayer { + connect(destination: AudioNode): unknown; + dispose(): unknown; + releaseAll(time?: number): unknown; + triggerAttackRelease(note: number, duration: number, time?: number, velocity?: number): unknown; +} + +export interface TActiveVoice { + instrumentId: TAudioInstrumentId; + noteNumber: number; + cleanupId: ReturnType | null; +} diff --git a/apps/midimarble/src/modules/engine/plugins/core/core-plugin.ts b/apps/midimarble/src/modules/engine/plugins/core/core-plugin.ts new file mode 100644 index 0000000..b2a0221 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/core/core-plugin.ts @@ -0,0 +1,23 @@ +import { TBundle } from 'ecsify'; +import type { TCoreApp, TCorePlugin } from './types'; + +export function createCorePlugin(): TCorePlugin { + return { + // Core owns the shared runtime primitives other engine plugins build on. + name: 'Core', + deps: ['Default'], + components: { + // Mixins + PositionMixin: [], + RotationMixin: [], + ScaleMixin: [] + }, + appExtensions: { + spawnBundle(this: TCoreApp, bundle: TBundle): number { + const eid = this.createEntity(); + this.addBundle(eid, bundle); + return eid; + } + } + }; +} diff --git a/apps/midimarble/src/modules/engine/plugins/core/index.ts b/apps/midimarble/src/modules/engine/plugins/core/index.ts new file mode 100644 index 0000000..4951241 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/core/index.ts @@ -0,0 +1,2 @@ +export * from './core-plugin'; +export * from './types'; diff --git a/apps/midimarble/src/modules/engine/plugins/core/types.ts b/apps/midimarble/src/modules/engine/plugins/core/types.ts new file mode 100644 index 0000000..088a6cf --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/core/types.ts @@ -0,0 +1,28 @@ +import type { TApp, TAppContext, TBundle, TDefaultPlugin, TPlugin } from 'ecsify'; +import type { TEngineSystemSet, TVec3 } from '../../types'; + +// MARK: - Plugin + +export type TCorePlugin = TPlugin< + { + name: 'Core'; + components: { + PositionMixin: TCPositionMixin[]; + RotationMixin: TCRotationMixin[]; + ScaleMixin: TCScaleMixin[]; + }; + appExtensions: { + spawnBundle(bundle: TBundle): number; + }; + systemSets: TEngineSystemSet; + }, + [TDefaultPlugin] +>; + +export type TCoreApp = TApp>; + +// MARK: - Components + +export interface TCPositionMixin extends TVec3 {} +export interface TCRotationMixin extends TVec3 {} +export interface TCScaleMixin extends TVec3 {} diff --git a/apps/midimarble/src/modules/engine/plugins/index.ts b/apps/midimarble/src/modules/engine/plugins/index.ts new file mode 100644 index 0000000..99ca6bf --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/index.ts @@ -0,0 +1,8 @@ +export * from './core'; +export * from './midi'; +export * from './transport'; +export * from './audio'; +export * from './physics'; +export * from './render'; +export * from './scene'; +export * from './trajectory'; diff --git a/apps/midimarble/src/modules/engine/plugins/midi/index.ts b/apps/midimarble/src/modules/engine/plugins/midi/index.ts new file mode 100644 index 0000000..bc72ffd --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/midi/index.ts @@ -0,0 +1,4 @@ +export * from './lib/midi-lookup'; +export * from './lib/timing'; +export * from './midi-plugin'; +export * from './types'; diff --git a/apps/midimarble/src/modules/engine/plugins/midi/lib/midi-lookup.ts b/apps/midimarble/src/modules/engine/plugins/midi/lib/midi-lookup.ts new file mode 100644 index 0000000..55838df --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/midi/lib/midi-lookup.ts @@ -0,0 +1,195 @@ +import type { TMidiNote, TMidiSong, TMidiTrack } from '../types'; + +export interface TMidiLookup { + tracksById: Map; + noteById: Map; +} + +export interface TMidiTrackLookup { + track: TMidiTrack; + noteIds: Set; + maxDurationTicks: number; +} + +export interface TMidiNoteMatch { + note: TMidiNote; + track: TMidiTrack; +} + +export function createEmptyMidiLookup(): TMidiLookup { + return { + tracksById: new Map(), + noteById: new Map() + }; +} + +export function buildMidiLookup(song: TMidiSong | null): TMidiLookup { + if (song == null) { + return createEmptyMidiLookup(); + } + + const tracksById = new Map(); + const noteById = new Map(); + + for (const track of song.tracks) { + const trackLookup = buildTrackLookup(track); + for (const note of track.notes) { + noteById.set(note.id, { note, track }); + } + tracksById.set(track.id, trackLookup); + } + + return { + tracksById, + noteById + }; +} + +export function getTrackLookup( + midiLookup: TMidiLookup | null | undefined, + trackId: number | null, + fallbackTrack?: TMidiTrack | null +): TMidiTrackLookup | null { + if (trackId == null) { + return null; + } + + return ( + midiLookup?.tracksById.get(trackId) ?? + (fallbackTrack == null ? null : buildTrackLookup(fallbackTrack)) + ); +} + +export function getTrackNotesInTickRange( + midiLookup: TMidiLookup | null | undefined, + trackId: number | null, + startTickExclusive: number, + endTickInclusive: number, + fallbackTrack?: TMidiTrack | null +): TMidiNote[] { + const trackLookup = getTrackLookup(midiLookup, trackId, fallbackTrack); + if (trackLookup == null || endTickInclusive <= startTickExclusive) { + return []; + } + + const notes = trackLookup.track.notes; + const startIndex = upperBoundByTick(notes, startTickExclusive); + const matches: TMidiNote[] = []; + + for (let index = startIndex; index < notes.length; index += 1) { + const note = notes[index]; + if (note == null || note.tick > endTickInclusive) { + break; + } + matches.push(note); + } + + return matches; +} + +export function getTrackNotesAtRoundedTick( + midiLookup: TMidiLookup | null | undefined, + trackId: number | null, + tick: number, + fallbackTrack?: TMidiTrack | null +): TMidiNote[] { + const trackLookup = getTrackLookup(midiLookup, trackId, fallbackTrack); + if (trackLookup == null) { + return []; + } + + const roundedTick = Math.round(tick); + const notes = trackLookup.track.notes; + const startIndex = lowerBoundByTick(notes, roundedTick); + const matches: TMidiNote[] = []; + + for (let index = startIndex; index < notes.length; index += 1) { + const note = notes[index]; + if (note == null || note.tick !== roundedTick) { + break; + } + matches.push(note); + } + + return matches; +} + +export function getTrackNotesOverlappingTickWindow( + midiLookup: TMidiLookup | null | undefined, + trackId: number | null, + startTickInclusive: number, + endTickExclusive: number, + fallbackTrack?: TMidiTrack | null +): TMidiNote[] { + const trackLookup = getTrackLookup(midiLookup, trackId, fallbackTrack); + if (trackLookup == null || endTickExclusive <= startTickInclusive) { + return []; + } + + const notes = trackLookup.track.notes; + const startTick = Math.max(0, startTickInclusive - trackLookup.maxDurationTicks); + const startIndex = lowerBoundByTick(notes, startTick); + const matches: TMidiNote[] = []; + + for (let index = startIndex; index < notes.length; index += 1) { + const note = notes[index]; + if (note == null || note.tick >= endTickExclusive) { + break; + } + if (note.tick + note.durationTicks > startTickInclusive) { + matches.push(note); + } + } + + return matches; +} + +function buildTrackLookup(track: TMidiTrack): TMidiTrackLookup { + let maxDurationTicks = 0; + const noteIds = new Set(); + + for (const note of track.notes) { + noteIds.add(note.id); + if (note.durationTicks > maxDurationTicks) { + maxDurationTicks = note.durationTicks; + } + } + + return { + track, + noteIds, + maxDurationTicks + }; +} + +function lowerBoundByTick(notes: readonly TMidiNote[], tick: number): number { + let low = 0; + let high = notes.length; + + while (low < high) { + const mid = Math.floor((low + high) / 2); + if ((notes[mid]?.tick ?? 0) < tick) { + low = mid + 1; + } else { + high = mid; + } + } + + return low; +} + +function upperBoundByTick(notes: readonly TMidiNote[], tick: number): number { + let low = 0; + let high = notes.length; + + while (low < high) { + const mid = Math.floor((low + high) / 2); + if ((notes[mid]?.tick ?? 0) <= tick) { + low = mid + 1; + } else { + high = mid; + } + } + + return low; +} diff --git a/apps/midimarble/src/modules/engine/plugins/midi/lib/midi-parser.test.ts b/apps/midimarble/src/modules/engine/plugins/midi/lib/midi-parser.test.ts new file mode 100644 index 0000000..c52e611 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/midi/lib/midi-parser.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { parseMidi } from './midi-parser'; + +describe('parseMidi', () => { + it('parses a valid midi file with one playable track', () => { + const song = parseMidi(createTestMidiBuffer(), 'demo.mid'); + + expect(song.name).toBe('Demo'); + expect(song.bpm).toBe(120); + expect(song.ticksPerBeat).toBe(480); + expect(song.totalTicks).toBe(720); + expect(song.tracks).toHaveLength(1); + expect(song.tracks[0]?.name).toBe('Lead'); + expect(song.tracks[0]?.notes).toHaveLength(2); + }); + + it('rejects invalid files', () => { + expect(() => parseMidi(new Uint8Array([0x00, 0x01, 0x02]).buffer)).toThrow( + 'Not a valid MIDI file. Missing MThd header.' + ); + }); +}); + +function createTestMidiBuffer(): ArrayBuffer { + const header = [ + 0x4d, 0x54, 0x68, 0x64, 0x00, 0x00, 0x00, 0x06, 0x00, 0x01, 0x00, 0x02, 0x01, 0xe0 + ]; + + const metaTrackData = [ + 0x00, 0xff, 0x03, 0x04, 0x44, 0x65, 0x6d, 0x6f, 0x00, 0xff, 0x51, 0x03, 0x07, 0xa1, 0x20, 0x00, + 0xff, 0x2f, 0x00 + ]; + const noteTrackData = [ + 0x00, 0xff, 0x03, 0x04, 0x4c, 0x65, 0x61, 0x64, 0x00, 0x90, 0x3c, 0x64, 0x83, 0x60, 0x80, 0x3c, + 0x40, 0x00, 0x90, 0x40, 0x64, 0x81, 0x70, 0x80, 0x40, 0x40, 0x00, 0xff, 0x2f, 0x00 + ]; + + const bytes = Uint8Array.from([ + ...header, + 0x4d, + 0x54, + 0x72, + 0x6b, + 0x00, + 0x00, + 0x00, + metaTrackData.length, + ...metaTrackData, + 0x4d, + 0x54, + 0x72, + 0x6b, + 0x00, + 0x00, + 0x00, + noteTrackData.length, + ...noteTrackData + ]); + + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); +} diff --git a/apps/midimarble/src/modules/engine/plugins/midi/lib/midi-parser.ts b/apps/midimarble/src/modules/engine/plugins/midi/lib/midi-parser.ts new file mode 100644 index 0000000..6ab9c48 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/midi/lib/midi-parser.ts @@ -0,0 +1,265 @@ +import type { TMidiNote, TMidiSong, TMidiTrack } from '../types'; + +interface TPendingNote { + tick: number; + channel: number; + noteNumber: number; + velocity: number; +} + +interface TParsedTrackChunk { + name: string; + bpm: number | null; + channels: Set; + notes: TMidiNote[]; +} + +function readByte(data: Uint8Array, position: number): number { + const value = data[position]; + if (value == null) { + throw new Error('Unexpected end of MIDI data.'); + } + + return value; +} + +function readUint16(data: Uint8Array, position: number): number { + return (readByte(data, position) << 8) | readByte(data, position + 1); +} + +function readUint32(data: Uint8Array, position: number): number { + return ( + ((readByte(data, position) << 24) | + (readByte(data, position + 1) << 16) | + (readByte(data, position + 2) << 8) | + readByte(data, position + 3)) >>> + 0 + ); +} + +function readString(data: Uint8Array, position: number, length: number): string { + return String.fromCharCode(...data.slice(position, position + length)); +} + +function readVariableLengthValue(data: Uint8Array, position: number): [number, number] { + let value = 0; + let bytes = 0; + let next: number; + + do { + next = readByte(data, position + bytes); + value = (value << 7) | (next & 0x7f); + bytes++; + } while (next & 0x80); + + return [value, bytes]; +} + +function parseTrackChunk(data: Uint8Array, start: number, length: number): TParsedTrackChunk { + const track: TParsedTrackChunk = { + name: '', + bpm: null, + channels: new Set(), + notes: [] + }; + + let position = start; + let currentTick = 0; + let runningStatus = 0; + let noteId = 0; + const pendingNotes = new Map(); + const end = start + length; + + const closePendingNote = (tick: number, channel: number, noteNumber: number) => { + const key = channel * 128 + noteNumber; + const activeNote = pendingNotes.get(key); + if (activeNote == null) { + return; + } + + track.notes.push({ + id: noteId++, + tick: activeNote.tick, + durationTicks: Math.max(1, tick - activeNote.tick), + noteNumber: activeNote.noteNumber, + velocity: activeNote.velocity, + channel: activeNote.channel + }); + pendingNotes.delete(key); + }; + + while (position < end) { + const [deltaTick, deltaBytes] = readVariableLengthValue(data, position); + position += deltaBytes; + currentTick += deltaTick; + + let statusByte: number; + if (readByte(data, position) & 0x80) { + statusByte = readByte(data, position); + position++; + if (statusByte < 0xf0) { + runningStatus = statusByte; + } + } else { + statusByte = runningStatus; + } + + const type = statusByte & 0xf0; + const channel = statusByte & 0x0f; + + if (type === 0x90) { + const noteNumber = readByte(data, position++); + const velocity = readByte(data, position++); + if (velocity > 0) { + track.channels.add(channel); + pendingNotes.set(channel * 128 + noteNumber, { + tick: currentTick, + channel, + noteNumber, + velocity + }); + } else { + closePendingNote(currentTick, channel, noteNumber); + } + continue; + } + + if (type === 0x80) { + const noteNumber = readByte(data, position++); + position++; + closePendingNote(currentTick, channel, noteNumber); + continue; + } + + if (type === 0xa0 || type === 0xb0 || type === 0xe0) { + position += 2; + continue; + } + + if (type === 0xc0 || type === 0xd0) { + position += 1; + continue; + } + + if (statusByte === 0xff) { + const metaType = readByte(data, position++); + const [metaLength, metaLengthBytes] = readVariableLengthValue(data, position); + position += metaLengthBytes; + + if (metaType === 0x03 && track.name === '') { + track.name = readString(data, position, metaLength); + } + + if (metaType === 0x51 && metaLength === 3) { + const microsecondsPerBeat = + ((readByte(data, position) << 16) | + (readByte(data, position + 1) << 8) | + readByte(data, position + 2)) >>> + 0; + if (microsecondsPerBeat > 0) { + track.bpm = Math.round(60_000_000 / microsecondsPerBeat); + } + } + + position += metaLength; + continue; + } + + if (statusByte === 0xf0 || statusByte === 0xf7) { + const [sysexLength, sysexLengthBytes] = readVariableLengthValue(data, position); + position += sysexLengthBytes + sysexLength; + continue; + } + + break; + } + + return track; +} + +function buildTrack(chunk: TParsedTrackChunk, id: number): TMidiTrack { + return { + id, + name: chunk.name.trim() || `Track ${id + 1}`, + notes: chunk.notes + }; +} + +export function parseMidi(buffer: ArrayBuffer, fileName?: string | null): TMidiSong { + const data = new Uint8Array(buffer); + + if (readString(data, 0, 4) !== 'MThd') { + throw new Error('Not a valid MIDI file. Missing MThd header.'); + } + + const format = readUint16(data, 8); + const trackCount = readUint16(data, 10); + const timeDivision = readUint16(data, 12); + + if (timeDivision & 0x8000) { + throw new Error('SMPTE MIDI timing is not supported.'); + } + + const ticksPerBeat = timeDivision; + const rawTracks: TParsedTrackChunk[] = []; + let position = 14; + + for (let index = 0; index < trackCount; index++) { + if (position >= data.length || readString(data, position, 4) !== 'MTrk') { + break; + } + + const chunkLength = readUint32(data, position + 4); + rawTracks.push(parseTrackChunk(data, position + 8, chunkLength)); + position += 8 + chunkLength; + } + + const bpm = rawTracks.find((track) => track.bpm != null)?.bpm ?? 120; + const firstTrack = rawTracks[0]; + const tracks = + format === 0 && rawTracks.length === 1 && firstTrack != null + ? Array.from( + firstTrack.notes + .reduce((map, note) => { + const notes = map.get(note.channel) ?? []; + notes.push(note); + map.set(note.channel, notes); + return map; + }, new Map()) + .entries() + ).map(([channel, notes]) => + buildTrack( + { + name: `Channel ${channel + 1}`, + bpm: null, + channels: new Set([channel]), + notes + }, + channel + ) + ) + : (format === 1 && rawTracks.length > 1 && firstTrack?.notes.length === 0 + ? rawTracks.slice(1) + : rawTracks + ).map((track, index) => buildTrack(track, index)); + + const totalTicks = + tracks.length === 0 + ? 0 + : Math.max( + ...tracks.map((track) => + track.notes.length === 0 + ? 0 + : Math.max(...track.notes.map((note) => note.tick + note.durationTicks)) + ) + ); + const songName = rawTracks.find((track) => track.name.trim() !== '')?.name.trim(); + + return { + name: songName || fileName?.replace(/\.midi?$/i, '') || 'Untitled', + bpm, + ticksPerBeat, + totalTicks, + tracks + }; +} diff --git a/apps/midimarble/src/modules/engine/plugins/midi/lib/midi-state.ts b/apps/midimarble/src/modules/engine/plugins/midi/lib/midi-state.ts new file mode 100644 index 0000000..724dee9 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/midi/lib/midi-state.ts @@ -0,0 +1,369 @@ +import type { TMidiApp, TMidiCreateNoteInput, TMidiNote, TMidiSong, TMidiTrack } from '../types'; +import { buildMidiLookup, createEmptyMidiLookup, getTrackLookup } from './midi-lookup'; +import { parseMidi } from './midi-parser'; +import { findFirstTrackWithNotes, findTrackById } from './timing'; + +type TMidiStateAccess = { + r?: Pick< + TMidiApp['r'], + | 'midiSong' + | 'midiLookup' + | 'selectedTrackId' + | 'selectedNoteId' + | 'selectedNoteIds' + | 'nextMidiNoteId' + >; + updateResource: TMidiApp['updateResource']; +}; + +type TMidiMutableStateAccess = TMidiStateAccess & { + r: Pick< + TMidiApp['r'], + | 'midiSong' + | 'midiLookup' + | 'selectedTrackId' + | 'selectedNoteId' + | 'selectedNoteIds' + | 'nextMidiNoteId' + >; +}; + +export async function loadMidiFileIntoState(app: TMidiStateAccess, file: File): Promise { + try { + const song = parseMidi(await file.arrayBuffer(), file.name); + const selectedTrack = findFirstTrackWithNotes(song); + + app.updateResource('midiSong', song); + app.updateResource('midiLookup', buildMidiLookup(song)); + app.updateResource('selectedTrackId', selectedTrack?.id ?? null); + app.updateResource('selectedNoteId', null); + app.updateResource('selectedNoteIds', new Set()); + app.updateResource('nextMidiNoteId', getNextMidiNoteId(song)); + app.updateResource('midiImportError', null); + } catch (error) { + app.updateResource('midiSong', null); + app.updateResource('midiLookup', createEmptyMidiLookup()); + app.updateResource('selectedTrackId', null); + app.updateResource('selectedNoteId', null); + app.updateResource('selectedNoteIds', new Set()); + app.updateResource('nextMidiNoteId', 0); + app.updateResource( + 'midiImportError', + error instanceof Error ? error.message : 'Failed to import MIDI file.' + ); + } +} + +export function clearMidiSongState(app: TMidiStateAccess): void { + app.updateResource('midiSong', null); + app.updateResource('midiLookup', createEmptyMidiLookup()); + app.updateResource('selectedTrackId', null); + app.updateResource('selectedNoteId', null); + app.updateResource('selectedNoteIds', new Set()); + app.updateResource('nextMidiNoteId', 0); + app.updateResource('midiImportError', null); +} + +export function selectMidiNote(app: TMidiStateAccess, noteId: number | null): void { + selectMidiNotes(app, noteId == null ? [] : [noteId], noteId); +} + +export function selectMidiNotes( + app: TMidiStateAccess, + noteIds: number[], + primaryNoteId: number | null +): void { + const trackLookup = getSelectedTrackLookup( + app.r?.midiSong ?? null, + app.r?.midiLookup ?? null, + app.r?.selectedTrackId ?? null + ); + if (trackLookup == null) { + clearMidiNoteSelection(app); + return; + } + + const validNoteIds = Array.from(new Set(noteIds)).filter((noteId) => + trackLookup.noteIds.has(noteId) + ); + const selectedNoteIds = new Set(validNoteIds); + const selectedNoteId = + primaryNoteId != null && selectedNoteIds.has(primaryNoteId) + ? primaryNoteId + : (validNoteIds[0] ?? null); + + app.updateResource('selectedNoteId', selectedNoteId); + app.updateResource('selectedNoteIds', selectedNoteIds); +} + +export function clearMidiNoteSelection(app: TMidiStateAccess): void { + app.updateResource('selectedNoteId', null); + app.updateResource('selectedNoteIds', new Set()); +} + +export function selectAllTrackMidiNotes( + app: TMidiMutableStateAccess, + trackId: number | undefined +): void { + const resolvedTrackId = trackId ?? app.r.selectedTrackId; + const track = findTrackById(app.r.midiSong, resolvedTrackId, app.r.midiLookup); + const trackLookup = getTrackLookup(app.r.midiLookup, resolvedTrackId, track); + if (trackLookup == null || track == null || track.notes.length === 0) { + clearMidiNoteSelection(app); + return; + } + + selectMidiNotes( + app, + track.notes.map((note) => note.id), + app.r.selectedNoteId != null && track.notes.some((note) => note.id === app.r.selectedNoteId) + ? app.r.selectedNoteId + : (track.notes[0]?.id ?? null) + ); +} + +export function createMidiNote( + app: TMidiMutableStateAccess, + input: TMidiCreateNoteInput +): number | null { + const track = findTrackById( + app.r.midiSong, + input.trackId ?? app.r.selectedTrackId, + app.r.midiLookup + ); + if (track == null || app.r.midiSong == null) { + return null; + } + + const noteId = app.r.nextMidiNoteId; + const note: TMidiNote = { + id: noteId, + tick: normalizeTick(input.tick), + durationTicks: Math.max(1, normalizeTick(input.durationTicks)), + noteNumber: clampNoteNumber(input.noteNumber), + velocity: 100, + channel: track.notes[0]?.channel ?? 0 + }; + + const nextSong = updateTrackNotes(app.r.midiSong, track.id, (notes) => [...notes, note]); + app.updateResource('midiSong', nextSong); + app.updateResource('midiLookup', buildMidiLookup(nextSong)); + app.updateResource('nextMidiNoteId', noteId + 1); + selectMidiNotes(app, [noteId], noteId); + return noteId; +} + +export function moveSelectedMidiNotes( + app: TMidiMutableStateAccess, + deltaTick: number, + deltaNoteNumber: number +): boolean { + const track = findTrackById(app.r.midiSong, app.r.selectedTrackId, app.r.midiLookup); + if (track == null || app.r.midiSong == null || app.r.selectedNoteIds.size === 0) { + return false; + } + + const selectedNotes = track.notes.filter((note) => app.r.selectedNoteIds.has(note.id)); + if (selectedNotes.length === 0) { + clearMidiNoteSelection(app); + return false; + } + + const clampedDeltaTick = clampMoveDeltaTick(selectedNotes, deltaTick); + const clampedDeltaNoteNumber = clampMoveDeltaNoteNumber(selectedNotes, deltaNoteNumber); + if (clampedDeltaTick === 0 && clampedDeltaNoteNumber === 0) { + return false; + } + + const nextSong = updateTrackNotes(app.r.midiSong, track.id, (notes) => + notes.map((note) => + app.r.selectedNoteIds.has(note.id) + ? { + ...note, + tick: normalizeTick(note.tick + clampedDeltaTick), + noteNumber: clampNoteNumber(note.noteNumber + clampedDeltaNoteNumber) + } + : note + ) + ); + app.updateResource('midiSong', nextSong); + app.updateResource('midiLookup', buildMidiLookup(nextSong)); + return true; +} + +export function resizePrimarySelectedMidiNote( + app: TMidiMutableStateAccess, + edge: 'start' | 'end', + deltaTick: number +): boolean { + const track = findTrackById(app.r.midiSong, app.r.selectedTrackId, app.r.midiLookup); + const primaryNoteId = app.r.selectedNoteId; + if (track == null || app.r.midiSong == null || primaryNoteId == null) { + return false; + } + + const note = track.notes.find((entry) => entry.id === primaryNoteId); + if (note == null) { + clearMidiNoteSelection(app); + return false; + } + + const delta = Math.round(deltaTick); + if (delta === 0) { + return false; + } + + const noteEndTick = note.tick + note.durationTicks; + const nextNote = + edge === 'start' + ? { + ...note, + tick: Math.max(0, Math.min(noteEndTick - 1, note.tick + delta)), + durationTicks: noteEndTick - Math.max(0, Math.min(noteEndTick - 1, note.tick + delta)) + } + : { + ...note, + durationTicks: Math.max(1, note.durationTicks + delta) + }; + + if (nextNote.tick === note.tick && nextNote.durationTicks === note.durationTicks) { + return false; + } + + const nextSong = updateTrackNotes(app.r.midiSong, track.id, (notes) => + notes.map((entry) => (entry.id === note.id ? nextNote : entry)) + ); + app.updateResource('midiSong', nextSong); + app.updateResource('midiLookup', buildMidiLookup(nextSong)); + return true; +} + +export function deleteSelectedMidiNotes(app: TMidiMutableStateAccess): number { + const trackLookup = getSelectedTrackLookup( + app.r.midiSong, + app.r.midiLookup, + app.r.selectedTrackId + ); + if (trackLookup == null || app.r.midiSong == null || app.r.selectedNoteIds.size === 0) { + return 0; + } + const track = trackLookup.track; + + const noteIdsToDelete = new Set( + Array.from(app.r.selectedNoteIds).filter((noteId) => trackLookup.noteIds.has(noteId)) + ); + if (noteIdsToDelete.size === 0) { + clearMidiNoteSelection(app); + return 0; + } + + const nextSong = updateTrackNotes(app.r.midiSong, track.id, (notes) => + notes.filter((note) => !noteIdsToDelete.has(note.id)) + ); + const removedCount = track.notes.length - (findTrackById(nextSong, track.id)?.notes.length ?? 0); + if (removedCount === 0) { + return 0; + } + + app.updateResource('midiSong', nextSong); + app.updateResource('midiLookup', buildMidiLookup(nextSong)); + clearMidiNoteSelection(app); + return removedCount; +} + +function updateTrackNotes( + song: TMidiSong, + trackId: number, + updateNotes: (notes: TMidiNote[]) => TMidiNote[] +): TMidiSong { + const nextTracks = song.tracks.map((track) => + track.id === trackId + ? { + ...track, + notes: sortMidiNotes(updateNotes(track.notes)) + } + : track + ); + + return { + ...song, + tracks: nextTracks, + totalTicks: getSongTotalTicks(nextTracks) + }; +} + +function clampMoveDeltaTick(notes: TMidiNote[], deltaTick: number): number { + const roundedDeltaTick = Math.round(deltaTick); + const minTick = Math.min(...notes.map((note) => note.tick)); + return Math.max(-minTick, roundedDeltaTick); +} + +function clampMoveDeltaNoteNumber(notes: TMidiNote[], deltaNoteNumber: number): number { + const roundedDeltaNoteNumber = Math.round(deltaNoteNumber); + const minNoteNumber = Math.min(...notes.map((note) => note.noteNumber)); + const maxNoteNumber = Math.max(...notes.map((note) => note.noteNumber)); + return Math.max(-minNoteNumber, Math.min(127 - maxNoteNumber, roundedDeltaNoteNumber)); +} + +function normalizeTick(tick: number): number { + if (!Number.isFinite(tick)) { + return 0; + } + + return Math.max(0, Math.round(tick)); +} + +function clampNoteNumber(noteNumber: number): number { + if (!Number.isFinite(noteNumber)) { + return 60; + } + + return Math.max(0, Math.min(127, Math.round(noteNumber))); +} + +function getSongTotalTicks(tracks: TMidiTrack[]): number { + return tracks.reduce( + (maxTick, track) => + Math.max( + maxTick, + ...track.notes.map((note) => normalizeTick(note.tick + note.durationTicks)) + ), + 0 + ); +} + +function getNextMidiNoteId(song: TMidiSong | null): number { + if (song == null) { + return 0; + } + + return ( + song.tracks.reduce( + (maxId, track) => Math.max(maxId, ...track.notes.map((note) => note.id)), + -1 + ) + 1 + ); +} + +function getSelectedTrackLookup( + song: TMidiSong | null, + midiLookup: TMidiApp['r']['midiLookup'] | null, + trackId: number | null +) { + if (song == null || trackId == null) { + return null; + } + + const track = findTrackById(song, trackId, midiLookup); + return getTrackLookup(midiLookup, trackId, track); +} + +function sortMidiNotes(notes: TMidiNote[]): TMidiNote[] { + return [...notes].sort( + (left, right) => + left.tick - right.tick || + left.noteNumber - right.noteNumber || + left.durationTicks - right.durationTicks || + left.id - right.id + ); +} diff --git a/apps/midimarble/src/modules/engine/plugins/midi/lib/timing.test.ts b/apps/midimarble/src/modules/engine/plugins/midi/lib/timing.test.ts new file mode 100644 index 0000000..f6a51d0 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/midi/lib/timing.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { clampMidiTick, stepToTick, tickToStep } from './timing'; + +const TEST_SONG = { + name: 'Timing Test', + bpm: 120, + ticksPerBeat: 480, + totalTicks: 960, + tracks: [] +}; + +describe('midi timing helpers', () => { + it('clamps the playhead tick into the song range', () => { + expect(clampMidiTick(-5, 20)).toBe(0); + expect(clampMidiTick(24, 20)).toBe(20); + }); + + it('maps ticks to physics steps deterministically', () => { + expect(tickToStep(0, TEST_SONG, 1 / 240)).toBe(0); + expect(tickToStep(2, TEST_SONG, 1 / 240)).toBe(0); + expect(tickToStep(4, TEST_SONG, 1 / 240)).toBe(1); + expect(tickToStep(12, TEST_SONG, 1 / 240)).toBe(3); + }); + + it('maps physics steps back into ticks', () => { + expect(stepToTick(0, TEST_SONG, 1 / 240)).toBe(0); + expect(stepToTick(1, TEST_SONG, 1 / 240)).toBe(4); + expect(stepToTick(12, TEST_SONG, 1 / 240)).toBe(48); + }); +}); diff --git a/apps/midimarble/src/modules/engine/plugins/midi/lib/timing.ts b/apps/midimarble/src/modules/engine/plugins/midi/lib/timing.ts new file mode 100644 index 0000000..8bf8cc3 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/midi/lib/timing.ts @@ -0,0 +1,99 @@ +import type { TMidiNote, TMidiSong, TMidiTrack } from '../types'; +import type { TMidiLookup, TMidiNoteMatch } from './midi-lookup'; + +export function getSongMaxTick(song: TMidiSong | null): number { + return song?.totalTicks ?? 0; +} + +export function clampMidiTick(tick: number, maxTick: number): number { + if (!Number.isFinite(tick)) { + return 0; + } + + return Math.max(0, Math.min(tick, maxTick)); +} + +export function getTicksPerSecond(song: Pick): number { + return (song.ticksPerBeat * song.bpm) / 60; +} + +export function tickToSeconds(tick: number, song: Pick): number { + const ticksPerSecond = getTicksPerSecond(song); + if (ticksPerSecond <= 0) { + return 0; + } + + return Math.max(0, tick) / ticksPerSecond; +} + +export function tickToStep( + tick: number, + song: Pick, + fixedTimeStepSeconds: number +): number { + if (fixedTimeStepSeconds <= 0) { + return 0; + } + + return Math.max(0, Math.floor(tickToSeconds(tick, song) / fixedTimeStepSeconds)); +} + +export function stepToTick( + step: number, + song: Pick, + fixedTimeStepSeconds: number +): number { + if (fixedTimeStepSeconds <= 0) { + return 0; + } + + return Math.max(0, step) * fixedTimeStepSeconds * getTicksPerSecond(song); +} + +export function findFirstTrackWithNotes(song: TMidiSong | null): TMidiTrack | null { + if (song == null) { + return null; + } + + return song.tracks.find((track) => track.notes.length > 0) ?? null; +} + +export function findTrackById( + song: TMidiSong | null, + trackId: number | null, + midiLookup?: TMidiLookup | null +): TMidiTrack | null { + if (song == null || trackId == null) { + return null; + } + + return ( + midiLookup?.tracksById.get(trackId)?.track ?? + song.tracks.find((track) => track.id === trackId) ?? + null + ); +} + +export function findNoteById( + song: TMidiSong | null, + noteId: number | null, + midiLookup?: TMidiLookup | null +): TMidiNoteMatch | null { + if (song == null || noteId == null) { + return null; + } + + const indexed = midiLookup?.noteById.get(noteId); + if (indexed != null) { + return indexed; + } + + for (const track of song.tracks) { + const note = track.notes.find((entry) => entry.id === noteId); + if (note != null) { + return { note, track }; + } + } + + return null; +} diff --git a/apps/midimarble/src/modules/engine/plugins/midi/midi-plugin.test.ts b/apps/midimarble/src/modules/engine/plugins/midi/midi-plugin.test.ts new file mode 100644 index 0000000..dc01707 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/midi/midi-plugin.test.ts @@ -0,0 +1,151 @@ +import { createApp, createDefaultPlugin } from 'ecsify'; +import { describe, expect, it } from 'vitest'; +import { ENGINE_SYSTEM_SETS } from '../../types'; +import { createMidiPlugin } from './midi-plugin'; + +describe('midi plugin', () => { + it('imports a midi file and selects the first track with notes', async () => { + const app = createApp({ + plugins: [createDefaultPlugin(), createMidiPlugin()] as const, + systemSets: [...ENGINE_SYSTEM_SETS] + }); + + await app.loadMidiFile(new File([createTestMidiBuffer()], 'demo.mid', { type: 'audio/midi' })); + + expect(app.r.midiSong?.name).toBe('Demo'); + expect(app.r.selectedTrackId).toBe(0); + expect(app.r.selectedNoteId).toBeNull(); + expect(app.r.selectedNoteIds).toEqual(new Set()); + expect(app.r.nextMidiNoteId).toBe(2); + expect(app.r.midiImportError).toBeNull(); + }); + + it('stores an import error for invalid midi data', async () => { + const app = createApp({ + plugins: [createDefaultPlugin(), createMidiPlugin()] as const, + systemSets: [...ENGINE_SYSTEM_SETS] + }); + + await app.loadMidiFile(new File([new Uint8Array([0x00, 0x01, 0x02])], 'broken.mid')); + + expect(app.r.midiSong).toBeNull(); + expect(app.r.selectedTrackId).toBeNull(); + expect(app.r.selectedNoteId).toBeNull(); + expect(app.r.selectedNoteIds).toEqual(new Set()); + expect(app.r.nextMidiNoteId).toBe(0); + expect(app.r.midiImportError).toBe('Not a valid MIDI file. Missing MThd header.'); + }); + + it('selects and clears the current note id', async () => { + const app = createApp({ + plugins: [createDefaultPlugin(), createMidiPlugin()] as const, + systemSets: [...ENGINE_SYSTEM_SETS] + }); + + await app.loadMidiFile(new File([createTestMidiBuffer()], 'demo.mid', { type: 'audio/midi' })); + + app.selectNote(0); + expect(app.r.selectedNoteId).toBe(0); + expect(app.r.selectedNoteIds).toEqual(new Set([0])); + + app.selectNote(null); + expect(app.r.selectedNoteId).toBeNull(); + expect(app.r.selectedNoteIds).toEqual(new Set()); + }); + + it('creates, moves, resizes, selects, and deletes notes in the selected track', async () => { + const app = createApp({ + plugins: [createDefaultPlugin(), createMidiPlugin()] as const, + systemSets: [...ENGINE_SYSTEM_SETS] + }); + + await app.loadMidiFile(new File([createTestMidiBuffer()], 'demo.mid', { type: 'audio/midi' })); + + const createdNoteId = app.createNote({ + tick: 720, + durationTicks: 240, + noteNumber: 67 + }); + const getSelectedTrack = () => + app.r.midiSong?.tracks.find((track) => track.id === app.r.selectedTrackId); + + expect(createdNoteId).toBe(2); + expect(app.r.nextMidiNoteId).toBe(3); + expect(app.r.selectedNoteId).toBe(2); + expect(app.r.selectedNoteIds).toEqual(new Set([2])); + expect(app.r.midiSong?.totalTicks).toBe(960); + + app.selectAllTrackNotes(); + expect(app.r.selectedNoteIds).toEqual(new Set([0, 1, 2])); + expect(app.r.selectedNoteId).toBe(2); + + expect(app.moveSelectedNotes(120, 1)).toBe(true); + expect(getSelectedTrack()?.notes.map((note) => [note.id, note.tick, note.noteNumber])).toEqual([ + [0, 120, 61], + [1, 600, 65], + [2, 840, 68] + ]); + + app.selectNotes([2], 2); + expect(app.resizePrimarySelectedNote('end', -239)).toBe(true); + expect(getSelectedTrack()?.notes.find((note) => note.id === 2)?.durationTicks).toBe(1); + + expect(app.deleteSelectedNotes()).toBe(1); + expect(app.r.selectedNoteId).toBeNull(); + expect(app.r.selectedNoteIds).toEqual(new Set()); + expect(getSelectedTrack()?.notes.map((note) => note.id)).toEqual([0, 1]); + }); + + it('keeps selection and deletion scoped to the selected track', () => { + const app = createApp({ + plugins: [createDefaultPlugin(), createMidiPlugin()] as const, + systemSets: [...ENGINE_SYSTEM_SETS] + }); + + app.updateResource('midiSong', { + name: 'Demo', + bpm: 120, + ticksPerBeat: 480, + totalTicks: 360, + tracks: [ + { + id: 7, + name: 'Lead', + notes: [{ id: 1, tick: 0, durationTicks: 120, noteNumber: 60, velocity: 100, channel: 0 }] + }, + { + id: 9, + name: 'Bass', + notes: [ + { id: 2, tick: 120, durationTicks: 120, noteNumber: 48, velocity: 100, channel: 0 } + ] + } + ] + } as never); + app.updateResource('selectedTrackId', 7); + + app.selectNotes([1, 2], 2); + expect(app.r.selectedNoteIds).toEqual(new Set([1])); + expect(app.r.selectedNoteId).toBe(1); + + app.updateResource('selectedNoteIds', new Set([2])); + app.updateResource('selectedNoteId', 2); + + expect(app.deleteSelectedNotes()).toBe(0); + expect(app.r.selectedNoteIds).toEqual(new Set()); + expect(app.r.selectedNoteId).toBeNull(); + expect(app.r.midiSong?.tracks[1]?.notes.map((note) => note.id)).toEqual([2]); + }); +}); + +function createTestMidiBuffer(): ArrayBuffer { + const bytes = Uint8Array.from([ + 0x4d, 0x54, 0x68, 0x64, 0x00, 0x00, 0x00, 0x06, 0x00, 0x01, 0x00, 0x02, 0x01, 0xe0, 0x4d, 0x54, + 0x72, 0x6b, 0x00, 0x00, 0x00, 0x13, 0x00, 0xff, 0x03, 0x04, 0x44, 0x65, 0x6d, 0x6f, 0x00, 0xff, + 0x51, 0x03, 0x07, 0xa1, 0x20, 0x00, 0xff, 0x2f, 0x00, 0x4d, 0x54, 0x72, 0x6b, 0x00, 0x00, 0x00, + 0x1d, 0x00, 0xff, 0x03, 0x04, 0x4c, 0x65, 0x61, 0x64, 0x00, 0x90, 0x3c, 0x64, 0x83, 0x60, 0x80, + 0x3c, 0x40, 0x00, 0x90, 0x40, 0x64, 0x81, 0x70, 0x80, 0x40, 0x40, 0x00, 0xff, 0x2f, 0x00 + ]); + + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); +} diff --git a/apps/midimarble/src/modules/engine/plugins/midi/midi-plugin.ts b/apps/midimarble/src/modules/engine/plugins/midi/midi-plugin.ts new file mode 100644 index 0000000..cbc6831 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/midi/midi-plugin.ts @@ -0,0 +1,88 @@ +import { buildMidiLookup, createEmptyMidiLookup } from './lib/midi-lookup'; +import { + clearMidiNoteSelection, + clearMidiSongState, + createMidiNote, + deleteSelectedMidiNotes, + loadMidiFileIntoState, + moveSelectedMidiNotes, + resizePrimarySelectedMidiNote, + selectAllTrackMidiNotes, + selectMidiNote, + selectMidiNotes +} from './lib/midi-state'; +import type { TMidiApp, TMidiCreateNoteInput, TMidiPlugin, TMidiSong } from './types'; + +export function createMidiPlugin(options?: { + song?: TMidiSong | null; + selectedTrackId?: number | null; +}): TMidiPlugin { + const song = options?.song ?? null; + const selectedTrackId = options?.selectedTrackId ?? null; + const nextMidiNoteId = + song != null + ? song.tracks.reduce((maxId, track) => Math.max(maxId, ...track.notes.map((n) => n.id)), -1) + + 1 + : 0; + + return { + // Midi owns imported song data and the currently selected track. + name: 'Midi', + deps: ['Default'], + resources: { + midiSong: song, + midiLookup: song != null ? buildMidiLookup(song) : createEmptyMidiLookup(), + selectedTrackId, + selectedNoteId: null, + selectedNoteIds: new Set(), + nextMidiNoteId, + midiImportError: null + }, + appExtensions: { + async loadMidiFile(this: TMidiApp, file: File): Promise { + await loadMidiFileIntoState(this, file); + }, + loadMidiSongDirect(this: TMidiApp, song: TMidiSong, selectedTrackId: number | null): void { + const nextMidiNoteId = + song.tracks.reduce( + (maxId, track) => Math.max(maxId, ...track.notes.map((n) => n.id)), + -1 + ) + 1; + this.updateResource('midiSong', song); + this.updateResource('midiLookup', buildMidiLookup(song)); + this.updateResource('selectedTrackId', selectedTrackId); + this.updateResource('selectedNoteId', null); + this.updateResource('selectedNoteIds', new Set()); + this.updateResource('nextMidiNoteId', nextMidiNoteId); + this.updateResource('midiImportError', null); + }, + clearMidiSong(this: TMidiApp): void { + clearMidiSongState(this); + }, + selectNote(this: TMidiApp, noteId: number | null): void { + selectMidiNote(this, noteId); + }, + selectNotes(this: TMidiApp, noteIds: number[], primaryNoteId: number | null): void { + selectMidiNotes(this, noteIds, primaryNoteId); + }, + selectAllTrackNotes(this: TMidiApp, trackId?: number): void { + selectAllTrackMidiNotes(this, trackId); + }, + clearNoteSelection(this: TMidiApp): void { + clearMidiNoteSelection(this); + }, + createNote(this: TMidiApp, input: TMidiCreateNoteInput) { + return createMidiNote(this, input); + }, + moveSelectedNotes(this: TMidiApp, deltaTick: number, deltaNoteNumber: number): boolean { + return moveSelectedMidiNotes(this, deltaTick, deltaNoteNumber); + }, + resizePrimarySelectedNote(this: TMidiApp, edge: 'start' | 'end', deltaTick: number): boolean { + return resizePrimarySelectedMidiNote(this, edge, deltaTick); + }, + deleteSelectedNotes(this: TMidiApp): number { + return deleteSelectedMidiNotes(this); + } + } + }; +} diff --git a/apps/midimarble/src/modules/engine/plugins/midi/types.ts b/apps/midimarble/src/modules/engine/plugins/midi/types.ts new file mode 100644 index 0000000..934190b --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/midi/types.ts @@ -0,0 +1,65 @@ +import type { TApp, TAppContext, TDefaultPlugin, TPlugin } from 'ecsify'; +import type { TEngineSystemSet } from '../../types'; +import type { TMidiLookup } from './lib/midi-lookup'; + +export type TMidiPlugin = TPlugin< + { + name: 'Midi'; + resources: { + midiSong: TMidiSong | null; + midiLookup: TMidiLookup; + selectedTrackId: number | null; + selectedNoteId: number | null; + selectedNoteIds: Set; + nextMidiNoteId: number; + midiImportError: string | null; + }; + appExtensions: { + loadMidiFile(file: File): Promise; + loadMidiSongDirect(song: TMidiSong, selectedTrackId: number | null): void; + clearMidiSong(): void; + selectNote(noteId: number | null): void; + selectNotes(noteIds: number[], primaryNoteId: number | null): void; + selectAllTrackNotes(trackId?: number): void; + clearNoteSelection(): void; + createNote(input: TMidiCreateNoteInput): number | null; + moveSelectedNotes(deltaTick: number, deltaNoteNumber: number): boolean; + resizePrimarySelectedNote(edge: 'start' | 'end', deltaTick: number): boolean; + deleteSelectedNotes(): number; + }; + systemSets: TEngineSystemSet; + }, + [TDefaultPlugin] +>; + +export type TMidiApp = TApp>; + +export interface TMidiNote { + id: number; + tick: number; + durationTicks: number; + noteNumber: number; + velocity: number; + channel: number; +} + +export interface TMidiTrack { + id: number; + name: string; + notes: TMidiNote[]; +} + +export interface TMidiSong { + name: string; + bpm: number; + ticksPerBeat: number; + totalTicks: number; + tracks: TMidiTrack[]; +} + +export interface TMidiCreateNoteInput { + trackId?: number; + tick: number; + durationTicks: number; + noteNumber: number; +} diff --git a/apps/midimarble/src/modules/engine/plugins/physics/config.ts b/apps/midimarble/src/modules/engine/plugins/physics/config.ts new file mode 100644 index 0000000..9b9fd96 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/physics/config.ts @@ -0,0 +1,11 @@ +export const physicsConfig = { + timeStepSeconds: 1 / 240, + gravity: { x: 0, y: -9.81, z: 0 }, + simulation: { + checkpointIntervalSteps: 60, + preloadHorizonSteps: 2400, + maxPreloadStepsPerUpdate: 120, + maxLiveStepsPerUpdate: 12, + maxSyncStepsPerUpdate: 240 + } +} as const; diff --git a/apps/midimarble/src/modules/engine/plugins/physics/index.ts b/apps/midimarble/src/modules/engine/plugins/physics/index.ts new file mode 100644 index 0000000..383f6a7 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/physics/index.ts @@ -0,0 +1,3 @@ +export * from './lib/simulation'; +export * from './physics-plugin'; +export * from './types'; diff --git a/apps/midimarble/src/modules/engine/plugins/physics/lib/simulation-sync.ts b/apps/midimarble/src/modules/engine/plugins/physics/lib/simulation-sync.ts new file mode 100644 index 0000000..008dce9 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/physics/lib/simulation-sync.ts @@ -0,0 +1,60 @@ +import { updateTransport } from '../../transport'; +import type { TPhysicsApp } from '../types'; +import { getTransportTargetStep } from './transport-step'; +import { createEditedWorldBase, ensureSimulationBaseInitialized } from './world'; + +export function markSimulationDirty(app: TPhysicsApp): void { + ensureSimulationBaseInitialized(app); + + const currentSync = app.r.simulationSync; + const resumeWhenReady = + currentSync.mode === 'idle' ? app.r.transport.mode === 'running' : currentSync.resumeWhenReady; + + if (currentSync.mode === 'rebuilding') { + currentSync.world.free(); + } + + app.r.preloadWorld?.free(); + app.updateResource('preloadWorld', null); + app.updateResource('preloadStep', getTransportTargetStep(app)); + app.updateResource('simulationSync', { + mode: 'dirty', + resumeWhenReady, + requested: false + }); + updateTransport(app, { mode: 'paused' }); + app.updateResource('bufferedStep', 0); +} + +export function requestSimulationSync(app: TPhysicsApp): void { + if (app.r.simulationSync.mode !== 'dirty') { + return; + } + + app.updateResource('simulationSync', { + ...app.r.simulationSync, + requested: true + }); +} + +export function startSimulationSync(app: TPhysicsApp): void { + const simulationSync = app.r.simulationSync; + if (simulationSync.mode !== 'dirty' || !simulationSync.requested) { + return; + } + + const rebuiltWorldBase = createEditedWorldBase(app); + if (rebuiltWorldBase == null) { + return; + } + + app.updateResource('simulationSync', { + mode: 'rebuilding', + targetStep: getTransportTargetStep(app), + currentStep: 0, + resumeWhenReady: simulationSync.resumeWhenReady, + world: rebuiltWorldBase.world, + checkpointStore: new Map([[0, rebuiltWorldBase.world.takeSnapshot()]]), + fixedHandles: rebuiltWorldBase.fixedHandles + }); +} diff --git a/apps/midimarble/src/modules/engine/plugins/physics/lib/simulation.test.ts b/apps/midimarble/src/modules/engine/plugins/physics/lib/simulation.test.ts new file mode 100644 index 0000000..9d967ef --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/physics/lib/simulation.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from 'vitest'; +import { replaceLiveWorld } from './simulation'; + +describe('replaceLiveWorld', () => { + it('drops stale rigid-body handles that do not exist in the restored world', () => { + const liveBody = { handle: 10 }; + const staleBody = { handle: 99 }; + const liveCollider = { handle: 20 }; + const staleCollider = { handle: 88 }; + const restoredBody = { handle: 10, kind: 'restored' }; + const restoredCollider = { handle: 20, kind: 'restored' }; + const oldWorld = { free: vi.fn() }; + const newWorld = { + getRigidBody: vi.fn((handle: number) => (handle === 10 ? restoredBody : null)), + getCollider: vi.fn((handle: number) => (handle === 20 ? restoredCollider : null)) + }; + const updateResource = vi.fn(); + const app = { + r: { + world: oldWorld, + rigidBodies: new Map([ + [1, liveBody], + [2, staleBody] + ]), + colliders: new Map([ + [1, [liveCollider]], + [2, [staleCollider]] + ]) + }, + updateResource + } as unknown as Parameters[0]; + + replaceLiveWorld(app, newWorld as unknown as Parameters[1]); + + const rigidBodies = updateResource.mock.calls.find((call) => call[0] === 'rigidBodies')?.[1]; + const colliders = updateResource.mock.calls.find((call) => call[0] === 'colliders')?.[1]; + + expect(rigidBodies.get(1)).toBe(restoredBody); + expect(rigidBodies.has(2)).toBe(false); + expect(colliders.get(1)).toEqual([restoredCollider]); + expect(colliders.has(2)).toBe(false); + expect(updateResource).toHaveBeenCalledWith('world', newWorld); + expect(oldWorld.free).toHaveBeenCalledOnce(); + }); + + it('installs rebuilt fixed-body handles atomically during world swap', () => { + const liveBody = { handle: 10 }; + const liveCollider = { handle: 20 }; + const rebuiltFixedBody = { handle: 71, kind: 'fixed' } as unknown as Parameters< + typeof replaceLiveWorld + >[0]['r']['rigidBodies'] extends Map + ? TBody + : never; + const rebuiltFixedCollider = { handle: 72, kind: 'fixed' } as unknown as Parameters< + typeof replaceLiveWorld + >[0]['r']['colliders'] extends Map + ? TColliderArray extends Array + ? TCollider + : never + : never; + const restoredBody = { handle: 10, kind: 'restored-dynamic' }; + const restoredCollider = { handle: 20, kind: 'restored-dynamic' }; + const oldWorld = { free: vi.fn() }; + const newWorld = { + getRigidBody: vi.fn((handle: number) => (handle === 10 ? restoredBody : null)), + getCollider: vi.fn((handle: number) => (handle === 20 ? restoredCollider : null)) + }; + const updateResource = vi.fn(); + const app = { + r: { + world: oldWorld, + rigidBodies: new Map([ + [1, liveBody], + [7, { handle: 70 }] + ]), + colliders: new Map([ + [1, [liveCollider]], + [7, [{ handle: 73 }]] + ]) + }, + updateResource + } as unknown as Parameters[0]; + + replaceLiveWorld(app, newWorld as unknown as Parameters[1], { + rigidBodies: new Map([[7, rebuiltFixedBody]]), + colliders: new Map([[7, [rebuiltFixedCollider]]]) + }); + + const rigidBodies = updateResource.mock.calls.find((call) => call[0] === 'rigidBodies')?.[1]; + const colliders = updateResource.mock.calls.find((call) => call[0] === 'colliders')?.[1]; + + expect(rigidBodies.get(1)).toBe(restoredBody); + expect(rigidBodies.get(7)).toBe(rebuiltFixedBody); + expect(colliders.get(1)).toEqual([restoredCollider]); + expect(colliders.get(7)).toEqual([rebuiltFixedCollider]); + expect(app.updateResource).toHaveBeenCalledWith('world', newWorld); + expect(oldWorld.free).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/midimarble/src/modules/engine/plugins/physics/lib/simulation.ts b/apps/midimarble/src/modules/engine/plugins/physics/lib/simulation.ts new file mode 100644 index 0000000..e865720 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/physics/lib/simulation.ts @@ -0,0 +1,145 @@ +import type * as RAPIER from '@dimforge/rapier3d-compat'; +import type { TCheckpointStore, TPhysicsApp, TPhysicsWorldHandles } from '../types'; + +type TPhysicsRestoreAccess = { + r: Pick< + TPhysicsApp['r'], + 'rapier' | 'preloadWorld' | 'preloadStep' | 'fixedTimeStepSeconds' | 'checkpointStore' + >; + updateResource: TPhysicsApp['updateResource']; +}; + +type TPhysicsWorldSwapAccess = TPhysicsRestoreAccess & { + r: TPhysicsRestoreAccess['r'] & Pick; +}; + +export function storeCheckpoint( + checkpointStore: TCheckpointStore, + step: number, + snapshot: Uint8Array +): void { + checkpointStore.set(step, snapshot); +} + +export function findNearestCheckpointStep( + checkpointStore: TCheckpointStore, + targetStep: number +): number | null { + let checkpointStep: number | null = null; + for (const step of checkpointStore.keys()) { + if (step <= targetStep && (checkpointStep == null || step > checkpointStep)) { + checkpointStep = step; + } + } + + return checkpointStep; +} + +export function restoreWorldAtStep( + app: TPhysicsRestoreAccess, + targetStep: number +): RAPIER.World | null { + const rapier = app.r.rapier; + if (rapier == null) { + return null; + } + + const checkpointStep = findNearestCheckpointStep(app.r.checkpointStore, targetStep); + if (checkpointStep == null) { + return null; + } + + const snapshot = app.r.checkpointStore.get(checkpointStep); + if (snapshot == null) { + return null; + } + + const restoredWorld = rapier.World.restoreSnapshot(snapshot); + restoredWorld.timestep = app.r.fixedTimeStepSeconds; + + for (let step = checkpointStep; step < targetStep; step++) { + restoredWorld.step(); + } + + return restoredWorld; +} + +export function syncPreloadWorldToStep(app: TPhysicsRestoreAccess, targetStep: number): void { + const rapier = app.r.rapier; + if (rapier == null || !app.r.checkpointStore.has(0)) { + return; + } + + if (app.r.preloadWorld != null && app.r.preloadStep >= targetStep) { + return; + } + + const restoredWorld = restoreWorldAtStep(app, targetStep); + if (restoredWorld == null) { + return; + } + + app.r.preloadWorld?.free(); + app.updateResource('preloadWorld', restoredWorld); + app.updateResource('preloadStep', targetStep); +} + +export function replaceLiveWorld( + app: TPhysicsWorldSwapAccess, + world: NonNullable, + fixedHandles: TPhysicsWorldHandles | null = null +): void { + const nextHandles = createSwappedPhysicsHandles(app, world, fixedHandles); + const oldWorld = app.r.world; + app.updateResource('world', world); + app.updateResource('rigidBodies', nextHandles.rigidBodies); + app.updateResource('colliders', nextHandles.colliders); + oldWorld?.free(); +} + +function createSwappedPhysicsHandles( + app: TPhysicsWorldSwapAccess, + world: NonNullable, + fixedHandles: TPhysicsWorldHandles | null +): TPhysicsWorldHandles { + const nextRigidBodies: TPhysicsApp['r']['rigidBodies'] = new Map(); + const nextColliders: TPhysicsApp['r']['colliders'] = new Map(); + + if (fixedHandles != null) { + for (const [eid, body] of fixedHandles.rigidBodies) { + nextRigidBodies.set(eid, body); + } + for (const [eid, colliders] of fixedHandles.colliders) { + nextColliders.set(eid, colliders); + } + } + + for (const [eid, body] of app.r.rigidBodies) { + if (fixedHandles?.rigidBodies.has(eid)) { + continue; + } + + const restoredBody = world.getRigidBody(body.handle); + if (restoredBody != null) { + nextRigidBodies.set(eid, restoredBody); + } + } + + for (const [eid, colliders] of app.r.colliders) { + if (fixedHandles?.colliders.has(eid)) { + continue; + } + + const restoredColliders = colliders + .map((collider) => world.getCollider(collider.handle)) + .filter((collider): collider is NonNullable => collider != null); + if (restoredColliders.length > 0) { + nextColliders.set(eid, restoredColliders); + } + } + + return { + rigidBodies: nextRigidBodies, + colliders: nextColliders + }; +} diff --git a/apps/midimarble/src/modules/engine/plugins/physics/lib/transport-step.ts b/apps/midimarble/src/modules/engine/plugins/physics/lib/transport-step.ts new file mode 100644 index 0000000..a3e2402 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/physics/lib/transport-step.ts @@ -0,0 +1,14 @@ +import { tickToStep } from '../../midi'; +import type { TPhysicsApp } from '../types'; + +type TTransportStepAccess = { + r: Pick; +}; + +export function getTransportTargetStep(app: TTransportStepAccess): number { + if (app.r.midiSong == null) { + return 0; + } + + return tickToStep(app.r.transport.playheadTick, app.r.midiSong, app.r.fixedTimeStepSeconds); +} diff --git a/apps/midimarble/src/modules/engine/plugins/physics/lib/world.ts b/apps/midimarble/src/modules/engine/plugins/physics/lib/world.ts new file mode 100644 index 0000000..8f18227 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/physics/lib/world.ts @@ -0,0 +1,284 @@ +import type * as RAPIER from '@dimforge/rapier3d-compat'; +import { Entity } from 'ecsify'; +import type { + TCRigidBodyMixin, + TPhysicsApp, + TPhysicsColliderDescriptor, + TPhysicsWorldHandles +} from '../types'; +import { storeCheckpoint } from './simulation'; + +export function createRigidBodyDesc( + rapier: typeof RAPIER, + rigidBody: TCRigidBodyMixin +): RAPIER.RigidBodyDesc { + const desc = + rigidBody.kind === 'dynamic' + ? rapier.RigidBodyDesc.dynamic() + : rigidBody.kind === 'kinematicPosition' + ? rapier.RigidBodyDesc.kinematicPositionBased() + : rapier.RigidBodyDesc.fixed(); + + if (rigidBody.gravityScale != null) { + desc.setGravityScale(rigidBody.gravityScale); + } + if (rigidBody.canSleep != null) { + desc.setCanSleep(rigidBody.canSleep); + } + if (rigidBody.linearDamping != null) { + desc.setLinearDamping(rigidBody.linearDamping); + } + if (rigidBody.angularDamping != null) { + desc.setAngularDamping(rigidBody.angularDamping); + } + if (rigidBody.linearVelocity != null) { + desc.setLinvel( + rigidBody.linearVelocity.x, + rigidBody.linearVelocity.y, + rigidBody.linearVelocity.z + ); + } + if (rigidBody.angularVelocity != null) { + desc.setAngvel(rigidBody.angularVelocity); + } + + return desc; +} + +export function createColliderDesc( + rapier: typeof RAPIER, + descriptor: TPhysicsColliderDescriptor +): RAPIER.ColliderDesc { + const collider = + descriptor.shape === 'ball' + ? rapier.ColliderDesc.ball(descriptor.radius) + : rapier.ColliderDesc.cuboid( + descriptor.halfExtents.x, + descriptor.halfExtents.y, + descriptor.halfExtents.z + ); + + if (descriptor.translation != null) { + collider.setTranslation( + descriptor.translation.x, + descriptor.translation.y, + descriptor.translation.z + ); + } + if (descriptor.rotation != null) { + collider.setRotation( + createQuaternionFromEuler(descriptor.rotation.x, descriptor.rotation.y, descriptor.rotation.z) + ); + } + if (descriptor.friction != null) { + collider.setFriction(descriptor.friction); + } + if (descriptor.restitution != null) { + collider.setRestitution(descriptor.restitution); + } + if (descriptor.restitutionCombineRule != null) { + collider.setRestitutionCombineRule( + descriptor.restitutionCombineRule === 'max' + ? rapier.CoefficientCombineRule.Max + : descriptor.restitutionCombineRule === 'min' + ? rapier.CoefficientCombineRule.Min + : descriptor.restitutionCombineRule === 'multiply' + ? rapier.CoefficientCombineRule.Multiply + : rapier.CoefficientCombineRule.Average + ); + } + if (descriptor.density != null) { + collider.setDensity(descriptor.density); + } + if (descriptor.sensor != null) { + collider.setSensor(descriptor.sensor); + } + + return collider; +} + +export function createQuaternionFromEuler(x: number, y: number, z: number): RAPIER.Rotation { + const cx = Math.cos(x * 0.5); + const sx = Math.sin(x * 0.5); + const cy = Math.cos(y * 0.5); + const sy = Math.sin(y * 0.5); + const cz = Math.cos(z * 0.5); + const sz = Math.sin(z * 0.5); + + return { + x: sx * cy * cz - cx * sy * sz, + y: cx * sy * cz + sx * cy * sz, + z: cx * cy * sz - sx * sy * cz, + w: cx * cy * cz + sx * sy * sz + }; +} + +export function quaternionToEuler(x: number, y: number, z: number, w: number) { + const sinrCosp = 2 * (w * x + y * z); + const cosrCosp = 1 - 2 * (x * x + y * y); + const roll = Math.atan2(sinrCosp, cosrCosp); + + const sinp = 2 * (w * y - z * x); + const pitch = Math.abs(sinp) >= 1 ? Math.sign(sinp) * (Math.PI / 2) : Math.asin(sinp); + + const sinyCosp = 2 * (w * z + x * y); + const cosyCosp = 1 - 2 * (y * y + z * z); + const yaw = Math.atan2(sinyCosp, cosyCosp); + + return { x: roll, y: pitch, z: yaw }; +} + +export function syncBodyTransform( + body: RAPIER.RigidBody, + kind: TCRigidBodyMixin['kind'], + position: { x: number; y: number; z: number }, + rotation: { x: number; y: number; z: number } +): void { + if (kind === 'kinematicPosition') { + body.setNextKinematicTranslation(position); + body.setNextKinematicRotation(createQuaternionFromEuler(rotation.x, rotation.y, rotation.z)); + return; + } + + body.setTranslation(position, true); + body.setRotation(createQuaternionFromEuler(rotation.x, rotation.y, rotation.z), true); +} + +export function ensureSimulationBaseInitialized(app: TPhysicsApp): void { + const world = app.r.world; + const rapier = app.r.rapier; + if (world == null || rapier == null || app.r.checkpointStore.has(0)) { + return; + } + + const initialSnapshot = world.takeSnapshot(); + storeCheckpoint(app.r.checkpointStore, 0, initialSnapshot); + + const preloadWorld = rapier.World.restoreSnapshot(initialSnapshot); + preloadWorld.timestep = app.r.fixedTimeStepSeconds; + + app.updateResource('preloadWorld', preloadWorld); + app.updateResource('preloadStep', 0); + app.updateResource('liveStep', 0); + app.updateResource('bufferedStep', 0); +} + +export function createEditedWorldBase( + app: TPhysicsApp +): { world: RAPIER.World; fixedHandles: TPhysicsWorldHandles } | null { + ensureSimulationBaseInitialized(app); + + const rapier = app.r.rapier; + if (rapier == null) { + return null; + } + + const baseSnapshot = app.r.checkpointStore.get(0); + if (baseSnapshot == null) { + return null; + } + + const rebuiltWorld = rapier.World.restoreSnapshot(baseSnapshot); + rebuiltWorld.timestep = app.r.fixedTimeStepSeconds; + const fixedHandles = reapplyAuthoredStaticScene(app, rebuiltWorld, rapier); + return { + world: rebuiltWorld, + fixedHandles + }; +} + +function reapplyAuthoredStaticScene( + app: TPhysicsApp, + world: RAPIER.World, + rapier: typeof RAPIER +): TPhysicsWorldHandles { + const rigidBodies = new Map(); + const colliders = new Map(); + + for (const [eid, position, rotation, rigidBody, collider] of app.queryComponents([ + Entity, + app.c.PositionMixin, + app.c.RotationMixin, + app.c.RigidBodyMixin, + app.c.ColliderMixin + ] as const)) { + const currentBody = app.r.rigidBodies.get(eid); + const rebuiltBody = currentBody == null ? null : world.getRigidBody(currentBody.handle); + if (rebuiltBody == null && rigidBody.kind !== 'fixed') { + continue; + } + + const nextBody = + rebuiltBody ?? createRebuiltStaticBody(world, rapier, position, rotation, rigidBody); + const rebuiltColliders = syncRebuiltBody( + world, + rapier, + nextBody, + position, + rotation, + rigidBody, + collider.descriptors + ); + + rigidBodies.set(eid, nextBody); + colliders.set(eid, rebuiltColliders); + } + + return { rigidBodies, colliders }; +} + +function syncRebuiltBody( + world: RAPIER.World, + rapier: typeof RAPIER, + body: RAPIER.RigidBody, + position: { x: number; y: number; z: number }, + rotation: { x: number; y: number; z: number }, + rigidBody: TCRigidBodyMixin, + colliderDescriptors: TPhysicsColliderDescriptor[] +): RAPIER.Collider[] { + if (rigidBody.kind !== 'dynamic') { + syncBodyTransform(body, rigidBody.kind, position, rotation); + } + if (rigidBody.gravityScale != null) { + body.setGravityScale(rigidBody.gravityScale, true); + } + if (rigidBody.linearDamping != null) { + body.setLinearDamping(rigidBody.linearDamping); + } + if (rigidBody.angularDamping != null) { + body.setAngularDamping(rigidBody.angularDamping); + } + if (rigidBody.linearVelocity != null) { + body.setLinvel(rigidBody.linearVelocity, true); + } + if (rigidBody.angularVelocity != null) { + body.setAngvel(rigidBody.angularVelocity, true); + } + + const existingColliderCount = body.numColliders(); + for (let index = existingColliderCount - 1; index >= 0; index--) { + const existingCollider = body.collider(index); + if (existingCollider != null) { + world.removeCollider(existingCollider, true); + } + } + + const rebuiltColliders = []; + for (const descriptor of colliderDescriptors) { + rebuiltColliders.push(world.createCollider(createColliderDesc(rapier, descriptor), body)); + } + return rebuiltColliders; +} + +function createRebuiltStaticBody( + world: RAPIER.World, + rapier: typeof RAPIER, + position: { x: number; y: number; z: number }, + rotation: { x: number; y: number; z: number }, + rigidBody: TCRigidBodyMixin +): RAPIER.RigidBody { + const bodyDesc = createRigidBodyDesc(rapier, rigidBody); + bodyDesc.setTranslation(position.x, position.y, position.z); + bodyDesc.setRotation(createQuaternionFromEuler(rotation.x, rotation.y, rotation.z)); + return world.createRigidBody(bodyDesc); +} diff --git a/apps/midimarble/src/modules/engine/plugins/physics/physics-plugin.ts b/apps/midimarble/src/modules/engine/plugins/physics/physics-plugin.ts new file mode 100644 index 0000000..1494924 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/physics/physics-plugin.ts @@ -0,0 +1,111 @@ +import * as RAPIER from '@dimforge/rapier3d-compat'; +import { physicsConfig } from './config'; +import { markSimulationDirty, requestSimulationSync } from './lib/simulation-sync'; +import { + advanceSimulationSyncSystem, + beginSimulationSyncSystem, + cleanupOrphanedPhysicsBodiesSystem, + preloadPhysicsWorldSystem, + spawnRigidBodiesSystem, + stepPhysicsWorldSystem, + syncDynamicBodiesToComponentsSystem, + syncLiveWorldToTransportSystem, + syncNonDynamicBodiesFromComponentsSystem +} from './systems'; +import type { TPhysicsApp, TPhysicsPlugin } from './types'; + +export function createPhysicsPlugin(): TPhysicsPlugin { + const initPromise = RAPIER.init(); + + return { + // Physics owns simulation state, stepping, checkpoints, and resync. + name: 'Physics', + deps: ['Default', 'Core', 'Midi', 'Transport'], + components: { + RigidBodyMixin: [], + ColliderMixin: [] + }, + resources: { + rapier: null, + world: null, + preloadWorld: null, + isReady: false, + fixedTimeStepSeconds: physicsConfig.timeStepSeconds, + bufferedStep: 0, + liveStep: 0, + checkpointStore: new Map(), + preloadStep: 0, + rigidBodies: new Map(), + colliders: new Map(), + simulationSync: { + mode: 'idle' + } + }, + appExtensions: { + markSimulationDirty(this: TPhysicsApp): void { + markSimulationDirty(this); + }, + requestSimulationSync(this: TPhysicsApp): void { + requestSimulationSync(this); + }, + setSimulationResumeWhenReady(this: TPhysicsApp, resumeWhenReady: boolean): boolean { + if (this.r.simulationSync.mode === 'idle') { + return false; + } + + if (this.r.simulationSync.resumeWhenReady === resumeWhenReady) { + return true; + } + + this.updateResource('simulationSync', { + ...this.r.simulationSync, + resumeWhenReady + }); + return true; + } + }, + setup(app: TPhysicsApp) { + void initPromise.then(() => { + app.updateResource('rapier', RAPIER); + const world = new RAPIER.World(physicsConfig.gravity); + world.timestep = app.r.fixedTimeStepSeconds; + app.updateResource('world', world); + app.updateResource('isReady', true); + }); + + app.addSystem(spawnRigidBodiesSystem, { set: 'First' }); + app.addSystem(syncNonDynamicBodiesFromComponentsSystem, { + set: 'Update', + after: spawnRigidBodiesSystem + }); + app.addSystem(beginSimulationSyncSystem, { + set: 'Update', + after: syncNonDynamicBodiesFromComponentsSystem + }); + app.addSystem(syncLiveWorldToTransportSystem, { + set: 'Update', + after: beginSimulationSyncSystem + }); + app.addSystem(stepPhysicsWorldSystem, { + set: 'Update', + after: syncLiveWorldToTransportSystem + }); + app.addSystem(advanceSimulationSyncSystem, { + set: 'Update', + after: stepPhysicsWorldSystem + }); + app.addSystem(preloadPhysicsWorldSystem, { + set: 'Update', + after: advanceSimulationSyncSystem + }); + app.addSystem(syncDynamicBodiesToComponentsSystem, { + set: 'Update', + after: preloadPhysicsWorldSystem + }); + app.addSystem(cleanupOrphanedPhysicsBodiesSystem, { + set: 'Last', + after: syncDynamicBodiesToComponentsSystem + }); + } + }; +} diff --git a/apps/midimarble/src/modules/engine/plugins/physics/systems.ts b/apps/midimarble/src/modules/engine/plugins/physics/systems.ts new file mode 100644 index 0000000..68267d6 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/physics/systems.ts @@ -0,0 +1,305 @@ +import { Entity, With } from 'ecsify'; +import { updateTransport } from '../transport'; +import { physicsConfig } from './config'; +import { + replaceLiveWorld, + restoreWorldAtStep, + storeCheckpoint, + syncPreloadWorldToStep +} from './lib/simulation'; +import { startSimulationSync } from './lib/simulation-sync'; +import { getTransportTargetStep } from './lib/transport-step'; +import { + createColliderDesc, + createQuaternionFromEuler, + createRigidBodyDesc, + ensureSimulationBaseInitialized, + quaternionToEuler, + syncBodyTransform +} from './lib/world'; +import type { TPhysicsApp } from './types'; + +export function spawnRigidBodiesSystem(app: TPhysicsApp) { + const world = app.r.world; + const rapier = app.r.rapier; + if (world == null || rapier == null) { + return; + } + + for (const [eid, position, rotation, rigidBody, collider] of app.queryComponents([ + Entity, + app.c.PositionMixin, + app.c.RotationMixin, + app.c.RigidBodyMixin, + app.c.ColliderMixin + ] as const)) { + if (app.r.rigidBodies.has(eid)) { + continue; + } + + const bodyDesc = createRigidBodyDesc(rapier, rigidBody); + bodyDesc.setTranslation(position.x, position.y, position.z); + bodyDesc.setRotation(createQuaternionFromEuler(rotation.x, rotation.y, rotation.z)); + + const body = world.createRigidBody(bodyDesc); + const colliders = collider.descriptors.map((descriptor) => + world.createCollider(createColliderDesc(rapier, descriptor), body) + ); + + app.r.rigidBodies.set(eid, body); + app.r.colliders.set(eid, colliders); + } +} + +export function syncNonDynamicBodiesFromComponentsSystem(app: TPhysicsApp) { + for (const [eid, position, rotation, rigidBody] of app.queryComponents([ + Entity, + app.c.PositionMixin, + app.c.RotationMixin, + app.c.RigidBodyMixin + ] as const)) { + if (rigidBody.kind === 'dynamic') { + continue; + } + if (rigidBody.kind === 'fixed' && app.r.simulationSync.mode !== 'idle') { + continue; + } + + const body = app.r.rigidBodies.get(eid); + if (body == null) { + continue; + } + + syncBodyTransform(body, rigidBody.kind, position, rotation); + } +} + +export function beginSimulationSyncSystem(app: TPhysicsApp) { + startSimulationSync(app); +} + +export function syncLiveWorldToTransportSystem(app: TPhysicsApp) { + if (app.r.world == null || app.r.simulationSync.mode !== 'idle') { + return; + } + + ensureSimulationBaseInitialized(app); + + const targetStep = getTransportTargetStep(app); + if (targetStep === app.r.liveStep) { + return; + } + if (targetStep > app.r.liveStep && app.r.transport.mode === 'running') { + return; + } + + const restoredWorld = restoreWorldAtStep(app, targetStep); + if (restoredWorld == null) { + return; + } + + replaceLiveWorld(app, restoredWorld); + const nextBufferedStep = Math.max(app.r.bufferedStep, targetStep); + app.updateResource('liveStep', targetStep); + app.updateResource('bufferedStep', nextBufferedStep); + syncPreloadWorldToStep(app, nextBufferedStep); +} + +export function stepPhysicsWorldSystem(app: TPhysicsApp) { + const world = app.r.world; + if (world == null || app.r.simulationSync.mode !== 'idle') { + return; + } + + ensureSimulationBaseInitialized(app); + + if (app.r.transport.mode !== 'running') { + return; + } + + const { simulation } = physicsConfig; + const targetStep = getTransportTargetStep(app); + let liveStep = app.r.liveStep; + let bufferedStep = app.r.bufferedStep; + let stepsRun = 0; + + while (liveStep < targetStep && stepsRun < simulation.maxLiveStepsPerUpdate) { + world.step(); + liveStep++; + stepsRun++; + + if (liveStep % simulation.checkpointIntervalSteps === 0) { + storeCheckpoint(app.r.checkpointStore, liveStep, world.takeSnapshot()); + } + } + + if (stepsRun === 0) { + return; + } + + bufferedStep = Math.max(bufferedStep, liveStep); + app.updateResource('liveStep', liveStep); + app.updateResource('bufferedStep', bufferedStep); +} + +export function advanceSimulationSyncSystem(app: TPhysicsApp) { + const simulationSync = app.r.simulationSync; + if (simulationSync.mode !== 'rebuilding') { + return; + } + + const { simulation } = physicsConfig; + let currentStep = simulationSync.currentStep; + let stepsRun = 0; + + while (currentStep < simulationSync.targetStep && stepsRun < simulation.maxSyncStepsPerUpdate) { + simulationSync.world.step(); + currentStep++; + stepsRun++; + + if (currentStep % simulation.checkpointIntervalSteps === 0) { + storeCheckpoint( + simulationSync.checkpointStore, + currentStep, + simulationSync.world.takeSnapshot() + ); + } + } + + if (currentStep !== simulationSync.currentStep) { + app.updateResource('simulationSync', { + ...simulationSync, + currentStep + }); + app.updateResource('bufferedStep', currentStep); + } + + if (currentStep < simulationSync.targetStep) { + return; + } + + if (!simulationSync.checkpointStore.has(currentStep)) { + storeCheckpoint( + simulationSync.checkpointStore, + currentStep, + simulationSync.world.takeSnapshot() + ); + } + + app.r.checkpointStore.clear(); + for (const [step, snapshot] of simulationSync.checkpointStore) { + storeCheckpoint(app.r.checkpointStore, step, snapshot); + } + + replaceLiveWorld(app, simulationSync.world, simulationSync.fixedHandles); + + app.r.preloadWorld?.free(); + const rapier = app.r.rapier; + if (rapier != null) { + const preloadWorld = rapier.World.restoreSnapshot(simulationSync.world.takeSnapshot()); + preloadWorld.timestep = app.r.fixedTimeStepSeconds; + app.updateResource('preloadWorld', preloadWorld); + } + app.updateResource('preloadStep', currentStep); + app.updateResource('simulationSync', { mode: 'idle' }); + app.updateResource('liveStep', currentStep); + app.updateResource('bufferedStep', currentStep); + updateTransport(app, { + mode: simulationSync.resumeWhenReady ? 'running' : 'paused' + }); +} + +export function preloadPhysicsWorldSystem(app: TPhysicsApp) { + const rapier = app.r.rapier; + const world = app.r.world; + if (rapier == null || world == null || app.r.simulationSync.mode !== 'idle') { + return; + } + + ensureSimulationBaseInitialized(app); + syncPreloadWorldToStep(app, app.r.bufferedStep); + + const preloadWorld = app.r.preloadWorld; + if (preloadWorld == null) { + return; + } + + const { simulation } = physicsConfig; + const targetBufferedStep = getTransportTargetStep(app) + simulation.preloadHorizonSteps; + if (app.r.bufferedStep >= targetBufferedStep) { + return; + } + + let preloadStep = app.r.preloadStep; + let bufferedStep = app.r.bufferedStep; + let stepsRun = 0; + + while (bufferedStep < targetBufferedStep && stepsRun < simulation.maxPreloadStepsPerUpdate) { + preloadWorld.step(); + preloadStep++; + bufferedStep = preloadStep; + stepsRun++; + + if (preloadStep % simulation.checkpointIntervalSteps === 0) { + storeCheckpoint(app.r.checkpointStore, preloadStep, preloadWorld.takeSnapshot()); + } + } + + if (stepsRun === 0) { + return; + } + + app.updateResource('preloadStep', preloadStep); + app.updateResource('bufferedStep', bufferedStep); +} + +export function syncDynamicBodiesToComponentsSystem(app: TPhysicsApp) { + for (const [eid, rigidBody] of app.queryComponents( + [Entity, app.c.RigidBodyMixin] as const, + With(app.c.RigidBodyMixin) + )) { + const body = app.r.rigidBodies.get(eid); + if (body == null || rigidBody.kind !== 'dynamic') { + continue; + } + + const translation = body.translation(); + const rotation = body.rotation(); + const euler = quaternionToEuler(rotation.x, rotation.y, rotation.z, rotation.w); + + app.updateComponent(eid, app.c.PositionMixin, { + x: translation.x, + y: translation.y, + z: translation.z + }); + app.updateComponent(eid, app.c.RotationMixin, euler); + } +} + +export function cleanupOrphanedPhysicsBodiesSystem(app: TPhysicsApp) { + const activeEntities = new Set(app.queryEntities(With(app.c.RigidBodyMixin))); + const world = app.r.world; + + for (const [eid, colliders] of app.r.colliders) { + if (activeEntities.has(eid)) { + continue; + } + + if (world != null) { + for (const collider of colliders) { + world.removeCollider(collider, true); + } + } + + app.r.colliders.delete(eid); + } + + for (const [eid, body] of app.r.rigidBodies) { + if (activeEntities.has(eid)) { + continue; + } + + world?.removeRigidBody(body); + app.r.rigidBodies.delete(eid); + } +} diff --git a/apps/midimarble/src/modules/engine/plugins/physics/types.ts b/apps/midimarble/src/modules/engine/plugins/physics/types.ts new file mode 100644 index 0000000..7b1a076 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/physics/types.ts @@ -0,0 +1,111 @@ +import type * as RAPIER from '@dimforge/rapier3d-compat'; +import type { TApp, TAppContext, TDefaultPlugin, TPlugin } from 'ecsify'; +import type { TEngineSystemSet, TVec3 } from '../../types'; +import type { TCorePlugin } from '../core'; +import type { TMidiPlugin } from '../midi'; +import type { TTransportPlugin } from '../transport'; + +export type TPhysicsPlugin = TPlugin< + { + name: 'Physics'; + components: { + RigidBodyMixin: TCRigidBodyMixin[]; + ColliderMixin: TCColliderMixin[]; + }; + resources: { + rapier: typeof RAPIER | null; + world: RAPIER.World | null; + preloadWorld: RAPIER.World | null; + isReady: boolean; + fixedTimeStepSeconds: number; + bufferedStep: number; + liveStep: number; + checkpointStore: TCheckpointStore; + preloadStep: number; + rigidBodies: TRRigidBodies; + colliders: TRColliders; + simulationSync: TSimulationSync; + }; + appExtensions: { + markSimulationDirty(): void; + requestSimulationSync(): void; + setSimulationResumeWhenReady(resumeWhenReady: boolean): boolean; + }; + systemSets: TEngineSystemSet; + }, + [TDefaultPlugin, TCorePlugin, TMidiPlugin, TTransportPlugin] +>; + +export type TPhysicsApp = TApp< + TAppContext<[TDefaultPlugin, TCorePlugin, TMidiPlugin, TTransportPlugin, TPhysicsPlugin]> +>; + +export type TRRigidBodies = Map; +export type TRColliders = Map; +export type TCheckpointStore = Map; + +export interface TPhysicsWorldHandles { + rigidBodies: TRRigidBodies; + colliders: TRColliders; +} + +export type TSimulationSync = + | TIdleSimulationSync + | TDirtySimulationSync + | TRebuildingSimulationSync; + +export interface TIdleSimulationSync { + mode: 'idle'; +} + +export interface TDirtySimulationSync { + mode: 'dirty'; + resumeWhenReady: boolean; + requested: boolean; +} + +export interface TRebuildingSimulationSync { + mode: 'rebuilding'; + targetStep: number; + currentStep: number; + resumeWhenReady: boolean; + world: RAPIER.World; + checkpointStore: TCheckpointStore; + fixedHandles: TPhysicsWorldHandles; +} + +export interface TCRigidBodyMixin { + kind: 'dynamic' | 'fixed' | 'kinematicPosition'; + gravityScale?: number; + canSleep?: boolean; + linearDamping?: number; + angularDamping?: number; + linearVelocity?: TVec3; + angularVelocity?: TVec3; +} + +export interface TCColliderMixin { + descriptors: TPhysicsColliderDescriptor[]; +} + +interface TBaseColliderDescriptor { + translation?: TVec3; + rotation?: TVec3; + friction?: number; + restitution?: number; + restitutionCombineRule?: 'average' | 'min' | 'multiply' | 'max'; + density?: number; + sensor?: boolean; +} + +export interface TBallColliderDescriptor extends TBaseColliderDescriptor { + shape: 'ball'; + radius: number; +} + +export interface TCuboidColliderDescriptor extends TBaseColliderDescriptor { + shape: 'cuboid'; + halfExtents: TVec3; +} + +export type TPhysicsColliderDescriptor = TBallColliderDescriptor | TCuboidColliderDescriptor; diff --git a/apps/midimarble/src/modules/engine/plugins/render/config.ts b/apps/midimarble/src/modules/engine/plugins/render/config.ts new file mode 100644 index 0000000..6eb5b20 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/render/config.ts @@ -0,0 +1,23 @@ +export const renderConfig = { + camera: { + forwardFallback: { x: 0, y: 0, z: 1 } + }, + preview: { + initial: { + enabled: false, + mode: 'followMarble', + fov: 64, + distance: 16, + height: 2, + lookAhead: 0, + smoothing: 0.12 + }, + limits: { + fov: { min: 20, max: 90, step: 1 }, + distance: { min: 3, max: 30, step: 0.1 }, + height: { min: -8, max: 8, step: 0.1 }, + lookAhead: { min: -8, max: 8, step: 0.1 }, + smoothing: { min: 0.02, max: 0.4, step: 0.01 } + } + } +} as const; diff --git a/apps/midimarble/src/modules/engine/plugins/render/index.ts b/apps/midimarble/src/modules/engine/plugins/render/index.ts new file mode 100644 index 0000000..31bd09c --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/render/index.ts @@ -0,0 +1,2 @@ +export * from './render-plugin'; +export * from './types'; diff --git a/apps/midimarble/src/modules/engine/plugins/render/lib/Viewport.ts b/apps/midimarble/src/modules/engine/plugins/render/lib/Viewport.ts new file mode 100644 index 0000000..4efe22c --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/render/lib/Viewport.ts @@ -0,0 +1,204 @@ +import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; +import type { TVec3 } from '../../../types'; + +export interface TCameraSnapshot { + position: TVec3; + target: TVec3; + fov: number; +} + +export class Viewport { + private _container: HTMLDivElement | null = null; + private readonly _scene: THREE.Scene; + private readonly _camera: THREE.PerspectiveCamera; + private readonly _renderer: THREE.WebGLRenderer; + private readonly _controls: OrbitControls; + private _resizeObserver: ResizeObserver | null = null; + private readonly _trackedObjects = new Set(); + + constructor() { + this._scene = new THREE.Scene(); + this._scene.background = new THREE.Color(0xe1dbd5); + + this._camera = new THREE.PerspectiveCamera(25, 1, 0.1, 1000); + this._camera.position.set(100, 0, 0); + this._camera.lookAt(0, 0, 0); + + this._renderer = new THREE.WebGLRenderer({ + antialias: true, + powerPreference: 'high-performance' + }); + this._renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + this._renderer.setSize(1, 1); + this._renderer.setClearColor(0xe1dbd5); + this._renderer.outputColorSpace = THREE.SRGBColorSpace; + this._renderer.shadowMap.enabled = true; + this._renderer.shadowMap.type = THREE.PCFSoftShadowMap; + + this._controls = new OrbitControls(this._camera, this._renderer.domElement); + this._controls.enableDamping = true; + this._controls.target.set(0, 0, 0); + this._controls.minDistance = 10; + this._controls.maxDistance = 100; + this._controls.maxAzimuthAngle = Math.PI - 0.1; + this._controls.minAzimuthAngle = 0.1; + this._controls.minPolarAngle = 0.1; + this._controls.maxPolarAngle = Math.PI - 0.1; + this._controls.update(); + + const ambient = new THREE.AmbientLight(0xffffff, 1); + const keyLight = new THREE.DirectionalLight(0xffffff, 2); + keyLight.position.set(100, 50, 50); + keyLight.castShadow = true; + keyLight.shadow.mapSize.set(4096, 4096); + keyLight.shadow.camera.left = -50; + keyLight.shadow.camera.right = 50; + keyLight.shadow.camera.top = 50; + keyLight.shadow.camera.bottom = -50; + keyLight.shadow.radius = 3; + keyLight.shadow.intensity = 0.6; + + const fillLight = new THREE.DirectionalLight('#3333ca', 0.5); + fillLight.position.set(0.2, -1, 0.05); + + this._scene.add(ambient, keyLight, fillLight); + } + + public get scene(): THREE.Scene { + return this._scene; + } + + public get camera(): THREE.PerspectiveCamera { + return this._camera; + } + + public get domElement(): HTMLCanvasElement { + return this._renderer.domElement; + } + + public get controlsEnabled(): boolean { + return this._controls.enabled; + } + + public setControlsEnabled(enabled: boolean): void { + this._controls.enabled = enabled; + } + + public getCameraSnapshot(): TCameraSnapshot { + return { + position: { + x: this._camera.position.x, + y: this._camera.position.y, + z: this._camera.position.z + }, + target: { + x: this._controls.target.x, + y: this._controls.target.y, + z: this._controls.target.z + }, + fov: this._camera.fov + }; + } + + public applyCameraSnapshot(snapshot: TCameraSnapshot): void { + this.setCameraPose(snapshot.position, snapshot.target, snapshot.fov); + } + + public setCameraPose(position: TVec3, target: TVec3, fov?: number): void { + this._camera.position.set(position.x, position.y, position.z); + this._controls.target.set(target.x, target.y, target.z); + if (fov != null && this._camera.fov !== fov) { + this._camera.fov = fov; + this._camera.updateProjectionMatrix(); + } + this._camera.lookAt(target.x, target.y, target.z); + this._controls.update(); + } + + public setContainer(container: HTMLDivElement | null): void { + if (this._container === container) { + return; + } + + this.detachContainer(); + this._container = container; + + if (container == null) { + return; + } + + container.appendChild(this._renderer.domElement); + this._resizeObserver = new ResizeObserver(() => this.resize()); + this._resizeObserver.observe(container); + this.resize(); + } + + public renderFrame(): void { + if (this._container == null) { + return; + } + + if (this._controls.enabled) { + this._controls.update(); + } + this._renderer.render(this._scene, this._camera); + } + + public trackObject(object: THREE.Object3D): void { + this._trackedObjects.add(object); + } + + public disposeObject(object: THREE.Object3D | null): void { + if (object == null) { + return; + } + + this._scene.remove(object); + this._trackedObjects.delete(object); + object.traverse((child) => { + const mesh = child as THREE.Mesh; + if (mesh.geometry != null) { + mesh.geometry.dispose(); + } + + const material = mesh.material; + if (Array.isArray(material)) { + for (const entry of material) { + entry.dispose(); + } + } else if (material != null) { + material.dispose(); + } + }); + } + + public dispose(): void { + this.detachContainer(); + this._controls.dispose(); + + for (const object of Array.from(this._trackedObjects)) { + this.disposeObject(object); + } + + this._renderer.dispose(); + } + + private detachContainer(): void { + this._resizeObserver?.disconnect(); + this._resizeObserver = null; + + const parent = this._renderer.domElement.parentNode; + if (parent instanceof HTMLElement) { + parent.removeChild(this._renderer.domElement); + } + } + + private resize(): void { + const width = Math.max(1, this._container?.clientWidth ?? 1); + const height = Math.max(1, this._container?.clientHeight ?? 1); + this._camera.aspect = width / height; + this._camera.updateProjectionMatrix(); + this._renderer.setSize(width, height); + } +} diff --git a/apps/midimarble/src/modules/engine/plugins/render/lib/preview-camera.test.ts b/apps/midimarble/src/modules/engine/plugins/render/lib/preview-camera.test.ts new file mode 100644 index 0000000..8249efb --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/render/lib/preview-camera.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + computePreviewCameraPose, + enterPreview, + exitPreview, + getPreviewSideSign, + getPreviewSmoothingAlpha +} from './preview-camera'; + +describe('preview camera helpers', () => { + it('saves and restores the camera snapshot when preview toggles', () => { + const snapshot = { + position: { x: 1, y: 2, z: 3 }, + target: { x: 4, y: 5, z: 6 }, + fov: 28 + }; + const getCameraSnapshot = vi.fn(() => snapshot); + const applyCameraSnapshot = vi.fn(); + const setControlsEnabled = vi.fn(); + const initialState = { + savedCameraSnapshot: null, + lastFollowDirection: { x: 0, y: 0, z: 1 }, + targetEntityId: 12 + }; + + const entered = enterPreview( + { + getCameraSnapshot, + setControlsEnabled + }, + initialState + ); + const exited = exitPreview( + { + applyCameraSnapshot, + setControlsEnabled + }, + entered + ); + + expect(getCameraSnapshot).toHaveBeenCalledOnce(); + expect(setControlsEnabled).toHaveBeenNthCalledWith(1, false); + expect(setControlsEnabled).toHaveBeenNthCalledWith(2, true); + expect(entered.savedCameraSnapshot).toEqual(snapshot); + expect(entered.lastFollowDirection).toBeNull(); + expect(applyCameraSnapshot).toHaveBeenCalledWith(snapshot); + expect(exited.savedCameraSnapshot).toBeNull(); + expect(exited.lastFollowDirection).toBeNull(); + expect(exited.targetEntityId).toBe(12); + }); + + it('reuses the last non-zero direction when the marble slows down', () => { + const pose = computePreviewCameraPose( + { x: 0, y: 1, z: 2 }, + { x: 0, y: 0, z: 0 }, + { x: 0, y: 0, z: -1 }, + { + distance: 7, + height: 2.2, + lookAhead: 2.4 + }, + 1 + ); + + expect(pose.forward).toEqual({ x: 0, y: 0, z: -1 }); + expect(pose.position).toEqual({ x: 6.16, y: 3.2, z: 3.8200000000000003 }); + expect(pose.target).toEqual({ x: 0, y: 1.264, z: -0.3999999999999999 }); + }); + + it('inherits the preview side from the saved editor camera snapshot', () => { + expect( + getPreviewSideSign({ + position: { x: -12, y: 6, z: 4 }, + target: { x: 0, y: 0, z: 0 }, + fov: 25 + }) + ).toBe(-1); + + const pose = computePreviewCameraPose( + { x: 0, y: 1, z: 2 }, + { x: 0, y: 0, z: -1 }, + null, + { + distance: 7, + height: 2.2, + lookAhead: 2.4 + }, + -1 + ); + + expect(pose.position.x).toBeCloseTo(-6.16, 5); + }); + + it('keeps smoothing frame-rate aware', () => { + expect(getPreviewSmoothingAlpha(0.14, 1 / 60)).toBeCloseTo(0.14, 5); + expect(getPreviewSmoothingAlpha(0.14, 1 / 30)).toBeGreaterThan(0.14); + }); +}); diff --git a/apps/midimarble/src/modules/engine/plugins/render/lib/preview-camera.ts b/apps/midimarble/src/modules/engine/plugins/render/lib/preview-camera.ts new file mode 100644 index 0000000..9d57d71 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/render/lib/preview-camera.ts @@ -0,0 +1,153 @@ +import type { TVec3 } from '../../../types'; +import { renderConfig } from '../config'; +import type { TPreviewConfig, TPreviewState } from '../types'; +import type { TCameraSnapshot } from './Viewport'; + +export interface TPreviewCameraPose { + position: TVec3; + target: TVec3; + forward: TVec3; +} + +export function getPreviewSideSign(snapshot: TCameraSnapshot | null): number { + if (snapshot == null) { + return 1; + } + + const sideOffsetX = snapshot.position.x - snapshot.target.x; + if (Math.abs(sideOffsetX) < 1e-4) { + return 1; + } + + return sideOffsetX < 0 ? -1 : 1; +} + +export function enterPreview( + viewport: { + getCameraSnapshot(): TCameraSnapshot; + setControlsEnabled(enabled: boolean): void; + }, + state: TPreviewState +): TPreviewState { + viewport.setControlsEnabled(false); + return { + ...state, + savedCameraSnapshot: state.savedCameraSnapshot ?? viewport.getCameraSnapshot(), + lastFollowDirection: null + }; +} + +export function exitPreview( + viewport: { + applyCameraSnapshot(snapshot: TCameraSnapshot): void; + setControlsEnabled(enabled: boolean): void; + }, + state: TPreviewState +): TPreviewState { + if (state.savedCameraSnapshot != null) { + viewport.applyCameraSnapshot(state.savedCameraSnapshot); + } + viewport.setControlsEnabled(true); + return { + ...state, + savedCameraSnapshot: null, + lastFollowDirection: null + }; +} + +export function computePreviewCameraPose( + marblePosition: TVec3, + velocity: TVec3 | null, + lastFollowDirection: TVec3 | null, + config: Pick, + sideSign: number = 1 +): TPreviewCameraPose { + const forward = normalizeOrFallback( + flattenTravelDirection(velocity), + flattenTravelDirection(lastFollowDirection ?? renderConfig.camera.forwardFallback) ?? + renderConfig.camera.forwardFallback + ); + const normalizedSideSign = sideSign < 0 ? -1 : 1; + const sideDistance = config.distance * 0.88; + const trailingDistance = config.distance * 0.26; + + return { + position: { + x: marblePosition.x + sideDistance * normalizedSideSign, + y: marblePosition.y + config.height, + z: marblePosition.z - forward.z * trailingDistance - forward.x * config.distance * 0.1 + }, + target: { + x: marblePosition.x, + y: marblePosition.y + config.height * 0.12, + z: marblePosition.z + forward.z * config.lookAhead + forward.x * config.lookAhead * 0.25 + }, + forward + }; +} + +export function getPreviewSmoothingAlpha(smoothing: number, delta: number): number { + if (delta <= 0) { + return smoothing; + } + + const clampedSmoothing = clamp(smoothing, 0, 1); + return clamp(1 - Math.pow(1 - clampedSmoothing, delta * 60), 0, 1); +} + +export function lerpVec3(from: TVec3, to: TVec3, alpha: number): TVec3 { + return { + x: from.x + (to.x - from.x) * alpha, + y: from.y + (to.y - from.y) * alpha, + z: from.z + (to.z - from.z) * alpha + }; +} + +export function areVec3Close( + left: TVec3 | null, + right: TVec3 | null, + epsilon: number = 1e-4 +): boolean { + if (left == null || right == null) { + return left === right; + } + + return ( + Math.abs(left.x - right.x) <= epsilon && + Math.abs(left.y - right.y) <= epsilon && + Math.abs(left.z - right.z) <= epsilon + ); +} + +function normalizeOrFallback(vector: TVec3 | null, fallback: TVec3): TVec3 { + if (vector == null) { + return fallback; + } + + const length = Math.hypot(vector.x, vector.y, vector.z); + if (length < 1e-4) { + return fallback; + } + + return { + x: vector.x / length, + y: vector.y / length, + z: vector.z / length + }; +} + +function flattenTravelDirection(vector: TVec3 | null): TVec3 | null { + if (vector == null) { + return null; + } + + return { + x: vector.x, + y: 0, + z: vector.z + }; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} diff --git a/apps/midimarble/src/modules/engine/plugins/render/lib/three-object.ts b/apps/midimarble/src/modules/engine/plugins/render/lib/three-object.ts new file mode 100644 index 0000000..b161fa9 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/render/lib/three-object.ts @@ -0,0 +1,34 @@ +import type * as THREE from 'three'; +import type { TRenderApp } from '../types'; + +export function syncThreeObjectTransform( + object: THREE.Object3D, + transform: { + position: { x: number; y: number; z: number }; + rotation: { x: number; y: number; z: number }; + scale: { x: number; y: number; z: number }; + } +): void { + object.position.set(transform.position.x, transform.position.y, transform.position.z); + object.rotation.set(transform.rotation.x, transform.rotation.y, transform.rotation.z); + object.scale.set(transform.scale.x, transform.scale.y, transform.scale.z); +} + +export function replaceMountedThreeObject( + app: TRenderApp, + eid: number, + nextObject: THREE.Object3D +): void { + const prevObject = app.r.sceneObjects.get(eid); + if (prevObject === nextObject) { + return; + } + + if (prevObject != null) { + app.r.viewport.disposeObject(prevObject); + } + + app.r.viewport.scene.add(nextObject); + app.r.viewport.trackObject(nextObject); + app.r.sceneObjects.set(eid, nextObject); +} diff --git a/apps/midimarble/src/modules/engine/plugins/render/render-plugin.ts b/apps/midimarble/src/modules/engine/plugins/render/render-plugin.ts new file mode 100644 index 0000000..0d629ac --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/render/render-plugin.ts @@ -0,0 +1,98 @@ +import { renderConfig } from './config'; +import { enterPreview, exitPreview } from './lib/preview-camera'; +import { Viewport } from './lib/Viewport'; +import { + cleanupOrphanedThreeObjectsSystem, + mountThreeObjectsSystem, + renderFrameSystem, + syncPreviewCameraSystem, + syncThreeObjectTransformsSystem +} from './systems'; +import type { TRenderApp, TRenderPlugin } from './types'; + +export function createRenderPlugin(): TRenderPlugin { + const viewport = new Viewport(); + + return { + // Render owns viewport lifecycle and mounted scene objects only. + name: 'Render', + deps: ['Default', 'Core', 'Physics'], + components: { + // Mixins + MeshMixin: [] + }, + resources: { + viewport, + sceneObjects: new Map(), + previewConfig: { ...renderConfig.preview.initial }, + previewState: { + savedCameraSnapshot: null, + lastFollowDirection: null, + targetEntityId: null + } + }, + appExtensions: { + setRenderContainer(this: TRenderApp, container: HTMLDivElement | null): void { + this.r.viewport.setContainer(container); + }, + setPreviewEnabled(this: TRenderApp, enabled: boolean): void { + if (this.r.previewConfig.enabled === enabled) { + return; + } + + this.updateResource('previewConfig', { + ...this.r.previewConfig, + enabled + }); + this.updateResource( + 'previewState', + enabled + ? enterPreview(this.r.viewport, this.r.previewState) + : exitPreview(this.r.viewport, this.r.previewState) + ); + }, + togglePreview(this: TRenderApp): void { + this.setPreviewEnabled(!this.r.previewConfig.enabled); + }, + updatePreviewConfig( + this: TRenderApp, + patch: Partial + ): void { + this.updateResource('previewConfig', { + ...this.r.previewConfig, + ...patch + }); + }, + setPreviewTargetEntity(this: TRenderApp, entityId: number | null): void { + if (this.r.previewState.targetEntityId === entityId) { + return; + } + + this.updateResource('previewState', { + ...this.r.previewState, + targetEntityId: entityId + }); + }, + disposeRender(this: TRenderApp): void { + this.r.sceneObjects.clear(); + this.r.viewport.dispose(); + } + }, + setup(app: TRenderApp) { + app.addSystem(mountThreeObjectsSystem, { set: 'PostUpdate' }); + app.addSystem(syncThreeObjectTransformsSystem, { + set: 'PostUpdate', + after: mountThreeObjectsSystem + }); + app.addSystem(cleanupOrphanedThreeObjectsSystem, { + set: 'Last', + after: syncThreeObjectTransformsSystem + }); + app.addSystem(syncPreviewCameraSystem, { + set: 'Last', + after: cleanupOrphanedThreeObjectsSystem + }); + app.addSystem(renderFrameSystem, { set: 'Last', after: syncPreviewCameraSystem }); + } + }; +} diff --git a/apps/midimarble/src/modules/engine/plugins/render/systems.test.ts b/apps/midimarble/src/modules/engine/plugins/render/systems.test.ts new file mode 100644 index 0000000..89a03d5 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/render/systems.test.ts @@ -0,0 +1,27 @@ +import * as THREE from 'three'; +import { describe, expect, it, vi } from 'vitest'; +import { cleanupOrphanedThreeObjectsSystem } from './systems'; + +describe('cleanupOrphanedThreeObjectsSystem', () => { + it('prunes mounted objects whose entity no longer has MeshMixin', () => { + const object = new THREE.Group(); + const app = { + c: { + MeshMixin: Symbol('MeshMixin') + }, + r: { + sceneObjects: new Map([[14, object]]), + viewport: { + disposeObject: vi.fn() + } + }, + queryEntities: vi.fn(() => []), + hasComponent: vi.fn(() => false) + }; + + cleanupOrphanedThreeObjectsSystem(app as never); + + expect(app.r.viewport.disposeObject).toHaveBeenCalledWith(object); + expect(app.r.sceneObjects.has(14)).toBe(false); + }); +}); diff --git a/apps/midimarble/src/modules/engine/plugins/render/systems.ts b/apps/midimarble/src/modules/engine/plugins/render/systems.ts new file mode 100644 index 0000000..fe3415b --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/render/systems.ts @@ -0,0 +1,115 @@ +import { Added, Changed, Entity, Or, Removed } from 'ecsify'; +import { + areVec3Close, + computePreviewCameraPose, + getPreviewSideSign, + getPreviewSmoothingAlpha, + lerpVec3 +} from './lib/preview-camera'; +import { replaceMountedThreeObject, syncThreeObjectTransform } from './lib/three-object'; +import type { TRenderApp } from './types'; + +export function mountThreeObjectsSystem(app: TRenderApp) { + for (const [eid, mesh] of app.queryComponents( + [Entity, app.c.MeshMixin] as const, + Or(Added(app.c.MeshMixin), Changed(app.c.MeshMixin)) + )) { + if (mesh.type !== 'three') { + continue; + } + + replaceMountedThreeObject(app, eid, mesh.object); + } +} + +export function syncThreeObjectTransformsSystem(app: TRenderApp) { + for (const [eid, position, rotation, scale] of app.queryComponents( + [Entity, app.c.PositionMixin, app.c.RotationMixin, app.c.ScaleMixin] as const, + Or( + Added(app.c.MeshMixin), + Changed(app.c.MeshMixin), + Added(app.c.PositionMixin), + Changed(app.c.PositionMixin), + Added(app.c.RotationMixin), + Changed(app.c.RotationMixin), + Added(app.c.ScaleMixin), + Changed(app.c.ScaleMixin) + ) + )) { + const object = app.r.sceneObjects.get(eid); + if (object == null) { + continue; + } + + syncThreeObjectTransform(object, { position, rotation, scale }); + } +} + +export function cleanupOrphanedThreeObjectsSystem(app: TRenderApp) { + const staleEntityIds = new Set(app.queryEntities(Removed(app.c.MeshMixin))); + + for (const [eid] of app.r.sceneObjects) { + if (!app.hasComponent(eid, app.c.MeshMixin)) { + staleEntityIds.add(eid); + } + } + + for (const eid of staleEntityIds) { + const object = app.r.sceneObjects.get(eid); + if (object == null) { + continue; + } + app.r.viewport.disposeObject(object); + app.r.sceneObjects.delete(eid); + } +} + +export function syncPreviewCameraSystem(app: TRenderApp, delta = 0) { + if (!app.r.previewConfig.enabled) { + return; + } + + const targetEntityId = app.r.previewState.targetEntityId; + if (targetEntityId == null) { + return; + } + + const body = app.r.rigidBodies.get(targetEntityId); + if (body == null) { + return; + } + + const translation = body.translation(); + const velocity = body.linvel(); + const desiredPose = computePreviewCameraPose( + { + x: translation.x, + y: translation.y, + z: translation.z + }, + { + x: velocity.x, + y: velocity.y, + z: velocity.z + }, + app.r.previewState.lastFollowDirection, + app.r.previewConfig, + getPreviewSideSign(app.r.previewState.savedCameraSnapshot) + ); + const currentSnapshot = app.r.viewport.getCameraSnapshot(); + const alpha = getPreviewSmoothingAlpha(app.r.previewConfig.smoothing, delta); + + app.r.viewport.setCameraPose( + lerpVec3(currentSnapshot.position, desiredPose.position, alpha), + lerpVec3(currentSnapshot.target, desiredPose.target, alpha), + app.r.previewConfig.fov + ); + + if (!areVec3Close(app.r.previewState.lastFollowDirection, desiredPose.forward)) { + app.r.previewState.lastFollowDirection = desiredPose.forward; + } +} + +export function renderFrameSystem(app: TRenderApp) { + app.r.viewport.renderFrame(); +} diff --git a/apps/midimarble/src/modules/engine/plugins/render/types.ts b/apps/midimarble/src/modules/engine/plugins/render/types.ts new file mode 100644 index 0000000..14cf5d4 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/render/types.ts @@ -0,0 +1,66 @@ +import type { TApp, TAppContext, TDefaultPlugin, TPlugin } from 'ecsify'; +import type * as THREE from 'three'; +import type { TEngineSystemSet, TVec3 } from '../../types'; +import type { TCorePlugin } from '../core'; +import type { TPhysicsPlugin } from '../physics'; +import type { TCameraSnapshot, Viewport } from './lib/Viewport'; + +// MARK: - Plugin + +export type TRenderPlugin = TPlugin< + { + name: 'Render'; + components: { + MeshMixin: TCMeshMixin[]; + }; + resources: { + viewport: Viewport; + sceneObjects: TRSceneObjects; + previewConfig: TPreviewConfig; + previewState: TPreviewState; + }; + appExtensions: { + setRenderContainer(container: HTMLDivElement | null): void; + setPreviewEnabled(enabled: boolean): void; + togglePreview(): void; + updatePreviewConfig(patch: Partial): void; + setPreviewTargetEntity(entityId: number | null): void; + disposeRender(): void; + }; + systemSets: TEngineSystemSet; + }, + [TDefaultPlugin, TCorePlugin, TPhysicsPlugin] +>; + +export type TRenderApp = TApp< + TAppContext<[TDefaultPlugin, TCorePlugin, TPhysicsPlugin, TRenderPlugin]> +>; + +// MARK: - Resources + +export type TRSceneObjects = Map; + +export interface TPreviewConfig { + enabled: boolean; + mode: 'followMarble'; + fov: number; + distance: number; + height: number; + lookAhead: number; + smoothing: number; +} + +export interface TPreviewState { + savedCameraSnapshot: TCameraSnapshot | null; + lastFollowDirection: TVec3 | null; + targetEntityId: number | null; +} + +// MARK: - Components + +export type TCMeshMixin = TCThreeMeshMixin; + +export interface TCThreeMeshMixin { + type: 'three'; + object: THREE.Object3D; +} diff --git a/apps/midimarble/src/modules/engine/plugins/scene/bundles/index.ts b/apps/midimarble/src/modules/engine/plugins/scene/bundles/index.ts new file mode 100644 index 0000000..adb636a --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/scene/bundles/index.ts @@ -0,0 +1,5 @@ +export * from './marble'; +export * from './note-platform'; +export * from './pegboard'; +export * from './straight-track'; +export * from './types'; diff --git a/apps/midimarble/src/modules/engine/plugins/scene/bundles/marble.ts b/apps/midimarble/src/modules/engine/plugins/scene/bundles/marble.ts new file mode 100644 index 0000000..c18fe21 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/scene/bundles/marble.ts @@ -0,0 +1,64 @@ +import { bundleEntry, defineBundle } from 'ecsify'; +import * as THREE from 'three'; +import { TVec3 } from '../../../types'; +import { sceneConfig } from '../config'; +import { createMarbleColliderDescriptors } from '../lib/marble'; +import type { TSceneApp } from '../types'; +import type { TSceneBundle } from './types'; + +export function createMarbleBundle( + app: TSceneApp, + options: TCreateMarbleBundleOptions = {} +): TSceneBundle { + const { + radius = sceneConfig.marble.defaultRadius, + position = sceneConfig.marble.spawn.position, + rotation = sceneConfig.marble.spawn.rotation, + scale = { x: 1, y: 1, z: 1 } + } = options; + + return defineBundle( + bundleEntry(app.c.PositionMixin, position), + bundleEntry(app.c.RotationMixin, rotation), + bundleEntry(app.c.ScaleMixin, scale), + bundleEntry(app.c.MarbleTag, {}), + bundleEntry(app.c.MarblePhysicsMixin, { + bounce: sceneConfig.marble.physics.defaults.bounce + }), + bundleEntry(app.c.TrajectorySourceTag, {}), + bundleEntry(app.c.MeshMixin, { type: 'three', object: createMarbleObject(radius) }), + bundleEntry(app.c.RigidBodyMixin, { + kind: 'dynamic', + canSleep: false, + linearDamping: 0.04, + angularDamping: 0.08 + }), + bundleEntry(app.c.ColliderMixin, { + descriptors: createMarbleColliderDescriptors( + radius, + sceneConfig.marble.physics.defaults.bounce + ) + }) + ); +} + +export interface TCreateMarbleBundleOptions { + position?: TVec3; + rotation?: TVec3; + scale?: TVec3; + radius?: number; +} + +export function createMarbleObject(radius: number): THREE.Object3D { + const marble = new THREE.Mesh( + new THREE.SphereGeometry(radius, 48, 48), + new THREE.MeshStandardMaterial({ + color: '#c6525b', + roughness: 0.18, + metalness: 0.12 + }) + ); + marble.castShadow = true; + marble.receiveShadow = true; + return marble; +} diff --git a/apps/midimarble/src/modules/engine/plugins/scene/bundles/note-platform.ts b/apps/midimarble/src/modules/engine/plugins/scene/bundles/note-platform.ts new file mode 100644 index 0000000..89c3608 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/scene/bundles/note-platform.ts @@ -0,0 +1,108 @@ +import { bundleEntry, defineBundle } from 'ecsify'; +import * as THREE from 'three'; +import type { TPhysicsColliderDescriptor } from '../../physics'; +import { sceneConfig } from '../config'; +import { + getDefaultNotePlatformColor, + getNotePlatformGeometryKey, + resolveNotePlatformTransform +} from '../lib/note-platform'; +import { + createTrackColliders, + createTrackGeometry, + getScaledTrackChannelDepth, + getScaledTrackChannelWidth +} from '../lib/track-shape'; +import type { TCNoteBindingMixin, TCNotePlatformMixin, TSceneApp } from '../types'; +import type { TSceneBundle } from './types'; + +export function createNotePlatformBundle( + app: TSceneApp, + noteId: number, + anchorPosition: { x: number; y: number; z: number }, + options: Partial = {} +): TSceneBundle { + const platform = { + ...sceneConfig.notePlatform.defaults, + color: getDefaultNotePlatformColor(noteId), + ...options + } satisfies TCNotePlatformMixin; + const binding = { + noteId + } satisfies TCNoteBindingMixin; + const transform = resolveNotePlatformTransform( + anchorPosition, + platform.offsetY, + platform.offsetZ, + platform.rotationX, + platform.thickness, + platform.width + ); + + return defineBundle( + bundleEntry(app.c.PositionMixin, transform.position), + bundleEntry(app.c.RotationMixin, transform.rotation), + bundleEntry(app.c.ScaleMixin, { x: 1, y: 1, z: 1 }), + bundleEntry(app.c.NoteBindingMixin, binding), + bundleEntry(app.c.NotePlatformMixin, platform), + bundleEntry(app.c.MeshMixin, { + type: 'three', + object: createNotePlatformObject(platform) + }), + bundleEntry(app.c.RigidBodyMixin, { kind: 'fixed' }), + bundleEntry(app.c.ColliderMixin, { + descriptors: createNotePlatformColliders(platform) + }) + ); +} + +export function createNotePlatformObject( + platform: Pick +): THREE.Object3D { + const mesh = new THREE.Mesh( + createNotePlatformGeometry(platform), + new THREE.MeshStandardMaterial({ + color: platform.color, + roughness: 0.74, + metalness: 0.04 + }) + ); + mesh.castShadow = true; + mesh.receiveShadow = true; + return mesh; +} + +export function createNotePlatformGeometry( + platform: Pick +): THREE.ExtrudeGeometry { + const geometry = createTrackGeometry(getNotePlatformShape(platform), 'wall-only-negative'); + geometry.userData['notePlatformGeometryKey'] = getNotePlatformGeometryKey(platform); + return geometry; +} + +export function createNotePlatformColliders( + platform: Pick +): TPhysicsColliderDescriptor[] { + return createTrackColliders(getNotePlatformShape(platform), 'wall-only-negative', { + friction: 0.55, + restitution: platform.bounce + }); +} + +function getNotePlatformShape( + platform: Pick +): { + length: number; + width: number; + height: number; + channelWidth: number; + channelDepth: number; +} { + return { + length: platform.length, + width: platform.width, + height: platform.thickness, + channelWidth: getScaledTrackChannelWidth(platform.width), + channelDepth: getScaledTrackChannelDepth(platform.thickness) + }; +} diff --git a/apps/midimarble/src/modules/engine/plugins/scene/bundles/pegboard.ts b/apps/midimarble/src/modules/engine/plugins/scene/bundles/pegboard.ts new file mode 100644 index 0000000..73ae3ea --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/scene/bundles/pegboard.ts @@ -0,0 +1,68 @@ +import { bundleEntry, defineBundle } from 'ecsify'; +import * as THREE from 'three'; +import { TVec3 } from '../../../types'; +import type { TCAuthoredTransformMixin, TSceneApp } from '../types'; +import type { TSceneBundle } from './types'; + +export function createPegboardBundle( + app: TSceneApp, + options: TCreatePegboardBundleOptions = {} +): TSceneBundle { + const { + position = { x: -8, y: 0, z: 8 }, + rotation = { x: 0, y: Math.PI / 2, z: 0 }, + scale = { x: 1, y: 1, z: 1 }, + width = 120, + height = 120, + repeatWorldSize = 14.75 + } = options; + + const authoredTransform = { + position, + rotation, + scale + } satisfies TCAuthoredTransformMixin; + + return defineBundle( + bundleEntry(app.c.PositionMixin, position), + bundleEntry(app.c.RotationMixin, rotation), + bundleEntry(app.c.ScaleMixin, scale), + bundleEntry(app.c.AuthoredTransformMixin, authoredTransform), + bundleEntry(app.c.MeshMixin, { + type: 'three', + object: createPegboardObject(width, height, repeatWorldSize) + }) + ); +} + +interface TCreatePegboardBundleOptions { + position?: TVec3; + rotation?: TVec3; + scale?: TVec3; + width?: number; + height?: number; + repeatWorldSize?: number; +} + +export function createPegboardObject( + width: number, + height: number, + repeatWorldSize: number +): THREE.Object3D { + const normalTexture = new THREE.TextureLoader().load('/textures/pegboard-normals.jpg'); + normalTexture.wrapS = THREE.RepeatWrapping; + normalTexture.wrapT = THREE.RepeatWrapping; + normalTexture.repeat.set(width / repeatWorldSize, height / repeatWorldSize); + + const board = new THREE.Mesh( + new THREE.PlaneGeometry(width, height), + new THREE.MeshStandardMaterial({ + color: '#f6efe6', + dithering: true, + normalMap: normalTexture, + bumpMap: normalTexture + }) + ); + board.receiveShadow = true; + return board; +} diff --git a/apps/midimarble/src/modules/engine/plugins/scene/bundles/straight-track.ts b/apps/midimarble/src/modules/engine/plugins/scene/bundles/straight-track.ts new file mode 100644 index 0000000..ed76953 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/scene/bundles/straight-track.ts @@ -0,0 +1,115 @@ +import { bundleEntry, defineBundle } from 'ecsify'; +import * as THREE from 'three'; +import { TVec3 } from '../../../types'; +import type { TPhysicsColliderDescriptor } from '../../physics'; +import { sceneConfig } from '../config'; +import { createTrackColliders, createTrackGeometry } from '../lib/track-shape'; +import type { + TCAuthoredTransformMixin, + TCLinearElementMixin, + TCStraightTrackMixin, + TSceneApp +} from '../types'; +import type { TSceneBundle } from './types'; + +export function createStraightTrackBundle( + app: TSceneApp, + options: TCreateStraightTrackBundleOptions = {} +): TSceneBundle { + const { + position = { x: 0, y: 0, z: 0 }, + rotation = { x: 0, y: 0, z: 0 }, + scale = { x: 1, y: 1, z: 1 }, + length = sceneConfig.track.defaultLength, + height = sceneConfig.track.defaultHeight, + width = sceneConfig.track.defaultWidth, + channelWidth = sceneConfig.track.defaultChannelWidth, + channelDepth = sceneConfig.track.defaultChannelDepth, + color = sceneConfig.track.colorPalette[ + Math.floor(Math.random() * sceneConfig.track.colorPalette.length) + ] + } = options; + + const authoredTransform = { + position, + rotation, + scale + } satisfies TCAuthoredTransformMixin; + const linearElement = { + length, + minLength: sceneConfig.track.minLength, + maxLength: sceneConfig.track.maxLength, + handleOffset: sceneConfig.track.handleOffset + } satisfies TCLinearElementMixin; + const track = { + height, + width, + channelWidth, + channelDepth, + color: color as string + } satisfies TCStraightTrackMixin; + + return defineBundle( + bundleEntry(app.c.PositionMixin, position), + bundleEntry(app.c.RotationMixin, rotation), + bundleEntry(app.c.ScaleMixin, scale), + bundleEntry(app.c.AuthoredTransformMixin, authoredTransform), + bundleEntry(app.c.StraightTrackMixin, track), + bundleEntry(app.c.LinearElementMixin, linearElement), + bundleEntry(app.c.MeshMixin, { + type: 'three', + object: createStraightTrackObject({ ...track, length }) + }), + bundleEntry(app.c.RigidBodyMixin, { kind: 'fixed' }), + bundleEntry(app.c.ColliderMixin, { + descriptors: createStraightTrackColliders({ ...track, length }) + }) + ); +} + +export interface TCreateStraightTrackBundleOptions { + position?: TVec3; + rotation?: TVec3; + scale?: TVec3; + length?: number; + height?: number; + width?: number; + channelWidth?: number; + channelDepth?: number; + color?: string; +} + +export function createStraightTrackObject( + track: TStraightTrackShapeConfig & Pick +): THREE.Object3D { + const geometry = createStraightTrackGeometry(track); + const material = new THREE.MeshStandardMaterial({ + color: track.color, + roughness: 0.74, + metalness: 0.04 + }); + + const mesh = new THREE.Mesh(geometry, material); + mesh.castShadow = true; + mesh.receiveShadow = true; + return mesh; +} + +export function createStraightTrackColliders( + track: TStraightTrackShapeConfig +): TPhysicsColliderDescriptor[] { + return createTrackColliders(track, 'both', { + friction: 0.5 + }); +} + +export function createStraightTrackGeometry( + track: TStraightTrackShapeConfig +): THREE.ExtrudeGeometry { + return createTrackGeometry(track, 'both'); +} + +type TStraightTrackShapeConfig = Pick< + TCStraightTrackMixin, + 'height' | 'width' | 'channelWidth' | 'channelDepth' +> & { length: number }; diff --git a/apps/midimarble/src/modules/engine/plugins/scene/bundles/types.ts b/apps/midimarble/src/modules/engine/plugins/scene/bundles/types.ts new file mode 100644 index 0000000..4488111 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/scene/bundles/types.ts @@ -0,0 +1,4 @@ +import type { TBundle } from 'ecsify'; +import type { TSceneApp } from '../types'; + +export type TSceneBundle = TBundle; diff --git a/apps/midimarble/src/modules/engine/plugins/scene/config.ts b/apps/midimarble/src/modules/engine/plugins/scene/config.ts new file mode 100644 index 0000000..63ca6f7 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/scene/config.ts @@ -0,0 +1,52 @@ +export const sceneConfig = { + marble: { + defaultRadius: 0.36, + spawn: { + position: { x: -7.25, y: 20, z: 8 }, + rotation: { x: 0, y: 0, z: 0 } + }, + physics: { + defaults: { bounce: 0.32 }, + limits: { bounce: { min: 0, max: 0.9 } } + } + }, + notePlatform: { + defaultColor: '#2a5e92', + handleOffset: 0.22, + defaults: { + offsetY: 0, + offsetZ: 0, + rotationX: 0, + length: 1.2, + width: 1.5, + thickness: 0.22, + bounce: 0.58 + }, + limits: { + offsetY: { min: -4, max: 4 }, + offsetZ: { min: -6, max: 6 }, + rotationX: { min: -1.2, max: 1.2 }, + length: { min: 0.8, max: 1.8 }, + bounce: { min: 0, max: 0.9 } + } + }, + track: { + defaultWidth: 1.5, + defaultHeight: 0.7, + defaultChannelWidth: 1.3, + defaultChannelDepth: 0.2, + defaultLength: 14, + minLength: 6, + maxLength: 28, + handleOffset: 0.8, + colorPalette: ['#2a5e92', '#ffeead', '#ff9943', '#8ac6d6'] as const, + wallLaneX: -7.25, + newTrackYOffset: -3, + newTrackZOffset: 5 + }, + manipulation: { + handleRadius: 0.48, + handleColor: '#facc15', + dragStartPixels: 3 + } +} as const; diff --git a/apps/midimarble/src/modules/engine/plugins/scene/index.ts b/apps/midimarble/src/modules/engine/plugins/scene/index.ts new file mode 100644 index 0000000..bc00fa1 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/scene/index.ts @@ -0,0 +1,2 @@ +export * from './scene-plugin'; +export * from './types'; diff --git a/apps/midimarble/src/modules/engine/plugins/scene/lib/linear-element.ts b/apps/midimarble/src/modules/engine/plugins/scene/lib/linear-element.ts new file mode 100644 index 0000000..cfcb7a0 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/scene/lib/linear-element.ts @@ -0,0 +1,58 @@ +import { Entity, With } from 'ecsify'; +import * as THREE from 'three'; +import type { TSceneApp } from '../types'; + +export function getLinearElementForward(rotationX: number): THREE.Vector3 { + return new THREE.Vector3(0, -Math.sin(rotationX), Math.cos(rotationX)).normalize(); +} + +export function getLinearElementEndpoints( + position: { x: number; y: number; z: number }, + rotationX: number, + length: number +): { start: THREE.Vector3; end: THREE.Vector3 } { + const center = new THREE.Vector3(position.x, position.y, position.z); + const halfForward = getLinearElementForward(rotationX).multiplyScalar(length * 0.5); + return { + start: center.clone().sub(halfForward), + end: center.clone().add(halfForward) + }; +} + +export function getLinearElementHandlePositions( + position: { x: number; y: number; z: number }, + rotationX: number, + length: number, + handleOffset: number +): { start: THREE.Vector3; end: THREE.Vector3 } { + const endpoints = getLinearElementEndpoints(position, rotationX, length); + const offset = getLinearElementForward(rotationX).multiplyScalar(handleOffset); + + return { + start: endpoints.start.clone().sub(offset), + end: endpoints.end.clone().add(offset) + }; +} + +export function getLinearElementEntityIds(app: TSceneApp): number[] { + return app.queryEntities(With(app.c.LinearElementMixin)); +} + +export function getLinearElement( + app: TSceneApp, + entityId: number +): { + transform: TSceneApp['c']['AuthoredTransformMixin'][number]; + linear: TSceneApp['c']['LinearElementMixin'][number]; +} | null { + for (const [eid, transform, linear] of app.queryComponents( + [Entity, app.c.AuthoredTransformMixin, app.c.LinearElementMixin] as const, + With(app.c.LinearElementMixin) + )) { + if (eid === entityId) { + return { transform, linear }; + } + } + + return null; +} diff --git a/apps/midimarble/src/modules/engine/plugins/scene/lib/manipulation-handles.ts b/apps/midimarble/src/modules/engine/plugins/scene/lib/manipulation-handles.ts new file mode 100644 index 0000000..4b1bb1a --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/scene/lib/manipulation-handles.ts @@ -0,0 +1,75 @@ +import * as THREE from 'three'; + +const HANDLE_KIND_KEY = 'linearElementHandleKind'; + +export function createSceneManipulationHandles( + handleRadius: number, + handleColor: string +): { start: THREE.Mesh; end: THREE.Mesh } { + const start = createSceneManipulationHandle(handleRadius, handleColor); + start.userData[HANDLE_KIND_KEY] = 'start'; + start.visible = false; + start.renderOrder = 10; + + const end = createSceneManipulationHandle(handleRadius, handleColor); + end.userData[HANDLE_KIND_KEY] = 'end'; + end.visible = false; + end.renderOrder = 10; + + return { start, end }; +} + +export function disposeSceneManipulationHandles(handles: { + start: THREE.Mesh; + end: THREE.Mesh; +}): void { + for (const handle of [handles.start, handles.end]) { + handle.parent?.remove(handle); + handle.geometry.dispose(); + if (Array.isArray(handle.material)) { + for (const material of handle.material) { + material.dispose(); + } + } else { + handle.material.dispose(); + } + } +} + +export function getLinearElementHandleKind(object: THREE.Object3D | null): 'start' | 'end' | null { + if (object == null) { + return null; + } + + const kind = object.userData[HANDLE_KIND_KEY]; + return kind === 'start' || kind === 'end' ? kind : null; +} + +export function updateHandleAppearance( + handle: THREE.Mesh, + handleRadius: number, + handleColor: string +): void { + handle.geometry.dispose(); + handle.geometry = new THREE.SphereGeometry(handleRadius, 24, 16); + + if (Array.isArray(handle.material)) { + for (const material of handle.material) { + material.dispose(); + } + handle.material = new THREE.MeshBasicMaterial({ color: handleColor }); + return; + } + + handle.material.dispose(); + handle.material = new THREE.MeshBasicMaterial({ color: handleColor }); +} + +function createSceneManipulationHandle(handleRadius: number, handleColor: string): THREE.Mesh { + const geometry = new THREE.SphereGeometry(handleRadius, 24, 16); + const material = new THREE.MeshBasicMaterial({ + color: handleColor + }); + + return new THREE.Mesh(geometry, material); +} diff --git a/apps/midimarble/src/modules/engine/plugins/scene/lib/manipulation-math.ts b/apps/midimarble/src/modules/engine/plugins/scene/lib/manipulation-math.ts new file mode 100644 index 0000000..553e94b --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/scene/lib/manipulation-math.ts @@ -0,0 +1,79 @@ +import * as THREE from 'three'; +import type { TVec3 } from '../../../types'; +import { getLinearElementForward } from './linear-element'; + +export interface TLinearResizeInput { + position: TVec3; + rotation: TVec3; + minLength: number; + maxLength: number; + handleOffset: number; +} + +export function getDraggedHandlePoint( + planeX: number, + planePoint: { y: number; z: number }, + dragOffset: TVec3 | null +): THREE.Vector3 { + return new THREE.Vector3( + planeX, + planePoint.y - (dragOffset?.y ?? 0), + planePoint.z - (dragOffset?.z ?? 0) + ); +} + +export function computeLinearResizeResult( + linearElement: TLinearResizeInput, + mode: 'resizeStart' | 'resizeEnd', + draggedPoint: THREE.Vector3 +): { position: TVec3; rotation: TVec3; length: number } { + const center = new THREE.Vector3( + linearElement.position.x, + linearElement.position.y, + linearElement.position.z + ); + const handleVector = draggedPoint.clone().sub(center); + handleVector.x = 0; + + const handleDistance = handleVector.length(); + const desiredDirection = + handleDistance < 1e-6 + ? getLinearElementForward(linearElement.rotation.x) + : handleVector.clone().normalize(); + if (mode === 'resizeStart') { + desiredDirection.negate(); + } + + const clampedHandleDistance = THREE.MathUtils.clamp( + handleDistance, + linearElement.minLength * 0.5 + linearElement.handleOffset, + linearElement.maxLength * 0.5 + linearElement.handleOffset + ); + + return { + position: linearElement.position, + rotation: { + x: -Math.atan2(desiredDirection.y, desiredDirection.z), + y: 0, + z: 0 + }, + length: (clampedHandleDistance - linearElement.handleOffset) * 2 + }; +} + +export function computeRotationXFromDraggedPoint( + position: TVec3, + draggedPoint: THREE.Vector3, + fallbackRotationX: number +): number { + const center = new THREE.Vector3(position.x, position.y, position.z); + const handleVector = draggedPoint.clone().sub(center); + handleVector.x = 0; + + if (handleVector.length() < 1e-6) { + return fallbackRotationX; + } + + const desiredDirection = handleVector.normalize(); + return -Math.atan2(desiredDirection.y, desiredDirection.z); +} diff --git a/apps/midimarble/src/modules/engine/plugins/scene/lib/manipulation-state.ts b/apps/midimarble/src/modules/engine/plugins/scene/lib/manipulation-state.ts new file mode 100644 index 0000000..0ef3ba2 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/scene/lib/manipulation-state.ts @@ -0,0 +1,16 @@ +import type { TSceneApp } from '../types'; + +export function resetSceneManipulationState( + patch: Partial = {} +): TSceneApp['r']['sceneManipulationState'] { + return { + mode: 'idle', + entityId: null, + isDragging: false, + didEdit: false, + pointerDownClient: null, + dragPlaneX: null, + dragOffset: null, + ...patch + }; +} diff --git a/apps/midimarble/src/modules/engine/plugins/scene/lib/marble.ts b/apps/midimarble/src/modules/engine/plugins/scene/lib/marble.ts new file mode 100644 index 0000000..c988c90 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/scene/lib/marble.ts @@ -0,0 +1,53 @@ +import { Entity, With } from 'ecsify'; +import type { TBallColliderDescriptor } from '../../physics'; +import { sceneConfig } from '../config'; +import type { TSceneApp } from '../types'; + +export function createMarbleColliderDescriptors( + radius: number, + bounce: number +): TBallColliderDescriptor[] { + return [ + { + shape: 'ball', + radius, + density: 1.25, + friction: 0.12, + restitution: bounce, + restitutionCombineRule: 'max' + } + ]; +} + +export function getMarbleRadius( + descriptors: TSceneApp['c']['ColliderMixin'][number]['descriptors'] +): number { + const descriptor = descriptors.find( + (entry): entry is TBallColliderDescriptor => entry.shape === 'ball' + ); + return descriptor?.radius ?? sceneConfig.marble.defaultRadius; +} + +export function getMarbleEntityId(app: TSceneApp): number | null { + for (const eid of app.queryEntities(With(app.c.MarbleTag))) { + return eid; + } + + return null; +} + +export function getMarblePhysics( + app: TSceneApp, + entityId: number +): TSceneApp['c']['MarblePhysicsMixin'][number] | null { + for (const [eid, marblePhysics] of app.queryComponents( + [Entity, app.c.MarblePhysicsMixin] as const, + With(app.c.MarbleTag) + )) { + if (eid === entityId) { + return marblePhysics; + } + } + + return null; +} diff --git a/apps/midimarble/src/modules/engine/plugins/scene/lib/note-platform-runtime.ts b/apps/midimarble/src/modules/engine/plugins/scene/lib/note-platform-runtime.ts new file mode 100644 index 0000000..e7a2f2f --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/scene/lib/note-platform-runtime.ts @@ -0,0 +1,150 @@ +import * as THREE from 'three'; +import type { TVec3 } from '../../../types'; +import { createNotePlatformColliders, createNotePlatformGeometry } from '../bundles/note-platform'; +import type { TCNotePlatformMixin, TSceneApp } from '../types'; +import { getNotePlatformGeometryKey, resolveNotePlatformTransform } from './note-platform'; +import { sameVec3 } from './vec3'; + +export function syncResolvedNotePlatform( + app: TSceneApp, + entityId: number, + platform: TCNotePlatformMixin, + position: TVec3, + rotation: TVec3, + colliderDescriptors: TSceneApp['c']['ColliderMixin'][number]['descriptors'], + meshObject: THREE.Object3D | null, + anchorPosition: TVec3 +): boolean { + const transform = resolveNotePlatformTransform( + anchorPosition, + platform.offsetY, + platform.offsetZ, + platform.rotationX, + platform.thickness, + platform.width + ); + let didRuntimeChange = false; + + if (!sameVec3(position, transform.position)) { + app.updateComponent(entityId, app.c.PositionMixin, transform.position); + didRuntimeChange = true; + } + + if (!sameVec3(rotation, transform.rotation)) { + app.updateComponent(entityId, app.c.RotationMixin, transform.rotation); + didRuntimeChange = true; + } + + if (meshObject != null) { + if (!meshObject.visible) { + meshObject.visible = true; + didRuntimeChange = true; + } + meshObject.position.set(transform.position.x, transform.position.y, transform.position.z); + meshObject.rotation.set(transform.rotation.x, transform.rotation.y, transform.rotation.z); + if (meshObject instanceof THREE.Mesh) { + const nextGeometryKey = getNotePlatformGeometryKey(platform); + if (meshObject.geometry.userData['notePlatformGeometryKey'] !== nextGeometryKey) { + meshObject.geometry.dispose(); + meshObject.geometry = createNotePlatformGeometry(platform); + didRuntimeChange = true; + } + if (meshObject.material instanceof THREE.MeshStandardMaterial) { + meshObject.material.color.set(platform.color); + } + } + } + + const nextDescriptors = createNotePlatformColliders(platform); + if (!areColliderDescriptorsEqual(colliderDescriptors, nextDescriptors)) { + app.updateComponent(entityId, app.c.ColliderMixin, { + descriptors: nextDescriptors + }); + didRuntimeChange = true; + } + + return didRuntimeChange; +} + +function areColliderDescriptorsEqual( + left: TSceneApp['c']['ColliderMixin'][number]['descriptors'], + right: TSceneApp['c']['ColliderMixin'][number]['descriptors'] +): boolean { + if (left.length !== right.length) { + return false; + } + + for (let index = 0; index < left.length; index += 1) { + const leftEntry = left[index]; + const rightEntry = right[index]; + if ( + leftEntry == null || + rightEntry == null || + !areColliderDescriptorEntriesEqual(leftEntry, rightEntry) + ) { + return false; + } + } + + return true; +} + +function areColliderDescriptorEntriesEqual( + left: TSceneApp['c']['ColliderMixin'][number]['descriptors'][number], + right: TSceneApp['c']['ColliderMixin'][number]['descriptors'][number] +): boolean { + if ( + left.shape !== right.shape || + left.friction !== right.friction || + left.restitution !== right.restitution || + left.restitutionCombineRule !== right.restitutionCombineRule || + left.density !== right.density || + left.sensor !== right.sensor || + !sameOptionalVec3(left.translation, right.translation) || + !sameOptionalVec3(left.rotation, right.rotation) + ) { + return false; + } + + if (left.shape === 'ball' && right.shape === 'ball') { + return left.radius === right.radius; + } + + if (left.shape === 'cuboid' && right.shape === 'cuboid') { + return sameVec3(left.halfExtents, right.halfExtents); + } + + return false; +} + +function sameOptionalVec3(left?: TVec3, right?: TVec3): boolean { + if (left == null || right == null) { + return left == null && right == null; + } + + return sameVec3(left, right); +} + +export function syncUnresolvedNotePlatform( + app: TSceneApp, + entityId: number, + colliderDescriptors: TSceneApp['c']['ColliderMixin'][number]['descriptors'], + meshObject: THREE.Object3D | null +): boolean { + let didRuntimeChange = false; + if (meshObject != null) { + if (meshObject.visible) { + didRuntimeChange = true; + } + meshObject.visible = false; + } + + if (colliderDescriptors.length > 0) { + app.updateComponent(entityId, app.c.ColliderMixin, { + descriptors: [] + }); + didRuntimeChange = true; + } + + return didRuntimeChange; +} diff --git a/apps/midimarble/src/modules/engine/plugins/scene/lib/note-platform.test.ts b/apps/midimarble/src/modules/engine/plugins/scene/lib/note-platform.test.ts new file mode 100644 index 0000000..c351831 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/scene/lib/note-platform.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { createNotePlatformColliders } from '../bundles/note-platform'; +import { + getNotePlatformNoteState, + isNotePlatformAdjusted, + resolveNotePlatformTransform +} from './note-platform'; +import { DEFAULT_TRACK_WIDTH } from './track-shape'; + +describe('note platform helpers', () => { + it('derives the platform center from the note anchor and platform normal', () => { + expect(resolveNotePlatformTransform({ x: 1, y: 2, z: 3 }, 0.4, -1.2, 0, 0.24, 0.84)).toEqual({ + position: { x: 1 - (DEFAULT_TRACK_WIDTH - 0.84) / 2, y: 2 - (0.36 + 0.12) + 0.4, z: 3 - 1.2 }, + rotation: { x: 0, y: 0, z: 0 } + }); + }); + + it('builds note-platform colliders as a track body plus one wall-side rail', () => { + const colliders = createNotePlatformColliders({ + length: 1.2, + width: 0.84, + thickness: 0.22, + bounce: 0.58 + }); + + expect(colliders).toHaveLength(2); + expect(colliders[1]).toEqual( + expect.objectContaining({ + shape: 'cuboid', + translation: expect.objectContaining({ + x: expect.any(Number) + }) + }) + ); + }); + + it('collects placed and adjusted note ids from note platforms', () => { + const noteState = getNotePlatformNoteState({ + c: { + NoteBindingMixin: Symbol('NoteBindingMixin'), + NotePlatformMixin: Symbol('NotePlatformMixin') + }, + queryComponents: () => + [ + [11, { noteId: 3 }, { offsetY: 0, offsetZ: 0 }], + [12, { noteId: 7 }, { offsetY: 0.2, offsetZ: 0 }] + ] as never + } as never); + + expect(noteState).toEqual({ + placedNoteIds: new Set([3, 7]), + adjustedNoteIds: new Set([7]) + }); + }); + + it('treats lift or push offsets as an adjusted note platform', () => { + expect(isNotePlatformAdjusted({ offsetY: 0, offsetZ: 0 })).toBe(false); + expect(isNotePlatformAdjusted({ offsetY: 0.2, offsetZ: 0 })).toBe(true); + expect(isNotePlatformAdjusted({ offsetY: 0, offsetZ: -0.3 })).toBe(true); + }); +}); diff --git a/apps/midimarble/src/modules/engine/plugins/scene/lib/note-platform.ts b/apps/midimarble/src/modules/engine/plugins/scene/lib/note-platform.ts new file mode 100644 index 0000000..9511c9e --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/scene/lib/note-platform.ts @@ -0,0 +1,122 @@ +import { Entity, With } from 'ecsify'; +import * as THREE from 'three'; +import type { TVec3 } from '../../../types'; +import { sceneConfig } from '../config'; +import type { TCNotePlatformMixin, TSceneApp } from '../types'; +import { getLinearElementHandlePositions } from './linear-element'; + +const NOTE_PLATFORM_COLORS = ['#2a5e92', '#ffeead', '#ff9943', '#8ac6d6'] as const; + +export function isNotePlatformAdjusted( + platform: Pick +): boolean { + return platform.offsetY !== 0 || platform.offsetZ !== 0; +} + +export function getNotePlatformGeometryKey( + platform: Pick +): string { + return `${platform.length}:${platform.width}:${platform.thickness}`; +} + +export function findNotePlatformEntityId(app: TSceneApp, noteId: number): number | null { + for (const [eid, binding] of app.queryComponents( + [Entity, app.c.NoteBindingMixin] as const, + With(app.c.NotePlatformMixin) + )) { + if (binding.noteId === noteId) { + return eid; + } + } + + return null; +} + +export function getNotePlatformNoteState(app: TSceneApp): { + placedNoteIds: Set; + adjustedNoteIds: Set; +} { + const placedNoteIds = new Set(); + const adjustedNoteIds = new Set(); + for (const [, binding, platform] of app.queryComponents( + [Entity, app.c.NoteBindingMixin, app.c.NotePlatformMixin] as const, + With(app.c.NotePlatformMixin) + )) { + placedNoteIds.add(binding.noteId); + if (isNotePlatformAdjusted(platform)) { + adjustedNoteIds.add(binding.noteId); + } + } + return { placedNoteIds, adjustedNoteIds }; +} + +export function getDefaultNotePlatformColor(noteId: number): string { + return ( + NOTE_PLATFORM_COLORS[Math.abs(noteId) % NOTE_PLATFORM_COLORS.length] ?? + sceneConfig.notePlatform.defaultColor + ); +} + +export function getNotePlatform( + app: TSceneApp, + entityId: number +): { + position: TSceneApp['c']['PositionMixin'][number]; + rotation: TSceneApp['c']['RotationMixin'][number]; + platform: TSceneApp['c']['NotePlatformMixin'][number]; +} | null { + for (const [eid, position, rotation, platform] of app.queryComponents( + [Entity, app.c.PositionMixin, app.c.RotationMixin, app.c.NotePlatformMixin] as const, + With(app.c.NotePlatformMixin) + )) { + if (eid === entityId) { + return { position, rotation, platform }; + } + } + + return null; +} + +export function getNotePlatformHandlePositions( + position: { x: number; y: number; z: number }, + rotationX: number, + length: number +): { start: THREE.Vector3; end: THREE.Vector3 } { + return getLinearElementHandlePositions( + position, + rotationX, + length, + sceneConfig.notePlatform.handleOffset + ); +} + +export function resolveNotePlatformTransform( + anchorPosition: TVec3, + offsetY: number, + offsetZ: number, + rotationX: number, + thickness: number, + platformWidth: number, + marbleRadius: number = sceneConfig.marble.defaultRadius +): { position: TVec3; rotation: TVec3 } { + const normal = getNotePlatformSurfaceNormal(rotationX); + const centerOffset = marbleRadius + thickness / 2; + const wallMountOffsetX = (sceneConfig.track.defaultWidth - platformWidth) / 2; + + return { + position: { + x: anchorPosition.x - wallMountOffsetX - normal.x * centerOffset, + y: anchorPosition.y - normal.y * centerOffset + offsetY, + z: anchorPosition.z - normal.z * centerOffset + offsetZ + }, + rotation: { + x: rotationX, + y: 0, + z: 0 + } + }; +} + +export function getNotePlatformSurfaceNormal(rotationX: number): THREE.Vector3 { + return new THREE.Vector3(0, 1, 0).applyEuler(new THREE.Euler(rotationX, 0, 0)).normalize(); +} diff --git a/apps/midimarble/src/modules/engine/plugins/scene/lib/scene-authoring.test.ts b/apps/midimarble/src/modules/engine/plugins/scene/lib/scene-authoring.test.ts new file mode 100644 index 0000000..d998c9a --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/scene/lib/scene-authoring.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest'; +import { updateMarblePhysicsAuthoring, updateNotePlatformAuthoring } from './scene-authoring'; + +describe('scene authoring helpers', () => { + it('updates note platforms only when authored values actually change', () => { + const updateComponent = vi.fn(); + const components = { + NotePlatformMixin: Symbol('NotePlatformMixin') + }; + const app = { + c: components, + queryComponents: vi.fn(() => [ + [ + 17, + { + offsetY: 0, + offsetZ: 0, + rotationX: 0.2, + length: 1.2, + width: 0.84, + thickness: 0.22, + bounce: 0.58, + color: '#2a5e92' + } + ] + ]), + updateComponent + }; + const sceneApp = app as unknown as Parameters[0]; + + expect(updateNotePlatformAuthoring(sceneApp, 17, { rotationX: 0.2 })).toBe(false); + expect(updateComponent).not.toHaveBeenCalled(); + + expect(updateNotePlatformAuthoring(sceneApp, 17, { bounce: 0.72 })).toBe(true); + expect(updateComponent).toHaveBeenCalledWith(17, components.NotePlatformMixin, { + offsetY: 0, + offsetZ: 0, + rotationX: 0.2, + length: 1.2, + width: 0.84, + thickness: 0.22, + bounce: 0.72, + color: '#2a5e92' + }); + }); + + it('updates marble physics only when authored values actually change', () => { + const updateComponent = vi.fn(); + const components = { + MarbleTag: Symbol('MarbleTag'), + MarblePhysicsMixin: Symbol('MarblePhysicsMixin') + }; + const app = { + c: components, + queryComponents: vi.fn(() => [[3, { bounce: 0.32 }]]), + updateComponent + }; + const sceneApp = app as unknown as Parameters[0]; + + expect(updateMarblePhysicsAuthoring(sceneApp, 3, { bounce: 0.32 })).toBe(false); + expect(updateComponent).not.toHaveBeenCalled(); + + expect(updateMarblePhysicsAuthoring(sceneApp, 3, { bounce: 0.61 })).toBe(true); + expect(updateComponent).toHaveBeenCalledWith(3, components.MarblePhysicsMixin, { + bounce: 0.61 + }); + }); +}); diff --git a/apps/midimarble/src/modules/engine/plugins/scene/lib/scene-authoring.ts b/apps/midimarble/src/modules/engine/plugins/scene/lib/scene-authoring.ts new file mode 100644 index 0000000..4dc2236 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/scene/lib/scene-authoring.ts @@ -0,0 +1,129 @@ +import { Entity, With } from 'ecsify'; +import type { + TCAuthoredTransformMixin, + TCMarblePhysicsMixin, + TCNotePlatformMixin, + TCStraightTrackMixin, + TSceneApp +} from '../types'; + +export function updateNotePlatformAuthoring( + app: TSceneApp, + entityId: number, + patch: Partial +): boolean { + for (const [eid, platform] of app.queryComponents( + [Entity, app.c.NotePlatformMixin] as const, + With(app.c.NotePlatformMixin) + )) { + if (eid !== entityId) { + continue; + } + + const nextPlatform = { + ...platform, + ...patch + }; + if (areNotePlatformsEqual(platform, nextPlatform)) { + return false; + } + + app.updateComponent(entityId, app.c.NotePlatformMixin, nextPlatform); + return true; + } + + return false; +} + +export function updateMarblePhysicsAuthoring( + app: TSceneApp, + entityId: number, + patch: Partial +): boolean { + for (const [eid, marblePhysics] of app.queryComponents( + [Entity, app.c.MarblePhysicsMixin] as const, + With(app.c.MarbleTag) + )) { + if (eid !== entityId) { + continue; + } + + const nextMarblePhysics = { + ...marblePhysics, + ...patch + }; + if (areMarblePhysicsEqual(marblePhysics, nextMarblePhysics)) { + return false; + } + + app.updateComponent(entityId, app.c.MarblePhysicsMixin, nextMarblePhysics); + return true; + } + + return false; +} + +export function updateStraightTrackTransformAuthoring( + app: TSceneApp, + entityId: number, + patch: Partial +): boolean { + for (const [eid, transform] of app.queryComponents( + [Entity, app.c.AuthoredTransformMixin] as const, + With(app.c.StraightTrackMixin) + )) { + if (eid !== entityId) { + continue; + } + + const next = { ...transform, ...patch }; + app.updateComponent(entityId, app.c.AuthoredTransformMixin, next); + app.updateComponent(entityId, app.c.PositionMixin, next.position); + app.updateComponent(entityId, app.c.RotationMixin, next.rotation); + app.updateComponent(entityId, app.c.ScaleMixin, next.scale); + return true; + } + + return false; +} + +export function updateStraightTrackGeometryAuthoring( + app: TSceneApp, + entityId: number, + patch: Partial +): boolean { + for (const [eid, track, linear] of app.queryComponents( + [Entity, app.c.StraightTrackMixin, app.c.LinearElementMixin] as const, + With(app.c.StraightTrackMixin) + )) { + if (eid !== entityId) { + continue; + } + + const { length, ...trackPatch } = patch; + app.updateComponent(entityId, app.c.StraightTrackMixin, { ...track, ...trackPatch }); + if (length != null) { + app.updateComponent(entityId, app.c.LinearElementMixin, { ...linear, length }); + } + return true; + } + + return false; +} + +function areNotePlatformsEqual(left: TCNotePlatformMixin, right: TCNotePlatformMixin): boolean { + return ( + left.offsetY === right.offsetY && + left.offsetZ === right.offsetZ && + left.rotationX === right.rotationX && + left.length === right.length && + left.width === right.width && + left.thickness === right.thickness && + left.bounce === right.bounce && + left.color === right.color + ); +} + +function areMarblePhysicsEqual(left: TCMarblePhysicsMixin, right: TCMarblePhysicsMixin): boolean { + return left.bounce === right.bounce; +} diff --git a/apps/midimarble/src/modules/engine/plugins/scene/lib/scene-manipulation-adapters.ts b/apps/midimarble/src/modules/engine/plugins/scene/lib/scene-manipulation-adapters.ts new file mode 100644 index 0000000..3c62deb --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/scene/lib/scene-manipulation-adapters.ts @@ -0,0 +1,291 @@ +import type * as THREE from 'three'; +import { sceneConfig } from '../config'; +import type { TSceneApp } from '../types'; +import { getLinearElement, getLinearElementHandlePositions } from './linear-element'; +import { computeLinearResizeResult, getDraggedHandlePoint } from './manipulation-math'; +import { resetSceneManipulationState } from './manipulation-state'; +import { getNotePlatform } from './note-platform'; +import { sameVec3 } from './vec3'; + +export type TSceneManipulationPickTarget = + | 'element' + | 'note-platform' + | 'marble' + | 'handle-start' + | 'handle-end'; + +export interface TSceneManipulationPick { + entityId: number; + target: TSceneManipulationPickTarget; +} + +interface TSceneManipulationAdapter { + getPlaneX(app: TSceneApp, entityId: number): number | null; + begin( + app: TSceneApp, + pickedTarget: TSceneManipulationPick, + pointerDownClient: { x: number; y: number }, + dragPlaneX: number, + planePoint: THREE.Vector3 + ): TSceneApp['r']['sceneManipulationState'] | null; + update( + app: TSceneApp, + entityId: number, + state: TSceneApp['r']['sceneManipulationState'], + planePoint: THREE.Vector3 + ): boolean; +} + +const linearElementManipulationAdapter: TSceneManipulationAdapter = { + getPlaneX(app, entityId) { + return getLinearElement(app, entityId)?.transform.position.x ?? null; + }, + begin(app, pickedTarget, pointerDownClient, dragPlaneX, planePoint) { + const linearElement = getLinearElement(app, pickedTarget.entityId); + if (linearElement == null) { + return null; + } + + const mode = + pickedTarget.target === 'handle-start' + ? 'resizeStart' + : pickedTarget.target === 'handle-end' + ? 'resizeEnd' + : 'move'; + const handlePositions = getLinearElementHandlePositions( + linearElement.transform.position, + linearElement.transform.rotation.x, + linearElement.linear.length, + linearElement.linear.handleOffset + ); + const dragAnchor = + mode === 'move' + ? linearElement.transform.position + : mode === 'resizeStart' + ? toVec3(handlePositions.start) + : toVec3(handlePositions.end); + + return resetSceneManipulationState({ + mode, + entityId: pickedTarget.entityId, + pointerDownClient, + dragPlaneX, + dragOffset: { + x: 0, + y: planePoint.y - dragAnchor.y, + z: planePoint.z - dragAnchor.z + } + }); + }, + update(app, entityId, state, planePoint) { + const linearElement = getLinearElement(app, entityId); + if (linearElement == null) { + return false; + } + + if (state.mode === 'move' && state.dragOffset != null) { + const nextPosition = { + x: linearElement.transform.position.x, + y: planePoint.y - state.dragOffset.y, + z: planePoint.z - state.dragOffset.z + }; + if (sameVec3(linearElement.transform.position, nextPosition)) { + return false; + } + + app.updateComponent(entityId, app.c.AuthoredTransformMixin, { + ...linearElement.transform, + position: nextPosition + }); + markSceneManipulationEdit(app); + return true; + } + + if (state.mode !== 'resizeStart' && state.mode !== 'resizeEnd') { + return false; + } + + const draggedPoint = getDraggedHandlePoint(state.dragPlaneX!, planePoint, state.dragOffset); + const resized = computeLinearResizeResult( + { + position: linearElement.transform.position, + rotation: linearElement.transform.rotation, + minLength: linearElement.linear.minLength, + maxLength: linearElement.linear.maxLength, + handleOffset: linearElement.linear.handleOffset + }, + state.mode, + draggedPoint + ); + + let didChange = false; + if ( + !sameVec3(linearElement.transform.position, resized.position) || + !sameVec3(linearElement.transform.rotation, resized.rotation) + ) { + app.updateComponent(entityId, app.c.AuthoredTransformMixin, { + ...linearElement.transform, + position: resized.position, + rotation: resized.rotation + }); + didChange = true; + } + if (linearElement.linear.length !== resized.length) { + app.updateComponent(entityId, app.c.LinearElementMixin, { + ...linearElement.linear, + length: resized.length + }); + didChange = true; + } + + if (!didChange) { + return false; + } + + markSceneManipulationEdit(app); + return true; + } +}; + +const notePlatformManipulationAdapter: TSceneManipulationAdapter = { + getPlaneX(app, entityId) { + return getNotePlatform(app, entityId)?.position.x ?? null; + }, + begin(app, pickedTarget, pointerDownClient, dragPlaneX, planePoint) { + if (pickedTarget.target !== 'handle-start' && pickedTarget.target !== 'handle-end') { + return null; + } + + const notePlatform = getNotePlatform(app, pickedTarget.entityId); + if (notePlatform == null) { + return null; + } + + const handlePositions = getLinearElementHandlePositions( + notePlatform.position, + notePlatform.platform.rotationX, + notePlatform.platform.length, + sceneConfig.notePlatform.handleOffset + ); + const dragAnchor = + pickedTarget.target === 'handle-start' + ? toVec3(handlePositions.start) + : toVec3(handlePositions.end); + + return resetSceneManipulationState({ + mode: pickedTarget.target === 'handle-start' ? 'resizeStart' : 'resizeEnd', + entityId: pickedTarget.entityId, + pointerDownClient, + dragPlaneX, + dragOffset: { + x: 0, + y: planePoint.y - dragAnchor.y, + z: planePoint.z - dragAnchor.z + } + }); + }, + update(app, entityId, state, planePoint) { + if (state.mode !== 'resizeStart' && state.mode !== 'resizeEnd') { + return false; + } + + const notePlatform = getNotePlatform(app, entityId); + if (notePlatform == null) { + return false; + } + + const draggedPoint = getDraggedHandlePoint(state.dragPlaneX!, planePoint, state.dragOffset); + const resized = computeLinearResizeResult( + { + position: notePlatform.position, + rotation: { + x: notePlatform.platform.rotationX, + y: 0, + z: 0 + }, + minLength: sceneConfig.notePlatform.limits.length.min, + maxLength: sceneConfig.notePlatform.limits.length.max, + handleOffset: sceneConfig.notePlatform.handleOffset + }, + state.mode, + draggedPoint + ); + if ( + Math.abs(resized.rotation.x - notePlatform.platform.rotationX) < 1e-4 && + Math.abs(resized.length - notePlatform.platform.length) < 1e-4 + ) { + return false; + } + + app.updateComponent(entityId, app.c.NotePlatformMixin, { + ...notePlatform.platform, + rotationX: resized.rotation.x, + length: resized.length + }); + markSceneManipulationEdit(app); + return true; + } +}; + +export function beginSceneManipulation( + app: TSceneApp, + pickedTarget: TSceneManipulationPick, + pointerDownClient: { x: number; y: number }, + planePoint: THREE.Vector3 +): TSceneApp['r']['sceneManipulationState'] | null { + const adapter = getSceneManipulationAdapter(app, pickedTarget.entityId); + if (adapter == null) { + return null; + } + + const dragPlaneX = adapter.getPlaneX(app, pickedTarget.entityId); + if (dragPlaneX == null) { + return null; + } + + return adapter.begin(app, pickedTarget, pointerDownClient, dragPlaneX, planePoint); +} + +export function getSceneManipulationPlaneX(app: TSceneApp, entityId: number): number | null { + return getSceneManipulationAdapter(app, entityId)?.getPlaneX(app, entityId) ?? null; +} + +export function updateSceneManipulation( + app: TSceneApp, + entityId: number, + state: TSceneApp['r']['sceneManipulationState'], + planePoint: THREE.Vector3 +): boolean { + return ( + getSceneManipulationAdapter(app, entityId)?.update(app, entityId, state, planePoint) ?? false + ); +} + +function getSceneManipulationAdapter( + app: TSceneApp, + entityId: number +): TSceneManipulationAdapter | null { + if (app.hasComponent(entityId, app.c.LinearElementMixin)) { + return linearElementManipulationAdapter; + } + if (app.hasComponent(entityId, app.c.NotePlatformMixin)) { + return notePlatformManipulationAdapter; + } + return null; +} + +function markSceneManipulationEdit(app: TSceneApp): void { + app.setSceneEditPending(true); + app.updateResource('sceneManipulationState', { + ...app.r.sceneManipulationState, + didEdit: true + }); +} + +function toVec3(vector: THREE.Vector3): { x: number; y: number; z: number } { + return { + x: vector.x, + y: vector.y, + z: vector.z + }; +} diff --git a/apps/midimarble/src/modules/engine/plugins/scene/lib/scene-manipulation.ts b/apps/midimarble/src/modules/engine/plugins/scene/lib/scene-manipulation.ts new file mode 100644 index 0000000..92fa7df --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/scene/lib/scene-manipulation.ts @@ -0,0 +1,266 @@ +import { With } from 'ecsify'; +import * as THREE from 'three'; +import type { TSceneApp } from '../types'; +import { getLinearElementEntityIds } from './linear-element'; +import { + disposeSceneManipulationHandles, + getLinearElementHandleKind +} from './manipulation-handles'; +import { resetSceneManipulationState } from './manipulation-state'; +import { + beginSceneManipulation, + getSceneManipulationPlaneX, + updateSceneManipulation +} from './scene-manipulation-adapters'; +import { clearSceneEntitySelection, selectSceneEntity } from './scene-selection'; + +const dragPlaneNormal = new THREE.Vector3(1, 0, 0); + +type TSceneCleanup = () => void; + +export function setupSceneManipulation(app: TSceneApp): TSceneCleanup { + const raycaster = new THREE.Raycaster(); + const scene = app.r.viewport.scene; + scene.add(app.r.sceneManipulationHandles.start, app.r.sceneManipulationHandles.end); + + const onPointerDown = (event: PointerEvent) => handlePointerDown(app, raycaster, event); + const onPointerMove = (event: PointerEvent) => handlePointerMove(app, raycaster, event); + const onPointerUp = () => handlePointerUp(app); + + const canvas = app.r.viewport.domElement; + canvas.addEventListener('pointerdown', onPointerDown); + window.addEventListener('pointermove', onPointerMove); + window.addEventListener('pointerup', onPointerUp); + window.addEventListener('pointercancel', onPointerUp); + + return () => { + canvas.removeEventListener('pointerdown', onPointerDown); + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerup', onPointerUp); + window.removeEventListener('pointercancel', onPointerUp); + app.r.viewport.setControlsEnabled(true); + disposeSceneManipulationHandles(app.r.sceneManipulationHandles); + }; +} + +function handlePointerDown(app: TSceneApp, raycaster: THREE.Raycaster, event: PointerEvent): void { + if (app.r.previewConfig.enabled) { + return; + } + + const pointer = getNormalizedPointer(app, event); + if (pointer == null) { + return; + } + + const pickedHandle = pickHandle(app, raycaster, pointer); + const pickedElement = pickSceneElement(app, raycaster, pointer); + const pickedTarget = pickedHandle ?? pickedElement; + + if (pickedTarget == null) { + app.selectNote(null); + clearSceneEntitySelection(app); + return; + } + + event.preventDefault(); + + if (pickedTarget.target === 'marble') { + selectSceneEntity(app, pickedTarget.entityId); + app.updateResource('sceneManipulationState', resetSceneManipulationState()); + app.r.viewport.setControlsEnabled(true); + return; + } + + if (pickedTarget.target === 'note-platform') { + selectSceneEntity(app, pickedTarget.entityId); + app.updateResource('sceneManipulationState', resetSceneManipulationState()); + app.r.viewport.setControlsEnabled(true); + return; + } + + app.r.viewport.setControlsEnabled(false); + + const dragPlaneX = getSceneManipulationPlaneX(app, pickedTarget.entityId); + if (dragPlaneX == null) { + app.updateResource('sceneManipulationState', resetSceneManipulationState()); + app.r.viewport.setControlsEnabled(true); + return; + } + + const planePoint = raycastScenePlane(app, raycaster, pointer, dragPlaneX); + if (planePoint == null) { + app.updateResource('sceneManipulationState', resetSceneManipulationState()); + app.r.viewport.setControlsEnabled(true); + return; + } + + const nextState = beginSceneManipulation( + app, + pickedTarget, + { x: event.clientX, y: event.clientY }, + planePoint + ); + if (nextState == null) { + app.updateResource('sceneManipulationState', resetSceneManipulationState()); + app.r.viewport.setControlsEnabled(true); + return; + } + + selectSceneEntity(app, pickedTarget.entityId); + app.updateResource('sceneManipulationState', nextState); +} + +function handlePointerMove(app: TSceneApp, raycaster: THREE.Raycaster, event: PointerEvent): void { + if (app.r.previewConfig.enabled) { + return; + } + + const state = app.r.sceneManipulationState; + if (state.entityId == null || state.pointerDownClient == null || state.dragPlaneX == null) { + return; + } + + const pointer = getNormalizedPointer(app, event); + if (pointer == null) { + return; + } + + const dx = event.clientX - state.pointerDownClient.x; + const dy = event.clientY - state.pointerDownClient.y; + const dragDistance = Math.hypot(dx, dy); + if (!state.isDragging && dragDistance < app.r.sceneManipulationConfig.dragStartPixels) { + return; + } + + const planePoint = raycastScenePlane(app, raycaster, pointer, state.dragPlaneX); + if (planePoint == null) { + return; + } + + if (!state.isDragging) { + app.updateResource('sceneManipulationState', { + ...state, + isDragging: true + }); + } + + updateSceneManipulation(app, state.entityId, state, planePoint); +} + +function handlePointerUp(app: TSceneApp): void { + const state = app.r.sceneManipulationState; + if (state.mode === 'idle' && app.r.viewport.controlsEnabled) { + return; + } + + if (state.didEdit) { + app.markSimulationDirty(); + app.requestSimulationSync(); + app.setSceneEditPending(false); + } + + app.updateResource('sceneManipulationState', resetSceneManipulationState()); + app.r.viewport.setControlsEnabled(true); +} + +function pickHandle( + app: TSceneApp, + raycaster: THREE.Raycaster, + pointer: THREE.Vector2 +): { entityId: number; target: 'handle-start' | 'handle-end' } | null { + if (app.r.sceneSelection.entityId == null) { + return null; + } + + raycaster.setFromCamera(pointer, app.r.viewport.camera); + const intersections = raycaster.intersectObjects( + [app.r.sceneManipulationHandles.start, app.r.sceneManipulationHandles.end].filter( + (handle) => handle.visible + ), + false + ); + const hit = intersections[0]; + const handleKind = getLinearElementHandleKind(hit?.object ?? null); + if (handleKind == null) { + return null; + } + + return { + entityId: app.r.sceneSelection.entityId, + target: handleKind === 'start' ? 'handle-start' : 'handle-end' + }; +} + +function pickSceneElement( + app: TSceneApp, + raycaster: THREE.Raycaster, + pointer: THREE.Vector2 +): { entityId: number; target: 'element' | 'marble' | 'note-platform' } | null { + const objectMap = new Map(); + for (const eid of getLinearElementEntityIds(app)) { + const object = app.r.sceneObjects.get(eid); + if (object != null) { + objectMap.set(object, eid); + } + } + for (const eid of app.queryEntities(With(app.c.NotePlatformMixin))) { + const object = app.r.sceneObjects.get(eid); + if (object != null) { + objectMap.set(object, eid); + } + } + for (const eid of app.queryEntities(With(app.c.MarbleTag))) { + const object = app.r.sceneObjects.get(eid); + if (object != null) { + objectMap.set(object, eid); + } + } + + if (objectMap.size === 0) { + return null; + } + + raycaster.setFromCamera(pointer, app.r.viewport.camera); + const intersections = raycaster.intersectObjects([...objectMap.keys()], true); + for (const intersection of intersections) { + let current: THREE.Object3D | null = intersection.object; + while (current != null) { + const entityId = objectMap.get(current); + if (entityId != null) { + return app.hasComponent(entityId, app.c.MarbleTag) + ? { entityId, target: 'marble' } + : app.hasComponent(entityId, app.c.NotePlatformMixin) + ? { entityId, target: 'note-platform' } + : { entityId, target: 'element' }; + } + current = current.parent; + } + } + + return null; +} + +function raycastScenePlane( + app: TSceneApp, + raycaster: THREE.Raycaster, + pointer: THREE.Vector2, + planeX: number +): THREE.Vector3 | null { + raycaster.setFromCamera(pointer, app.r.viewport.camera); + const plane = new THREE.Plane(dragPlaneNormal, -planeX); + const point = new THREE.Vector3(); + return raycaster.ray.intersectPlane(plane, point); +} + +function getNormalizedPointer(app: TSceneApp, event: PointerEvent): THREE.Vector2 | null { + const rect = app.r.viewport.domElement.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) { + return null; + } + + return new THREE.Vector2( + ((event.clientX - rect.left) / rect.width) * 2 - 1, + -((event.clientY - rect.top) / rect.height) * 2 + 1 + ); +} diff --git a/apps/midimarble/src/modules/engine/plugins/scene/lib/scene-selection.test.ts b/apps/midimarble/src/modules/engine/plugins/scene/lib/scene-selection.test.ts new file mode 100644 index 0000000..6ff948e --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/scene/lib/scene-selection.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest'; +import { syncExclusiveSelectionSystem } from '../systems'; +import { selectSceneEntity } from './scene-selection'; + +describe('scene selection helpers', () => { + it('clears selected notes when selecting a scene entity', () => { + const selectNote = vi.fn(); + const updateResource = vi.fn(); + + selectSceneEntity( + { + selectNote, + updateResource + } as never, + 12 + ); + + expect(selectNote).toHaveBeenCalledWith(null); + expect(updateResource).toHaveBeenCalledWith('sceneSelection', { entityId: 12 }); + }); + + it('clears scene selection when a note becomes selected', () => { + const updateResource = vi.fn(); + const setControlsEnabled = vi.fn(); + + syncExclusiveSelectionSystem({ + r: { + selectedNoteId: 7, + sceneSelection: { entityId: 3 }, + sceneManipulationState: { mode: 'move' }, + viewport: { setControlsEnabled } + }, + updateResource + } as never); + + expect(updateResource).toHaveBeenCalledWith('sceneSelection', { entityId: null }); + expect(updateResource).toHaveBeenCalledWith( + 'sceneManipulationState', + expect.objectContaining({ mode: 'idle' }) + ); + expect(setControlsEnabled).toHaveBeenCalledWith(true); + }); +}); diff --git a/apps/midimarble/src/modules/engine/plugins/scene/lib/scene-selection.ts b/apps/midimarble/src/modules/engine/plugins/scene/lib/scene-selection.ts new file mode 100644 index 0000000..47c6000 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/scene/lib/scene-selection.ts @@ -0,0 +1,17 @@ +import type { TSceneApp } from '../types'; +import { resetSceneManipulationState } from './manipulation-state'; + +export function clearSceneEntitySelection(app: TSceneApp): void { + if (app.r.sceneSelection.entityId != null) { + app.updateResource('sceneSelection', { entityId: null }); + } + if (app.r.sceneManipulationState.mode !== 'idle') { + app.updateResource('sceneManipulationState', resetSceneManipulationState()); + } + app.r.viewport.setControlsEnabled(true); +} + +export function selectSceneEntity(app: TSceneApp, entityId: number): void { + app.selectNote(null); + app.updateResource('sceneSelection', { entityId }); +} diff --git a/apps/midimarble/src/modules/engine/plugins/scene/lib/track-shape.ts b/apps/midimarble/src/modules/engine/plugins/scene/lib/track-shape.ts new file mode 100644 index 0000000..593f875 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/scene/lib/track-shape.ts @@ -0,0 +1,134 @@ +import * as THREE from 'three'; +import type { TPhysicsColliderDescriptor } from '../../physics'; +import { sceneConfig } from '../config'; + +export type TTrackRailMode = 'both' | 'wall-only-negative'; + +export interface TTrackShapeConfig { + height: number; + width: number; + channelWidth: number; + channelDepth: number; + length: number; +} + +interface TTrackColliderOptions { + friction?: number; + restitution?: number; +} + +export function getTrackWallWidth(width: number, channelWidth: number): number { + return Math.max(0, (width - channelWidth) / 2); +} + +export function getScaledTrackChannelDepth(height: number): number { + const trackChannelDepthRatio = + sceneConfig.track.defaultChannelDepth / sceneConfig.track.defaultHeight; + return Math.min(height * trackChannelDepthRatio, height / 2 - 1e-4); +} + +export function getScaledTrackChannelWidth(width: number): number { + const trackChannelWidthRatio = + sceneConfig.track.defaultChannelWidth / sceneConfig.track.defaultWidth; + return Math.max(1e-4, width * trackChannelWidthRatio); +} + +export function createTrackProfile( + track: Omit, + railMode: TTrackRailMode +): THREE.Shape { + const wallWidth = getTrackWallWidth(track.width, track.channelWidth); + const profile = new THREE.Shape(); + + if (railMode === 'wall-only-negative') { + profile.moveTo(0, -track.height / 2); + profile.lineTo(0, track.height / 2); + profile.lineTo(wallWidth, track.height / 2); + profile.lineTo(wallWidth, track.height / 2 - track.channelDepth); + profile.lineTo(track.width, track.height / 2 - track.channelDepth); + profile.lineTo(track.width, -track.height / 2 + track.channelDepth); + profile.lineTo(wallWidth, -track.height / 2 + track.channelDepth); + profile.lineTo(wallWidth, -track.height / 2); + profile.lineTo(0, -track.height / 2); + return profile; + } + + profile.moveTo(0, 0); + profile.lineTo(0, -track.height / 2); + profile.lineTo(wallWidth, -track.height / 2); + profile.lineTo(wallWidth, -track.height / 2 + track.channelDepth); + profile.lineTo(wallWidth + track.channelWidth, -track.height / 2 + track.channelDepth); + profile.lineTo(wallWidth + track.channelWidth, -track.height / 2); + profile.lineTo(track.width, -track.height / 2); + profile.lineTo(track.width, track.height / 2); + profile.lineTo(wallWidth + track.channelWidth, track.height / 2); + profile.lineTo(wallWidth + track.channelWidth, track.height / 2 - track.channelDepth); + profile.lineTo(wallWidth, track.height / 2 - track.channelDepth); + profile.lineTo(wallWidth, track.height / 2); + profile.lineTo(0, track.height / 2); + profile.lineTo(0, 0); + + return profile; +} + +export function createTrackGeometry( + track: TTrackShapeConfig, + railMode: TTrackRailMode +): THREE.ExtrudeGeometry { + const profile = createTrackProfile(track, railMode); + const geometry = new THREE.ExtrudeGeometry(profile, { + steps: 1, + depth: track.length, + bevelEnabled: true, + bevelThickness: 0, + bevelSize: 0 + }); + + geometry.translate(-track.width / 2, 0, -track.length / 2); + geometry.computeVertexNormals(); + return geometry; +} + +export function createTrackColliders( + track: TTrackShapeConfig, + railMode: TTrackRailMode, + options: TTrackColliderOptions = {} +): TPhysicsColliderDescriptor[] { + const wallWidth = getTrackWallWidth(track.width, track.channelWidth); + const descriptors: TPhysicsColliderDescriptor[] = [ + { + shape: 'cuboid', + halfExtents: { + x: + railMode === 'wall-only-negative' + ? track.width / 2 - wallWidth / 2 + : track.width / 2 - wallWidth, + y: (track.height - track.channelDepth * 2) / 2, + z: track.length / 2 + }, + translation: { + x: railMode === 'wall-only-negative' ? wallWidth / 2 : 0, + y: 0, + z: 0 + }, + ...options + }, + { + shape: 'cuboid', + halfExtents: { x: wallWidth / 2, y: track.height / 2, z: track.length / 2 }, + translation: { x: -(track.width / 2) + wallWidth / 2, y: 0, z: 0 }, + ...options + } + ]; + + if (railMode === 'both') { + descriptors.push({ + shape: 'cuboid', + halfExtents: { x: wallWidth / 2, y: track.height / 2, z: track.length / 2 }, + translation: { x: track.width / 2 - wallWidth / 2, y: 0, z: 0 }, + ...options + }); + } + + return descriptors; +} diff --git a/apps/midimarble/src/modules/engine/plugins/scene/lib/vec3.ts b/apps/midimarble/src/modules/engine/plugins/scene/lib/vec3.ts new file mode 100644 index 0000000..3ff7a82 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/scene/lib/vec3.ts @@ -0,0 +1,6 @@ +export function sameVec3( + a: { x: number; y: number; z: number }, + b: { x: number; y: number; z: number } +): boolean { + return Math.abs(a.x - b.x) < 1e-5 && Math.abs(a.y - b.y) < 1e-5 && Math.abs(a.z - b.z) < 1e-5; +} diff --git a/apps/midimarble/src/modules/engine/plugins/scene/manipulation-math.test.ts b/apps/midimarble/src/modules/engine/plugins/scene/manipulation-math.test.ts new file mode 100644 index 0000000..5ee2643 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/scene/manipulation-math.test.ts @@ -0,0 +1,51 @@ +import * as THREE from 'three'; +import { describe, expect, it } from 'vitest'; +import { computeLinearResizeResult, getDraggedHandlePoint } from './lib/manipulation-math'; + +const baseLinearElement = { + position: { x: -7.25, y: 16, z: -18 }, + rotation: { x: 0, y: 0, z: 0 }, + length: 14, + minLength: 6, + maxLength: 28, + handleOffset: 0.8 +}; + +describe('scene manipulation resize math', () => { + it('keeps the authored length when the pointer starts on the current handle', () => { + const draggedPoint = new THREE.Vector3( + baseLinearElement.position.x, + baseLinearElement.position.y, + baseLinearElement.position.z + baseLinearElement.length * 0.5 + baseLinearElement.handleOffset + ); + + const resized = computeLinearResizeResult(baseLinearElement, 'resizeEnd', draggedPoint); + + expect(resized.length).toBeCloseTo(baseLinearElement.length, 5); + expect(resized.rotation.x).toBeCloseTo(0, 5); + expect(resized.position).toEqual(baseLinearElement.position); + }); + + it('extends the track continuously when the end handle moves away from center', () => { + const draggedPoint = new THREE.Vector3( + baseLinearElement.position.x, + baseLinearElement.position.y, + baseLinearElement.position.z + 10.8 + ); + + const resized = computeLinearResizeResult(baseLinearElement, 'resizeEnd', draggedPoint); + + expect(resized.length).toBeCloseTo(20, 5); + expect(resized.rotation.x).toBeCloseTo(0, 5); + }); + + it('preserves pointer-to-handle offset during drag reconstruction', () => { + const draggedPoint = getDraggedHandlePoint( + baseLinearElement.position.x, + { y: 12, z: -8 }, + { x: 0, y: 2, z: -3 } + ); + + expect(draggedPoint).toEqual(new THREE.Vector3(baseLinearElement.position.x, 10, -5)); + }); +}); diff --git a/apps/midimarble/src/modules/engine/plugins/scene/scene-plugin.test.ts b/apps/midimarble/src/modules/engine/plugins/scene/scene-plugin.test.ts new file mode 100644 index 0000000..9dab6fe --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/scene/scene-plugin.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createScenePlugin } from './scene-plugin'; + +describe('scene authoring commands', () => { + it('creates a straight track near the live marble and selects it', () => { + const plugin = createScenePlugin(); + const createStraightTrack = plugin.appExtensions?.createStraightTrack; + expect(createStraightTrack).toBeTypeOf('function'); + const app = createMockSceneApp({ + r: { + ...baseResources(), + rigidBodies: new Map([ + [ + 3, + { + translation: () => ({ x: -7.25, y: 21, z: -4 }) + } + ] + ]) + }, + queryComponents: vi.fn((components: unknown[]) => + components[1] === componentKeys.PositionMixin + ? ([[3, { x: -7.25, y: 18.4, z: -25.2 }]] as never) + : ([] as never) + ), + spawnBundle: vi.fn((bundle) => { + const position = bundle.find( + (item: { component: symbol }) => item.component === componentKeys.PositionMixin + )?.value; + const rotation = bundle.find( + (item: { component: symbol }) => item.component === componentKeys.RotationMixin + )?.value; + expect(position).toEqual({ x: -7.25, y: 18, z: 4 }); + expect(rotation).toEqual({ x: 0, y: 0, z: 0 }); + return 61; + }) + }); + + const entityId = createStraightTrack?.call(app as never); + + expect(entityId).toBe(61); + expect(app.spawnBundle).toHaveBeenCalledOnce(); + expect(app.selectNote).toHaveBeenCalledWith(null); + expect(app.updateResource).toHaveBeenCalledWith('sceneSelection', { entityId: 61 }); + expect(app.markSimulationDirty).toHaveBeenCalledOnce(); + expect(app.requestSimulationSync).toHaveBeenCalledOnce(); + }); + + it('deletes only straight tracks and clears selection state', () => { + const plugin = createScenePlugin(); + const deleteStraightTrack = plugin.appExtensions?.deleteStraightTrack; + expect(deleteStraightTrack).toBeTypeOf('function'); + const app = createMockSceneApp({ + hasComponent: vi.fn( + (eid: number, component: symbol) => + eid === 51 && component === componentKeys.StraightTrackMixin + ), + r: { + ...baseResources(), + sceneSelection: { entityId: 51 }, + sceneManipulationState: { + mode: 'move', + entityId: 51, + isDragging: false, + didEdit: false, + pointerDownClient: null, + dragPlaneX: null, + dragOffset: null + } + } + }); + + const deleted = deleteStraightTrack?.call(app as never, 51); + + expect(deleted).toBe(true); + expect(app.destroyEntity).toHaveBeenCalledWith(51); + expect(app.updateResource).toHaveBeenNthCalledWith(1, 'sceneSelection', { entityId: null }); + expect(app.updateResource).toHaveBeenNthCalledWith( + 2, + 'sceneManipulationState', + expect.objectContaining({ mode: 'idle', entityId: null }) + ); + expect(app.markSimulationDirty).toHaveBeenCalledOnce(); + expect(app.requestSimulationSync).toHaveBeenCalledOnce(); + }); + + it('does not delete non-straight scene entities', () => { + const plugin = createScenePlugin(); + const deleteStraightTrack = plugin.appExtensions?.deleteStraightTrack; + expect(deleteStraightTrack).toBeTypeOf('function'); + const app = createMockSceneApp({ + hasComponent: vi.fn(() => false) + }); + + const deleted = deleteStraightTrack?.call(app as never, 9); + + expect(deleted).toBe(false); + expect(app.destroyEntity).not.toHaveBeenCalled(); + expect(app.updateResource).not.toHaveBeenCalled(); + expect(app.markSimulationDirty).not.toHaveBeenCalled(); + expect(app.requestSimulationSync).not.toHaveBeenCalled(); + }); + + it('creates a note platform once and selects the existing entity on repeat', () => { + const plugin = createScenePlugin(); + const createOrSelectNotePlatform = plugin.appExtensions?.createOrSelectNotePlatform; + expect(createOrSelectNotePlatform).toBeTypeOf('function'); + let nextEntityId = 51; + const spawnedBindings = new Map(); + const app = createMockSceneApp({ + spawnBundle: vi.fn((bundle) => { + const entry = bundle.find( + (item: { component: symbol }) => item.component === componentKeys.NoteBindingMixin + ); + const entityId = nextEntityId++; + spawnedBindings.set(entityId, entry?.value as { noteId: number }); + return entityId; + }), + queryComponents: vi.fn( + () => Array.from(spawnedBindings.entries()).map(([eid, binding]) => [eid, binding]) as never + ) + }); + + const firstEntityId = createOrSelectNotePlatform?.call(app as never, 7); + const secondEntityId = createOrSelectNotePlatform?.call(app as never, 7); + + expect(firstEntityId).toBe(51); + expect(secondEntityId).toBe(51); + expect(app.spawnBundle).toHaveBeenCalledTimes(1); + expect(app.selectNote).toHaveBeenNthCalledWith(1, null); + expect(app.selectNote).toHaveBeenNthCalledWith(2, null); + expect(app.updateResource).toHaveBeenNthCalledWith(1, 'sceneSelection', { entityId: 51 }); + expect(app.updateResource).toHaveBeenNthCalledWith(2, 'sceneSelection', { entityId: 51 }); + expect(app.markSimulationDirty).toHaveBeenCalledOnce(); + expect(app.requestSimulationSync).toHaveBeenCalledOnce(); + }); + + it('does not create a note platform when the note anchor is unresolved', () => { + const plugin = createScenePlugin(); + const createOrSelectNotePlatform = plugin.appExtensions?.createOrSelectNotePlatform; + expect(createOrSelectNotePlatform).toBeTypeOf('function'); + const app = createMockSceneApp(); + + const entityId = createOrSelectNotePlatform?.call(app as never, 11); + + expect(entityId).toBeNull(); + expect(app.spawnBundle).not.toHaveBeenCalled(); + expect(app.updateResource).not.toHaveBeenCalled(); + expect(app.selectNote).not.toHaveBeenCalled(); + expect(app.markSimulationDirty).not.toHaveBeenCalled(); + expect(app.requestSimulationSync).not.toHaveBeenCalled(); + }); +}); + +const componentKeys = { + PositionMixin: Symbol('PositionMixin'), + RotationMixin: Symbol('RotationMixin'), + ScaleMixin: Symbol('ScaleMixin'), + AuthoredTransformMixin: Symbol('AuthoredTransformMixin'), + LinearElementMixin: Symbol('LinearElementMixin'), + MarbleTag: Symbol('MarbleTag'), + StraightTrackMixin: Symbol('StraightTrackMixin'), + NoteBindingMixin: Symbol('NoteBindingMixin'), + NotePlatformMixin: Symbol('NotePlatformMixin'), + MeshMixin: Symbol('MeshMixin'), + RigidBodyMixin: Symbol('RigidBodyMixin'), + ColliderMixin: Symbol('ColliderMixin') +}; + +function baseResources() { + return { + trajectoryProjection: { + noteAnchorsById: new Map([ + [ + 7, + { + tick: 120, + step: 30, + position: { x: 1, y: 2, z: 3 }, + phase: 'future' + } + ] + ]) + }, + sceneSelection: { + entityId: null + }, + sceneManipulationState: { + mode: 'idle', + entityId: null, + isDragging: false, + didEdit: false, + pointerDownClient: null, + dragPlaneX: null, + dragOffset: null + }, + viewport: { + setControlsEnabled: vi.fn() + }, + rigidBodies: new Map() + }; +} + +function createMockSceneApp( + overrides: Partial<{ + queryComponents: ReturnType; + spawnBundle: ReturnType; + hasComponent: ReturnType; + destroyEntity: ReturnType; + r: Record; + }> = {} +) { + return { + c: componentKeys, + r: overrides.r ?? baseResources(), + selectNote: vi.fn(), + updateResource: vi.fn(), + markSimulationDirty: vi.fn(), + requestSimulationSync: vi.fn(), + destroyEntity: overrides.destroyEntity ?? vi.fn(), + hasComponent: overrides.hasComponent ?? vi.fn(() => false), + queryComponents: overrides.queryComponents ?? vi.fn(() => [] as never), + spawnBundle: overrides.spawnBundle ?? vi.fn(() => 51) + }; +} diff --git a/apps/midimarble/src/modules/engine/plugins/scene/scene-plugin.ts b/apps/midimarble/src/modules/engine/plugins/scene/scene-plugin.ts new file mode 100644 index 0000000..6994c83 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/scene/scene-plugin.ts @@ -0,0 +1,291 @@ +import { Entity, With } from 'ecsify'; +import { + createMarbleBundle, + createNotePlatformBundle, + createPegboardBundle, + createStraightTrackBundle +} from './bundles'; +import { sceneConfig } from './config'; +import { createSceneManipulationHandles } from './lib/manipulation-handles'; +import { resetSceneManipulationState } from './lib/manipulation-state'; +import { findNotePlatformEntityId } from './lib/note-platform'; +import { + updateMarblePhysicsAuthoring, + updateNotePlatformAuthoring, + updateStraightTrackGeometryAuthoring, + updateStraightTrackTransformAuthoring +} from './lib/scene-authoring'; +import { setupSceneManipulation } from './lib/scene-manipulation'; +import { clearSceneEntitySelection, selectSceneEntity } from './lib/scene-selection'; +import { + syncAuthoredTransformsToLiveSystem, + syncExclusiveSelectionSystem, + syncMarbleRuntimeMixinsSystem, + syncNotePlatformMarkerStateSystem, + syncNotePlatformRuntimeSystem, + syncOrphanedNotePlatformsSystem, + syncPreviewInteractionSystem, + syncSceneManipulationHandleAppearanceSystem, + syncSceneManipulationHandlesSystem, + syncStraightTrackRuntimeMixinsSystem +} from './systems'; +import type { TSceneApp, TScenePlugin } from './types'; + +interface TScenePluginOptions { + marble?: { + position: { x: number; y: number; z: number }; + rotation: { x: number; y: number; z: number }; + bounce: number; + }; + straightTracks?: Array<{ + position: { x: number; y: number; z: number }; + rotation: { x: number; y: number; z: number }; + scale: { x: number; y: number; z: number }; + length: number; + height: number; + width: number; + channelWidth: number; + channelDepth: number; + color: string; + }>; + notePlatforms?: Array<{ + noteId: number; + offsetY: number; + offsetZ: number; + rotationX: number; + length: number; + width: number; + thickness: number; + bounce: number; + color: string; + }>; +} + +export function createScenePlugin(options?: TScenePluginOptions): TScenePlugin { + const sceneManipulationConfig = sceneConfig.manipulation; + const pendingNotePlatforms = options?.notePlatforms ? [...options.notePlatforms] : []; + let disposeScene: (() => void) | null = null; + + return { + // Scene is Midimarble's app-specific composition root and editing domain. + name: 'Scene', + deps: ['Default', 'Core', 'Midi', 'Physics', 'Render', 'Trajectory'], + components: { + MarbleTag: [], + MarblePhysicsMixin: [], + AuthoredTransformMixin: [], + StraightTrackMixin: [], + LinearElementMixin: [], + NoteBindingMixin: [], + NotePlatformMixin: [] + }, + resources: { + sceneSelection: { + entityId: null + }, + sceneEditState: { + pending: false + }, + sceneManipulationState: resetSceneManipulationState(), + sceneManipulationConfig, + sceneManipulationHandles: createSceneManipulationHandles( + sceneManipulationConfig.handleRadius, + sceneManipulationConfig.handleColor + ) + }, + appExtensions: { + disposeScene(this: TSceneApp): void { + disposeScene?.(); + disposeScene = null; + }, + createStraightTrack(this: TSceneApp): number | null { + const spawnPosition = resolveStraightTrackSpawnPosition(this); + if (spawnPosition == null) { + return null; + } + + const entityId = this.spawnBundle( + createStraightTrackBundle(this, { + position: spawnPosition, + rotation: { x: 0, y: 0, z: 0 } + }) + ); + selectSceneEntity(this, entityId); + this.markSimulationDirty(); + this.requestSimulationSync(); + return entityId; + }, + deleteStraightTrack(this: TSceneApp, entityId: number): boolean { + if (!this.hasComponent(entityId, this.c.StraightTrackMixin)) { + return false; + } + + if ( + this.r.sceneSelection.entityId === entityId || + this.r.sceneManipulationState.entityId === entityId + ) { + clearSceneEntitySelection(this); + } + + this.destroyEntity(entityId); + this.markSimulationDirty(); + this.requestSimulationSync(); + return true; + }, + createOrSelectNotePlatform(this: TSceneApp, noteId: number): number | null { + const existingEntityId = findNotePlatformEntityId(this, noteId); + if (existingEntityId != null) { + selectSceneEntity(this, existingEntityId); + return existingEntityId; + } + + const anchor = this.r.trajectoryProjection.noteAnchorsById.get(noteId); + if (anchor == null) { + return null; + } + + const entityId = this.spawnBundle(createNotePlatformBundle(this, noteId, anchor.position)); + selectSceneEntity(this, entityId); + this.markSimulationDirty(); + this.requestSimulationSync(); + return entityId; + }, + updateNotePlatform( + this: TSceneApp, + entityId: number, + patch: Partial + ) { + return updateNotePlatformAuthoring(this, entityId, patch); + }, + updateMarblePhysics( + this: TSceneApp, + entityId: number, + patch: Partial + ) { + return updateMarblePhysicsAuthoring(this, entityId, patch); + }, + updateStraightTrackTransform( + this: TSceneApp, + entityId: number, + patch: Partial + ): boolean { + return updateStraightTrackTransformAuthoring(this, entityId, patch); + }, + updateStraightTrackGeometry( + this: TSceneApp, + entityId: number, + patch: Partial + ): boolean { + return updateStraightTrackGeometryAuthoring(this, entityId, patch); + }, + setSceneEditPending(this: TSceneApp, pending: boolean): void { + if (this.r.sceneEditState.pending === pending) { + return; + } + + this.updateResource('sceneEditState', { pending }); + } + }, + setup(app: TSceneApp) { + app.spawnBundle(createPegboardBundle(app)); + + const marblePosition = options?.marble?.position ?? sceneConfig.marble.spawn.position; + const marbleEntityId = app.spawnBundle( + createMarbleBundle(app, { + position: marblePosition, + rotation: options?.marble?.rotation + }) + ); + if (options?.marble != null) { + updateMarblePhysicsAuthoring(app, marbleEntityId, { bounce: options.marble.bounce }); + } + app.setPreviewTargetEntity(marbleEntityId); + + for (const snapshot of options?.straightTracks ?? []) { + app.spawnBundle(createStraightTrackBundle(app, snapshot)); + } + + if (pendingNotePlatforms.length > 0) { + app.addSystem( + () => { + if (pendingNotePlatforms.length === 0) return; + for (let i = pendingNotePlatforms.length - 1; i >= 0; i--) { + const snapshot = pendingNotePlatforms[i]; + if (snapshot == null) continue; + // Anchors from the previous PostUpdate trajectory computation persist here + const anchor = app.r.trajectoryProjection.noteAnchorsById.get( + snapshot.noteId + ); + if (anchor == null) continue; + if (findNotePlatformEntityId(app, snapshot.noteId) != null) { + pendingNotePlatforms.splice(i, 1); + continue; + } + const eid = app.spawnBundle( + createNotePlatformBundle(app, snapshot.noteId, anchor.position) + ); + app.updateNotePlatform(eid, { + offsetY: snapshot.offsetY, + offsetZ: snapshot.offsetZ, + rotationX: snapshot.rotationX, + length: snapshot.length, + width: snapshot.width, + thickness: snapshot.thickness, + bounce: snapshot.bounce, + color: snapshot.color + }); + pendingNotePlatforms.splice(i, 1); + } + }, + { set: 'PreUpdate' } + ); + } + + app.addSystem(syncAuthoredTransformsToLiveSystem, { set: 'PreUpdate' }); + app.addSystem(syncMarbleRuntimeMixinsSystem, { set: 'PreUpdate' }); + app.addSystem(syncStraightTrackRuntimeMixinsSystem, { set: 'PreUpdate' }); + app.addSystem(syncExclusiveSelectionSystem, { set: 'Update' }); + app.addSystem(syncOrphanedNotePlatformsSystem, { + set: 'Update', + after: syncExclusiveSelectionSystem + }); + app.addSystem(syncPreviewInteractionSystem, { + set: 'Update', + after: syncOrphanedNotePlatformsSystem + }); + app.addSystem(syncSceneManipulationHandleAppearanceSystem, { set: 'Update' }); + app.addSystem(syncNotePlatformRuntimeSystem, { set: 'PostUpdate' }); + app.addSystem(syncSceneManipulationHandlesSystem, { set: 'PostUpdate' }); + app.addSystem(syncNotePlatformMarkerStateSystem, { set: 'PostUpdate' }); + disposeScene = setupSceneManipulation(app); + } + }; +} + +function resolveStraightTrackSpawnPosition( + app: TSceneApp +): { x: number; y: number; z: number } | null { + for (const [eid, position] of app.queryComponents( + [Entity, app.c.PositionMixin] as const, + With(app.c.MarbleTag) + )) { + const liveBody = app.r.rigidBodies.get(eid); + const translation = liveBody?.translation(); + const basePosition = + translation == null + ? position + : { + x: translation.x, + y: translation.y, + z: translation.z + }; + + return { + x: sceneConfig.track.wallLaneX, + y: basePosition.y + sceneConfig.track.newTrackYOffset, + z: basePosition.z + sceneConfig.track.newTrackZOffset + }; + } + + return null; +} diff --git a/apps/midimarble/src/modules/engine/plugins/scene/systems.test.ts b/apps/midimarble/src/modules/engine/plugins/scene/systems.test.ts new file mode 100644 index 0000000..e8a3d46 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/scene/systems.test.ts @@ -0,0 +1,285 @@ +import * as THREE from 'three'; +import { describe, expect, it, vi } from 'vitest'; +import { createNotePlatformColliders, createNotePlatformObject } from './bundles/note-platform'; +import { DEFAULT_TRACK_WIDTH } from './lib/track-shape'; +import { + syncNotePlatformRuntimeSystem, + syncOrphanedNotePlatformsSystem, + syncPreviewInteractionSystem, + syncSceneManipulationHandlesSystem +} from './systems'; + +describe('syncNotePlatformRuntimeSystem', () => { + it('requests a follow-up simulation sync when note anchors move a bound platform', () => { + const updateComponent = vi.fn(); + const markSimulationDirty = vi.fn(); + const requestSimulationSync = vi.fn(); + const notePlatformMesh = new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshBasicMaterial() + ); + const app = { + c: { + NotePlatformMixin: Symbol('NotePlatformMixin'), + NoteBindingMixin: Symbol('NoteBindingMixin'), + PositionMixin: Symbol('PositionMixin'), + RotationMixin: Symbol('RotationMixin'), + MeshMixin: Symbol('MeshMixin'), + ColliderMixin: Symbol('ColliderMixin') + }, + r: { + simulationSync: { mode: 'idle' }, + trajectoryProjection: { + noteAnchorsById: new Map([ + [ + 7, + { + tick: 120, + step: 30, + position: { x: 1, y: 2, z: 3 }, + phase: 'future' + } + ] + ]) + } + }, + wasResourceChanged: vi.fn((resource: string) => resource === 'trajectoryProjection'), + queryEntities: vi.fn(() => []), + queryComponents: vi.fn(() => [ + [ + 12, + { noteId: 7 }, + { + offsetY: 0.25, + offsetZ: -0.5, + rotationX: 0, + length: 1.2, + width: 0.84, + thickness: 0.22, + bounce: 0.58, + color: '#2a5e92' + }, + { x: 0, y: 0, z: 0 }, + { x: 0, y: 0, z: 0 }, + { type: 'three', object: notePlatformMesh }, + { + descriptors: [ + { + shape: 'cuboid', + halfExtents: { x: 0.4, y: 0.11, z: 0.6 } + } + ] + } + ] + ]), + updateComponent, + markSimulationDirty, + requestSimulationSync + }; + + syncNotePlatformRuntimeSystem(app as never); + + expect(updateComponent).toHaveBeenCalledWith( + 12, + app.c.PositionMixin, + expect.objectContaining({ + x: 1 - (DEFAULT_TRACK_WIDTH - 0.84) / 2, + y: 2 - (0.36 + 0.11) + 0.25, + z: 2.5 + }) + ); + expect(markSimulationDirty).toHaveBeenCalledOnce(); + expect(requestSimulationSync).toHaveBeenCalledOnce(); + }); + + it('does not touch note-platform runtime while simulation sync is rebuilding', () => { + const app = { + r: { + simulationSync: { mode: 'rebuilding' } + }, + wasResourceChanged: vi.fn(), + queryEntities: vi.fn(), + queryComponents: vi.fn(), + updateComponent: vi.fn(), + markSimulationDirty: vi.fn(), + requestSimulationSync: vi.fn() + }; + + syncNotePlatformRuntimeSystem(app as never); + + expect(app.wasResourceChanged).not.toHaveBeenCalled(); + expect(app.updateComponent).not.toHaveBeenCalled(); + expect(app.markSimulationDirty).not.toHaveBeenCalled(); + expect(app.requestSimulationSync).not.toHaveBeenCalled(); + }); + + it('keeps note-platform geometry and colliders intact for transform-only edits', () => { + const updateComponent = vi.fn(); + const platform = { + offsetY: 0.25, + offsetZ: -0.5, + rotationX: 0.1, + length: 1.2, + width: 0.84, + thickness: 0.22, + bounce: 0.58, + color: '#2a5e92' + }; + const notePlatformMesh = createNotePlatformObject(platform) as THREE.Mesh; + const originalGeometry = notePlatformMesh.geometry; + const app = { + c: { + NotePlatformMixin: Symbol('NotePlatformMixin'), + NoteBindingMixin: Symbol('NoteBindingMixin'), + PositionMixin: Symbol('PositionMixin'), + RotationMixin: Symbol('RotationMixin'), + MeshMixin: Symbol('MeshMixin'), + ColliderMixin: Symbol('ColliderMixin') + }, + r: { + simulationSync: { mode: 'idle' }, + trajectoryProjection: { + noteAnchorsById: new Map([ + [ + 7, + { + tick: 120, + step: 30, + position: { x: 1, y: 2, z: 3 }, + phase: 'future' + } + ] + ]) + } + }, + wasResourceChanged: vi.fn(() => false), + queryEntities: vi.fn(() => [12]), + queryComponents: vi.fn(() => [ + [ + 12, + { noteId: 7 }, + platform, + { x: 0, y: 0, z: 0 }, + { x: 0, y: 0, z: 0 }, + { type: 'three', object: notePlatformMesh }, + { + descriptors: createNotePlatformColliders(platform) + } + ] + ]), + updateComponent, + markSimulationDirty: vi.fn(), + requestSimulationSync: vi.fn() + }; + + syncNotePlatformRuntimeSystem(app as never); + + expect(notePlatformMesh.geometry).toBe(originalGeometry); + expect(updateComponent).not.toHaveBeenCalledWith(12, app.c.ColliderMixin, expect.anything()); + }); + + it('removes orphaned note platforms when the bound note disappears from midi state', () => { + const destroyEntity = vi.fn(); + const markSimulationDirty = vi.fn(); + const requestSimulationSync = vi.fn(); + const updateResource = vi.fn(); + + syncOrphanedNotePlatformsSystem({ + c: { + NoteBindingMixin: Symbol('NoteBindingMixin'), + NotePlatformMixin: Symbol('NotePlatformMixin') + }, + r: { + midiSong: { + name: 'Demo', + bpm: 120, + ticksPerBeat: 480, + totalTicks: 960, + tracks: [ + { + id: 0, + name: 'Lead', + notes: [ + { id: 3, tick: 0, durationTicks: 120, noteNumber: 60, velocity: 100, channel: 0 } + ] + } + ] + }, + sceneSelection: { entityId: 12 }, + sceneManipulationState: { + mode: 'move', + entityId: 12, + isDragging: false, + didEdit: false, + pointerDownClient: null, + dragPlaneX: null, + dragOffset: null + }, + viewport: { + setControlsEnabled: vi.fn() + } + }, + wasResourceChanged: vi.fn((resource: string) => resource === 'midiSong'), + queryComponents: vi.fn(() => [[12, { noteId: 7 }]]), + updateResource, + destroyEntity, + markSimulationDirty, + requestSimulationSync + } as never); + + expect(destroyEntity).toHaveBeenCalledWith(12); + expect(markSimulationDirty).toHaveBeenCalledOnce(); + expect(requestSimulationSync).toHaveBeenCalledOnce(); + expect(updateResource).toHaveBeenCalledWith('sceneSelection', { entityId: null }); + }); + + it('hides manipulation handles while preview mode is active', () => { + const handles = { + start: { visible: true, position: new THREE.Vector3() }, + end: { visible: true, position: new THREE.Vector3() } + }; + + syncSceneManipulationHandlesSystem({ + r: { + previewConfig: { enabled: true }, + sceneSelection: { entityId: 12 }, + sceneManipulationHandles: handles + } + } as never); + + expect(handles.start.visible).toBe(false); + expect(handles.end.visible).toBe(false); + }); + + it('commits pending scene edits before clearing manipulation state for preview', () => { + const markSimulationDirty = vi.fn(); + const requestSimulationSync = vi.fn(); + const setSceneEditPending = vi.fn(); + const updateResource = vi.fn(); + + syncPreviewInteractionSystem({ + r: { + previewConfig: { enabled: true }, + sceneManipulationState: { + mode: 'resizeEnd', + entityId: 12, + pointerDownClient: { x: 10, y: 20 }, + dragPlaneX: 0, + dragOffset: { x: 0, y: 0, z: 0 }, + isDragging: true, + didEdit: true + } + }, + wasResourceChanged: vi.fn((resource: string) => resource === 'previewConfig'), + markSimulationDirty, + requestSimulationSync, + setSceneEditPending, + updateResource + } as never); + + expect(markSimulationDirty).toHaveBeenCalledOnce(); + expect(requestSimulationSync).toHaveBeenCalledOnce(); + expect(setSceneEditPending).toHaveBeenCalledWith(false); + expect(updateResource).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/midimarble/src/modules/engine/plugins/scene/systems.ts b/apps/midimarble/src/modules/engine/plugins/scene/systems.ts new file mode 100644 index 0000000..33b31b9 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/scene/systems.ts @@ -0,0 +1,301 @@ +import { Added, Changed, Entity, Or, Removed, With } from 'ecsify'; +import * as THREE from 'three'; +import { findNoteById } from '../midi'; +import { createStraightTrackColliders, createStraightTrackGeometry } from './bundles'; +import { getLinearElement, getLinearElementHandlePositions } from './lib/linear-element'; +import { updateHandleAppearance } from './lib/manipulation-handles'; +import { resetSceneManipulationState } from './lib/manipulation-state'; +import { createMarbleColliderDescriptors, getMarbleRadius } from './lib/marble'; +import { + getNotePlatform, + getNotePlatformHandlePositions, + getNotePlatformNoteState +} from './lib/note-platform'; +import { syncResolvedNotePlatform, syncUnresolvedNotePlatform } from './lib/note-platform-runtime'; +import { clearSceneEntitySelection } from './lib/scene-selection'; +import { sameVec3 } from './lib/vec3'; +import type { TSceneApp } from './types'; + +export function syncAuthoredTransformsToLiveSystem(app: TSceneApp) { + for (const [eid, authoredTransform, position, rotation, scale] of app.queryComponents( + [ + Entity, + app.c.AuthoredTransformMixin, + app.c.PositionMixin, + app.c.RotationMixin, + app.c.ScaleMixin + ] as const, + Or(Added(app.c.AuthoredTransformMixin), Changed(app.c.AuthoredTransformMixin)) + )) { + if (!sameVec3(position, authoredTransform.position)) { + app.updateComponent(eid, app.c.PositionMixin, { ...authoredTransform.position }); + } + if (!sameVec3(rotation, authoredTransform.rotation)) { + app.updateComponent(eid, app.c.RotationMixin, { ...authoredTransform.rotation }); + } + if (!sameVec3(scale, authoredTransform.scale)) { + app.updateComponent(eid, app.c.ScaleMixin, { ...authoredTransform.scale }); + } + } +} + +export function syncStraightTrackRuntimeMixinsSystem(app: TSceneApp) { + for (const [eid, mesh, linear, track] of app.queryComponents( + [Entity, app.c.MeshMixin, app.c.LinearElementMixin, app.c.StraightTrackMixin] as const, + Or( + Added(app.c.StraightTrackMixin), + Changed(app.c.StraightTrackMixin), + Added(app.c.LinearElementMixin), + Changed(app.c.LinearElementMixin) + ) + )) { + if (mesh.type === 'three' && mesh.object instanceof THREE.Mesh) { + mesh.object.geometry.dispose(); + mesh.object.geometry = createStraightTrackGeometry({ ...track, length: linear.length }); + + const material = mesh.object.material; + if (Array.isArray(material)) { + for (const entry of material) { + if ('color' in entry) { + entry.color.set(track.color); + } + } + } else if (material instanceof THREE.MeshStandardMaterial) { + material.color.set(track.color); + } + } + + app.updateComponent(eid, app.c.ColliderMixin, { + descriptors: createStraightTrackColliders({ ...track, length: linear.length }) + }); + } +} + +export function syncMarbleRuntimeMixinsSystem(app: TSceneApp) { + for (const [eid, marblePhysics, collider] of app.queryComponents( + [Entity, app.c.MarblePhysicsMixin, app.c.ColliderMixin] as const, + Or(Added(app.c.MarblePhysicsMixin), Changed(app.c.MarblePhysicsMixin)) + )) { + const radius = getMarbleRadius(collider.descriptors); + app.updateComponent(eid, app.c.ColliderMixin, { + descriptors: createMarbleColliderDescriptors(radius, marblePhysics.bounce) + }); + } +} + +export function syncNotePlatformRuntimeSystem(app: TSceneApp) { + if (app.r.simulationSync.mode !== 'idle') { + return; + } + + const shouldSyncProjection = app.wasResourceChanged('trajectoryProjection'); + const changedPlatformEntities = new Set( + app.queryEntities(Or(Added(app.c.NotePlatformMixin), Changed(app.c.NotePlatformMixin))) + ); + if (!shouldSyncProjection && changedPlatformEntities.size === 0) { + return; + } + let didProjectionAffectSimulation = false; + + for (const [eid, binding, platform, position, rotation, mesh, collider] of app.queryComponents([ + Entity, + app.c.NoteBindingMixin, + app.c.NotePlatformMixin, + app.c.PositionMixin, + app.c.RotationMixin, + app.c.MeshMixin, + app.c.ColliderMixin + ] as const)) { + const anchor = app.r.trajectoryProjection.noteAnchorsById.get(binding.noteId); + if (anchor == null) { + const didRuntimeChange = syncUnresolvedNotePlatform( + app, + eid, + collider.descriptors, + mesh.type === 'three' ? mesh.object : null + ); + if (shouldSyncProjection && didRuntimeChange) { + didProjectionAffectSimulation = true; + } + continue; + } + + const didRuntimeChange = syncResolvedNotePlatform( + app, + eid, + platform, + position, + rotation, + collider.descriptors, + mesh.type === 'three' ? mesh.object : null, + anchor.position + ); + if (shouldSyncProjection && didRuntimeChange) { + didProjectionAffectSimulation = true; + } + } + + if (shouldSyncProjection && didProjectionAffectSimulation) { + app.markSimulationDirty(); + app.requestSimulationSync(); + } +} + +export function syncSceneManipulationHandlesSystem(app: TSceneApp) { + const selection = app.r.sceneSelection; + const handles = app.r.sceneManipulationHandles; + + if (app.r.previewConfig.enabled) { + handles.start.visible = false; + handles.end.visible = false; + return; + } + + if (selection.entityId == null) { + handles.start.visible = false; + handles.end.visible = false; + return; + } + + const linearElement = getLinearElement(app, selection.entityId); + if (linearElement != null) { + const handlePositions = getLinearElementHandlePositions( + linearElement.transform.position, + linearElement.transform.rotation.x, + linearElement.linear.length, + linearElement.linear.handleOffset + ); + + handles.start.position.copy(handlePositions.start); + handles.end.position.copy(handlePositions.end); + handles.start.visible = true; + handles.end.visible = true; + return; + } + + const notePlatform = getNotePlatform(app, selection.entityId); + const notePlatformObject = app.r.sceneObjects.get(selection.entityId); + if (notePlatform == null || notePlatformObject?.visible === false) { + handles.start.visible = false; + handles.end.visible = false; + return; + } + + const handlePositions = getNotePlatformHandlePositions( + notePlatform.position, + notePlatform.platform.rotationX, + notePlatform.platform.length + ); + handles.start.position.copy(handlePositions.start); + handles.end.position.copy(handlePositions.end); + handles.start.visible = true; + handles.end.visible = true; +} + +export function syncSceneManipulationHandleAppearanceSystem(app: TSceneApp) { + if (!app.wasResourceChanged('sceneManipulationConfig')) { + return; + } + + updateHandleAppearance( + app.r.sceneManipulationHandles.start, + app.r.sceneManipulationConfig.handleRadius, + app.r.sceneManipulationConfig.handleColor + ); + updateHandleAppearance( + app.r.sceneManipulationHandles.end, + app.r.sceneManipulationConfig.handleRadius, + app.r.sceneManipulationConfig.handleColor + ); +} + +export function syncExclusiveSelectionSystem(app: TSceneApp) { + if (app.r.selectedNoteId == null || app.r.sceneSelection.entityId == null) { + return; + } + + clearSceneEntitySelection(app); +} + +export function syncOrphanedNotePlatformsSystem(app: TSceneApp) { + if (!app.wasResourceChanged('midiSong')) { + return; + } + + let didRemovePlatform = false; + for (const [eid, binding] of app.queryComponents( + [Entity, app.c.NoteBindingMixin] as const, + With(app.c.NotePlatformMixin) + )) { + if (findNoteById(app.r.midiSong, binding.noteId, app.r.midiLookup) != null) { + continue; + } + + if (app.r.sceneSelection.entityId === eid || app.r.sceneManipulationState.entityId === eid) { + clearSceneEntitySelection(app); + } + + app.destroyEntity(eid); + didRemovePlatform = true; + } + + if (didRemovePlatform) { + app.markSimulationDirty(); + app.requestSimulationSync(); + } +} + +export function syncPreviewInteractionSystem(app: TSceneApp) { + if (!app.wasResourceChanged('previewConfig') || !app.r.previewConfig.enabled) { + return; + } + + const state = app.r.sceneManipulationState; + if (state.mode === 'idle') { + return; + } + + if (state.didEdit) { + app.markSimulationDirty(); + app.requestSimulationSync(); + app.setSceneEditPending(false); + } + + app.updateResource('sceneManipulationState', resetSceneManipulationState()); +} + +export function syncNotePlatformMarkerStateSystem(app: TSceneApp) { + if (app.r.previewConfig.enabled) { + return; + } + + const didNotePlatformStateChange = + app.queryEntities( + Or( + Added(app.c.NotePlatformMixin), + Changed(app.c.NotePlatformMixin), + Removed(app.c.NotePlatformMixin), + Added(app.c.NoteBindingMixin), + Removed(app.c.NoteBindingMixin) + ) + ).length > 0; + const didSelectionChange = + app.wasResourceChanged('selectedNoteId') || app.wasResourceChanged('selectedNoteIds'); + const didPreviewModeChange = app.wasResourceChanged('previewConfig'); + const didLiveStepChange = app.wasResourceChanged('liveStep'); + if ( + !didNotePlatformStateChange && + !didSelectionChange && + !didPreviewModeChange && + !didLiveStepChange + ) { + return; + } + + if (!didNotePlatformStateChange && !didSelectionChange && !didPreviewModeChange) { + app.syncNoteMarkerPhase(); + return; + } + + app.syncNoteMarkers(getNotePlatformNoteState(app)); +} diff --git a/apps/midimarble/src/modules/engine/plugins/scene/types.ts b/apps/midimarble/src/modules/engine/plugins/scene/types.ts new file mode 100644 index 0000000..322a5e8 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/scene/types.ts @@ -0,0 +1,136 @@ +import type { TApp, TAppContext, TDefaultPlugin, TPlugin } from 'ecsify'; +import type * as THREE from 'three'; +import type { TEngineSystemSet, TVec3 } from '../../types'; +import type { TCorePlugin } from '../core/types'; +import type { TMidiPlugin } from '../midi'; +import type { TPhysicsPlugin } from '../physics/types'; +import type { TRenderPlugin } from '../render/types'; +import type { TTrajectoryPlugin } from '../trajectory/types'; + +// MARK: - Plugin + +export type TScenePlugin = TPlugin< + { + name: 'Scene'; + components: { + MarbleTag: TCMarbleTag[]; + MarblePhysicsMixin: TCMarblePhysicsMixin[]; + AuthoredTransformMixin: TCAuthoredTransformMixin[]; + StraightTrackMixin: TCStraightTrackMixin[]; + LinearElementMixin: TCLinearElementMixin[]; + NoteBindingMixin: TCNoteBindingMixin[]; + NotePlatformMixin: TCNotePlatformMixin[]; + }; + resources: { + sceneSelection: TSceneSelection; + sceneEditState: TSceneEditState; + sceneManipulationState: TSceneManipulationState; + sceneManipulationConfig: TSceneManipulationConfig; + sceneManipulationHandles: TSceneManipulationHandles; + }; + appExtensions: { + disposeScene(): void; + createStraightTrack(): number | null; + deleteStraightTrack(entityId: number): boolean; + createOrSelectNotePlatform(noteId: number): number | null; + updateNotePlatform(entityId: number, patch: Partial): boolean; + updateMarblePhysics(entityId: number, patch: Partial): boolean; + updateStraightTrackTransform( + entityId: number, + patch: Partial + ): boolean; + updateStraightTrackGeometry( + entityId: number, + patch: Partial + ): boolean; + setSceneEditPending(pending: boolean): void; + }; + systemSets: TEngineSystemSet; + }, + [TDefaultPlugin, TCorePlugin, TMidiPlugin, TPhysicsPlugin, TRenderPlugin, TTrajectoryPlugin] +>; + +export type TSceneApp = TApp< + TAppContext< + [ + TDefaultPlugin, + TCorePlugin, + TMidiPlugin, + TPhysicsPlugin, + TRenderPlugin, + TTrajectoryPlugin, + TScenePlugin + ] + > +>; + +export interface TCAuthoredTransformMixin { + position: TVec3; + rotation: TVec3; + scale: TVec3; +} + +export interface TCMarbleTag {} + +export interface TCMarblePhysicsMixin { + bounce: number; +} + +export interface TCStraightTrackMixin { + height: number; + width: number; + channelWidth: number; + channelDepth: number; + color: string; +} + +export interface TCLinearElementMixin { + length: number; + minLength: number; + maxLength: number; + handleOffset: number; +} + +export interface TCNoteBindingMixin { + noteId: number; +} + +export interface TCNotePlatformMixin { + offsetY: number; + offsetZ: number; + rotationX: number; + length: number; + width: number; + thickness: number; + bounce: number; + color: string; +} + +export interface TSceneSelection { + entityId: number | null; +} + +export interface TSceneEditState { + pending: boolean; +} + +export interface TSceneManipulationState { + mode: 'idle' | 'move' | 'resizeStart' | 'resizeEnd'; + entityId: number | null; + isDragging: boolean; + didEdit: boolean; + pointerDownClient: { x: number; y: number } | null; + dragPlaneX: number | null; + dragOffset: TVec3 | null; +} + +export interface TSceneManipulationConfig { + handleRadius: number; + handleColor: string; + dragStartPixels: number; +} + +export interface TSceneManipulationHandles { + start: THREE.Mesh; + end: THREE.Mesh; +} diff --git a/apps/midimarble/src/modules/engine/plugins/trajectory/config.ts b/apps/midimarble/src/modules/engine/plugins/trajectory/config.ts new file mode 100644 index 0000000..9dad0b1 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/trajectory/config.ts @@ -0,0 +1,20 @@ +export const trajectoryConfig = { + defaults: { + enabled: true, + futureColor: '#4a90e2', + pastColor: '#ff9943' + }, + marker: { + scale: 0.22, + selectedScale: 0.3, + colors: { + past: '#ffb05b', + pastPlaced: '#34d399', + pastAdjusted: '#f59e0b', + future: '#68aef2', + futurePlaced: '#6ee7b7', + futureAdjusted: '#facc15', + selected: '#f43f5e' + } + } +} as const; diff --git a/apps/midimarble/src/modules/engine/plugins/trajectory/index.ts b/apps/midimarble/src/modules/engine/plugins/trajectory/index.ts new file mode 100644 index 0000000..190038f --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/trajectory/index.ts @@ -0,0 +1,2 @@ +export * from './trajectory-plugin'; +export * from './types'; diff --git a/apps/midimarble/src/modules/engine/plugins/trajectory/lib/line-state.ts b/apps/midimarble/src/modules/engine/plugins/trajectory/lib/line-state.ts new file mode 100644 index 0000000..7f7939f --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/trajectory/lib/line-state.ts @@ -0,0 +1,90 @@ +import * as THREE from 'three'; +import type { TTrajectoryApp } from '../types'; + +const INITIAL_POINT_CAPACITY = 2; + +export function buildTrajectoryLine(color: string): THREE.Line { + const geometry = new THREE.BufferGeometry(); + const positions = new Float32Array(INITIAL_POINT_CAPACITY * 3); + const attr = new THREE.BufferAttribute(positions, 3); + attr.setUsage(THREE.DynamicDrawUsage); + geometry.setAttribute('position', attr); + geometry.setDrawRange(0, 0); + return new THREE.Line(geometry, new THREE.LineBasicMaterial({ color, linewidth: 2 })); +} + +export function syncTrajectoryLineColors(app: TTrajectoryApp): void { + const config = app.r.trajectoryConfig; + const state = app.r.trajectoryState; + (state.futureLine.material as THREE.LineBasicMaterial).color.set(config.futureColor); + (state.pastLine.material as THREE.LineBasicMaterial).color.set(config.pastColor); + state.futureMarkerMaterial.color.set(config.futureColor); + state.pastMarkerMaterial.color.set(config.pastColor); +} + +export function setTrajectoryLinePoints( + line: THREE.Line, + points: Float32Array, + pointCount: number +): void { + setTrajectoryLinePointSlice(line, points, 0, pointCount); +} + +export function setTrajectoryLinePointSlice( + line: THREE.Line, + points: Float32Array, + startPoint: number, + pointCount: number +): void { + const positions = ensureLineCapacity(line, pointCount); + positions.set(points.subarray(startPoint * 3, (startPoint + pointCount) * 3)); + const attribute = line.geometry.getAttribute('position') as THREE.BufferAttribute; + attribute.needsUpdate = true; + line.geometry.setDrawRange(0, pointCount); +} + +export function clearTrajectoryVisuals(app: TTrajectoryApp): void { + const state = app.r.trajectoryState; + state.pastLine.geometry.setDrawRange(0, 0); + state.futureLine.geometry.setDrawRange(0, 0); + state.sampledPoints = new Float32Array(0); + state.sampledEndStep = -1; + state.projectedTrackId = null; + state.projectedBufferedTick = -1; + state.projectedMarkers = []; + state.styledLiveStep = app.r.liveStep; + clearNoteMarkers(state.noteMarkerGroup, state.noteIdToMarker, state.markerToNoteId); +} + +export function clearNoteMarkers( + group: THREE.Group, + noteIdToMarker: Map, + markerToNoteId: Map +): void { + for (const marker of noteIdToMarker.values()) { + group.remove(marker); + } + + noteIdToMarker.clear(); + markerToNoteId.clear(); +} + +function ensureLineCapacity(line: THREE.Line, pointCount: number): Float32Array { + const attribute = line.geometry.getAttribute('position') as THREE.BufferAttribute; + const current = attribute.array as Float32Array; + if (current.length >= pointCount * 3) { + return current; + } + + const nextCapacity = Math.max( + pointCount, + Math.ceil(current.length / 3) * 2, + INITIAL_POINT_CAPACITY + ); + const next = new Float32Array(nextCapacity * 3); + next.set(current); + const nextAttribute = new THREE.BufferAttribute(next, 3); + nextAttribute.setUsage(THREE.DynamicDrawUsage); + line.geometry.setAttribute('position', nextAttribute); + return next; +} diff --git a/apps/midimarble/src/modules/engine/plugins/trajectory/lib/marker-interaction.test.ts b/apps/midimarble/src/modules/engine/plugins/trajectory/lib/marker-interaction.test.ts new file mode 100644 index 0000000..1ddbbe6 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/trajectory/lib/marker-interaction.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from 'vitest'; +import { applyTrajectoryMarkerSelection } from './marker-interaction'; + +describe('applyTrajectoryMarkerSelection', () => { + it('pauses, seeks, and selects the clicked note', () => { + const pause = vi.fn(); + const seekToTick = vi.fn(); + const selectNote = vi.fn(); + const previewNote = vi.fn(); + const update = vi.fn(); + const updateResource = vi.fn(); + + applyTrajectoryMarkerSelection( + { + r: { + simulationSync: { mode: 'idle' } + }, + pause, + seekToTick, + selectNote, + previewNote, + update, + updateResource + } as never, + 4, + 960 + ); + + expect(updateResource).not.toHaveBeenCalled(); + expect(pause).toHaveBeenCalledOnce(); + expect(seekToTick).toHaveBeenCalledWith(960); + expect(selectNote).toHaveBeenCalledWith(4); + expect(update).toHaveBeenCalledWith(0); + expect(previewNote).toHaveBeenCalledWith(4); + }); + + it('clears resumeWhenReady before selecting while sync is active', () => { + const pause = vi.fn(); + const seekToTick = vi.fn(); + const selectNote = vi.fn(); + const previewNote = vi.fn(); + const update = vi.fn(); + const updateResource = vi.fn(); + const world = {} as never; + const checkpointStore = new Map(); + + applyTrajectoryMarkerSelection( + { + r: { + simulationSync: { + mode: 'rebuilding', + targetStep: 10, + currentStep: 4, + resumeWhenReady: true, + world, + checkpointStore + } + }, + pause, + seekToTick, + selectNote, + previewNote, + update, + updateResource + } as never, + 7, + 480 + ); + + expect(updateResource).toHaveBeenCalledWith('simulationSync', { + mode: 'rebuilding', + targetStep: 10, + currentStep: 4, + resumeWhenReady: false, + world, + checkpointStore + }); + expect(pause).toHaveBeenCalledOnce(); + expect(seekToTick).toHaveBeenCalledWith(480); + expect(selectNote).toHaveBeenCalledWith(7); + expect(update).toHaveBeenCalledWith(0); + expect(previewNote).toHaveBeenCalledWith(7); + }); +}); diff --git a/apps/midimarble/src/modules/engine/plugins/trajectory/lib/marker-interaction.ts b/apps/midimarble/src/modules/engine/plugins/trajectory/lib/marker-interaction.ts new file mode 100644 index 0000000..8515b72 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/trajectory/lib/marker-interaction.ts @@ -0,0 +1,103 @@ +import * as THREE from 'three'; +import type { TTrajectoryApp } from '../types'; + +type TPointerTarget = { + noteId: number; + tick: number; +} | null; + +export function setupTrajectoryMarkerInteraction(app: TTrajectoryApp): () => void { + const raycaster = new THREE.Raycaster(); + const onPointerDown = (event: PointerEvent) => { + const target = pickTrajectoryMarker(app, raycaster, event); + if (target == null) { + return; + } + + event.preventDefault(); + event.stopImmediatePropagation(); + applyTrajectoryMarkerSelection(app, target.noteId, target.tick); + }; + + const canvas = app.r.viewport.domElement; + canvas.addEventListener('pointerdown', onPointerDown); + + return () => { + canvas.removeEventListener('pointerdown', onPointerDown); + }; +} + +export function applyTrajectoryMarkerSelection( + app: Pick< + TTrajectoryApp, + 'pause' | 'seekToTick' | 'selectNote' | 'previewNote' | 'update' | 'updateResource' + > & { + r: Pick; + }, + noteId: number, + tick: number +): void { + if (app.r.simulationSync.mode !== 'idle') { + app.updateResource('simulationSync', { + ...app.r.simulationSync, + resumeWhenReady: false + }); + } + + app.pause(); + app.seekToTick(tick); + app.selectNote(noteId); + app.update(0); + void app.previewNote(noteId); +} + +function pickTrajectoryMarker( + app: TTrajectoryApp, + raycaster: THREE.Raycaster, + event: PointerEvent +): TPointerTarget { + if ( + !app.r.isReady || + !app.r.trajectoryConfig.enabled || + app.r.previewConfig.enabled || + app.r.simulationSync.mode !== 'idle' + ) { + return null; + } + + const pointer = getNormalizedPointer(app, event); + if (pointer == null) { + return null; + } + + raycaster.setFromCamera(pointer, app.r.viewport.camera); + const intersections = raycaster.intersectObjects( + app.r.trajectoryState.noteMarkerGroup.children, + true + ); + for (const intersection of intersections) { + let current: THREE.Object3D | null = intersection.object; + while (current != null) { + const noteId = app.r.trajectoryState.markerToNoteId.get(current); + if (noteId != null) { + const anchor = app.r.trajectoryProjection.noteAnchorsById.get(noteId); + return anchor == null ? null : { noteId, tick: anchor.tick }; + } + current = current.parent; + } + } + + return null; +} + +function getNormalizedPointer(app: TTrajectoryApp, event: PointerEvent): THREE.Vector2 | null { + const rect = app.r.viewport.domElement.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) { + return null; + } + + return new THREE.Vector2( + ((event.clientX - rect.left) / rect.width) * 2 - 1, + -((event.clientY - rect.top) / rect.height) * 2 + 1 + ); +} diff --git a/apps/midimarble/src/modules/engine/plugins/trajectory/lib/note-markers.test.ts b/apps/midimarble/src/modules/engine/plugins/trajectory/lib/note-markers.test.ts new file mode 100644 index 0000000..733944b --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/trajectory/lib/note-markers.test.ts @@ -0,0 +1,258 @@ +import * as THREE from 'three'; +import { describe, expect, it } from 'vitest'; +import { buildMidiLookup } from '../../midi'; +import { + buildProjectedMarkers, + buildTrajectoryMarkerDescriptors, + buildTrajectoryProjection, + syncNoteMarkerPhase, + syncPlacedNoteMarkers +} from './note-markers'; + +const SONG = { + bpm: 120, + ticksPerBeat: 480 +} as const; + +describe('buildTrajectoryMarkerDescriptors', () => { + it('builds past and buffered-future markers only', () => { + const midiLookup = buildMidiLookup({ + name: 'test', + bpm: 120, + ticksPerBeat: 480, + totalTicks: 20, + tracks: [ + { + id: 1, + name: 'Track 1', + notes: [ + { id: 1, tick: 0, durationTicks: 120, noteNumber: 60, velocity: 100, channel: 0 }, + { id: 2, tick: 8, durationTicks: 120, noteNumber: 62, velocity: 100, channel: 0 }, + { id: 3, tick: 20, durationTicks: 120, noteNumber: 64, velocity: 100, channel: 0 } + ] + } + ] + }); + const descriptors = buildTrajectoryMarkerDescriptors(SONG, midiLookup, 1, -1, 16, 1 / 240, { + points: new Float32Array([0, 0, 0, 1, 0, 0, 2, 0, 0]), + endStep: 2 + }); + + expect(descriptors).toEqual([ + { + noteId: 1, + tick: 0, + step: 0, + position: { x: 0, y: 0, z: 0 } + }, + { + noteId: 2, + tick: 8, + step: 2, + position: { x: 2, y: 0, z: 0 } + } + ]); + }); + + it('builds note anchor projection from the same marker descriptors', () => { + const midiLookup = buildMidiLookup({ + name: 'test', + bpm: 120, + ticksPerBeat: 480, + totalTicks: 12, + tracks: [ + { + id: 4, + name: 'Track 4', + notes: [{ id: 4, tick: 12, durationTicks: 120, noteNumber: 65, velocity: 88, channel: 0 }] + } + ] + }); + const descriptors = buildTrajectoryMarkerDescriptors(SONG, midiLookup, 4, -1, 16, 1 / 240, { + points: new Float32Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 2, 1]), + endStep: 3 + }); + + expect(buildTrajectoryProjection(descriptors)).toEqual( + new Map([ + [ + 4, + { + tick: 12, + step: 3, + position: { x: 3, y: 2, z: 1 } + } + ] + ]) + ); + }); + + it('updates only markers that cross the live step boundary', () => { + const past = new THREE.MeshBasicMaterial(); + const pastPlaced = new THREE.MeshBasicMaterial(); + const pastAdjusted = new THREE.MeshBasicMaterial(); + const future = new THREE.MeshBasicMaterial(); + const futurePlaced = new THREE.MeshBasicMaterial(); + const futureAdjusted = new THREE.MeshBasicMaterial(); + const selected = new THREE.MeshBasicMaterial(); + const geometry = new THREE.SphereGeometry(1); + const markerOne = new THREE.Mesh(geometry, past); + const markerTwo = new THREE.Mesh(geometry, future); + const noteIdToMarker = new Map([ + [1, markerOne], + [2, markerTwo] + ]); + const projectedMarkers = buildProjectedMarkers([ + { noteId: 1, tick: 0, step: 0, position: { x: 0, y: 0, z: 0 } }, + { noteId: 2, tick: 8, step: 2, position: { x: 2, y: 0, z: 0 } } + ]); + + syncNoteMarkerPhase( + noteIdToMarker, + new Map([ + [1, { tick: 0, step: 0, position: { x: 0, y: 0, z: 0 } }], + [2, { tick: 8, step: 2, position: { x: 2, y: 0, z: 0 } }] + ]), + projectedMarkers, + 1, + 2, + new Set(), + { + placedNoteIds: new Set(), + adjustedNoteIds: new Set() + }, + { + past, + pastPlaced, + pastAdjusted, + future, + futurePlaced, + futureAdjusted, + selected + } + ); + + expect(markerOne.material).toBe(past); + expect(markerTwo.material).toBe(past); + }); + + it('styles placed markers without scene mutating trajectory internals directly', () => { + const past = new THREE.MeshBasicMaterial(); + const pastPlaced = new THREE.MeshBasicMaterial(); + const pastAdjusted = new THREE.MeshBasicMaterial(); + const future = new THREE.MeshBasicMaterial(); + const futurePlaced = new THREE.MeshBasicMaterial(); + const futureAdjusted = new THREE.MeshBasicMaterial(); + const selected = new THREE.MeshBasicMaterial(); + const noteIdToMarker = new Map([ + [1, new THREE.Mesh(new THREE.SphereGeometry(1), past)], + [2, new THREE.Mesh(new THREE.SphereGeometry(1), future)] + ]); + + syncPlacedNoteMarkers( + noteIdToMarker, + new Map([ + [1, { tick: 0, step: 0, position: { x: 0, y: 0, z: 0 } }], + [2, { tick: 8, step: 2, position: { x: 2, y: 0, z: 0 } }] + ]), + new Set([2]), + 1, + { + placedNoteIds: new Set([1]), + adjustedNoteIds: new Set() + }, + { + past, + pastPlaced, + pastAdjusted, + future, + futurePlaced, + futureAdjusted, + selected + } + ); + + expect((noteIdToMarker.get(1) as THREE.Mesh).material).toBe(pastPlaced); + expect((noteIdToMarker.get(2) as THREE.Mesh).material).toBe(selected); + }); + + it('styles adjusted markers separately from merely placed ones', () => { + const past = new THREE.MeshBasicMaterial(); + const pastPlaced = new THREE.MeshBasicMaterial(); + const pastAdjusted = new THREE.MeshBasicMaterial(); + const future = new THREE.MeshBasicMaterial(); + const futurePlaced = new THREE.MeshBasicMaterial(); + const futureAdjusted = new THREE.MeshBasicMaterial(); + const selected = new THREE.MeshBasicMaterial(); + const noteIdToMarker = new Map([ + [1, new THREE.Mesh(new THREE.SphereGeometry(1), past)], + [2, new THREE.Mesh(new THREE.SphereGeometry(1), future)] + ]); + + syncPlacedNoteMarkers( + noteIdToMarker, + new Map([ + [1, { tick: 0, step: 0, position: { x: 0, y: 0, z: 0 } }], + [2, { tick: 8, step: 2, position: { x: 2, y: 0, z: 0 } }] + ]), + new Set(), + 1, + { + placedNoteIds: new Set([1, 2]), + adjustedNoteIds: new Set([2]) + }, + { + past, + pastPlaced, + pastAdjusted, + future, + futurePlaced, + futureAdjusted, + selected + } + ); + + expect((noteIdToMarker.get(1) as THREE.Mesh).material).toBe(pastPlaced); + expect((noteIdToMarker.get(2) as THREE.Mesh).material).toBe(futureAdjusted); + }); + + it('styles every selected marker, not only the primary note', () => { + const past = new THREE.MeshBasicMaterial(); + const pastPlaced = new THREE.MeshBasicMaterial(); + const pastAdjusted = new THREE.MeshBasicMaterial(); + const future = new THREE.MeshBasicMaterial(); + const futurePlaced = new THREE.MeshBasicMaterial(); + const futureAdjusted = new THREE.MeshBasicMaterial(); + const selected = new THREE.MeshBasicMaterial(); + const noteIdToMarker = new Map([ + [1, new THREE.Mesh(new THREE.SphereGeometry(1), past)], + [2, new THREE.Mesh(new THREE.SphereGeometry(1), future)] + ]); + + syncPlacedNoteMarkers( + noteIdToMarker, + new Map([ + [1, { tick: 0, step: 0, position: { x: 0, y: 0, z: 0 } }], + [2, { tick: 8, step: 2, position: { x: 2, y: 0, z: 0 } }] + ]), + new Set([1, 2]), + 1, + { + placedNoteIds: new Set(), + adjustedNoteIds: new Set() + }, + { + past, + pastPlaced, + pastAdjusted, + future, + futurePlaced, + futureAdjusted, + selected + } + ); + + expect((noteIdToMarker.get(1) as THREE.Mesh).material).toBe(selected); + expect((noteIdToMarker.get(2) as THREE.Mesh).material).toBe(selected); + }); +}); diff --git a/apps/midimarble/src/modules/engine/plugins/trajectory/lib/note-markers.ts b/apps/midimarble/src/modules/engine/plugins/trajectory/lib/note-markers.ts new file mode 100644 index 0000000..7a802bc --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/trajectory/lib/note-markers.ts @@ -0,0 +1,278 @@ +import * as THREE from 'three'; +import type { TVec3 } from '../../../types'; +import { getTrackNotesInTickRange, tickToStep, type TMidiLookup, type TMidiSong } from '../../midi'; +import { trajectoryConfig } from '../config'; +import type { + TTrajectoryNoteAnchor, + TTrajectoryNoteMarkerState, + TTrajectoryProjectedMarker +} from '../types'; +import { getTrajectorySamplePosition, type TTrajectorySampleCache } from './trajectory-samples'; + +export interface TTrajectoryMarkerDescriptor { + noteId: number; + tick: number; + step: number; + position: TVec3; +} + +export function buildTrajectoryProjection( + descriptors: TTrajectoryMarkerDescriptor[] +): Map { + const noteAnchorsById = new Map(); + for (const descriptor of descriptors) { + noteAnchorsById.set(descriptor.noteId, { + tick: descriptor.tick, + step: descriptor.step, + position: descriptor.position + }); + } + return noteAnchorsById; +} + +export function buildProjectedMarkers( + descriptors: TTrajectoryMarkerDescriptor[] +): TTrajectoryProjectedMarker[] { + return descriptors.map(({ noteId, step }) => ({ noteId, step })); +} + +export function buildTrajectoryMarkerDescriptors( + song: Pick | null, + midiLookup: TMidiLookup, + selectedTrackId: number | null, + startTickExclusive: number, + bufferedTick: number, + fixedTimeStepSeconds: number, + sampleCache: TTrajectorySampleCache +): TTrajectoryMarkerDescriptor[] { + if (song == null || selectedTrackId == null) { + return []; + } + + const visibleNotes = getTrackNotesInTickRange( + midiLookup, + selectedTrackId, + startTickExclusive, + bufferedTick + ); + const descriptors: TTrajectoryMarkerDescriptor[] = []; + + for (const note of visibleNotes) { + const step = tickToStep(note.tick, song, fixedTimeStepSeconds); + const position = getTrajectorySamplePosition(sampleCache, step); + if (position == null) { + continue; + } + + descriptors.push({ + noteId: note.id, + tick: note.tick, + step, + position + }); + } + + return descriptors; +} + +export function syncTrajectoryMarkers( + group: THREE.Group, + noteIdToMarker: Map, + markerToNoteId: Map, + material: THREE.Material, + geometry: THREE.BufferGeometry, + descriptors: TTrajectoryMarkerDescriptor[], + removeMissing: boolean = true +): void { + if (removeMissing) { + const nextNoteIds = new Set(descriptors.map((descriptor) => descriptor.noteId)); + + for (const [noteId, marker] of noteIdToMarker) { + if (nextNoteIds.has(noteId)) { + continue; + } + + group.remove(marker); + noteIdToMarker.delete(noteId); + markerToNoteId.delete(marker); + } + } + + for (const descriptor of descriptors) { + const existing = noteIdToMarker.get(descriptor.noteId); + const marker = existing instanceof THREE.Mesh ? existing : new THREE.Mesh(geometry, material); + marker.material = material; + marker.position.set(descriptor.position.x, descriptor.position.y, descriptor.position.z); + marker.scale.setScalar(trajectoryConfig.marker.scale); + if (existing == null) { + group.add(marker); + noteIdToMarker.set(descriptor.noteId, marker); + markerToNoteId.set(marker, descriptor.noteId); + } + } +} + +export function syncPlacedNoteMarkers( + noteIdToMarker: Map, + noteAnchorsById: Map, + selectedNoteIds: ReadonlySet, + liveStep: number, + noteState: TTrajectoryNoteMarkerState, + materials: TTrajectoryMarkerMaterials +): void { + for (const [noteId, marker] of noteIdToMarker) { + const anchor = noteAnchorsById.get(noteId); + if (!(marker instanceof THREE.Mesh) || anchor == null) { + continue; + } + + applyMarkerStyle(marker, noteId, anchor.step, selectedNoteIds, liveStep, noteState, materials); + } +} + +export function syncTrajectoryMarkerDescriptorStyles( + noteIdToMarker: Map, + descriptors: readonly TTrajectoryMarkerDescriptor[], + selectedNoteIds: ReadonlySet, + liveStep: number, + noteState: TTrajectoryNoteMarkerState, + materials: TTrajectoryMarkerMaterials +): void { + for (const descriptor of descriptors) { + const marker = noteIdToMarker.get(descriptor.noteId); + if (!(marker instanceof THREE.Mesh)) { + continue; + } + + applyMarkerStyle( + marker, + descriptor.noteId, + descriptor.step, + selectedNoteIds, + liveStep, + noteState, + materials + ); + } +} + +export function syncNoteMarkerPhase( + noteIdToMarker: Map, + noteAnchorsById: Map, + projectedMarkers: readonly TTrajectoryProjectedMarker[], + previousLiveStep: number, + nextLiveStep: number, + selectedNoteIds: ReadonlySet, + noteState: TTrajectoryNoteMarkerState, + materials: TTrajectoryMarkerMaterials +): void { + if (previousLiveStep === nextLiveStep || projectedMarkers.length === 0) { + return; + } + + const startStep = Math.min(previousLiveStep, nextLiveStep); + const endStep = Math.max(previousLiveStep, nextLiveStep); + const startIndex = upperBoundProjectedMarkerStep(projectedMarkers, startStep); + const endIndex = upperBoundProjectedMarkerStep(projectedMarkers, endStep); + + for (let index = startIndex; index < endIndex; index += 1) { + const projectedMarker = projectedMarkers[index]; + if (projectedMarker == null) { + continue; + } + + const marker = noteIdToMarker.get(projectedMarker.noteId); + const anchor = noteAnchorsById.get(projectedMarker.noteId); + if (!(marker instanceof THREE.Mesh) || anchor == null) { + continue; + } + + applyMarkerStyle( + marker, + projectedMarker.noteId, + anchor.step, + selectedNoteIds, + nextLiveStep, + noteState, + materials + ); + } +} + +interface TTrajectoryMarkerMaterials { + past: THREE.Material; + pastPlaced: THREE.Material; + pastAdjusted: THREE.Material; + future: THREE.Material; + futurePlaced: THREE.Material; + futureAdjusted: THREE.Material; + selected: THREE.Material; +} + +function applyMarkerStyle( + marker: THREE.Mesh, + noteId: number, + step: number, + selectedNoteIds: ReadonlySet, + liveStep: number, + noteState: TTrajectoryNoteMarkerState, + materials: TTrajectoryMarkerMaterials +): void { + marker.material = resolveMarkerMaterial( + noteId, + step, + selectedNoteIds, + liveStep, + noteState, + materials + ); + marker.scale.setScalar( + selectedNoteIds.has(noteId) + ? trajectoryConfig.marker.selectedScale + : trajectoryConfig.marker.scale + ); +} + +function resolveMarkerMaterial( + noteId: number, + step: number, + selectedNoteIds: ReadonlySet, + liveStep: number, + noteState: TTrajectoryNoteMarkerState, + materials: TTrajectoryMarkerMaterials +): THREE.Material { + if (selectedNoteIds.has(noteId)) { + return materials.selected; + } + + const isAdjusted = noteState.adjustedNoteIds.has(noteId); + const isPlaced = noteState.placedNoteIds.has(noteId); + if (step <= liveStep) { + return isAdjusted ? materials.pastAdjusted : isPlaced ? materials.pastPlaced : materials.past; + } + + return isAdjusted + ? materials.futureAdjusted + : isPlaced + ? materials.futurePlaced + : materials.future; +} + +function upperBoundProjectedMarkerStep( + projectedMarkers: readonly TTrajectoryProjectedMarker[], + step: number +): number { + let low = 0; + let high = projectedMarkers.length; + + while (low < high) { + const mid = Math.floor((low + high) / 2); + if ((projectedMarkers[mid]?.step ?? 0) <= step) { + low = mid + 1; + } else { + high = mid; + } + } + + return low; +} diff --git a/apps/midimarble/src/modules/engine/plugins/trajectory/lib/refresh.ts b/apps/midimarble/src/modules/engine/plugins/trajectory/lib/refresh.ts new file mode 100644 index 0000000..8243ba7 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/trajectory/lib/refresh.ts @@ -0,0 +1,46 @@ +import { Added, Or, Removed } from 'ecsify'; +import { stepToTick } from '../../midi'; +import type { TTrajectoryApp } from '../types'; + +export function shouldRebuildTrajectorySamples(app: TTrajectoryApp): boolean { + return ( + app.wasResourceAdded('midiSong') || + app.wasResourceChanged('midiSong') || + app.wasResourceChanged('simulationSync') || + app.wasResourceChanged('fixedTimeStepSeconds') || + app.queryEntities(Or(Added(app.c.TrajectorySourceTag), Removed(app.c.TrajectorySourceTag))) + .length > 0 + ); +} + +export function shouldExtendTrajectorySamples(app: TTrajectoryApp): boolean { + return app.wasResourceChanged('bufferedStep'); +} + +export function shouldRefreshTrajectoryProjection( + app: TTrajectoryApp, + projectedTrackId: number | null, + projectedBufferedTick: number +): boolean { + const nextBufferedTick = + app.r.midiSong == null + ? -1 + : Math.round(stepToTick(app.r.bufferedStep, app.r.midiSong, app.r.fixedTimeStepSeconds)); + + return ( + app.wasResourceChanged('midiSong') || + app.wasResourceChanged('selectedTrackId') || + projectedTrackId !== app.r.selectedTrackId || + projectedBufferedTick !== nextBufferedTick + ); +} + +export function shouldRefreshTrajectoryPresentation(app: TTrajectoryApp): boolean { + return ( + shouldRebuildTrajectorySamples(app) || + shouldExtendTrajectorySamples(app) || + app.wasResourceChanged('previewConfig') || + app.wasResourceChanged('liveStep') || + app.wasResourceChanged('selectedNoteIds') + ); +} diff --git a/apps/midimarble/src/modules/engine/plugins/trajectory/lib/trajectory-samples.test.ts b/apps/midimarble/src/modules/engine/plugins/trajectory/lib/trajectory-samples.test.ts new file mode 100644 index 0000000..fa94343 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/trajectory/lib/trajectory-samples.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { getTrajectoryBufferedStep } from './trajectory-samples'; + +const SONG = { + totalTicks: 16, + bpm: 120, + ticksPerBeat: 480 +} as const; + +describe('getTrajectoryBufferedStep', () => { + it('clamps the buffered step to the song end', () => { + expect(getTrajectoryBufferedStep(SONG, 100, 1 / 240)).toBe(4); + }); + + it('keeps the buffered step unchanged without a song', () => { + expect(getTrajectoryBufferedStep(null, 100, 1 / 240)).toBe(100); + }); +}); diff --git a/apps/midimarble/src/modules/engine/plugins/trajectory/lib/trajectory-samples.ts b/apps/midimarble/src/modules/engine/plugins/trajectory/lib/trajectory-samples.ts new file mode 100644 index 0000000..0216016 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/trajectory/lib/trajectory-samples.ts @@ -0,0 +1,146 @@ +import type * as RAPIER from '@dimforge/rapier3d-compat'; +import type { TVec3 } from '../../../types'; +import { tickToStep, type TMidiSong } from '../../midi'; +import { restoreWorldAtStep } from '../../physics/lib/simulation'; +import type { TTrajectoryApp } from '../types'; + +export interface TTrajectorySampleCache { + points: Float32Array; + endStep: number; +} + +export function rebuildTrajectorySampleCache( + app: TTrajectoryApp, + marbleHandle: number +): TTrajectorySampleCache | null { + const targetBufferedStep = getTrajectoryBufferedStep( + app.r.midiSong, + app.r.bufferedStep, + app.r.fixedTimeStepSeconds + ); + if (targetBufferedStep < 0) { + return { + points: new Float32Array(0), + endStep: -1 + }; + } + + return sampleTrajectorySteps(app, marbleHandle, 0, targetBufferedStep); +} + +export function extendTrajectorySampleCache( + app: TTrajectoryApp, + marbleHandle: number, + cache: TTrajectorySampleCache +): TTrajectorySampleCache | null { + const targetBufferedStep = getTrajectoryBufferedStep( + app.r.midiSong, + app.r.bufferedStep, + app.r.fixedTimeStepSeconds + ); + if (targetBufferedStep <= cache.endStep) { + return cache; + } + if (cache.endStep < 0) { + return rebuildTrajectorySampleCache(app, marbleHandle); + } + + const appended = sampleTrajectorySteps(app, marbleHandle, cache.endStep, targetBufferedStep); + if (appended == null) { + return null; + } + + const nextPoints = new Float32Array((targetBufferedStep + 1) * 3); + nextPoints.set(cache.points); + nextPoints.set(appended.points.subarray(3), cache.points.length); + + return { + points: nextPoints, + endStep: targetBufferedStep + }; +} + +export function getTrajectorySamplePosition( + cache: TTrajectorySampleCache, + step: number +): TVec3 | null { + if (step < 0 || step > cache.endStep) { + return null; + } + + return { + x: cache.points[step * 3] ?? 0, + y: cache.points[step * 3 + 1] ?? 0, + z: cache.points[step * 3 + 2] ?? 0 + }; +} + +export function getTrajectoryBufferedStep( + song: Pick | null, + bufferedStep: number, + fixedTimeStepSeconds: number +): number { + if (song == null) { + return Math.max(0, bufferedStep); + } + + return Math.max( + 0, + Math.min(bufferedStep, tickToStep(song.totalTicks, song, fixedTimeStepSeconds)) + ); +} + +function sampleTrajectorySteps( + app: TTrajectoryApp, + marbleHandle: number, + startStep: number, + endStep: number +): TTrajectorySampleCache | null { + const world = restoreWorldAtStep(app, startStep); + if (world == null) { + return null; + } + + try { + return sampleTrajectoryStepsFromWorld(world, marbleHandle, startStep, endStep); + } finally { + world.free(); + } +} + +function sampleTrajectoryStepsFromWorld( + world: RAPIER.World, + marbleHandle: number, + startStep: number, + endStep: number +): TTrajectorySampleCache | null { + const body = world.getRigidBody(marbleHandle); + if (body == null) { + return null; + } + + const count = Math.max(1, endStep - startStep + 1); + const points = new Float32Array(count * 3); + + for (let step = startStep; step <= endStep; step += 1) { + writePoint(points, step - startStep, body.translation()); + if (step < endStep) { + world.step(); + } + } + + return { + points, + endStep + }; +} + +function writePoint( + buffer: Float32Array, + index: number, + point: { x: number; y: number; z: number } +): void { + buffer[index * 3] = point.x; + buffer[index * 3 + 1] = point.y; + buffer[index * 3 + 2] = point.z; +} diff --git a/apps/midimarble/src/modules/engine/plugins/trajectory/systems.ts b/apps/midimarble/src/modules/engine/plugins/trajectory/systems.ts new file mode 100644 index 0000000..00864e3 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/trajectory/systems.ts @@ -0,0 +1,231 @@ +import { Entity, With } from 'ecsify'; +import { stepToTick } from '../midi'; +import { + clearTrajectoryVisuals, + setTrajectoryLinePointSlice, + syncTrajectoryLineColors +} from './lib/line-state'; +import { + buildProjectedMarkers, + buildTrajectoryMarkerDescriptors, + buildTrajectoryProjection, + syncTrajectoryMarkerDescriptorStyles, + syncTrajectoryMarkers +} from './lib/note-markers'; +import { + shouldExtendTrajectorySamples, + shouldRebuildTrajectorySamples, + shouldRefreshTrajectoryPresentation, + shouldRefreshTrajectoryProjection +} from './lib/refresh'; +import { + extendTrajectorySampleCache, + rebuildTrajectorySampleCache +} from './lib/trajectory-samples'; +import type { TTrajectoryApp } from './types'; + +export function updateTrajectorySystem(app: TTrajectoryApp) { + const config = app.r.trajectoryConfig; + const state = app.r.trajectoryState; + const isPreviewEnabled = app.r.previewConfig.enabled; + + state.futureLine.visible = config.enabled && !isPreviewEnabled; + state.pastLine.visible = config.enabled && !isPreviewEnabled; + state.noteMarkerGroup.visible = config.enabled && !isPreviewEnabled; + + if (!config.enabled || !app.r.isReady) { + clearTrajectoryVisuals(app); + return; + } + + syncTrajectoryLineColors(app); + + if (app.r.simulationSync.mode !== 'idle') { + clearTrajectoryVisuals(app); + return; + } + + const sourceEid = resolveTrajectorySourceEntity(app); + if (sourceEid == null) { + clearTrajectoryVisuals(app); + return; + } + + const marbleBody = app.r.rigidBodies.get(sourceEid); + if (marbleBody == null) { + clearTrajectoryVisuals(app); + return; + } + + let didRebuildSamples = false; + let didExtendSamples = false; + + if (shouldRebuildTrajectorySamples(app) || state.sampledEndStep < 0) { + const nextCache = rebuildTrajectorySampleCache(app, marbleBody.handle); + if (nextCache == null) { + clearTrajectoryVisuals(app); + return; + } + + state.sampledPoints = nextCache.points; + state.sampledEndStep = nextCache.endStep; + didRebuildSamples = true; + } else if (shouldExtendTrajectorySamples(app)) { + const nextCache = extendTrajectorySampleCache(app, marbleBody.handle, { + points: state.sampledPoints, + endStep: state.sampledEndStep + }); + if (nextCache == null) { + clearTrajectoryVisuals(app); + return; + } + + if (nextCache.endStep !== state.sampledEndStep) { + state.sampledPoints = nextCache.points; + state.sampledEndStep = nextCache.endStep; + didExtendSamples = true; + } + } + + if (!shouldRefreshTrajectoryPresentation(app) && !didRebuildSamples && !didExtendSamples) { + return; + } + + if (isPreviewEnabled) { + return; + } + + syncTrajectoryLines(app); + + const bufferedTick = + app.r.midiSong == null + ? -1 + : Math.round(stepToTick(state.sampledEndStep, app.r.midiSong, app.r.fixedTimeStepSeconds)); + const shouldRefreshProjection = + didRebuildSamples || + didExtendSamples || + shouldRefreshTrajectoryProjection(app, state.projectedTrackId, state.projectedBufferedTick); + + if (!shouldRefreshProjection) { + return; + } + + const didRewindProjection = + app.wasResourceChanged('midiSong') || + app.wasResourceChanged('selectedTrackId') || + state.projectedTrackId !== app.r.selectedTrackId || + state.projectedBufferedTick > bufferedTick || + state.projectedBufferedTick < 0; + const markers = buildTrajectoryMarkerDescriptors( + app.r.midiSong, + app.r.midiLookup, + app.r.selectedTrackId, + didRewindProjection ? -1 : state.projectedBufferedTick, + bufferedTick, + app.r.fixedTimeStepSeconds, + { + points: state.sampledPoints, + endStep: state.sampledEndStep + } + ); + + if (didRewindProjection) { + syncTrajectoryMarkers( + state.noteMarkerGroup, + state.noteIdToMarker, + state.markerToNoteId, + state.futureMarkerMaterial, + state.markerGeometry, + markers + ); + state.projectedTrackId = app.r.selectedTrackId; + state.projectedBufferedTick = bufferedTick; + state.projectedMarkers = buildProjectedMarkers(markers); + app.updateResource('trajectoryProjection', { + noteAnchorsById: buildTrajectoryProjection(markers) + }); + syncTrajectoryMarkerDescriptorStyles( + state.noteIdToMarker, + markers, + app.r.selectedNoteIds, + app.r.liveStep, + state.lastNoteMarkerState, + { + past: state.pastMarkerMaterial, + pastPlaced: state.pastPlacedMarkerMaterial, + pastAdjusted: state.pastAdjustedMarkerMaterial, + future: state.futureMarkerMaterial, + futurePlaced: state.futurePlacedMarkerMaterial, + futureAdjusted: state.futureAdjustedMarkerMaterial, + selected: state.selectedMarkerMaterial + } + ); + return; + } + + syncTrajectoryMarkers( + state.noteMarkerGroup, + state.noteIdToMarker, + state.markerToNoteId, + state.futureMarkerMaterial, + state.markerGeometry, + markers, + false + ); + + if (markers.length > 0) { + state.projectedMarkers.push(...buildProjectedMarkers(markers)); + } + state.projectedTrackId = app.r.selectedTrackId; + state.projectedBufferedTick = bufferedTick; + if (markers.length === 0) { + return; + } + const nextAnchors = new Map(app.r.trajectoryProjection.noteAnchorsById); + for (const [noteId, anchor] of buildTrajectoryProjection(markers)) { + nextAnchors.set(noteId, anchor); + } + app.updateResource('trajectoryProjection', { + noteAnchorsById: nextAnchors + }); + syncTrajectoryMarkerDescriptorStyles( + state.noteIdToMarker, + markers, + app.r.selectedNoteIds, + app.r.liveStep, + state.lastNoteMarkerState, + { + past: state.pastMarkerMaterial, + pastPlaced: state.pastPlacedMarkerMaterial, + pastAdjusted: state.pastAdjustedMarkerMaterial, + future: state.futureMarkerMaterial, + futurePlaced: state.futurePlacedMarkerMaterial, + futureAdjusted: state.futureAdjustedMarkerMaterial, + selected: state.selectedMarkerMaterial + } + ); +} + +function syncTrajectoryLines(app: TTrajectoryApp): void { + const state = app.r.trajectoryState; + if (state.sampledEndStep < 0 || state.sampledPoints.length === 0) { + state.pastLine.geometry.setDrawRange(0, 0); + state.futureLine.geometry.setDrawRange(0, 0); + return; + } + + const liveStep = Math.max(0, Math.min(app.r.liveStep, state.sampledEndStep)); + const pastCount = Math.max(0, liveStep + 1); + const futureCount = Math.max(0, state.sampledEndStep - liveStep + 1); + + setTrajectoryLinePointSlice(state.pastLine, state.sampledPoints, 0, pastCount); + setTrajectoryLinePointSlice(state.futureLine, state.sampledPoints, liveStep, futureCount); +} + +function resolveTrajectorySourceEntity(app: TTrajectoryApp): number | null { + for (const [eid] of app.queryComponents([Entity] as const, With(app.c.TrajectorySourceTag))) { + return eid; + } + + return null; +} diff --git a/apps/midimarble/src/modules/engine/plugins/trajectory/trajectory-plugin.ts b/apps/midimarble/src/modules/engine/plugins/trajectory/trajectory-plugin.ts new file mode 100644 index 0000000..b49ee40 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/trajectory/trajectory-plugin.ts @@ -0,0 +1,170 @@ +import * as THREE from 'three'; +import { trajectoryConfig } from './config'; +import { buildTrajectoryLine } from './lib/line-state'; +import { setupTrajectoryMarkerInteraction } from './lib/marker-interaction'; +import { + syncPlacedNoteMarkers, + syncNoteMarkerPhase as syncTrajectoryNoteMarkerPhase +} from './lib/note-markers'; +import { updateTrajectorySystem } from './systems'; +import type { TTrajectoryApp, TTrajectoryNoteMarkerState, TTrajectoryPlugin } from './types'; + +export function createTrajectoryPlugin(options?: { + trajectoryConfig?: { enabled?: boolean; futureColor?: string; pastColor?: string }; +}): TTrajectoryPlugin { + const futureColor = + options?.trajectoryConfig?.futureColor ?? trajectoryConfig.defaults.futureColor; + const pastColor = options?.trajectoryConfig?.pastColor ?? trajectoryConfig.defaults.pastColor; + const { colors } = trajectoryConfig.marker; + const noteMarkerGroup = new THREE.Group(); + const markerGeometry = new THREE.SphereGeometry(1, 14, 14); + const pastMarkerMaterial = new THREE.MeshBasicMaterial({ color: colors.past }); + const pastPlacedMarkerMaterial = new THREE.MeshBasicMaterial({ color: colors.pastPlaced }); + const pastAdjustedMarkerMaterial = new THREE.MeshBasicMaterial({ color: colors.pastAdjusted }); + const futureMarkerMaterial = new THREE.MeshBasicMaterial({ color: colors.future }); + const futurePlacedMarkerMaterial = new THREE.MeshBasicMaterial({ color: colors.futurePlaced }); + const futureAdjustedMarkerMaterial = new THREE.MeshBasicMaterial({ + color: colors.futureAdjusted + }); + const selectedMarkerMaterial = new THREE.MeshBasicMaterial({ color: colors.selected }); + let cleanupMarkerInteraction: (() => void) | null = null; + + return { + // Trajectory is a separate authoring surface driven by physics state. + name: 'Trajectory', + deps: ['Default', 'Core', 'Midi', 'Transport', 'Audio', 'Physics', 'Render'], + components: { + TrajectorySourceTag: [] + }, + resources: { + trajectoryConfig: { + enabled: options?.trajectoryConfig?.enabled ?? trajectoryConfig.defaults.enabled, + futureColor, + pastColor + }, + trajectoryState: { + futureLine: buildTrajectoryLine(futureColor), + pastLine: buildTrajectoryLine(pastColor), + noteMarkerGroup, + noteIdToMarker: new Map(), + markerToNoteId: new Map(), + markerGeometry, + sampledPoints: new Float32Array(0), + sampledEndStep: -1, + projectedTrackId: null, + projectedBufferedTick: -1, + projectedMarkers: [], + styledLiveStep: 0, + lastNoteMarkerState: { + placedNoteIds: new Set(), + adjustedNoteIds: new Set() + }, + pastMarkerMaterial, + pastPlacedMarkerMaterial, + pastAdjustedMarkerMaterial, + futureMarkerMaterial, + futurePlacedMarkerMaterial, + futureAdjustedMarkerMaterial, + selectedMarkerMaterial + }, + trajectoryProjection: { + noteAnchorsById: new Map() + } + }, + appExtensions: { + disposeTrajectory(this: TTrajectoryApp): void { + cleanupMarkerInteraction?.(); + cleanupMarkerInteraction = null; + const state = this.r.trajectoryState; + state.markerGeometry.dispose(); + state.pastMarkerMaterial.dispose(); + state.pastPlacedMarkerMaterial.dispose(); + state.pastAdjustedMarkerMaterial.dispose(); + state.futureMarkerMaterial.dispose(); + state.futurePlacedMarkerMaterial.dispose(); + state.futureAdjustedMarkerMaterial.dispose(); + state.selectedMarkerMaterial.dispose(); + }, + syncNoteMarkers(this: TTrajectoryApp, noteState: TTrajectoryNoteMarkerState): void { + if (this.r.previewConfig.enabled) { + return; + } + + const state = this.r.trajectoryState; + state.lastNoteMarkerState = noteState; + state.styledLiveStep = this.r.liveStep; + syncPlacedNoteMarkers( + state.noteIdToMarker, + this.r.trajectoryProjection.noteAnchorsById, + this.r.selectedNoteIds, + this.r.liveStep, + noteState, + { + past: state.pastMarkerMaterial, + pastPlaced: state.pastPlacedMarkerMaterial, + pastAdjusted: state.pastAdjustedMarkerMaterial, + future: state.futureMarkerMaterial, + futurePlaced: state.futurePlacedMarkerMaterial, + futureAdjusted: state.futureAdjustedMarkerMaterial, + selected: state.selectedMarkerMaterial + } + ); + }, + syncNoteMarkerPhase(this: TTrajectoryApp): void { + if (this.r.previewConfig.enabled) { + return; + } + + const state = this.r.trajectoryState; + if (state.projectedMarkers.length === 0 || state.styledLiveStep === this.r.liveStep) { + state.styledLiveStep = this.r.liveStep; + return; + } + + syncTrajectoryNoteMarkerPhase( + state.noteIdToMarker, + this.r.trajectoryProjection.noteAnchorsById, + state.projectedMarkers, + state.styledLiveStep, + this.r.liveStep, + this.r.selectedNoteIds, + state.lastNoteMarkerState, + { + past: state.pastMarkerMaterial, + pastPlaced: state.pastPlacedMarkerMaterial, + pastAdjusted: state.pastAdjustedMarkerMaterial, + future: state.futureMarkerMaterial, + futurePlaced: state.futurePlacedMarkerMaterial, + futureAdjusted: state.futureAdjustedMarkerMaterial, + selected: state.selectedMarkerMaterial + } + ); + state.styledLiveStep = this.r.liveStep; + }, + updateTrajectoryConfig( + this: TTrajectoryApp, + patch: Partial + ): void { + this.updateResource('trajectoryConfig', { + ...this.r.trajectoryConfig, + ...patch + }); + } + }, + setup(app: TTrajectoryApp) { + const { futureLine, pastLine, noteMarkerGroup } = app.r.trajectoryState; + + const futureEid = app.createEntity(); + app.addComponent(futureEid, app.c.MeshMixin, { type: 'three', object: futureLine }); + + const pastEid = app.createEntity(); + app.addComponent(pastEid, app.c.MeshMixin, { type: 'three', object: pastLine }); + + const markersEid = app.createEntity(); + app.addComponent(markersEid, app.c.MeshMixin, { type: 'three', object: noteMarkerGroup }); + + cleanupMarkerInteraction = setupTrajectoryMarkerInteraction(app); + app.addSystem(updateTrajectorySystem, { set: 'PostUpdate' }); + } + }; +} diff --git a/apps/midimarble/src/modules/engine/plugins/trajectory/types.ts b/apps/midimarble/src/modules/engine/plugins/trajectory/types.ts new file mode 100644 index 0000000..55598c9 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/trajectory/types.ts @@ -0,0 +1,111 @@ +import type { TApp, TAppContext, TDefaultPlugin, TPlugin } from 'ecsify'; +import type * as THREE from 'three'; +import type { TEngineSystemSet, TVec3 } from '../../types'; +import type { TAudioPlugin } from '../audio'; +import type { TCorePlugin } from '../core'; +import type { TMidiPlugin } from '../midi'; +import type { TPhysicsPlugin } from '../physics'; +import type { TRenderPlugin } from '../render/types'; +import type { TTransportPlugin } from '../transport'; + +// MARK: - Plugin + +export type TTrajectoryPlugin = TPlugin< + { + name: 'Trajectory'; + components: { + TrajectorySourceTag: TCTrajectorySourceTag[]; + }; + resources: { + trajectoryConfig: TTrajectoryConfig; + trajectoryState: TTrajectoryState; + trajectoryProjection: TTrajectoryProjection; + }; + appExtensions: { + disposeTrajectory(): void; + syncNoteMarkers(noteState: TTrajectoryNoteMarkerState): void; + syncNoteMarkerPhase(): void; + updateTrajectoryConfig(patch: Partial): void; + }; + systemSets: TEngineSystemSet; + }, + [ + TDefaultPlugin, + TCorePlugin, + TMidiPlugin, + TTransportPlugin, + TAudioPlugin, + TPhysicsPlugin, + TRenderPlugin + ] +>; + +export type TTrajectoryApp = TApp< + TAppContext< + [ + TDefaultPlugin, + TCorePlugin, + TMidiPlugin, + TTransportPlugin, + TAudioPlugin, + TPhysicsPlugin, + TRenderPlugin, + TTrajectoryPlugin + ] + > +>; + +// MARK: - Resources + +export interface TTrajectoryConfig { + enabled: boolean; + futureColor: string; + pastColor: string; +} + +export interface TTrajectoryState { + futureLine: THREE.Line; + pastLine: THREE.Line; + noteMarkerGroup: THREE.Group; + noteIdToMarker: Map; + markerToNoteId: Map; + markerGeometry: THREE.SphereGeometry; + sampledPoints: Float32Array; + sampledEndStep: number; + projectedTrackId: number | null; + projectedBufferedTick: number; + projectedMarkers: TTrajectoryProjectedMarker[]; + styledLiveStep: number; + lastNoteMarkerState: TTrajectoryNoteMarkerState; + pastMarkerMaterial: THREE.MeshBasicMaterial; + pastPlacedMarkerMaterial: THREE.MeshBasicMaterial; + pastAdjustedMarkerMaterial: THREE.MeshBasicMaterial; + futureMarkerMaterial: THREE.MeshBasicMaterial; + futurePlacedMarkerMaterial: THREE.MeshBasicMaterial; + futureAdjustedMarkerMaterial: THREE.MeshBasicMaterial; + selectedMarkerMaterial: THREE.MeshBasicMaterial; +} + +export interface TTrajectoryProjection { + noteAnchorsById: Map; +} + +export interface TTrajectoryNoteAnchor { + tick: number; + step: number; + position: TVec3; +} + +export interface TTrajectoryNoteMarkerState { + placedNoteIds: Set; + adjustedNoteIds: Set; +} + +export interface TTrajectoryProjectedMarker { + noteId: number; + step: number; +} + +// MARK: - Components + +export interface TCTrajectorySourceTag {} diff --git a/apps/midimarble/src/modules/engine/plugins/transport/index.ts b/apps/midimarble/src/modules/engine/plugins/transport/index.ts new file mode 100644 index 0000000..b18461f --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/transport/index.ts @@ -0,0 +1,3 @@ +export * from './lib/transport'; +export * from './transport-plugin'; +export * from './types'; diff --git a/apps/midimarble/src/modules/engine/plugins/transport/lib/transport.ts b/apps/midimarble/src/modules/engine/plugins/transport/lib/transport.ts new file mode 100644 index 0000000..5046f0f --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/transport/lib/transport.ts @@ -0,0 +1,58 @@ +import { clampMidiTick, getSongMaxTick } from '../../midi'; +import type { TTransportApp } from '../types'; + +type TTransportAccess = { + r: Pick; + updateResource: TTransportApp['updateResource']; +}; + +export function updateTransport( + app: TTransportAccess, + patch: Partial +): void { + app.updateResource('transport', { + ...app.r.transport, + ...patch + }); +} + +export function runTransport(app: TTransportAccess): void { + if (app.r.midiSong == null || getSongMaxTick(app.r.midiSong) <= 0) { + return; + } + + if (app.r.transport.playheadTick >= getSongMaxTick(app.r.midiSong)) { + updateTransport(app, { + mode: 'paused', + playheadTick: getSongMaxTick(app.r.midiSong) + }); + return; + } + + updateTransport(app, { mode: 'running' }); +} + +export function pauseTransport(app: TTransportAccess): void { + updateTransport(app, { mode: 'paused' }); +} + +export function resetTransport(app: TTransportAccess): void { + updateTransport(app, { + mode: 'paused', + playheadTick: 0 + }); +} + +export function seekTransportToTick(app: TTransportAccess, tick: number): void { + updateTransport(app, { + playheadTick: clampMidiTick(tick, getSongMaxTick(app.r.midiSong)) + }); +} + +export function stepTransportBackwardTick(app: TTransportAccess): void { + seekTransportToTick(app, app.r.transport.playheadTick - 1); +} + +export function stepTransportForwardTick(app: TTransportAccess): void { + seekTransportToTick(app, app.r.transport.playheadTick + 1); +} diff --git a/apps/midimarble/src/modules/engine/plugins/transport/systems.ts b/apps/midimarble/src/modules/engine/plugins/transport/systems.ts new file mode 100644 index 0000000..5a76655 --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/transport/systems.ts @@ -0,0 +1,53 @@ +import { clampMidiTick, getSongMaxTick, getTicksPerSecond } from '../midi'; +import { updateTransport } from './lib/transport'; +import type { TTransportApp } from './types'; + +export function advanceTransportSystem(app: TTransportApp, dt = 0) { + const song = app.r.midiSong; + if (song == null) { + if (app.r.transport.mode !== 'paused' || app.r.transport.playheadTick !== 0) { + updateTransport(app, { + mode: 'paused', + playheadTick: 0 + }); + } + return; + } + + const maxTick = getSongMaxTick(song); + const clampedTick = clampMidiTick(app.r.transport.playheadTick, maxTick); + if (clampedTick !== app.r.transport.playheadTick) { + updateTransport(app, { + mode: 'paused', + playheadTick: clampedTick + }); + return; + } + + if (app.r.transport.mode !== 'running') { + return; + } + + if (maxTick <= 0) { + updateTransport(app, { + mode: 'paused', + playheadTick: 0 + }); + return; + } + + const deltaSeconds = Math.max(dt, 0); + if (deltaSeconds <= 0) { + return; + } + + const nextTick = clampMidiTick( + app.r.transport.playheadTick + deltaSeconds * getTicksPerSecond(song), + maxTick + ); + + updateTransport(app, { + mode: nextTick >= maxTick ? 'paused' : 'running', + playheadTick: nextTick + }); +} diff --git a/apps/midimarble/src/modules/engine/plugins/transport/transport-plugin.test.ts b/apps/midimarble/src/modules/engine/plugins/transport/transport-plugin.test.ts new file mode 100644 index 0000000..945021d --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/transport/transport-plugin.test.ts @@ -0,0 +1,93 @@ +import { createApp, createDefaultPlugin } from 'ecsify'; +import { describe, expect, it } from 'vitest'; +import { ENGINE_SYSTEM_SETS } from '../../types'; +import { createMidiPlugin } from '../midi'; +import { createTransportPlugin } from './transport-plugin'; + +describe('transport plugin', () => { + it('starts paused at tick 0', () => { + const app = createTransportApp(); + + expect(app.r.transport).toEqual({ + mode: 'paused', + playheadTick: 0 + }); + }); + + it('updates playback mode through run and pause when a song is loaded', () => { + const app = createTransportApp(); + setTestSong(app); + + app.run(); + expect(app.r.transport.mode).toBe('running'); + + app.pause(); + expect(app.r.transport.mode).toBe('paused'); + }); + + it('clamps seek and backward stepping at tick 0', () => { + const app = createTransportApp(); + setTestSong(app); + + app.seekToTick(-4); + expect(app.r.transport.playheadTick).toBe(0); + + app.stepBackwardTick(); + expect(app.r.transport.playheadTick).toBe(0); + }); + + it('steps forward by one tick and resets back to tick 0', () => { + const app = createTransportApp(); + setTestSong(app); + + app.stepForwardTick(); + app.stepForwardTick(); + expect(app.r.transport.playheadTick).toBe(2); + + app.resetTransport(); + expect(app.r.transport).toEqual({ + mode: 'paused', + playheadTick: 0 + }); + }); + + it('advances playhead tick from song timing while running', () => { + const app = createTransportApp(); + setTestSong(app); + + app.run(); + app.update(0.5); + + expect(app.r.transport.playheadTick).toBe(480); + expect(app.r.transport.mode).toBe('running'); + }); + + it('pauses automatically at the end of the song', () => { + const app = createTransportApp(); + setTestSong(app, 120); + + app.run(); + app.update(1); + + expect(app.r.transport.playheadTick).toBe(120); + expect(app.r.transport.mode).toBe('paused'); + }); +}); + +function createTransportApp() { + return createApp({ + plugins: [createDefaultPlugin(), createMidiPlugin(), createTransportPlugin()] as const, + systemSets: [...ENGINE_SYSTEM_SETS] + }); +} + +function setTestSong(app: ReturnType, totalTicks = 960): void { + app.updateResource('midiSong', { + name: 'Test Song', + bpm: 120, + ticksPerBeat: 480, + totalTicks, + tracks: [{ id: 0, name: 'Track 1', notes: [] }] + }); + app.updateResource('selectedTrackId', 0); +} diff --git a/apps/midimarble/src/modules/engine/plugins/transport/transport-plugin.ts b/apps/midimarble/src/modules/engine/plugins/transport/transport-plugin.ts new file mode 100644 index 0000000..d0f0cbe --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/transport/transport-plugin.ts @@ -0,0 +1,47 @@ +import { + pauseTransport, + resetTransport, + runTransport, + seekTransportToTick, + stepTransportBackwardTick, + stepTransportForwardTick +} from './lib/transport'; +import { advanceTransportSystem } from './systems'; +import type { TTransportApp, TTransportPlugin } from './types'; + +export function createTransportPlugin(): TTransportPlugin { + return { + // Transport owns shared playback mode and the tick-first playhead. + name: 'Transport', + deps: ['Default', 'Midi'], + resources: { + transport: { + mode: 'paused', + playheadTick: 0 + } + }, + appExtensions: { + run(this: TTransportApp): void { + runTransport(this); + }, + pause(this: TTransportApp): void { + pauseTransport(this); + }, + resetTransport(this: TTransportApp): void { + resetTransport(this); + }, + stepBackwardTick(this: TTransportApp): void { + stepTransportBackwardTick(this); + }, + stepForwardTick(this: TTransportApp): void { + stepTransportForwardTick(this); + }, + seekToTick(this: TTransportApp, tick: number): void { + seekTransportToTick(this, tick); + } + }, + setup(app: TTransportApp) { + app.addSystem(advanceTransportSystem, { set: 'Update' }); + } + }; +} diff --git a/apps/midimarble/src/modules/engine/plugins/transport/types.ts b/apps/midimarble/src/modules/engine/plugins/transport/types.ts new file mode 100644 index 0000000..808cc5b --- /dev/null +++ b/apps/midimarble/src/modules/engine/plugins/transport/types.ts @@ -0,0 +1,33 @@ +import type { TApp, TAppContext, TDefaultPlugin, TPlugin } from 'ecsify'; +import type { TEngineSystemSet } from '../../types'; +import type { TMidiPlugin } from '../midi'; + +// MARK: - Plugin + +export type TTransportPlugin = TPlugin< + { + name: 'Transport'; + resources: { + transport: TRTransport; + }; + appExtensions: { + run(): void; + pause(): void; + resetTransport(): void; + stepBackwardTick(): void; + stepForwardTick(): void; + seekToTick(tick: number): void; + }; + systemSets: TEngineSystemSet; + }, + [TDefaultPlugin, TMidiPlugin] +>; + +export type TTransportApp = TApp>; + +// MARK: - Resources + +export interface TRTransport { + mode: 'paused' | 'running'; + playheadTick: number; +} diff --git a/apps/midimarble/src/modules/engine/types.ts b/apps/midimarble/src/modules/engine/types.ts new file mode 100644 index 0000000..f8963f4 --- /dev/null +++ b/apps/midimarble/src/modules/engine/types.ts @@ -0,0 +1,16 @@ +export interface TVec3 { + x: number; + y: number; + z: number; +} + +export type TEngineSystemSet = 'First' | 'PreUpdate' | 'Update' | 'PostUpdate' | 'Last' | 'Flush'; + +export const ENGINE_SYSTEM_SETS = [ + 'First', + 'PreUpdate', + 'Update', + 'PostUpdate', + 'Last', + 'Flush' +] as const satisfies readonly TEngineSystemSet[]; diff --git a/apps/midimarble/src/modules/midi/MidiFileCx.tsx b/apps/midimarble/src/modules/midi/MidiFileCx.tsx new file mode 100644 index 0000000..26a0eeb --- /dev/null +++ b/apps/midimarble/src/modules/midi/MidiFileCx.tsx @@ -0,0 +1,143 @@ +import { createState } from 'feature-state'; +import React from 'react'; +import { useMemoCleanup } from '@/hooks'; +import { parseMidi } from './lib'; +import type { MidiSong } from './types'; + +export class MidiFileCx { + public readonly $song = createState(null); + public readonly $selectedTrackId = createState(null); + public readonly $isLoading = createState(false); + public readonly $error = createState(null); + public readonly $playheadTick = createState(0); + public readonly $isPlaying = createState(false); + + private _animationFrameId: number | null = null; + private _lastFrameTime: number | null = null; + + // MARK: - Actions + + public async loadFile(file: File): Promise { + this.stop(); + this.$isLoading.set(true); + this.$error.set(null); + + try { + const song = parseMidi(await file.arrayBuffer(), file.name); + this.$song.set(song); + this.$selectedTrackId.set(song.tracks[0]?.id ?? null); + this.$playheadTick.set(0); + } catch (error) { + this.$song.set(null); + this.$selectedTrackId.set(null); + this.$error.set(error instanceof Error ? error.message : 'Failed to parse MIDI file.'); + } finally { + this.$isLoading.set(false); + } + } + + public selectTrack(trackId: number): void { + this.$selectedTrackId.set(trackId); + } + + public play(): void { + const song = this.$song.get(); + if (song == null || song.totalTicks <= 0) { + return; + } + + if (this.$playheadTick.get() >= song.totalTicks) { + this.$playheadTick.set(0); + } + + this.$isPlaying.set(true); + this._lastFrameTime = null; + this._tick(); + } + + public pause(): void { + this.$isPlaying.set(false); + this._cancelAnimationFrame(); + } + + public stop(): void { + this.pause(); + this.$playheadTick.set(0); + } + + public seekToTick(tick: number): void { + const song = this.$song.get(); + if (song == null) { + return; + } + + this.$playheadTick.set(Math.max(0, Math.min(song.totalTicks, tick))); + } + + private _tick(): void { + this._animationFrameId = requestAnimationFrame((now) => { + if (!this.$isPlaying.get()) { + return; + } + + const song = this.$song.get(); + if (song == null) { + return; + } + + if (this._lastFrameTime != null) { + const ticksPerSecond = (song.ticksPerBeat * song.bpm) / 60; + const elapsedSeconds = (now - this._lastFrameTime) / 1000; + const nextTick = this.$playheadTick.get() + elapsedSeconds * ticksPerSecond; + + if (nextTick >= song.totalTicks) { + this.$playheadTick.set(song.totalTicks); + this.$isPlaying.set(false); + this._cancelAnimationFrame(); + return; + } + + this.$playheadTick.set(nextTick); + } + + this._lastFrameTime = now; + this._tick(); + }); + } + + private _cancelAnimationFrame(): void { + if (this._animationFrameId != null) { + cancelAnimationFrame(this._animationFrameId); + } + + this._animationFrameId = null; + this._lastFrameTime = null; + } + + // MARK: - Effects + + public unmount(): void { + this._cancelAnimationFrame(); + } +} + +const ReactMidiFileCx = React.createContext(null); + +export const MidiFileCxProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const cx = useMemoCleanup(() => { + const midiFileCx = new MidiFileCx(); + return [midiFileCx, () => midiFileCx.unmount()]; + }, []); + + // MARK: - UI + + return {children}; +}; + +export function useMidiFileCx(): MidiFileCx { + const cx = React.useContext(ReactMidiFileCx); + if (cx == null) { + throw new Error('useMidiFileCx must be used within MidiFileCxProvider'); + } + return cx; +} diff --git a/apps/midimarble/src/modules/midi/MidiViewportCx.tsx b/apps/midimarble/src/modules/midi/MidiViewportCx.tsx new file mode 100644 index 0000000..5a44fd2 --- /dev/null +++ b/apps/midimarble/src/modules/midi/MidiViewportCx.tsx @@ -0,0 +1,146 @@ +import { createState } from 'feature-state'; +import React from 'react'; +import { useMemoCleanup } from '@/hooks'; +import { midiConfig, MidiLayout } from './lib'; +import type { MidiSong, MidiViewportRect } from './types'; + +export class MidiViewportCx { + public readonly scrollContainerRef = React.createRef(); + public readonly $containerRect = createState({ width: 0, height: 0, left: 0 }); + public readonly $scrollLeft = createState(0); + public readonly $scrollTop = createState(0); + public readonly $pixelsPerBeat = createState(midiConfig.zoom.defaultPixelsPerBeat); + + public isProgrammaticScroll = false; + + public get containerWidth(): number { + return this.$containerRect.get().width; + } + + // MARK: - Actions + + public setContainerRect(rect: MidiViewportRect): void { + this.$containerRect.set(rect); + } + + public setScrollPosition(scrollLeft: number, scrollTop: number): void { + this.$scrollLeft.set(Math.max(0, scrollLeft)); + this.$scrollTop.set(Math.max(0, scrollTop)); + } + + public setPixelsPerBeat(value: number): void { + const clamped = Math.max( + midiConfig.zoom.minPixelsPerBeat, + Math.min(midiConfig.zoom.maxPixelsPerBeat, value) + ); + if (this.$pixelsPerBeat.get() !== clamped) { + this.$pixelsPerBeat.set(clamped); + } + } + + public zoomIn(): void { + this.setPixelsPerBeat(this.$pixelsPerBeat.get() * midiConfig.zoom.stepFactor); + } + + public zoomOut(): void { + this.setPixelsPerBeat(this.$pixelsPerBeat.get() / midiConfig.zoom.stepFactor); + } + + public zoomAtClientX(song: MidiSong, clientX: number, notesLeft: number, factor: number): void { + const currentPixelsPerBeat = this.$pixelsPerBeat.get(); + const nextPixelsPerBeat = Math.max( + midiConfig.zoom.minPixelsPerBeat, + Math.min(midiConfig.zoom.maxPixelsPerBeat, currentPixelsPerBeat * factor) + ); + + if (nextPixelsPerBeat === currentPixelsPerBeat || song.ticksPerBeat <= 0) { + return; + } + + const mouseX = clientX - notesLeft; + const currentLayout = new MidiLayout(currentPixelsPerBeat, song.ticksPerBeat); + const tickAtPointer = currentLayout.pxToTick(this.$scrollLeft.get() + Math.max(0, mouseX)); + const nextLayout = new MidiLayout(nextPixelsPerBeat, song.ticksPerBeat); + const nextScrollLeft = this.clampScrollLeft( + song, + Math.max(0, nextLayout.tickToPx(tickAtPointer) - mouseX), + nextPixelsPerBeat + ); + + this.$pixelsPerBeat.set(nextPixelsPerBeat); + this.syncScroll(song, nextScrollLeft, this.$scrollTop.get()); + } + + public getLayout(song: MidiSong): MidiLayout { + return new MidiLayout(this.$pixelsPerBeat.get(), song.ticksPerBeat); + } + + public getNotesWidth(song: MidiSong, pixelsPerBeat = this.$pixelsPerBeat.get()): number { + return new MidiLayout(pixelsPerBeat, song.ticksPerBeat).notesWidth(song.totalTicks); + } + + public getInnerWidth(song: MidiSong, pixelsPerBeat = this.$pixelsPerBeat.get()): number { + const layout = new MidiLayout(pixelsPerBeat, song.ticksPerBeat); + return ( + midiConfig.layout.keyboardWidth + layout.notesWidth(song.totalTicks) + layout.endPadding() + ); + } + + public getMaxScrollLeft(song: MidiSong, pixelsPerBeat = this.$pixelsPerBeat.get()): number { + return Math.max(0, this.getInnerWidth(song, pixelsPerBeat) - this.containerWidth); + } + + public clampScrollLeft( + song: MidiSong, + scrollLeft: number, + pixelsPerBeat = this.$pixelsPerBeat.get() + ): number { + return Math.max(0, Math.min(this.getMaxScrollLeft(song, pixelsPerBeat), scrollLeft)); + } + + public syncScroll(song: MidiSong, scrollLeft: number, scrollTop: number): void { + const nextScrollLeft = this.clampScrollLeft(song, scrollLeft); + const nextScrollTop = Math.max(0, scrollTop); + + this.isProgrammaticScroll = true; + this.$scrollLeft.set(nextScrollLeft); + this.$scrollTop.set(nextScrollTop); + + const container = this.scrollContainerRef.current; + if (container != null) { + container.scrollLeft = nextScrollLeft; + container.scrollTop = nextScrollTop; + } + + requestAnimationFrame(() => { + this.isProgrammaticScroll = false; + }); + } + + // MARK: - Effects + + public unmount(): void { + this.isProgrammaticScroll = false; + } +} + +const ReactMidiViewportCx = React.createContext(null); + +export const MidiViewportCxProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const cx = useMemoCleanup(() => { + const midiViewportCx = new MidiViewportCx(); + return [midiViewportCx, () => midiViewportCx.unmount()]; + }, []); + + // MARK: - UI + + return {children}; +}; + +export function useMidiViewportCx(): MidiViewportCx { + const cx = React.useContext(ReactMidiViewportCx); + if (cx == null) { + throw new Error('useMidiViewportCx must be used within MidiViewportCxProvider'); + } + return cx; +} diff --git a/apps/midimarble/src/modules/midi/components/MidiPianoKeys.tsx b/apps/midimarble/src/modules/midi/components/MidiPianoKeys.tsx new file mode 100644 index 0000000..8e88254 --- /dev/null +++ b/apps/midimarble/src/modules/midi/components/MidiPianoKeys.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { getMidiNoteName, isMidiBlackKey, isMidiCNote, midiConfig } from '../lib'; + +const { keyboardWidth, noteHeight, totalNotes } = midiConfig.layout; +const blackKeyWidth = Math.round(keyboardWidth * 0.64); + +export const MidiPianoKeys: React.FC = React.memo(({ accentColor }) => ( +
+ {Array.from({ length: totalNotes }, (_, index) => { + const noteNumber = totalNotes - 1 - index; + return ; + })} +
+)); + +MidiPianoKeys.displayName = 'MidiPianoKeys'; + +const MidiPianoKeyRow: React.FC = ({ accentColor, noteNumber }) => { + if (isMidiBlackKey(noteNumber)) { + return ( +
+
+ +
+
+ ); + } + + const isC = isMidiCNote(noteNumber); + + return ( +
+ {isC && ( + <> +
+ + + {getMidiNoteName(noteNumber)} + + + )} +
+ ); +}; + +interface TMidiPianoKeysProps { + accentColor: string; +} + +interface TMidiPianoKeyRowProps extends TMidiPianoKeysProps { + noteNumber: number; +} diff --git a/apps/midimarble/src/modules/midi/components/MidiPianoRoll.tsx b/apps/midimarble/src/modules/midi/components/MidiPianoRoll.tsx new file mode 100644 index 0000000..53247f5 --- /dev/null +++ b/apps/midimarble/src/modules/midi/components/MidiPianoRoll.tsx @@ -0,0 +1,337 @@ +import { useFeatureState, useListener } from 'feature-react'; +import React from 'react'; +import { isMidiBlackKey, isMidiCNote, midiConfig, MidiLayout } from '../lib'; +import { useMidiFileCx } from '../MidiFileCx'; +import { useMidiViewportCx } from '../MidiViewportCx'; +import type { MidiTrack } from '../types'; +import { MidiPianoKeys } from './MidiPianoKeys'; + +const { defaultScrollNote, keyboardWidth, noteHeight, totalNotes } = midiConfig.layout; +const totalHeight = totalNotes * noteHeight; + +export const MidiPianoRoll: React.FC = () => { + const midiFileCx = useMidiFileCx(); + const midiViewportCx = useMidiViewportCx(); + + const song = useFeatureState(midiFileCx.$song); + const selectedTrackId = useFeatureState(midiFileCx.$selectedTrackId); + + const scrollRef = midiViewportCx.scrollContainerRef; + const innerRef = React.useRef(null); + const gridRef = React.useRef(null); + const playheadRef = React.useRef(null); + + const layout = React.useMemo( + () => (song == null ? null : midiViewportCx.getLayout(song)), + [midiViewportCx, song] + ); + + const notesWidth = layout == null || song == null ? 0 : layout.notesWidth(song.totalTicks); + const endPadding = layout?.endPadding() ?? 0; + const selectedTrack = React.useMemo( + () => song?.tracks.find((track) => track.id === selectedTrackId) ?? song?.tracks[0] ?? null, + [selectedTrackId, song] + ); + + // MARK: - Actions + + const handleScroll = React.useCallback( + (event: React.UIEvent) => { + if (midiViewportCx.isProgrammaticScroll) { + return; + } + + midiViewportCx.setScrollPosition( + event.currentTarget.scrollLeft, + event.currentTarget.scrollTop + ); + }, + [midiViewportCx] + ); + + // MARK: - Effects + + React.useEffect(() => { + const container = scrollRef.current; + if (container == null) { + return; + } + + const updateContainerRect = () => { + const rect = container.getBoundingClientRect(); + midiViewportCx.setContainerRect({ width: rect.width, height: rect.height, left: rect.left }); + }; + + const observer = new ResizeObserver(() => { + updateContainerRect(); + + const currentSong = midiFileCx.$song.get(); + if (currentSong == null) { + return; + } + + const nextScrollLeft = midiViewportCx.clampScrollLeft(currentSong, container.scrollLeft); + if (Math.abs(nextScrollLeft - container.scrollLeft) > 1) { + midiViewportCx.syncScroll(currentSong, nextScrollLeft, container.scrollTop); + } + }); + + updateContainerRect(); + observer.observe(container); + return () => observer.disconnect(); + }, [midiFileCx, midiViewportCx, scrollRef]); + + useListener( + midiViewportCx.$pixelsPerBeat, + ({ value }) => { + const currentSong = midiFileCx.$song.get(); + const container = scrollRef.current; + if (currentSong == null || innerRef.current == null) { + return; + } + + const nextLayout = new MidiLayout(value, currentSong.ticksPerBeat); + innerRef.current.style.width = `${keyboardWidth + nextLayout.notesWidth(currentSong.totalTicks)}px`; + innerRef.current.style.paddingRight = `${nextLayout.endPadding()}px`; + + if (gridRef.current != null) { + gridRef.current.style.backgroundSize = `${value * 4}px 100%, ${value}px 100%`; + } + + if (container == null) { + return; + } + + const nextScrollLeft = midiViewportCx.clampScrollLeft( + currentSong, + container.scrollLeft, + value + ); + if (Math.abs(nextScrollLeft - container.scrollLeft) > 1) { + midiViewportCx.syncScroll(currentSong, nextScrollLeft, container.scrollTop); + } + }, + [midiFileCx, midiViewportCx, scrollRef] + ); + + React.useEffect(() => { + if (song == null) { + return; + } + + const container = scrollRef.current; + if (container == null) { + return; + } + + const nextScrollTop = Math.max( + 0, + (totalNotes - 1 - defaultScrollNote) * noteHeight - container.clientHeight / 2 + ); + midiViewportCx.syncScroll(song, 0, nextScrollTop); + }, [midiViewportCx, scrollRef, song]); + + React.useEffect(() => { + const container = scrollRef.current; + if (container == null) { + return; + } + + const handleWheel = (event: WheelEvent) => { + if (!event.ctrlKey && !event.metaKey) { + return; + } + + const currentSong = midiFileCx.$song.get(); + if (currentSong == null) { + return; + } + + event.preventDefault(); + midiViewportCx.zoomAtClientX( + currentSong, + event.clientX, + container.getBoundingClientRect().left + keyboardWidth, + event.deltaY > 0 ? 1 / midiConfig.zoom.wheelFactor : midiConfig.zoom.wheelFactor + ); + }; + + container.addEventListener('wheel', handleWheel, { passive: false }); + return () => container.removeEventListener('wheel', handleWheel); + }, [midiFileCx, midiViewportCx, scrollRef]); + + React.useEffect(() => { + if (song == null) { + return; + } + + const updatePlayhead = () => { + const playhead = playheadRef.current; + const container = scrollRef.current; + const currentSong = midiFileCx.$song.get(); + if (playhead == null || container == null || currentSong == null) { + return; + } + + const playheadTick = midiFileCx.$playheadTick.get(); + playhead.style.left = `${(playheadTick / Math.max(1, currentSong.totalTicks)) * 100}%`; + + if (!midiFileCx.$isPlaying.get()) { + return; + } + + const currentLayout = midiViewportCx.getLayout(currentSong); + const playheadX = currentLayout.tickToPx(playheadTick); + const viewportRight = + midiViewportCx.$scrollLeft.get() + container.clientWidth - keyboardWidth; + const margin = container.clientWidth * 0.22; + + if (playheadX < midiViewportCx.$scrollLeft.get() || playheadX > viewportRight - margin) { + midiViewportCx.syncScroll( + currentSong, + Math.max(0, playheadX - margin), + container.scrollTop + ); + } + }; + + updatePlayhead(); + return midiFileCx.$playheadTick.listen(updatePlayhead); + }, [midiFileCx, midiViewportCx, scrollRef, song]); + + // MARK: - UI + + if (song == null || layout == null) { + return null; + } + + return ( +
+
+ + +
+ + + + {song.tracks + .filter((track) => track.id !== selectedTrackId) + .map((track) => ( + + ))} + + {song.tracks + .filter((track) => track.id === selectedTrackId) + .map((track) => ( + + ))} + +
+
+
+
+ ); +}; + +const LaneBackground: React.FC = React.memo(() => ( +
+ {Array.from({ length: totalNotes }, (_, index) => { + const noteNumber = totalNotes - 1 - index; + return ( +
+ ); + })} +
+)); + +LaneBackground.displayName = 'LaneBackground'; + +const GridLines = React.forwardRef(({ pixelsPerBeat }, ref) => ( +
+)); + +GridLines.displayName = 'GridLines'; + +const TrackNotes: React.FC = React.memo(({ isSelected, totalTicks, track }) => ( +
+ {track.notes.map((note) => ( +
+ ))} +
+)); + +TrackNotes.displayName = 'TrackNotes'; + +interface TGridLinesProps { + pixelsPerBeat: number; +} + +interface TTrackNotesProps { + isSelected: boolean; + totalTicks: number; + track: MidiTrack; +} diff --git a/apps/midimarble/src/modules/midi/components/MidiSidebar.tsx b/apps/midimarble/src/modules/midi/components/MidiSidebar.tsx new file mode 100644 index 0000000..2b09cb4 --- /dev/null +++ b/apps/midimarble/src/modules/midi/components/MidiSidebar.tsx @@ -0,0 +1,77 @@ +import { useFeatureState } from 'feature-react'; +import React from 'react'; +import { formatMidiChannel, getMidiNoteName, midiConfig } from '../lib'; +import { useMidiFileCx } from '../MidiFileCx'; + +export const MidiSidebar: React.FC = () => { + const midiFileCx = useMidiFileCx(); + + const song = useFeatureState(midiFileCx.$song); + const selectedTrackId = useFeatureState(midiFileCx.$selectedTrackId); + + // MARK: - Actions + + const handleSelectTrack = React.useCallback( + (trackId: number) => { + midiFileCx.selectTrack(trackId); + }, + [midiFileCx] + ); + + // MARK: - UI + + if (song == null) { + return null; + } + + return ( + + ); +}; diff --git a/apps/midimarble/src/modules/midi/components/MidiTimeline.tsx b/apps/midimarble/src/modules/midi/components/MidiTimeline.tsx new file mode 100644 index 0000000..77ee477 --- /dev/null +++ b/apps/midimarble/src/modules/midi/components/MidiTimeline.tsx @@ -0,0 +1,174 @@ +import { useFeatureState, useListener } from 'feature-react'; +import React from 'react'; +import { midiConfig, MidiLayout } from '../lib'; +import { useMidiFileCx } from '../MidiFileCx'; +import { useMidiViewportCx } from '../MidiViewportCx'; + +const beatsPerMeasure = 4; + +export const MidiTimeline: React.FC = () => { + const midiFileCx = useMidiFileCx(); + const midiViewportCx = useMidiViewportCx(); + + const song = useFeatureState(midiFileCx.$song); + const [showMinorLabels, setShowMinorLabels] = React.useState( + midiViewportCx.$pixelsPerBeat.get() >= 56 + ); + + const scrollRef = React.useRef(null); + const innerRef = React.useRef(null); + const markerRefs = React.useRef>(new Map()); + + const layout = React.useMemo( + () => (song == null ? null : midiViewportCx.getLayout(song)), + [midiViewportCx, song] + ); + + // MARK: - Actions + + const updateMarkerPosition = React.useCallback( + (beat: number, element: HTMLDivElement) => { + element.style.left = `${beat * midiViewportCx.$pixelsPerBeat.get()}px`; + }, + [midiViewportCx] + ); + + const updateTimelineGeometry = React.useCallback( + (pixelsPerBeat: number) => { + const currentSong = midiFileCx.$song.get(); + if (currentSong == null) { + return; + } + + const nextLayout = new MidiLayout(pixelsPerBeat, currentSong.ticksPerBeat); + if (innerRef.current != null) { + innerRef.current.style.width = `${nextLayout.notesWidth(currentSong.totalTicks)}px`; + innerRef.current.style.paddingRight = `${nextLayout.endPadding()}px`; + } + + for (const [beat, element] of markerRefs.current) { + updateMarkerPosition(beat, element); + } + }, + [midiFileCx, updateMarkerPosition] + ); + + const setMarkerRef = React.useCallback( + (beat: number, element: HTMLDivElement | null) => { + if (element == null) { + markerRefs.current.delete(beat); + return; + } + + markerRefs.current.set(beat, element); + updateMarkerPosition(beat, element); + }, + [updateMarkerPosition] + ); + + const handleSeek = React.useCallback( + (event: React.MouseEvent) => { + const currentSong = midiFileCx.$song.get(); + if (currentSong == null) { + return; + } + + const currentLayout = midiViewportCx.getLayout(currentSong); + const x = + event.clientX - + event.currentTarget.getBoundingClientRect().left + + midiViewportCx.$scrollLeft.get(); + midiFileCx.seekToTick(currentLayout.pxToTick(x)); + }, + [midiFileCx, midiViewportCx] + ); + + // MARK: - Effects + + useListener( + midiViewportCx.$pixelsPerBeat, + ({ value }) => { + updateTimelineGeometry(value); + + const nextShowMinorLabels = value >= 56; + setShowMinorLabels((previousValue) => + previousValue === nextShowMinorLabels ? previousValue : nextShowMinorLabels + ); + }, + [updateTimelineGeometry] + ); + + useListener( + midiViewportCx.$scrollLeft, + ({ value }) => { + if (scrollRef.current != null) { + scrollRef.current.scrollLeft = value; + } + }, + [midiViewportCx] + ); + + React.useEffect(() => { + updateTimelineGeometry(midiViewportCx.$pixelsPerBeat.get()); + }, [midiViewportCx, song, updateTimelineGeometry, showMinorLabels]); + + // MARK: - UI + + if (song == null || layout == null) { + return null; + } + + return ( +
+
+ +
+
+ {Array.from({ length: song.totalBeats + 1 }, (_, beat) => { + const isMeasure = beat % beatsPerMeasure === 0; + if (!isMeasure && !showMinorLabels) { + return null; + } + + return ( +
setMarkerRef(beat, element)} + className="absolute inset-y-0" + > +
+ + + {isMeasure ? Math.floor(beat / beatsPerMeasure) + 1 : beat + 1} + +
+ ); + })} +
+
+
+ ); +}; diff --git a/apps/midimarble/src/modules/midi/components/MidiToolbar.tsx b/apps/midimarble/src/modules/midi/components/MidiToolbar.tsx new file mode 100644 index 0000000..d07c6cd --- /dev/null +++ b/apps/midimarble/src/modules/midi/components/MidiToolbar.tsx @@ -0,0 +1,135 @@ +import { useFeatureState } from 'feature-react'; +import React from 'react'; +import { formatMidiDuration } from '../lib'; +import { useMidiFileCx } from '../MidiFileCx'; +import { useMidiViewportCx } from '../MidiViewportCx'; + +export const MidiToolbar: React.FC = () => { + const midiFileCx = useMidiFileCx(); + const midiViewportCx = useMidiViewportCx(); + + const song = useFeatureState(midiFileCx.$song); + const isPlaying = useFeatureState(midiFileCx.$isPlaying); + const playheadTick = useFeatureState(midiFileCx.$playheadTick); + const pixelsPerBeat = useFeatureState(midiViewportCx.$pixelsPerBeat); + + const inputRef = React.useRef(null); + const playheadSeconds = + song == null || song.ticksPerBeat <= 0 + ? 0 + : (playheadTick / song.ticksPerBeat) * (60 / song.bpm); + + // MARK: - Actions + + const handleTogglePlayback = React.useCallback(() => { + if (isPlaying) { + midiFileCx.pause(); + return; + } + + midiFileCx.play(); + }, [isPlaying, midiFileCx]); + + const handleOpenFilePicker = React.useCallback(() => { + inputRef.current?.click(); + }, []); + + // MARK: - UI + + if (song == null) { + return null; + } + + return ( +
+ + {song.name} + + + + + midiFileCx.stop()}> + ■ + + + {isPlaying ? '⏸' : '▶'} + + + + + + {formatMidiDuration(playheadSeconds)} + + {song.bpm} BPM + +
+ + midiViewportCx.zoomOut()}> + − + + + {Math.round(pixelsPerBeat)}px/b + + midiViewportCx.zoomIn()}> + + + + + + + + + { + const file = event.target.files?.[0]; + if (file != null) { + void midiFileCx.loadFile(file); + } + event.target.value = ''; + }} + /> +
+ ); +}; + +const Divider: React.FC = () =>
; + +const ToolbarButton: React.FC = ({ + children, + isActive = false, + label, + onClick +}) => { + return ( + + ); +}; + +interface TToolbarButtonProps { + children: React.ReactNode; + isActive?: boolean; + label: string; + onClick: () => void; +} diff --git a/apps/midimarble/src/modules/midi/components/MidiViewer.tsx b/apps/midimarble/src/modules/midi/components/MidiViewer.tsx new file mode 100644 index 0000000..31dc4ca --- /dev/null +++ b/apps/midimarble/src/modules/midi/components/MidiViewer.tsx @@ -0,0 +1,143 @@ +import { useFeatureState } from 'feature-react'; +import React from 'react'; +import { Group, Panel, Separator } from 'react-resizable-panels'; +import { MidiFileCxProvider, useMidiFileCx } from '../MidiFileCx'; +import { MidiViewportCxProvider } from '../MidiViewportCx'; +import { MidiPianoRoll } from './MidiPianoRoll'; +import { MidiSidebar } from './MidiSidebar'; +import { MidiTimeline } from './MidiTimeline'; +import { MidiToolbar } from './MidiToolbar'; + +export const MidiViewer: React.FC = ({ className, style }) => { + return ( + + +
+ +
+
+
+ ); +}; + +const InnerMidiViewer: React.FC = () => { + const midiFileCx = useMidiFileCx(); + + const song = useFeatureState(midiFileCx.$song); + const isLoading = useFeatureState(midiFileCx.$isLoading); + const error = useFeatureState(midiFileCx.$error); + + // MARK: - UI + + if (song == null) { + return ; + } + + return ( + <> + + + + + + + +
+ + +
+
+
+ + ); +}; + +const MidiEmptyState: React.FC = ({ error, isLoading }) => { + const midiFileCx = useMidiFileCx(); + + const [isDragging, setIsDragging] = React.useState(false); + const inputRef = React.useRef(null); + + // MARK: - Actions + + const handleOpenFilePicker = React.useCallback(() => { + inputRef.current?.click(); + }, []); + + const handleDragOver = React.useCallback((event: React.DragEvent) => { + event.preventDefault(); + setIsDragging(true); + }, []); + + const handleDragLeave = React.useCallback(() => { + setIsDragging(false); + }, []); + + const handleDrop = React.useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + setIsDragging(false); + const file = event.dataTransfer.files[0]; + if (file != null) { + void midiFileCx.loadFile(file); + } + }, + [midiFileCx] + ); + + // MARK: - UI + + return ( +
+
+ {isLoading ? ( + Parsing... + ) : ( + <> + 🎹 +

Drop a MIDI file or click to open

+

+ Compact piano roll viewer for the marble workflow +

+ {error != null &&

{error}

} + + )} + + { + const file = event.target.files?.[0]; + if (file != null) { + void midiFileCx.loadFile(file); + } + event.target.value = ''; + }} + /> +
+
+ ); +}; + +interface TMidiViewerProps { + className?: string; + style?: React.CSSProperties; +} + +interface TMidiEmptyStateProps { + error: string | null; + isLoading: boolean; +} diff --git a/apps/midimarble/src/modules/midi/components/index.ts b/apps/midimarble/src/modules/midi/components/index.ts new file mode 100644 index 0000000..5c18df9 --- /dev/null +++ b/apps/midimarble/src/modules/midi/components/index.ts @@ -0,0 +1,6 @@ +export * from './MidiPianoRoll'; +export * from './MidiPianoKeys'; +export * from './MidiSidebar'; +export * from './MidiTimeline'; +export * from './MidiToolbar'; +export * from './MidiViewer'; diff --git a/apps/midimarble/src/modules/midi/index.ts b/apps/midimarble/src/modules/midi/index.ts new file mode 100644 index 0000000..65a95c1 --- /dev/null +++ b/apps/midimarble/src/modules/midi/index.ts @@ -0,0 +1,5 @@ +export * from './components'; +export * from './lib'; +export * from './MidiFileCx'; +export * from './MidiViewportCx'; +export * from './types'; diff --git a/apps/midimarble/src/modules/midi/lib/index.ts b/apps/midimarble/src/modules/midi/lib/index.ts new file mode 100644 index 0000000..f858ca4 --- /dev/null +++ b/apps/midimarble/src/modules/midi/lib/index.ts @@ -0,0 +1,3 @@ +export * from './midi-config'; +export * from './midi-layout'; +export * from './midi-parser'; diff --git a/apps/midimarble/src/modules/midi/lib/midi-config.ts b/apps/midimarble/src/modules/midi/lib/midi-config.ts new file mode 100644 index 0000000..f83ade9 --- /dev/null +++ b/apps/midimarble/src/modules/midi/lib/midi-config.ts @@ -0,0 +1,37 @@ +export const midiConfig = { + layout: { + noteHeight: 16, + keyboardWidth: 64, + rulerHeight: 32, + sidebarWidth: 140, + totalNotes: 128, + defaultScrollNote: 72, + endPaddingBeats: 6 + }, + zoom: { + defaultPixelsPerBeat: 80, + minPixelsPerBeat: 20, + maxPixelsPerBeat: 600, + stepFactor: 1.25, + wheelFactor: 1.15 + }, + colors: { + trackPalette: [ + '#4f6ef7', + '#22c55e', + '#f59e0b', + '#ef4444', + '#8b5cf6', + '#06b6d4', + '#f97316', + '#ec4899', + '#84cc16', + '#10b981' + ], + pianoWhite: '#fbfcff', + pianoWhiteC: '#f2f6ff', + pianoBlack: 'linear-gradient(to right, #212121 0%, #605d5d 89.5%, #303030 92.4%, #000000 100%)', + noteSelectedOpacity: 0.9, + noteGhostOpacity: 0.15 + } +} as const; diff --git a/apps/midimarble/src/modules/midi/lib/midi-layout.ts b/apps/midimarble/src/modules/midi/lib/midi-layout.ts new file mode 100644 index 0000000..c2bf810 --- /dev/null +++ b/apps/midimarble/src/modules/midi/lib/midi-layout.ts @@ -0,0 +1,61 @@ +import { midiConfig } from './midi-config'; + +const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; + +export class MidiLayout { + public constructor( + public readonly pixelsPerBeat: number, + public readonly ticksPerBeat: number + ) {} + + public get pixelsPerTick(): number { + return this.pixelsPerBeat / Math.max(1, this.ticksPerBeat); + } + + public tickToPx(tick: number): number { + return tick * this.pixelsPerTick; + } + + public pxToTick(px: number): number { + return px / this.pixelsPerTick; + } + + public getNoteY(noteNumber: number): number { + return (midiConfig.layout.totalNotes - 1 - noteNumber) * midiConfig.layout.noteHeight; + } + + public notesWidth(totalTicks: number): number { + return Math.max(0, this.tickToPx(totalTicks)); + } + + public endPadding(): number { + return this.pixelsPerBeat * midiConfig.layout.endPaddingBeats; + } +} + +export function getMidiNoteName(noteNumber: number): string { + const octave = Math.floor(noteNumber / 12) - 1; + return `${noteNames[noteNumber % 12]}${octave}`; +} + +export function isMidiBlackKey(noteNumber: number): boolean { + return [1, 3, 6, 8, 10].includes(noteNumber % 12); +} + +export function isMidiCNote(noteNumber: number): boolean { + return noteNumber % 12 === 0; +} + +export function formatMidiDuration(seconds: number): string { + if (!Number.isFinite(seconds) || seconds <= 0) { + return '0:00'; + } + + const minutes = Math.floor(seconds / 60); + const wholeSeconds = Math.floor(seconds % 60); + return `${minutes}:${String(wholeSeconds).padStart(2, '0')}`; +} + +export function formatMidiChannel(channel: number | null): string { + return channel == null ? 'Mixed' : `Ch ${channel + 1}`; +} diff --git a/apps/midimarble/src/modules/midi/lib/midi-parser.ts b/apps/midimarble/src/modules/midi/lib/midi-parser.ts new file mode 100644 index 0000000..8a9cd74 --- /dev/null +++ b/apps/midimarble/src/modules/midi/lib/midi-parser.ts @@ -0,0 +1,276 @@ +import type { MidiNote, MidiSong, MidiTrack } from '../types'; +import { midiConfig } from './midi-config'; + +interface PendingNote { + tick: number; + channel: number; + noteNumber: number; + velocity: number; +} + +interface ParsedTrackChunk { + name: string; + bpm: number | null; + channels: Set; + notes: MidiNote[]; +} + +function readByte(data: Uint8Array, pos: number): number { + const value = data[pos]; + if (value == null) { + throw new Error('Unexpected end of MIDI data.'); + } + + return value; +} + +function readUint16(data: Uint8Array, pos: number): number { + return (readByte(data, pos) << 8) | readByte(data, pos + 1); +} + +function readUint32(data: Uint8Array, pos: number): number { + return ( + ((readByte(data, pos) << 24) | + (readByte(data, pos + 1) << 16) | + (readByte(data, pos + 2) << 8) | + readByte(data, pos + 3)) >>> + 0 + ); +} + +function readString(data: Uint8Array, pos: number, length: number): string { + return String.fromCharCode(...data.slice(pos, pos + length)); +} + +function readVarLength(data: Uint8Array, pos: number): [number, number] { + let value = 0; + let bytes = 0; + let next: number; + + do { + next = readByte(data, pos + bytes); + value = (value << 7) | (next & 0x7f); + bytes++; + } while (next & 0x80); + + return [value, bytes]; +} + +function parseTrackChunk(data: Uint8Array, start: number, length: number): ParsedTrackChunk { + const track: ParsedTrackChunk = { + name: '', + bpm: null, + channels: new Set(), + notes: [] + }; + + let pos = start; + let currentTick = 0; + let runningStatus = 0; + let noteId = 0; + const end = start + length; + const pendingNotes = new Map(); + + const closeNote = (tick: number, channel: number, noteNumber: number) => { + const key = channel * 128 + noteNumber; + const activeNote = pendingNotes.get(key); + if (activeNote == null) { + return; + } + + track.notes.push({ + id: noteId++, + tick: activeNote.tick, + durationTicks: Math.max(1, tick - activeNote.tick), + noteNumber: activeNote.noteNumber, + velocity: activeNote.velocity, + channel: activeNote.channel + }); + pendingNotes.delete(key); + }; + + while (pos < end) { + const [delta, deltaByteCount] = readVarLength(data, pos); + pos += deltaByteCount; + currentTick += delta; + + let statusByte: number; + if (readByte(data, pos) & 0x80) { + statusByte = readByte(data, pos); + pos++; + if (statusByte < 0xf0) { + runningStatus = statusByte; + } + } else { + statusByte = runningStatus; + } + + const type = statusByte & 0xf0; + const channel = statusByte & 0x0f; + + if (type === 0x90) { + const noteNumber = readByte(data, pos++); + const velocity = readByte(data, pos++); + if (velocity > 0) { + track.channels.add(channel); + pendingNotes.set(channel * 128 + noteNumber, { + tick: currentTick, + channel, + noteNumber, + velocity + }); + } else { + closeNote(currentTick, channel, noteNumber); + } + continue; + } + + if (type === 0x80) { + const noteNumber = readByte(data, pos++); + pos++; + closeNote(currentTick, channel, noteNumber); + continue; + } + + if (type === 0xa0 || type === 0xb0 || type === 0xe0) { + pos += 2; + continue; + } + + if (type === 0xc0 || type === 0xd0) { + pos += 1; + continue; + } + + if (statusByte === 0xff) { + const metaType = readByte(data, pos++); + const [metaLength, metaLengthBytes] = readVarLength(data, pos); + pos += metaLengthBytes; + + if (metaType === 0x03 && track.name === '') { + track.name = readString(data, pos, metaLength); + } + + if (metaType === 0x51 && metaLength === 3) { + const microsecondsPerBeat = + ((readByte(data, pos) << 16) | + (readByte(data, pos + 1) << 8) | + readByte(data, pos + 2)) >>> + 0; + if (microsecondsPerBeat > 0) { + track.bpm = Math.round(60_000_000 / microsecondsPerBeat); + } + } + + pos += metaLength; + continue; + } + + if (statusByte === 0xf0 || statusByte === 0xf7) { + const [sysexLength, sysexLengthBytes] = readVarLength(data, pos); + pos += sysexLengthBytes + sysexLength; + continue; + } + + break; + } + + return track; +} + +function buildTrack(rawTrack: ParsedTrackChunk, id: number): MidiTrack { + const noteCount = rawTrack.notes.length; + const minNote = noteCount === 0 ? 0 : Math.min(...rawTrack.notes.map((note) => note.noteNumber)); + const maxNote = noteCount === 0 ? 0 : Math.max(...rawTrack.notes.map((note) => note.noteNumber)); + const startTick = noteCount === 0 ? 0 : Math.min(...rawTrack.notes.map((note) => note.tick)); + const endTick = + noteCount === 0 ? 0 : Math.max(...rawTrack.notes.map((note) => note.tick + note.durationTicks)); + + return { + id, + name: rawTrack.name.trim() || `Track ${id + 1}`, + channel: rawTrack.channels.size === 1 ? (Array.from(rawTrack.channels)[0] ?? null) : null, + color: midiConfig.colors.trackPalette[id % midiConfig.colors.trackPalette.length]!, + notes: rawTrack.notes, + noteCount, + minNote, + maxNote, + startTick, + endTick + }; +} + +export function parseMidi(buffer: ArrayBuffer, fileName?: string | null): MidiSong { + const data = new Uint8Array(buffer); + + if (readString(data, 0, 4) !== 'MThd') { + throw new Error('Not a valid MIDI file. Missing MThd header.'); + } + + const format = readUint16(data, 8); + const trackCount = readUint16(data, 10); + const timeDivision = readUint16(data, 12); + + if (timeDivision & 0x8000) { + throw new Error('SMPTE MIDI timing is not supported yet.'); + } + + const ticksPerBeat = timeDivision; + const rawTracks: ParsedTrackChunk[] = []; + + let pos = 14; + for (let index = 0; index < trackCount; index++) { + if (pos >= data.length || readString(data, pos, 4) !== 'MTrk') { + break; + } + + const chunkLength = readUint32(data, pos + 4); + rawTracks.push(parseTrackChunk(data, pos + 8, chunkLength)); + pos += 8 + chunkLength; + } + + const bpm = rawTracks.find((track) => track.bpm != null)?.bpm ?? 120; + const firstTrack = rawTracks[0]; + const tracks = + format === 0 && rawTracks.length === 1 && firstTrack != null + ? Array.from( + firstTrack.notes + .reduce((map, note) => { + const notes = map.get(note.channel) ?? []; + notes.push(note); + map.set(note.channel, notes); + return map; + }, new Map()) + .entries() + ).map(([channel, notes]) => + buildTrack( + { + name: `Channel ${channel + 1}`, + bpm: null, + channels: new Set([channel]), + notes + }, + channel + ) + ) + : (format === 1 && rawTracks.length > 1 && firstTrack?.notes.length === 0 + ? rawTracks.slice(1) + : rawTracks + ).map((track, index) => buildTrack(track, index)); + + const totalTicks = tracks.length === 0 ? 0 : Math.max(...tracks.map((track) => track.endTick)); + const totalBeats = ticksPerBeat === 0 ? 0 : Math.ceil(totalTicks / ticksPerBeat); + const durationSeconds = totalBeats * (60 / bpm); + const songName = rawTracks.find((track) => track.name.trim() !== '')?.name.trim(); + + return { + name: songName || fileName?.replace(/\.midi?$/i, '') || 'Untitled', + fileName: fileName ?? null, + ticksPerBeat, + bpm, + totalTicks, + totalBeats, + durationSeconds, + tracks + }; +} diff --git a/apps/midimarble/src/modules/midi/types.ts b/apps/midimarble/src/modules/midi/types.ts new file mode 100644 index 0000000..fb31f2d --- /dev/null +++ b/apps/midimarble/src/modules/midi/types.ts @@ -0,0 +1,38 @@ +export interface MidiNote { + id: number; + tick: number; + durationTicks: number; + noteNumber: number; + velocity: number; + channel: number; +} + +export interface MidiTrack { + id: number; + name: string; + channel: number | null; + color: string; + notes: MidiNote[]; + noteCount: number; + minNote: number; + maxNote: number; + startTick: number; + endTick: number; +} + +export interface MidiSong { + name: string; + fileName: string | null; + ticksPerBeat: number; + bpm: number; + totalTicks: number; + totalBeats: number; + durationSeconds: number; + tracks: MidiTrack[]; +} + +export interface MidiViewportRect { + width: number; + height: number; + left: number; +} diff --git a/apps/midimarble/src/modules/persistence/ProjectRepository.ts b/apps/midimarble/src/modules/persistence/ProjectRepository.ts new file mode 100644 index 0000000..b1b67dc --- /dev/null +++ b/apps/midimarble/src/modules/persistence/ProjectRepository.ts @@ -0,0 +1,79 @@ +import type { TProjectListItem, TProjectRecord } from './types'; + +const DB_NAME = 'midimarble-db'; +const DB_VERSION = 1; +const PROJECTS_STORE = 'projects'; +const MIDI_FILES_STORE = 'midi-files'; + +function idbReq(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +export class ProjectRepository { + private _dbPromise: Promise | null = null; + + private _getDb(): Promise { + if (this._dbPromise == null) { + this._dbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(PROJECTS_STORE)) { + db.createObjectStore(PROJECTS_STORE, { keyPath: 'id' }); + } + if (!db.objectStoreNames.contains(MIDI_FILES_STORE)) { + db.createObjectStore(MIDI_FILES_STORE); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + return this._dbPromise; + } + + async listProjects(): Promise { + const db = await this._getDb(); + const tx = db.transaction(PROJECTS_STORE, 'readonly'); + const store = tx.objectStore(PROJECTS_STORE); + const records = await idbReq(store.getAll()); + return records + .map(({ id, name, updatedAt }) => ({ id, name, updatedAt })) + .sort((a, b) => b.updatedAt - a.updatedAt); + } + + async getProject(id: string): Promise { + const db = await this._getDb(); + const tx = db.transaction(PROJECTS_STORE, 'readonly'); + const store = tx.objectStore(PROJECTS_STORE); + return idbReq(store.get(id)); + } + + async saveProject(record: TProjectRecord): Promise { + const db = await this._getDb(); + const tx = db.transaction(PROJECTS_STORE, 'readwrite'); + const store = tx.objectStore(PROJECTS_STORE); + await idbReq(store.put(record)); + } + + async saveMidiBytes(projectId: string, bytes: ArrayBuffer): Promise { + const db = await this._getDb(); + const tx = db.transaction(MIDI_FILES_STORE, 'readwrite'); + const store = tx.objectStore(MIDI_FILES_STORE); + await idbReq(store.put(bytes, projectId)); + } + + async deleteProject(id: string): Promise { + const db = await this._getDb(); + const tx = db.transaction([PROJECTS_STORE, MIDI_FILES_STORE], 'readwrite'); + await Promise.all([ + idbReq(tx.objectStore(PROJECTS_STORE).delete(id)), + idbReq(tx.objectStore(MIDI_FILES_STORE).delete(id)) + ]); + } +} + +export const projectRepository = new ProjectRepository(); diff --git a/apps/midimarble/src/modules/persistence/index.ts b/apps/midimarble/src/modules/persistence/index.ts new file mode 100644 index 0000000..3153e71 --- /dev/null +++ b/apps/midimarble/src/modules/persistence/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export { projectRepository } from './ProjectRepository'; diff --git a/apps/midimarble/src/modules/persistence/types.ts b/apps/midimarble/src/modules/persistence/types.ts new file mode 100644 index 0000000..0589ad8 --- /dev/null +++ b/apps/midimarble/src/modules/persistence/types.ts @@ -0,0 +1,52 @@ +import type { TAudioSettings } from '@/modules/engine/plugins/audio/types'; +import type { TMidiSong } from '@/modules/engine/plugins/midi/types'; +import type { TTrajectoryConfig } from '@/modules/engine/plugins/trajectory/types'; +import type { TVec3 } from '@/modules/engine/types'; + +export interface TProjectRecord { + id: string; + name: string; + createdAt: number; + updatedAt: number; + midiFileName: string | null; + midiSong: TMidiSong | null; + selectedTrackId: number | null; + playheadTick: number; + audioSettings: TAudioSettings; + trajectoryConfig: TTrajectoryConfig; + marble: TMarbleSnapshot; + notePlatforms: TNotePlatformSnapshot[]; + straightTracks: TStraightTrackSnapshot[]; +} + +export interface TMarbleSnapshot { + position: TVec3; + rotation: TVec3; + bounce: number; +} + +export interface TNotePlatformSnapshot { + noteId: number; + offsetY: number; + offsetZ: number; + rotationX: number; + length: number; + width: number; + thickness: number; + bounce: number; + color: string; +} + +export interface TStraightTrackSnapshot { + position: TVec3; + rotation: TVec3; + scale: TVec3; + length: number; + height: number; + width: number; + channelWidth: number; + channelDepth: number; + color: string; +} + +export type TProjectListItem = Pick; diff --git a/apps/midimarble/src/modules/projects/components/ProjectCard.tsx b/apps/midimarble/src/modules/projects/components/ProjectCard.tsx new file mode 100644 index 0000000..e626823 --- /dev/null +++ b/apps/midimarble/src/modules/projects/components/ProjectCard.tsx @@ -0,0 +1,43 @@ +import { Trash2 } from 'lucide-react'; +import React from 'react'; +import type { TProjectListItem } from '@/modules/persistence'; + +interface TProjectCardProps { + project: TProjectListItem; + onOpen(id: string): void; + onDelete(id: string): void; +} + +export const ProjectCard: React.FC = ({ project, onOpen, onDelete }) => { + const formattedDate = React.useMemo(() => { + return new Date(project.updatedAt).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + }, [project.updatedAt]); + + const handleDelete = (e: React.MouseEvent) => { + e.stopPropagation(); + onDelete(project.id); + }; + + return ( + + + ); +}; diff --git a/apps/midimarble/src/modules/projects/components/ProjectPicker.tsx b/apps/midimarble/src/modules/projects/components/ProjectPicker.tsx new file mode 100644 index 0000000..aa62390 --- /dev/null +++ b/apps/midimarble/src/modules/projects/components/ProjectPicker.tsx @@ -0,0 +1,81 @@ +import { FileUp } from 'lucide-react'; +import React from 'react'; +import { useProjects } from '../hooks/use-projects'; +import { ProjectCard } from './ProjectCard'; + +export const ProjectPicker: React.FC = () => { + const { projects, isLoading, createProject, deleteProject, openProject } = useProjects(); + const fileInputRef = React.useRef(null); + const [isCreating, setIsCreating] = React.useState(false); + + const handleFileChange = React.useCallback( + async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ''; + if (file == null) return; + setIsCreating(true); + try { + await createProject(file); + } finally { + setIsCreating(false); + } + }, + [createProject] + ); + + return ( +
+ + +
+
+
+

+ Midimarble +

+

Projects

+
+ +
+ +
+ {isLoading ? ( +

Loading projects…

+ ) : projects.length === 0 ? ( +
+

No projects yet

+

+ Open a MIDI file to create your first project. +

+
+ ) : ( +
+ {projects.map((project) => ( + + ))} +
+ )} +
+
+
+ ); +}; diff --git a/apps/midimarble/src/modules/projects/hooks/use-projects.ts b/apps/midimarble/src/modules/projects/hooks/use-projects.ts new file mode 100644 index 0000000..29cc18a --- /dev/null +++ b/apps/midimarble/src/modules/projects/hooks/use-projects.ts @@ -0,0 +1,88 @@ +import { useNavigate } from '@tanstack/react-router'; +import React from 'react'; +import { audioConfig } from '@/modules/engine/plugins/audio/config'; +import type { TAudioInstrumentId } from '@/modules/engine/plugins/audio/types'; +import { parseMidi } from '@/modules/engine/plugins/midi/lib/midi-parser'; +import { sceneConfig } from '@/modules/engine/plugins/scene/config'; +import { trajectoryConfig } from '@/modules/engine/plugins/trajectory/config'; +import { projectRepository, type TProjectListItem } from '@/modules/persistence'; + +export interface TUseProjects { + projects: TProjectListItem[]; + isLoading: boolean; + createProject(file: File): Promise; + deleteProject(id: string): Promise; + openProject(id: string): void; +} + +export function useProjects(): TUseProjects { + const navigate = useNavigate(); + const [projects, setProjects] = React.useState([]); + const [isLoading, setIsLoading] = React.useState(true); + + React.useEffect(() => { + void (async () => { + const list = await projectRepository.listProjects(); + setProjects(list); + setIsLoading(false); + })(); + }, []); + + const createProject = React.useCallback( + async (file: File) => { + const bytes = await file.arrayBuffer(); + const song = parseMidi(bytes, file.name); + const firstTrack = song.tracks.find((t) => t.notes.length > 0); + const id = crypto.randomUUID(); + const now = Date.now(); + + const record = { + id, + name: song.name, + createdAt: now, + updatedAt: now, + midiFileName: file.name, + midiSong: song, + selectedTrackId: firstTrack?.id ?? null, + playheadTick: 0, + audioSettings: { + enabled: audioConfig.defaults.enabled, + masterVolume: audioConfig.defaults.masterVolume, + trackInstrumentIds: {} as Record + }, + trajectoryConfig: { + enabled: trajectoryConfig.defaults.enabled, + futureColor: trajectoryConfig.defaults.futureColor, + pastColor: trajectoryConfig.defaults.pastColor + }, + marble: { + position: { ...sceneConfig.marble.spawn.position }, + rotation: { ...sceneConfig.marble.spawn.rotation }, + bounce: sceneConfig.marble.physics.defaults.bounce + }, + notePlatforms: [], + straightTracks: [] + }; + + await projectRepository.saveProject(record); + await projectRepository.saveMidiBytes(id, bytes); + setProjects((prev) => [{ id, name: song.name, updatedAt: now }, ...prev]); + void navigate({ to: '/editor/$projectId', params: { projectId: id } }); + }, + [navigate] + ); + + const deleteProject = React.useCallback(async (id: string) => { + await projectRepository.deleteProject(id); + setProjects((prev) => prev.filter((p) => p.id !== id)); + }, []); + + const openProject = React.useCallback( + (id: string) => { + void navigate({ to: '/editor/$projectId', params: { projectId: id } }); + }, + [navigate] + ); + + return { projects, isLoading, createProject, deleteProject, openProject }; +} diff --git a/apps/midimarble/src/routeTree.gen.ts b/apps/midimarble/src/routeTree.gen.ts new file mode 100644 index 0000000..9c177f0 --- /dev/null +++ b/apps/midimarble/src/routeTree.gen.ts @@ -0,0 +1,104 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as MidiRouteImport } from './routes/midi' +import { Route as IndexRouteImport } from './routes/index' +import { Route as EditorProjectIdRouteImport } from './routes/editor.$projectId' + +const MidiRoute = MidiRouteImport.update({ + id: '/midi', + path: '/midi', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const EditorProjectIdRoute = EditorProjectIdRouteImport.update({ + id: '/editor/$projectId', + path: '/editor/$projectId', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/midi': typeof MidiRoute + '/editor/$projectId': typeof EditorProjectIdRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/midi': typeof MidiRoute + '/editor/$projectId': typeof EditorProjectIdRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/midi': typeof MidiRoute + '/editor/$projectId': typeof EditorProjectIdRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/midi' | '/editor/$projectId' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/midi' | '/editor/$projectId' + id: '__root__' | '/' | '/midi' | '/editor/$projectId' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + MidiRoute: typeof MidiRoute + EditorProjectIdRoute: typeof EditorProjectIdRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/midi': { + id: '/midi' + path: '/midi' + fullPath: '/midi' + preLoaderRoute: typeof MidiRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/editor/$projectId': { + id: '/editor/$projectId' + path: '/editor/$projectId' + fullPath: '/editor/$projectId' + preLoaderRoute: typeof EditorProjectIdRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + MidiRoute: MidiRoute, + EditorProjectIdRoute: EditorProjectIdRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/apps/midimarble/src/router.tsx b/apps/midimarble/src/router.tsx new file mode 100644 index 0000000..61b5907 --- /dev/null +++ b/apps/midimarble/src/router.tsx @@ -0,0 +1,19 @@ +import { createRouter as createTanStackRouter } from '@tanstack/react-router'; +import { routeTree } from './routeTree.gen'; + +export function getRouter() { + const router = createTanStackRouter({ + routeTree, + scrollRestoration: true, + defaultPreload: 'intent', + defaultPreloadStaleTime: 0 + }); + + return router; +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType; + } +} diff --git a/apps/midimarble/src/routes/__root.tsx b/apps/midimarble/src/routes/__root.tsx new file mode 100644 index 0000000..79d2edf --- /dev/null +++ b/apps/midimarble/src/routes/__root.tsx @@ -0,0 +1,77 @@ +import { TanStackDevtools } from '@tanstack/react-devtools'; +import { createRootRoute, HeadContent, Scripts } from '@tanstack/react-router'; +import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'; +import styles from '../styles.css?url'; + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { + charSet: 'utf-8' + }, + { + name: 'viewport', + content: 'width=device-width, initial-scale=1' + }, + { + title: 'Midimarble' + } + ], + links: [ + { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, + { + rel: 'preconnect', + href: 'https://fonts.gstatic.com', + crossOrigin: 'anonymous' + }, + { rel: 'preconnect', href: 'https://api.fontshare.com' }, + // https://fonts.google.com/specimen/Inter + // https://fonts.google.com/specimen/Caveat + { + rel: 'stylesheet', + href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Caveat:wght@400..700&display=swap' + }, + // https://www.fontshare.com/fonts/erode + { + rel: 'stylesheet', + href: 'https://api.fontshare.com/v2/css?f[]=erode@1,2&display=swap' + }, + { + rel: 'stylesheet', + href: styles + } + ], + scripts: [ + // Apply theme before paint to prevent flash + { + children: `(function(){var mode='auto';try{var s=localStorage.getItem('focuscat-app-settings');if(s){var t=JSON.parse(s)?.appearance?.theme;if(t==='light'||t==='dark'||t==='auto')mode=t;}}catch(e){}var dark=mode==='dark'||(mode==='auto'&&window.matchMedia('(prefers-color-scheme: dark)').matches);var root=document.documentElement;root.classList.toggle('dark',dark);root.style.colorScheme=dark?'dark':'light';})();` + } + ] + }), + shellComponent: RootDocument +}); + +function RootDocument({ children }: { children: React.ReactNode }) { + return ( + + + + + + {children} + + } + ]} + /> + + + + ); +} diff --git a/apps/midimarble/src/routes/editor.$projectId.tsx b/apps/midimarble/src/routes/editor.$projectId.tsx new file mode 100644 index 0000000..c9bf6e0 --- /dev/null +++ b/apps/midimarble/src/routes/editor.$projectId.tsx @@ -0,0 +1,31 @@ +import { createFileRoute } from '@tanstack/react-router'; +import React from 'react'; + +const LazyEditorWithProject = React.lazy(async () => { + const mod = await import('@/modules/editor/components/Editor'); + return { default: mod.Editor }; +}); + +export const Route = createFileRoute('/editor/$projectId')({ + component: RouteComponent +}); + +function RouteComponent() { + const { projectId } = Route.useParams(); + const [isMounted, setIsMounted] = React.useState(false); + React.useEffect(() => { + setIsMounted(true); + }, []); + if (!isMounted) return ; + return ( + }> + + + ); +} + +const Fallback: React.FC = () => ( +
+

Loading editor…

+
+); diff --git a/apps/midimarble/src/routes/index.tsx b/apps/midimarble/src/routes/index.tsx new file mode 100644 index 0000000..4cd23f7 --- /dev/null +++ b/apps/midimarble/src/routes/index.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { ProjectPicker } from '@/modules/projects/components/ProjectPicker'; + +export const Route = createFileRoute('/')({ component: RouteComponent }); + +function RouteComponent() { + return ; +} diff --git a/apps/midimarble/src/routes/midi.tsx b/apps/midimarble/src/routes/midi.tsx new file mode 100644 index 0000000..6915876 --- /dev/null +++ b/apps/midimarble/src/routes/midi.tsx @@ -0,0 +1,12 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { MidiViewer } from '@/modules/midi'; + +export const Route = createFileRoute('/midi')({ component: RouteComponent }); + +function RouteComponent() { + return ( +
+ +
+ ); +} diff --git a/apps/midimarble/src/styles.css b/apps/midimarble/src/styles.css new file mode 100644 index 0000000..914be3c --- /dev/null +++ b/apps/midimarble/src/styles.css @@ -0,0 +1,55 @@ +@import 'tailwindcss'; +@plugin "@tailwindcss/typography"; + +@theme { + /* Zinc-based neutral scale (light mode) */ + --color-base-0: #ffffff; + --color-base-50: #fafafa; + --color-base-100: #f4f4f5; + --color-base-200: #e4e4e7; + --color-base-300: #d4d4d8; + --color-base-400: #a1a1aa; + --color-base-500: #71717a; + --color-base-600: #52525b; + --color-base-700: #3f3f46; + --color-base-800: #27272a; + --color-base-900: #18181b; + --color-base-950: #09090b; + + /* Semantic colors */ + --color-primary: #3b82f6; + --color-primary-content: #ffffff; + --color-secondary: #8b5cf6; + --color-secondary-content: #ffffff; + --color-accent: #f97316; + --color-accent-content: #ffffff; + --color-info: #0ea5e9; + --color-info-content: #ffffff; + --color-success: #22c55e; + --color-success-content: #ffffff; + --color-warning: #eab308; + --color-warning-content: #18181b; + --color-error: #ef4444; + --color-error-content: #ffffff; + + /* Fonts */ + --font-sans: 'Inter', system-ui, sans-serif; + --font-serif: 'Erode', serif; + --font-handwritten: 'Caveat', cursive; +} + +/* Dark mode */ +.dark { + --color-base-0: #09090b; + --color-base-50: #18181b; + --color-base-100: #27272a; + --color-base-200: #3f3f46; + --color-base-300: #52525b; + --color-base-400: #71717a; + --color-base-500: #a1a1aa; + --color-base-600: #d4d4d8; + --color-base-700: #e4e4e7; + --color-base-800: #f4f4f5; + --color-base-900: #fafafa; + --color-base-950: #ffffff; +} diff --git a/apps/midimarble/src/vite-env.d.ts b/apps/midimarble/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/apps/midimarble/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/midimarble/tsconfig.json b/apps/midimarble/tsconfig.json new file mode 100644 index 0000000..bb7f8c9 --- /dev/null +++ b/apps/midimarble/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@blgc/config/typescript/react-internal", + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "dom", "dom.iterable"], + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"], + "noEmit": true +} diff --git a/apps/midimarble/vite.config.ts b/apps/midimarble/vite.config.ts new file mode 100644 index 0000000..4eed3d6 --- /dev/null +++ b/apps/midimarble/vite.config.ts @@ -0,0 +1,25 @@ +import tailwindcss from '@tailwindcss/vite'; +import { devtools } from '@tanstack/devtools-vite'; +import { tanstackStart } from '@tanstack/react-start/plugin/vite'; +import viteReact from '@vitejs/plugin-react'; +import { nitro } from 'nitro/vite'; +import { defineConfig } from 'vite'; +import tsconfigPaths from 'vite-tsconfig-paths'; + +const config = defineConfig({ + server: { + port: 3000 + }, + plugins: [ + devtools(), + nitro(), + tsconfigPaths({ projects: ['./tsconfig.json'] }), + tailwindcss(), + tanstackStart({ + srcDirectory: 'src' + }), + viteReact() + ] +}); + +export default config; diff --git a/apps/midimarble/vitest.config.mjs b/apps/midimarble/vitest.config.mjs new file mode 100644 index 0000000..8482b93 --- /dev/null +++ b/apps/midimarble/vitest.config.mjs @@ -0,0 +1,4 @@ +import { nodeConfig } from '@blgc/config/vite/node'; +import { defineConfig, mergeConfig } from 'vitest/config'; + +export default mergeConfig(nodeConfig, defineConfig({})); diff --git a/package.json b/package.json index b3810e6..9dcf141 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "vite": "^7.3.1", "vitest": "^4.0.18" }, - "packageManager": "pnpm@10.30.3", + "packageManager": "pnpm@10.32.1", "engines": { "node": ">=24" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bfeb62f..31f3dc3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,7 +15,7 @@ importers: devDependencies: '@blgc/config': specifier: ^0.0.40 - version: 0.0.40(eslint@9.39.3(jiti@2.6.1))(postcss@8.5.6)(prettier@3.8.1)(turbo@2.8.11)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 0.0.40(eslint@9.39.3(jiti@2.6.1))(postcss@8.5.6)(prettier@3.8.1)(turbo@2.8.11)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) eslint: specifier: ^9.39.2 version: 9.39.3(jiti@2.6.1) @@ -42,10 +42,10 @@ importers: version: 5.9.3 vite: specifier: ^7.3.1 - version: 7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) apps/gazegames: dependencies: @@ -275,10 +275,10 @@ importers: version: 19.2.14 eas-cli: specifier: ^18.0.6 - version: 18.0.6(@types/node@25.3.2)(typescript@5.9.3) + version: 18.0.6(@types/node@25.5.0)(typescript@5.9.3) eslint-config-expo: specifier: ~55.0.0 - version: 55.0.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + version: 55.0.0(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.57.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) postcss: specifier: ^8.5.6 version: 8.5.6 @@ -290,7 +290,7 @@ importers: dependencies: '@react-router/fs-routes': specifier: ^7.13.0 - version: 7.13.1(@react-router/dev@7.13.1(@react-router/serve@7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.1)(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2))(typescript@5.9.3) + version: 7.13.1(@react-router/dev@7.13.1(@react-router/serve@7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2))(typescript@5.9.3) '@react-router/node': specifier: ^7.13.0 version: 7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) @@ -302,7 +302,7 @@ importers: version: 1.6.1(react@19.2.4) '@vercel/react-router': specifier: ^1.2.5 - version: 1.2.5(@react-router/dev@7.13.1(@react-router/serve@7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.1)(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2))(@react-router/node@7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(isbot@5.1.35)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 1.2.5(@react-router/dev@7.13.1(@react-router/serve@7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2))(@react-router/node@7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(isbot@5.1.35)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -336,13 +336,13 @@ importers: version: 3.1.1(rollup@4.59.0) '@react-router/dev': specifier: ^7.13.0 - version: 7.13.1(@react-router/serve@7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.1)(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2) + version: 7.13.1(@react-router/serve@7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2) '@tailwindcss/typography': specifier: ^0.5.19 version: 0.5.19(tailwindcss@4.2.1) '@tailwindcss/vite': specifier: ^4.1.18 - version: 4.2.1(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.2.1(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@types/mdx': specifier: ^2.0.13 version: 2.0.13 @@ -360,7 +360,86 @@ importers: version: 4.2.1 vite-tsconfig-paths: specifier: ^6.0.5 - version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + + apps/midimarble: + dependencies: + '@dimforge/rapier3d-compat': + specifier: ^0.19.3 + version: 0.19.3 + '@tanstack/react-devtools': + specifier: ^0.9.13 + version: 0.9.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11) + '@tanstack/react-router': + specifier: ^1.166.7 + version: 1.166.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-router-devtools': + specifier: ^1.166.7 + version: 1.166.7(@tanstack/react-router@1.166.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.166.7)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-start': + specifier: ^1.166.8 + version: 1.166.8(crossws@0.4.4(srvx@0.11.9))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + ecsify: + specifier: ^0.0.19 + version: 0.0.19 + feature-react: + specifier: ^0.0.67 + version: 0.0.67(react@19.2.4) + feature-state: + specifier: ^0.0.65 + version: 0.0.65 + lucide-react: + specifier: ^0.577.0 + version: 0.577.0(react@19.2.4) + react: + specifier: 19.2.4 + version: 19.2.4 + react-dom: + specifier: 19.2.4 + version: 19.2.4(react@19.2.4) + react-resizable-panels: + specifier: ^4.7.2 + version: 4.7.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + three: + specifier: ^0.183.2 + version: 0.183.2 + tone: + specifier: ^15.1.22 + version: 15.1.22 + devDependencies: + '@tailwindcss/typography': + specifier: ^0.5.19 + version: 0.5.19(tailwindcss@4.2.1) + '@tailwindcss/vite': + specifier: ^4.2.1 + version: 4.2.1(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@tanstack/devtools-vite': + specifier: ^0.5.5 + version: 0.5.5(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@types/node': + specifier: ^25.5.0 + version: 25.5.0 + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@types/three': + specifier: ^0.183.1 + version: 0.183.1 + '@vitejs/plugin-react': + specifier: ^5.1.4 + version: 5.2.0(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + nitro: + specifier: 3.0.260311-beta + version: 3.0.260311-beta(chokidar@4.0.3)(dotenv@16.4.7)(jiti@2.6.1)(lru-cache@11.2.6)(rollup@4.59.0)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + tailwindcss: + specifier: ^4.2.1 + version: 4.2.1 + vite-tsconfig-paths: + specifier: ^6.1.1 + version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) packages: @@ -383,6 +462,10 @@ packages: resolution: {integrity: sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -926,6 +1009,12 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@dimforge/rapier3d-compat@0.12.0': + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + + '@dimforge/rapier3d-compat@0.19.3': + resolution: {integrity: sha512-mMVdSj1PRTT108s9Swbu2GQOmHbn8kbJANRV5xfczL3s0T4vkgZAuoMRgvBzQcHanpKusbC0ZJj6z3mC3aj3vg==} + '@drizzle-team/brocli@0.11.0': resolution: {integrity: sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg==} @@ -936,12 +1025,21 @@ packages: '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} + '@emnapi/core@1.9.0': + resolution: {integrity: sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==} + '@emnapi/runtime@1.8.1': resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + '@emnapi/runtime@1.9.0': + resolution: {integrity: sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==} + '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@emnapi/wasi-threads@1.2.0': + resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} + '@esbuild/aix-ppc64@0.27.3': resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} @@ -1697,6 +1795,9 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@napi-rs/wasm-runtime@1.1.1': + resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@next/eslint-plugin-next@16.1.6': resolution: {integrity: sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==} @@ -1736,6 +1837,25 @@ packages: engines: {node: '>=12.0.0'} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + '@oozcitak/dom@2.0.2': + resolution: {integrity: sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w==} + engines: {node: '>=20.0'} + + '@oozcitak/infra@2.0.2': + resolution: {integrity: sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA==} + engines: {node: '>=20.0'} + + '@oozcitak/url@3.0.0': + resolution: {integrity: sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ==} + engines: {node: '>=20.0'} + + '@oozcitak/util@10.0.0': + resolution: {integrity: sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==} + engines: {node: '>=20.0'} + + '@oxc-project/types@0.115.0': + resolution: {integrity: sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -2155,6 +2275,110 @@ packages: '@remix-run/node-fetch-server@0.13.0': resolution: {integrity: sha512-1EsNo0ZpgXu/90AWoRZf/oE3RVTUS80tiTUpt+hv5pjtAkw7icN4WskDwz/KdAw5ARbJLMhZBrO1NqThmy/McA==} + '@rolldown/binding-android-arm64@1.0.0-rc.9': + resolution: {integrity: sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.9': + resolution: {integrity: sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.9': + resolution: {integrity: sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.9': + resolution: {integrity: sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9': + resolution: {integrity: sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9': + resolution: {integrity: sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.9': + resolution: {integrity: sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9': + resolution: {integrity: sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9': + resolution: {integrity: sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.9': + resolution: {integrity: sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.9': + resolution: {integrity: sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.9': + resolution: {integrity: sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.9': + resolution: {integrity: sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9': + resolution: {integrity: sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.9': + resolution: {integrity: sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-beta.40': + resolution: {integrity: sha512-s3GeJKSQOwBlzdUrj4ISjJj5SfSh+aqn0wjOar4Bx95iV1ETI7F6S/5hLcfAxZ9kXDcyrAkxPlqmd1ZITttf+w==} + + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + + '@rolldown/pluginutils@1.0.0-rc.9': + resolution: {integrity: sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==} + '@rollup/plugin-commonjs@29.0.0': resolution: {integrity: sha512-U2YHaxR2cU/yAiwKJtJRhnyLk7cifnQw0zUpISsocBDoHDJn+HTV74ABqnwr5bEgWUwFZC9oFL6wLe21lHu5eQ==} engines: {node: '>=16.0.0 || 14 >= 14.17'} @@ -2347,6 +2571,36 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@solid-primitives/event-listener@2.4.5': + resolution: {integrity: sha512-nwRV558mIabl4yVAhZKY8cb6G+O1F0M6Z75ttTu5hk+SxdOnKSGj+eetDIu7Oax1P138ZdUU01qnBPR8rnxaEA==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/keyboard@1.3.5': + resolution: {integrity: sha512-sav+l+PL+74z3yaftVs7qd8c2SXkqzuxPOVibUe5wYMt+U5Hxp3V3XCPgBPN2I6cANjvoFtz0NiU8uHVLdi9FQ==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/resize-observer@2.1.5': + resolution: {integrity: sha512-AiyTknKcNBaKHbcSMuxtSNM8FjIuiSuFyFghdD0TcCMU9hKi9EmsC5pjfjDwxE+5EueB1a+T/34PLRI5vbBbKw==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/rootless@1.5.3': + resolution: {integrity: sha512-N8cIDAHbWcLahNRLr0knAAQvXyEdEMoAZvIMZKmhNb1mlx9e2UOv9BRD5YNwQUJwbNoYVhhLwFOEOcVXFx0HqA==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/static-store@0.1.3': + resolution: {integrity: sha512-uxez7SXnr5GiRnzqO2IEDjOJRIXaG+0LZLBizmUA1FwSi+hrpuMzVBwyk70m4prcl8X6FDDXUl9O8hSq8wHbBQ==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/utils@6.4.0': + resolution: {integrity: sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A==} + peerDependencies: + solid-js: ^1.6.12 + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2452,6 +2706,171 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@tanstack/devtools-client@0.0.6': + resolution: {integrity: sha512-f85ZJXJnDIFOoykG/BFIixuAevJovCvJF391LPs6YjBAPhGYC50NWlx1y4iF/UmK5/cCMx+/JqI5SBOz7FanQQ==} + engines: {node: '>=18'} + + '@tanstack/devtools-event-bus@0.4.1': + resolution: {integrity: sha512-cNnJ89Q021Zf883rlbBTfsaxTfi2r73/qejGtyTa7ksErF3hyDyAq1aTbo5crK9dAL7zSHh9viKY1BtMls1QOA==} + engines: {node: '>=18'} + + '@tanstack/devtools-event-client@0.4.3': + resolution: {integrity: sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw==} + engines: {node: '>=18'} + hasBin: true + + '@tanstack/devtools-ui@0.5.0': + resolution: {integrity: sha512-nNZ14054n31fWB61jtWhZYLRdQ3yceCE3G/RINoINUB0RqIGZAIm9DnEDwOTAOfqt4/a/D8vNk8pJu6RQUp74g==} + engines: {node: '>=18'} + peerDependencies: + solid-js: '>=1.9.7' + + '@tanstack/devtools-vite@0.5.5': + resolution: {integrity: sha512-vtXZ3LipEknVg0X6yejgWzZXIJSrvlBMWB1lDJKW6GWztEV+uCAoqLAJS+Jk3c2mTXp/u+aI/jfE0gqT4zHTNw==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + vite: ^6.0.0 || ^7.0.0 + + '@tanstack/devtools@0.10.14': + resolution: {integrity: sha512-bg1e0PyjmMMsc9VSOGb9etu15CpFdAwlQ5DD2xS6N93iTPgCPWXiZQFZygrEDoKnnx1x7BM6QTaiukizaejgSA==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + solid-js: '>=1.9.7' + + '@tanstack/history@1.161.4': + resolution: {integrity: sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww==} + engines: {node: '>=20.19'} + + '@tanstack/react-devtools@0.9.13': + resolution: {integrity: sha512-O9YXTEe2dlnw2pPNKFZ4Wk7zC4qrDvc0SAALKfMVedeZ2Dyd0LEJUabYS6GPm+DmnrBhc7nJx6Zqc9aDjFrj4g==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=16.8' + '@types/react-dom': '>=16.8' + react: 19.2.4 + react-dom: 19.2.4 + + '@tanstack/react-router-devtools@1.166.7': + resolution: {integrity: sha512-sAh3gA3wkMvUI6rRLPW4lfP0XxeEA0wrlv4tW1cinb7eoD3avcdKwiE9jhQ3DgFlhVsHa9fa3AKxH46Y/d/e1g==} + engines: {node: '>=20.19'} + peerDependencies: + '@tanstack/react-router': ^1.166.7 + '@tanstack/router-core': ^1.166.7 + react: 19.2.4 + react-dom: 19.2.4 + peerDependenciesMeta: + '@tanstack/router-core': + optional: true + + '@tanstack/react-router@1.166.7': + resolution: {integrity: sha512-LLcXu2nrCn2WL+w0YAbg3CRZIIO2cYVSC3y+ZYlFBxBs4hh8eoNP1EWFvRLZGCFYpqON7x6qUf1u0W7tH0cJJw==} + engines: {node: '>=20.19'} + peerDependencies: + react: 19.2.4 + react-dom: 19.2.4 + + '@tanstack/react-start-client@1.166.7': + resolution: {integrity: sha512-s5hUibmThU1D0lEXX1ecZ5M1ANB4YBJ5gLUjuR3SXhBon3S5pvNmuSi9blrlL67nyGqe9y0w2+pTyJApobCFtw==} + engines: {node: '>=22.12.0'} + peerDependencies: + react: 19.2.4 + react-dom: 19.2.4 + + '@tanstack/react-start-server@1.166.7': + resolution: {integrity: sha512-36yHsjophX/8FZ6fnVXLuqWnpks8/kNcCWuIzSmFVMBSv70hZvEibMVlpCiuTXfv6kcKQuVqYBpF3+Kx98XaQA==} + engines: {node: '>=22.12.0'} + peerDependencies: + react: 19.2.4 + react-dom: 19.2.4 + + '@tanstack/react-start@1.166.8': + resolution: {integrity: sha512-2s8Je9/XdN3PR5lfK6Pqo5mcw0aTdBr7Y8aScfS2v70DRb7ZC6yGpe3+6qqOfAjNJ52fsV+4Y8Ad2nZY/IHZCA==} + engines: {node: '>=22.12.0'} + peerDependencies: + react: 19.2.4 + react-dom: 19.2.4 + vite: '>=7.0.0' + + '@tanstack/react-store@0.9.2': + resolution: {integrity: sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ==} + peerDependencies: + react: 19.2.4 + react-dom: 19.2.4 + + '@tanstack/router-core@1.166.7': + resolution: {integrity: sha512-MCc8wYIIcxmbeidM8PL2QeaAjUIHyhEDIZPW6NGfn/uwvyi+K2ucn3AGCxxcXl4JGGm0Mx9+7buYl1v3HdcFrg==} + engines: {node: '>=20.19'} + + '@tanstack/router-devtools-core@1.166.7': + resolution: {integrity: sha512-/OGLZlrw5NSNd9/PTL8vPSpmjxIbXNoeJATMHlU3YLCBVBtLx41CHIRc7OLkjyfVFJ4Sq7Pq+2/YH8PChShefg==} + engines: {node: '>=20.19'} + peerDependencies: + '@tanstack/router-core': ^1.166.7 + csstype: ^3.0.10 + peerDependenciesMeta: + csstype: + optional: true + + '@tanstack/router-generator@1.166.7': + resolution: {integrity: sha512-lBI0VS7J1zMrJhfvT+3FMq9jPdOrJ3VgciPXyYvZBF/a9Mr8T94MU78PqrBNuJbYh7qCFO14ZhArUFqkYGuozQ==} + engines: {node: '>=20.19'} + + '@tanstack/router-plugin@1.166.7': + resolution: {integrity: sha512-R06qe5UwApb/u02wDITVxN++6QE4xsLFQCr029VZ+4V8gyIe35kr8UCg3Jiyl6D5GXxhj62U2Ei8jccdkQaivw==} + engines: {node: '>=20.19'} + peerDependencies: + '@rsbuild/core': '>=1.0.2' + '@tanstack/react-router': ^1.166.7 + vite: '>=5.0.0 || >=6.0.0 || >=7.0.0' + vite-plugin-solid: ^2.11.10 + webpack: '>=5.92.0' + peerDependenciesMeta: + '@rsbuild/core': + optional: true + '@tanstack/react-router': + optional: true + vite: + optional: true + vite-plugin-solid: + optional: true + webpack: + optional: true + + '@tanstack/router-utils@1.161.4': + resolution: {integrity: sha512-r8TpjyIZoqrXXaf2DDyjd44gjGBoyE+/oEaaH68yLI9ySPO1gUWmQENZ1MZnmBnpUGN24NOZxdjDLc8npK0SAw==} + engines: {node: '>=20.19'} + + '@tanstack/start-client-core@1.166.7': + resolution: {integrity: sha512-GYSBqJF6yutorSSiOYtOuklAAocvzDM9hijrBSegzxeKJtxUVejIb6M1txH4c+dfikcwaGo6/lnKJWzUE+SjhQ==} + engines: {node: '>=22.12.0'} + + '@tanstack/start-fn-stubs@1.161.4': + resolution: {integrity: sha512-b8s6iSQ+ny0P4lGK0n3DKaL6EI7SECG0/89svDeYieVw2+MaFOJVcQo3rU3BUvmuOcIkgkE5IhdzkmzPXH6yfA==} + engines: {node: '>=22.12.0'} + + '@tanstack/start-plugin-core@1.166.8': + resolution: {integrity: sha512-mnOzxHs+s/AWPylgmo/rjHNFTdK9hsxyaWOhQJycih8VGSeWOtxXAdBDLjw6+81FQjAyudp/wy/s6VBtElDHGQ==} + engines: {node: '>=22.12.0'} + peerDependencies: + vite: '>=7.0.0' + + '@tanstack/start-server-core@1.166.7': + resolution: {integrity: sha512-LWUhi70PuiO2PCYPlXnK11ASw9TA67qo/PO4omSB5zD5ZE0W8PmMBEo1sHY1bc2iYGWRHWIdcPbMFZ2pm2dHkQ==} + engines: {node: '>=22.12.0'} + + '@tanstack/start-storage-context@1.166.7': + resolution: {integrity: sha512-u6gIqf5JAxMBaCUdF34fa0mQEDatj44kJ2i9U3RtI220TgwIxUKCYxhxUpXxtYBU3whCZ07EYwBpKeWxrIhXZQ==} + engines: {node: '>=22.12.0'} + + '@tanstack/store@0.9.2': + resolution: {integrity: sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA==} + + '@tanstack/virtual-file-routes@1.161.4': + resolution: {integrity: sha512-42WoRePf8v690qG8yGRe/YOh+oHni9vUaUUfoqlS91U2scd3a5rkLtVsc6b7z60w3RogH0I00vdrC5AaeiZ18w==} + engines: {node: '>=20.19'} + '@ts-morph/common@0.11.1': resolution: {integrity: sha512-7hWZS0NRpEsNV8vWJzg7FEz6V8MaLNeJOmwmghqUXTpzk16V1LLZhdo+4QvE/+zv4cVci0OviuJFnqhEfoV3+g==} @@ -2467,6 +2886,9 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -2539,6 +2961,9 @@ packages: '@types/node@25.3.2': resolution: {integrity: sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==} + '@types/node@25.5.0': + resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -2550,12 +2975,21 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + + '@types/three@0.183.1': + resolution: {integrity: sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -2583,16 +3017,32 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/project-service@8.57.0': + resolution: {integrity: sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/scope-manager@8.56.1': resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.57.0': + resolution: {integrity: sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.56.1': resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/tsconfig-utils@8.57.0': + resolution: {integrity: sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/type-utils@8.56.1': resolution: {integrity: sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2604,12 +3054,22 @@ packages: resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.57.0': + resolution: {integrity: sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.56.1': resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/typescript-estree@8.57.0': + resolution: {integrity: sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/utils@8.56.1': resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2617,10 +3077,21 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/utils@8.57.0': + resolution: {integrity: sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/visitor-keys@8.56.1': resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.57.0': + resolution: {integrity: sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} @@ -2771,6 +3242,12 @@ packages: '@vercel/static-config@3.1.2': resolution: {integrity: sha512-2d+TXr6K30w86a+WbMbGm2W91O0UzO5VeemZYBBUJbCjk/5FLLGIi8aV6RS2+WmaRvtcqNTn2pUA7nCOK3bGcQ==} + '@vitejs/plugin-react@5.2.0': + resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + '@vitest/expect@4.0.18': resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} @@ -2800,6 +3277,9 @@ packages: '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@webgpu/types@0.1.69': + resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==} + '@xmldom/xmldom@0.7.13': resolution: {integrity: sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==} engines: {node: '>=10.0.0'} @@ -2898,6 +3378,10 @@ packages: ansicolors@0.3.2: resolution: {integrity: sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg==} + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + engines: {node: '>=14'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -2973,6 +3457,10 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-types@0.16.1: + resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} + engines: {node: '>=4'} + astral-regex@2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} @@ -2995,6 +3483,10 @@ packages: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} engines: {node: '>= 4.0.0'} + automation-events@7.1.15: + resolution: {integrity: sha512-NsHJlve3twcgs8IyP4iEYph7Fzpnh6klN7G5LahwvypakBjFbsiGHJxrqTmeHKREdu/Tx6oZboqNI0tD4MnFlA==} + engines: {node: '>=18.2.0'} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -3121,6 +3613,10 @@ packages: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + body-parser@1.20.4: resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -3226,6 +3722,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -3241,6 +3741,17 @@ packages: charenc@0.0.2: resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} + cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + + cheerio@1.2.0: + resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} + engines: {node: '>=20.18.1'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -3354,6 +3865,10 @@ packages: resolution: {integrity: sha512-taEtr3ozUmOB7it68Jll7s0Pwm+aoiHyXKrEC8SEodL4rNpdfDLqa7PfBlrgFoCNNdR8ImL+muti5IGvktJAAg==} engines: {node: '>= 6'} + comment-parser@1.4.5: + resolution: {integrity: sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw==} + engines: {node: '>= 12.0.0'} + commondir@1.0.1: resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} @@ -3378,6 +3893,10 @@ packages: resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} engines: {node: '>= 0.10.0'} + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@0.5.4: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} @@ -3389,6 +3908,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-es@2.0.0: + resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==} + cookie-signature@1.0.7: resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} @@ -3420,6 +3942,14 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crossws@0.4.4: + resolution: {integrity: sha512-w6c4OdpRNnudVmcgr7brb/+/HmYjMQvYToO/oTrprTwxRUiom3LYWU1PMWuD006okbUWpII1Ea9/+kwpUfmyRg==} + peerDependencies: + srvx: '>=0.7.1' + peerDependenciesMeta: + srvx: + optional: true + crypt@0.0.2: resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==} @@ -3473,6 +4003,32 @@ packages: dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + dayjs@1.11.20: + resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} + + db0@0.3.4: + resolution: {integrity: sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw==} + peerDependencies: + '@electric-sql/pglite': '*' + '@libsql/client': '*' + better-sqlite3: '*' + drizzle-orm: '*' + mysql2: '*' + sqlite3: '*' + peerDependenciesMeta: + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + better-sqlite3: + optional: true + drizzle-orm: + optional: true + mysql2: + optional: true + sqlite3: + optional: true + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -3577,6 +4133,10 @@ packages: resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==} engines: {node: '>=0.3.1'} + diff@8.0.3: + resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} + engines: {node: '>=0.3.1'} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -3639,6 +4199,9 @@ packages: ecsify@0.0.15: resolution: {integrity: sha512-LoHb3uumPjI4/gguGPJX8Wk+IHcdFS3JzGMcgReSWLlF7GKnCwqCkw9AHkGlVbV3MhI7xu+/NAomyX2XYJ7ntw==} + ecsify@0.0.19: + resolution: {integrity: sha512-III0e6abUXv6qUxHITwL5uFxpqTNEvAcAwrj2w80VKBatZUqs+Xie2bgVYGePMSeKGL7iFq7pTICZvse+krmRA==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -3664,6 +4227,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + encoding-sniffer@0.2.1: + resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -3675,10 +4241,27 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + env-paths@2.2.0: resolution: {integrity: sha512-6u0VYSCo/OW6IoD5WCLLy9JUGARbamfSavcNXry/eu8aHVFei6CD3Sw+VGX5alea1i9pgPHW0mbu6Xj0uBh7gA==} engines: {node: '>=6'} + env-runner@0.1.6: + resolution: {integrity: sha512-fSb7X1zdda8k6611a6/SdSQpDe7a/bqMz2UWdbHjk9YWzpUR4/fn9YtE/hqgGQ2nhvVN0zUtcL1SRMKwIsDbAA==} + hasBin: true + peerDependencies: + miniflare: ^4.0.0 + peerDependenciesMeta: + miniflare: + optional: true + env-string@1.0.1: resolution: {integrity: sha512-/DhCJDf5DSFK32joQiWRpWrT0h7p3hVQfMKxiBb7Nt8C8IF8BYyPtclDnuGGLOoj16d/8udKeiE7JbkotDmorQ==} @@ -3772,6 +4355,15 @@ packages: peerDependencies: eslint: '>=7.0.0' + eslint-import-context@0.1.9: + resolution: {integrity: sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + peerDependencies: + unrs-resolver: ^1.0.0 + peerDependenciesMeta: + unrs-resolver: + optional: true + eslint-import-resolver-node@0.3.9: resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} @@ -3815,6 +4407,19 @@ packages: peerDependencies: eslint: '>=8.10' + eslint-plugin-import-x@4.16.1: + resolution: {integrity: sha512-vPZZsiOKaBAIATpFE2uMI4w5IRwdv/FpQ+qZZMR4E+PeOcM4OeoEbqxRMnywdxP19TyB/3h6QBB0EWon7letSQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/utils': ^8.0.0 + eslint: ^8.57.0 || ^9.0.0 + eslint-import-resolver-node: '*' + peerDependenciesMeta: + '@typescript-eslint/utils': + optional: true + eslint-import-resolver-node: + optional: true + eslint-plugin-import@2.32.0: resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} engines: {node: '>=4'} @@ -4253,6 +4858,9 @@ packages: fetch-retry@4.1.1: resolution: {integrity: sha512-e6eB7zN6UBSwGVwrbWVH+gdLnkW9WwHhmq2YDK1Sh30pzx1onRVGBvogTlUeWxwTa+L86NYdo4hFkh7O8ZjSnA==} + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -4464,6 +5072,11 @@ packages: golden-fleece@1.0.9: resolution: {integrity: sha512-YSwLaGMOgSBx9roJlNLL12c+FRiw7VECphinc6mGucphc/ZxTHgdEz6gmJqH6NOzYEd/yr64hwjom5pZ+tJVpg==} + goober@2.1.18: + resolution: {integrity: sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==} + peerDependencies: + csstype: ^3.0.10 + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -4485,6 +5098,16 @@ packages: resolution: {integrity: sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + h3@2.0.1-rc.16: + resolution: {integrity: sha512-h+pjvyujdo9way8qj6FUbhaQcHlR8FEq65EhTX9ViT5pK8aLj68uFl4hBkF+hsTJAH+H1END2Yv6hTIsabGfag==} + engines: {node: '>=20.11.1'} + hasBin: true + peerDependencies: + crossws: ^0.4.1 + peerDependenciesMeta: + crossws: + optional: true + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -4559,10 +5182,16 @@ packages: resolution: {integrity: sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==} engines: {node: '>=16.9.0'} + hookable@6.0.1: + resolution: {integrity: sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw==} + hosted-git-info@7.0.2: resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} engines: {node: ^16.14.0 || >=18.0.0} + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + http-call@5.3.0: resolution: {integrity: sha512-ahwimsC23ICE4kPl9xTBjKB4inbRaeLyZeRunC/1Jy/Z6X8tv22MEAjK+KBOMSVLaqXPTTmd8638waVIKLGx2w==} engines: {node: '>=8.0.0'} @@ -4579,6 +5208,9 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + httpxy@0.3.1: + resolution: {integrity: sha512-XjG/CEoofEisMrnFr0D6U6xOZ4mRfnwcYQ9qvvnT4lvnX8BoeA3x3WofB75D+vZwpaobFVkBIHrZzoK40w8XSw==} + human-signals@8.0.1: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} @@ -4594,6 +5226,10 @@ packages: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + idb@8.0.3: resolution: {integrity: sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==} @@ -4681,6 +5317,10 @@ packages: resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} engines: {node: '>= 0.4'} + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + is-boolean-object@1.2.2: resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} engines: {node: '>= 0.4'} @@ -4836,6 +5476,10 @@ packages: resolution: {integrity: sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg==} engines: {node: '>=18'} + isbot@5.1.36: + resolution: {integrity: sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ==} + engines: {node: '>=18'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -4990,6 +5634,9 @@ packages: resolution: {integrity: sha512-EZgbsXMrGS+oK+Ta12mCjzBFse+SIewGdwrSTr5g+MSymnjpox2x05ceI20PQejJOFvOgzcXrfDk/SdY7dSCtw==} hasBin: true + launch-editor@2.13.1: + resolution: {integrity: sha512-lPSddlAAluRKJ7/cjRFoXUFzaX7q/YKI7yPHuEvSJVqoXvFnJov1/Ud87Aa4zULIbA9Nja4mSPK8l0z/7eV2wA==} + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -5133,6 +5780,11 @@ packages: peerDependencies: react: 19.2.4 + lucide-react@0.577.0: + resolution: {integrity: sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==} + peerDependencies: + react: 19.2.4 + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -5206,6 +5858,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + meshoptimizer@1.0.1: + resolution: {integrity: sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==} + methods@1.1.2: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} @@ -5563,9 +6218,40 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + nf3@0.3.11: + resolution: {integrity: sha512-ObKp/SA3f1g1f/OMeDlRWaZmqGgk7A0NnDIbeO7c/MV4r/quMlpP/BsqMGuTi3lUlXbC1On8YH7ICM2u2bIAOw==} + nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} + nitro@3.0.260311-beta: + resolution: {integrity: sha512-0o0fJ9LUh4WKUqJNX012jyieUOtMCnadkNDWr0mHzdraoHpJP/1CGNefjRyZyMXSpoJfwoWdNEZu2iGf35TUvQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + dotenv: '*' + giget: '*' + jiti: ^2.6.1 + rollup: ^4.59.0 + vite: ^7 || ^8 || >=8.0.0-0 + xml2js: ^0.6.2 + zephyr-agent: ^0.1.15 + peerDependenciesMeta: + dotenv: + optional: true + giget: + optional: true + jiti: + optional: true + rollup: + optional: true + vite: + optional: true + xml2js: + optional: true + zephyr-agent: + optional: true + node-exports-info@1.6.0: resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} engines: {node: '>= 0.4'} @@ -5678,6 +6364,15 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + ocache@0.1.2: + resolution: {integrity: sha512-lI34wjM7cahEdrq2I5obbF7MEdE97vULf6vNj6ZCzwEadzyXO1w7QOl2qzzG4IL8cyO7wDtXPj9CqW/aG3mn7g==} + + ofetch@2.0.0-alpha.3: + resolution: {integrity: sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA==} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + on-finished@2.3.0: resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} engines: {node: '>= 0.8'} @@ -5775,6 +6470,15 @@ packages: resolution: {integrity: sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ==} engines: {node: '>=10'} + parse5-htmlparser2-tree-adapter@7.1.0: + resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} + + parse5-parser-stream@7.1.2: + resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -6151,6 +6855,10 @@ packages: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -6171,6 +6879,12 @@ packages: '@types/react': optional: true + react-resizable-panels@4.7.2: + resolution: {integrity: sha512-1L2vyeBG96hp7N6x6rzYXJ8EjYiDiffMsqj3cd+T9aOKwscvuyCn2CuZ5q3PoUSTIJUM6Q5DgXH1bdDe6uvh2w==} + peerDependencies: + react: 19.2.4 + react-dom: 19.2.4 + react-router-hono-server@2.25.0: resolution: {integrity: sha512-w/qAMf7DpFGK1cywMi2b7BRR2u8Bux3JR3bOe15tOtVyJj4pDKomgTWXzC1ASMaPdGik3VvSxCn0+/9aZrWriA==} engines: {node: '>=22.20.0'} @@ -6221,10 +6935,18 @@ packages: resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + recast@0.23.11: + resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} + engines: {node: '>= 4'} + rechoir@0.6.2: resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==} engines: {node: '>= 0.10'} @@ -6356,6 +7078,11 @@ packages: resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} hasBin: true + rolldown@1.0.0-rc.9: + resolution: {integrity: sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rollup-plugin-dts@6.3.0: resolution: {integrity: sha512-d0UrqxYd8KyZ6i3M2Nx7WOMy708qsV/7fTHMHxCMCBOAe3V/U7OMPu5GkX8hC+cmkHhzGnfeYongl1IgiooddA==} engines: {node: '>=16'} @@ -6384,6 +7111,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rou3@0.8.1: + resolution: {integrity: sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -6459,6 +7189,16 @@ packages: resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} engines: {node: '>=0.10.0'} + seroval-plugins@1.5.1: + resolution: {integrity: sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.1: + resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==} + engines: {node: '>=10'} + serve-static@1.16.3: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} @@ -6579,6 +7319,9 @@ packages: resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==} engines: {node: '>=8.0.0'} + solid-js@1.9.11: + resolution: {integrity: sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==} + sort-object-keys@2.1.0: resolution: {integrity: sha512-SOiEnthkJKPv2L6ec6HMwhUcN0/lppkeYuN1x63PbyPRrgSPIuBJCiYxYyvWRTtjMlOi14vQUCGUJqS6PLVm8g==} @@ -6616,6 +7359,15 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + srvx@0.11.9: + resolution: {integrity: sha512-97wWJS6F0KTKAhDlHVmBzMvlBOp5FiNp3XrLoodIgYJpXxgG5tE9rX4Pg7s46n2shI4wtEsMATTS1+rI3/ubzA==} + engines: {node: '>=20.16.0'} + hasBin: true + + stable-hash-x@0.2.0: + resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} + engines: {node: '>=12.0.0'} + stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -6633,6 +7385,9 @@ packages: resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==} engines: {node: '>=6'} + standardized-audio-context@25.3.77: + resolution: {integrity: sha512-Ki9zNz6pKcC5Pi+QPjPyVsD9GwJIJWgryji0XL9cAJXMGyn+dPOf6Qik1AHei0+UNVcc4BOCa0hWLBzlwqsW/A==} + statuses@1.5.0: resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} engines: {node: '>= 0.6'} @@ -6808,9 +7563,18 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + three@0.183.2: + resolution: {integrity: sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==} + throat@5.0.0: resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tiny-warning@1.0.3: + resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -6837,6 +7601,9 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tone@15.1.22: + resolution: {integrity: sha512-TCScAGD4sLsama5DjvTUXlLDXSqPealhL64nsdV1hhr6frPWve0DeSo63AKnSJwgfg55fhvxj0iPPRwPN5o0ag==} + toqr@0.1.1: resolution: {integrity: sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA==} @@ -7008,6 +7775,9 @@ packages: resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==} hasBin: true + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -7015,6 +7785,13 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici@7.24.1: + resolution: {integrity: sha512-5xoBibbmnjlcR3jdqtY2Lnx7WbrD/tHlT01TmvqZUFVc9Q1w4+j5hbnapTqbcXITMH1ovjq/W7BkqBilHiVAaA==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + unicode-canonical-property-names-ecmascript@2.0.1: resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} engines: {node: '>=4'} @@ -7076,9 +7853,87 @@ packages: resolution: {integrity: sha512-gwXJnPRewT4rT7sBi/IvxKTjsms7jX7QIDLOClApuZwR49SXbrB1z2NLUZ+vDHyqCj/n58OzRRqaW+B8OZi8vg==} engines: {node: '>=18.12.0'} + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + unstorage@2.0.0-alpha.6: + resolution: {integrity: sha512-w5vLYCJtnSx3OBtDk7cG4c1p3dfAnHA4WSZq9Xsurjbl2wMj7zqfOIjaHQI1Bl7yKzUxXAi+kbMr8iO2RhJmBA==} + peerDependencies: + '@azure/app-configuration': ^1.11.0 + '@azure/cosmos': ^4.9.1 + '@azure/data-tables': ^13.3.2 + '@azure/identity': ^4.13.0 + '@azure/keyvault-secrets': ^4.10.0 + '@azure/storage-blob': ^12.31.0 + '@capacitor/preferences': ^6 || ^7 || ^8 + '@deno/kv': '>=0.13.0' + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.36.2 + '@vercel/blob': '>=0.27.3' + '@vercel/functions': ^2.2.12 || ^3.0.0 + '@vercel/kv': ^1.0.1 + aws4fetch: ^1.0.20 + chokidar: ^4 || ^5 + db0: '>=0.3.4' + idb-keyval: ^6.2.2 + ioredis: ^5.9.3 + lru-cache: ^11.2.6 + mongodb: ^6 || ^7 + ofetch: '*' + uploadthing: ^7.7.4 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@deno/kv': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/blob': + optional: true + '@vercel/functions': + optional: true + '@vercel/kv': + optional: true + aws4fetch: + optional: true + chokidar: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + lru-cache: + optional: true + mongodb: + optional: true + ofetch: + optional: true + uploadthing: + optional: true + untildify@4.0.0: resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} engines: {node: '>=8'} @@ -7222,6 +8077,14 @@ packages: yaml: optional: true + vitefu@1.1.2: + resolution: {integrity: sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0 + peerDependenciesMeta: + vite: + optional: true + vitest@4.0.18: resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -7271,9 +8134,21 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + whatwg-fetch@3.6.20: resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + whatwg-url-minimum@0.1.1: resolution: {integrity: sha512-u2FNVjFVFZhdjb502KzXy1gKn1mEisQRJssmSJT8CPhZdZa0AP6VCbWlXERKyGu0l09t0k50FiDiralpGhBxgA==} @@ -7374,6 +8249,10 @@ packages: resolution: {integrity: sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==} engines: {node: '>=4.0.0'} + xmlbuilder2@4.0.3: + resolution: {integrity: sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==} + engines: {node: '>=20.0'} + xmlbuilder@11.0.1: resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} engines: {node: '>=4.0'} @@ -7462,6 +8341,12 @@ snapshots: '@babel/highlight': 7.25.9 chalk: 2.4.2 + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -8112,7 +8997,7 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@blgc/config@0.0.40(eslint@9.39.3(jiti@2.6.1))(postcss@8.5.6)(prettier@3.8.1)(turbo@2.8.11)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@blgc/config@0.0.40(eslint@9.39.3(jiti@2.6.1))(postcss@8.5.6)(prettier@3.8.1)(turbo@2.8.11)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@ianvs/prettier-plugin-sort-imports': 4.7.1(prettier@3.8.1) '@next/eslint-plugin-next': 16.1.6 @@ -8127,7 +9012,7 @@ snapshots: prettier-plugin-packagejson: 2.5.22(prettier@3.8.1) prettier-plugin-tailwindcss: 0.7.2(@ianvs/prettier-plugin-sort-imports@4.7.1(prettier@3.8.1))(prettier-plugin-css-order@2.2.0(postcss@8.5.6)(prettier@3.8.1))(prettier@3.8.1) typescript-eslint: 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) - vite-tsconfig-paths: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + vite-tsconfig-paths: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) transitivePeerDependencies: - '@prettier/plugin-hermes' - '@prettier/plugin-oxc' @@ -8162,6 +9047,10 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@dimforge/rapier3d-compat@0.12.0': {} + + '@dimforge/rapier3d-compat@0.19.3': {} + '@drizzle-team/brocli@0.11.0': {} '@egjs/hammerjs@2.0.17': @@ -8174,16 +9063,32 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/core@1.9.0': + dependencies: + '@emnapi/wasi-threads': 1.2.0 + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.9.0': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.1.0': dependencies: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.2.0': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.27.3': optional: true @@ -8731,18 +9636,18 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 - '@expo/plugin-help@5.1.23(@types/node@25.3.2)(typescript@5.9.3)': + '@expo/plugin-help@5.1.23(@types/node@25.5.0)(typescript@5.9.3)': dependencies: - '@oclif/core': 2.16.0(@types/node@25.3.2)(typescript@5.9.3) + '@oclif/core': 2.16.0(@types/node@25.5.0)(typescript@5.9.3) transitivePeerDependencies: - '@swc/core' - '@swc/wasm' - '@types/node' - typescript - '@expo/plugin-warn-if-update-available@2.5.1(@types/node@25.3.2)(typescript@5.9.3)': + '@expo/plugin-warn-if-update-available@2.5.1(@types/node@25.5.0)(typescript@5.9.3)': dependencies: - '@oclif/core': 2.16.0(@types/node@25.3.2)(typescript@5.9.3) + '@oclif/core': 2.16.0(@types/node@25.5.0)(typescript@5.9.3) chalk: 4.1.2 debug: 4.4.3(supports-color@8.1.1) ejs: 3.1.10 @@ -9052,14 +9957,14 @@ snapshots: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 25.3.2 + '@types/node': 25.5.0 jest-mock: 29.7.0 '@jest/fake-timers@29.7.0': dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 25.3.2 + '@types/node': 25.5.0 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -9093,7 +9998,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 25.3.2 + '@types/node': 25.5.0 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -9175,6 +10080,13 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@napi-rs/wasm-runtime@1.1.1': + dependencies: + '@emnapi/core': 1.9.0 + '@emnapi/runtime': 1.9.0 + '@tybys/wasm-util': 0.10.1 + optional: true + '@next/eslint-plugin-next@16.1.6': dependencies: fast-glob: 3.3.1 @@ -9224,7 +10136,7 @@ snapshots: widest-line: 3.1.0 wrap-ansi: 7.0.0 - '@oclif/core@2.16.0(@types/node@25.3.2)(typescript@5.9.3)': + '@oclif/core@2.16.0(@types/node@25.5.0)(typescript@5.9.3)': dependencies: '@types/cli-progress': 3.11.6 ansi-escapes: 4.3.2 @@ -9249,7 +10161,7 @@ snapshots: strip-ansi: 6.0.1 supports-color: 8.1.1 supports-hyperlinks: 2.3.0 - ts-node: 10.9.2(@types/node@25.3.2)(typescript@5.9.3) + ts-node: 10.9.2(@types/node@25.5.0)(typescript@5.9.3) tslib: 2.8.1 widest-line: 3.1.0 wordwrap: 1.0.0 @@ -9262,9 +10174,9 @@ snapshots: '@oclif/linewrap@1.0.0': {} - '@oclif/plugin-autocomplete@2.3.10(@types/node@25.3.2)(typescript@5.9.3)': + '@oclif/plugin-autocomplete@2.3.10(@types/node@25.5.0)(typescript@5.9.3)': dependencies: - '@oclif/core': 2.16.0(@types/node@25.3.2)(typescript@5.9.3) + '@oclif/core': 2.16.0(@types/node@25.5.0)(typescript@5.9.3) chalk: 4.1.2 debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: @@ -9276,6 +10188,25 @@ snapshots: '@oclif/screen@3.0.8': {} + '@oozcitak/dom@2.0.2': + dependencies: + '@oozcitak/infra': 2.0.2 + '@oozcitak/url': 3.0.0 + '@oozcitak/util': 10.0.0 + + '@oozcitak/infra@2.0.2': + dependencies: + '@oozcitak/util': 10.0.0 + + '@oozcitak/url@3.0.0': + dependencies: + '@oozcitak/infra': 2.0.2 + '@oozcitak/util': 10.0.0 + + '@oozcitak/util@10.0.0': {} + + '@oxc-project/types@0.115.0': {} + '@pkgjs/parseargs@0.11.0': optional: true @@ -9728,6 +10659,56 @@ snapshots: - tsx - yaml + '@react-router/dev@7.13.1(@react-router/serve@7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2)': + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.0 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@react-router/node': 7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) + '@remix-run/node-fetch-server': 0.13.0 + arg: 5.0.2 + babel-dead-code-elimination: 1.0.12 + chokidar: 4.0.3 + dedent: 1.7.1 + es-module-lexer: 1.7.0 + exit-hook: 2.2.1 + isbot: 5.1.35 + jsesc: 3.0.2 + lodash: 4.17.23 + p-map: 7.0.4 + pathe: 1.1.2 + picocolors: 1.1.1 + pkg-types: 2.3.0 + prettier: 3.8.1 + react-refresh: 0.14.2 + react-router: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + semver: 7.7.4 + tinyglobby: 0.2.15 + valibot: 1.2.0(typescript@5.9.3) + vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + optionalDependencies: + '@react-router/serve': 7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + '@react-router/express@7.13.1(express@4.22.1)(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)': dependencies: '@react-router/node': 7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) @@ -9743,6 +10724,13 @@ snapshots: optionalDependencies: typescript: 5.9.3 + '@react-router/fs-routes@7.13.1(@react-router/dev@7.13.1(@react-router/serve@7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2))(typescript@5.9.3)': + dependencies: + '@react-router/dev': 7.13.1(@react-router/serve@7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2) + minimatch: 9.0.9 + optionalDependencies: + typescript: 5.9.3 + '@react-router/node@7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)': dependencies: '@mjackson/node-fetch-server': 0.2.0 @@ -9767,6 +10755,59 @@ snapshots: '@remix-run/node-fetch-server@0.13.0': {} + '@rolldown/binding-android-arm64@1.0.0-rc.9': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.9': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.9': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.9': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.9': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.9': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.9': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.9': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.9': + dependencies: + '@napi-rs/wasm-runtime': 1.1.1 + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.9': + optional: true + + '@rolldown/pluginutils@1.0.0-beta.40': {} + + '@rolldown/pluginutils@1.0.0-rc.3': {} + + '@rolldown/pluginutils@1.0.0-rc.9': {} + '@rollup/plugin-commonjs@29.0.0(rollup@4.59.0)': dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.59.0) @@ -9895,6 +10936,40 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@solid-primitives/event-listener@2.4.5(solid-js@1.9.11)': + dependencies: + '@solid-primitives/utils': 6.4.0(solid-js@1.9.11) + solid-js: 1.9.11 + + '@solid-primitives/keyboard@1.3.5(solid-js@1.9.11)': + dependencies: + '@solid-primitives/event-listener': 2.4.5(solid-js@1.9.11) + '@solid-primitives/rootless': 1.5.3(solid-js@1.9.11) + '@solid-primitives/utils': 6.4.0(solid-js@1.9.11) + solid-js: 1.9.11 + + '@solid-primitives/resize-observer@2.1.5(solid-js@1.9.11)': + dependencies: + '@solid-primitives/event-listener': 2.4.5(solid-js@1.9.11) + '@solid-primitives/rootless': 1.5.3(solid-js@1.9.11) + '@solid-primitives/static-store': 0.1.3(solid-js@1.9.11) + '@solid-primitives/utils': 6.4.0(solid-js@1.9.11) + solid-js: 1.9.11 + + '@solid-primitives/rootless@1.5.3(solid-js@1.9.11)': + dependencies: + '@solid-primitives/utils': 6.4.0(solid-js@1.9.11) + solid-js: 1.9.11 + + '@solid-primitives/static-store@0.1.3(solid-js@1.9.11)': + dependencies: + '@solid-primitives/utils': 6.4.0(solid-js@1.9.11) + solid-js: 1.9.11 + + '@solid-primitives/utils@6.4.0(solid-js@1.9.11)': + dependencies: + solid-js: 1.9.11 + '@standard-schema/spec@1.1.0': {} '@tailwindcss/node@4.2.1': @@ -9978,6 +11053,285 @@ snapshots: tailwindcss: 4.2.1 vite: 7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@tailwindcss/node': 4.2.1 + '@tailwindcss/oxide': 4.2.1 + tailwindcss: 4.2.1 + vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + + '@tanstack/devtools-client@0.0.6': + dependencies: + '@tanstack/devtools-event-client': 0.4.3 + + '@tanstack/devtools-event-bus@0.4.1': + dependencies: + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@tanstack/devtools-event-client@0.4.3': {} + + '@tanstack/devtools-ui@0.5.0(csstype@3.2.3)(solid-js@1.9.11)': + dependencies: + clsx: 2.1.1 + dayjs: 1.11.20 + goober: 2.1.18(csstype@3.2.3) + solid-js: 1.9.11 + transitivePeerDependencies: + - csstype + + '@tanstack/devtools-vite@0.5.5(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.0 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@tanstack/devtools-client': 0.0.6 + '@tanstack/devtools-event-bus': 0.4.1 + chalk: 5.6.2 + launch-editor: 2.13.1 + picomatch: 4.0.3 + vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@tanstack/devtools@0.10.14(csstype@3.2.3)(solid-js@1.9.11)': + dependencies: + '@solid-primitives/event-listener': 2.4.5(solid-js@1.9.11) + '@solid-primitives/keyboard': 1.3.5(solid-js@1.9.11) + '@solid-primitives/resize-observer': 2.1.5(solid-js@1.9.11) + '@tanstack/devtools-client': 0.0.6 + '@tanstack/devtools-event-bus': 0.4.1 + '@tanstack/devtools-ui': 0.5.0(csstype@3.2.3)(solid-js@1.9.11) + clsx: 2.1.1 + goober: 2.1.18(csstype@3.2.3) + solid-js: 1.9.11 + transitivePeerDependencies: + - bufferutil + - csstype + - utf-8-validate + + '@tanstack/history@1.161.4': {} + + '@tanstack/react-devtools@0.9.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)': + dependencies: + '@tanstack/devtools': 0.10.14(csstype@3.2.3)(solid-js@1.9.11) + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + transitivePeerDependencies: + - bufferutil + - csstype + - solid-js + - utf-8-validate + + '@tanstack/react-router-devtools@1.166.7(@tanstack/react-router@1.166.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.166.7)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tanstack/react-router': 1.166.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/router-devtools-core': 1.166.7(@tanstack/router-core@1.166.7)(csstype@3.2.3) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@tanstack/router-core': 1.166.7 + transitivePeerDependencies: + - csstype + + '@tanstack/react-router@1.166.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tanstack/history': 1.161.4 + '@tanstack/react-store': 0.9.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/router-core': 1.166.7 + isbot: 5.1.36 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + + '@tanstack/react-start-client@1.166.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tanstack/react-router': 1.166.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/router-core': 1.166.7 + '@tanstack/start-client-core': 1.166.7 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + + '@tanstack/react-start-server@1.166.7(crossws@0.4.4(srvx@0.11.9))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tanstack/history': 1.161.4 + '@tanstack/react-router': 1.166.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/router-core': 1.166.7 + '@tanstack/start-client-core': 1.166.7 + '@tanstack/start-server-core': 1.166.7(crossws@0.4.4(srvx@0.11.9)) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + transitivePeerDependencies: + - crossws + + '@tanstack/react-start@1.166.8(crossws@0.4.4(srvx@0.11.9))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@tanstack/react-router': 1.166.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-start-client': 1.166.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-start-server': 1.166.7(crossws@0.4.4(srvx@0.11.9))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/router-utils': 1.161.4 + '@tanstack/start-client-core': 1.166.7 + '@tanstack/start-plugin-core': 1.166.8(@tanstack/react-router@1.166.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(crossws@0.4.4(srvx@0.11.9))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@tanstack/start-server-core': 1.166.7(crossws@0.4.4(srvx@0.11.9)) + pathe: 2.0.3 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - '@rsbuild/core' + - crossws + - supports-color + - vite-plugin-solid + - webpack + + '@tanstack/react-store@0.9.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tanstack/store': 0.9.2 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.4) + + '@tanstack/router-core@1.166.7': + dependencies: + '@tanstack/history': 1.161.4 + '@tanstack/store': 0.9.2 + cookie-es: 2.0.0 + seroval: 1.5.1 + seroval-plugins: 1.5.1(seroval@1.5.1) + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + + '@tanstack/router-devtools-core@1.166.7(@tanstack/router-core@1.166.7)(csstype@3.2.3)': + dependencies: + '@tanstack/router-core': 1.166.7 + clsx: 2.1.1 + goober: 2.1.18(csstype@3.2.3) + tiny-invariant: 1.3.3 + optionalDependencies: + csstype: 3.2.3 + + '@tanstack/router-generator@1.166.7': + dependencies: + '@tanstack/router-core': 1.166.7 + '@tanstack/router-utils': 1.161.4 + '@tanstack/virtual-file-routes': 1.161.4 + prettier: 3.8.1 + recast: 0.23.11 + source-map: 0.7.6 + tsx: 4.21.0 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@tanstack/router-plugin@1.166.7(@tanstack/react-router@1.166.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@tanstack/router-core': 1.166.7 + '@tanstack/router-generator': 1.166.7 + '@tanstack/router-utils': 1.161.4 + '@tanstack/virtual-file-routes': 1.161.4 + chokidar: 3.6.0 + unplugin: 2.3.11 + zod: 3.25.76 + optionalDependencies: + '@tanstack/react-router': 1.166.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - supports-color + + '@tanstack/router-utils@1.161.4': + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + ansis: 4.2.0 + babel-dead-code-elimination: 1.0.12 + diff: 8.0.3 + pathe: 2.0.3 + tinyglobby: 0.2.15 + transitivePeerDependencies: + - supports-color + + '@tanstack/start-client-core@1.166.7': + dependencies: + '@tanstack/router-core': 1.166.7 + '@tanstack/start-fn-stubs': 1.161.4 + '@tanstack/start-storage-context': 1.166.7 + seroval: 1.5.1 + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + + '@tanstack/start-fn-stubs@1.161.4': {} + + '@tanstack/start-plugin-core@1.166.8(@tanstack/react-router@1.166.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(crossws@0.4.4(srvx@0.11.9))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/core': 7.29.0 + '@babel/types': 7.29.0 + '@rolldown/pluginutils': 1.0.0-beta.40 + '@tanstack/router-core': 1.166.7 + '@tanstack/router-generator': 1.166.7 + '@tanstack/router-plugin': 1.166.7(@tanstack/react-router@1.166.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@tanstack/router-utils': 1.161.4 + '@tanstack/start-client-core': 1.166.7 + '@tanstack/start-server-core': 1.166.7(crossws@0.4.4(srvx@0.11.9)) + cheerio: 1.2.0 + exsolve: 1.0.8 + pathe: 2.0.3 + picomatch: 4.0.3 + source-map: 0.7.6 + srvx: 0.11.9 + tinyglobby: 0.2.15 + ufo: 1.6.3 + vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitefu: 1.1.2(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + xmlbuilder2: 4.0.3 + zod: 3.25.76 + transitivePeerDependencies: + - '@rsbuild/core' + - '@tanstack/react-router' + - crossws + - supports-color + - vite-plugin-solid + - webpack + + '@tanstack/start-server-core@1.166.7(crossws@0.4.4(srvx@0.11.9))': + dependencies: + '@tanstack/history': 1.161.4 + '@tanstack/router-core': 1.166.7 + '@tanstack/start-client-core': 1.166.7 + '@tanstack/start-storage-context': 1.166.7 + h3-v2: h3@2.0.1-rc.16(crossws@0.4.4(srvx@0.11.9)) + seroval: 1.5.1 + tiny-invariant: 1.3.3 + transitivePeerDependencies: + - crossws + + '@tanstack/start-storage-context@1.166.7': + dependencies: + '@tanstack/router-core': 1.166.7 + + '@tanstack/store@0.9.2': {} + + '@tanstack/virtual-file-routes@1.161.4': {} + '@ts-morph/common@0.11.1': dependencies: fast-glob: 3.3.3 @@ -9993,6 +11347,8 @@ snapshots: '@tsconfig/node16@1.0.4': {} + '@tweenjs/tween.js@23.1.3': {} + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -10021,7 +11377,7 @@ snapshots: '@types/bunyan@1.8.11': dependencies: - '@types/node': 25.3.2 + '@types/node': 25.5.0 '@types/chai@5.2.3': dependencies: @@ -10030,7 +11386,7 @@ snapshots: '@types/cli-progress@3.11.6': dependencies: - '@types/node': 25.3.2 + '@types/node': 25.5.0 '@types/debug@4.1.12': dependencies: @@ -10046,7 +11402,7 @@ snapshots: '@types/graceful-fs@4.1.9': dependencies: - '@types/node': 25.3.2 + '@types/node': 25.5.0 '@types/hammerjs@2.0.46': {} @@ -10080,6 +11436,10 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/node@25.5.0': + dependencies: + undici-types: 7.18.2 + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -10090,10 +11450,24 @@ snapshots: '@types/stack-utils@2.0.3': {} + '@types/stats.js@0.17.4': {} + + '@types/three@0.183.1': + dependencies: + '@dimforge/rapier3d-compat': 0.12.0 + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 + '@webgpu/types': 0.1.69 + fflate: 0.8.2 + meshoptimizer: 1.0.1 + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} + '@types/webxr@0.5.24': {} + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.35': @@ -10137,15 +11511,36 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.57.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@5.9.3) + '@typescript-eslint/types': 8.57.0 + debug: 4.4.3(supports-color@8.1.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + optional: true + '@typescript-eslint/scope-manager@8.56.1': dependencies: '@typescript-eslint/types': 8.56.1 '@typescript-eslint/visitor-keys': 8.56.1 + '@typescript-eslint/scope-manager@8.57.0': + dependencies: + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/visitor-keys': 8.57.0 + optional: true + '@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.9.3)': dependencies: typescript: 5.9.3 + '@typescript-eslint/tsconfig-utils@8.57.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + optional: true + '@typescript-eslint/type-utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.56.1 @@ -10160,6 +11555,9 @@ snapshots: '@typescript-eslint/types@8.56.1': {} + '@typescript-eslint/types@8.57.0': + optional: true + '@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)': dependencies: '@typescript-eslint/project-service': 8.56.1(typescript@5.9.3) @@ -10175,6 +11573,22 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.57.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.57.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@5.9.3) + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/visitor-keys': 8.57.0 + debug: 4.4.3(supports-color@8.1.1) + minimatch: 10.2.4 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + optional: true + '@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1)) @@ -10186,11 +11600,29 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.57.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.57.0 + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) + eslint: 9.39.3(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + optional: true + '@typescript-eslint/visitor-keys@8.56.1': dependencies: '@typescript-eslint/types': 8.56.1 eslint-visitor-keys: 5.0.1 + '@typescript-eslint/visitor-keys@8.57.0': + dependencies: + '@typescript-eslint/types': 8.57.0 + eslint-visitor-keys: 5.0.1 + optional: true + '@ungap/structured-clone@1.3.0': {} '@unrs/resolver-binding-android-arm-eabi@1.11.1': @@ -10280,12 +11712,34 @@ snapshots: react-dom: 19.2.4(react@19.2.4) ts-morph: 12.0.0 + '@vercel/react-router@1.2.5(@react-router/dev@7.13.1(@react-router/serve@7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2))(@react-router/node@7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(isbot@5.1.35)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@react-router/dev': 7.13.1(@react-router/serve@7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2) + '@react-router/node': 7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) + '@vercel/static-config': 3.1.2 + isbot: 5.1.35 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + ts-morph: 12.0.0 + '@vercel/static-config@3.1.2': dependencies: ajv: 8.6.3 json-schema-to-ts: 1.6.4 ts-morph: 12.0.0 + '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - supports-color + '@vitest/expect@4.0.18': dependencies: '@standard-schema/spec': 1.1.0 @@ -10295,13 +11749,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@4.0.18': dependencies: @@ -10325,6 +11779,8 @@ snapshots: '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 + '@webgpu/types@0.1.69': {} + '@xmldom/xmldom@0.7.13': {} '@xmldom/xmldom@0.8.11': {} @@ -10412,6 +11868,8 @@ snapshots: ansicolors@0.3.2: {} + ansis@4.2.0: {} + any-promise@1.3.0: {} anymatch@3.1.3: @@ -10514,6 +11972,10 @@ snapshots: assertion-error@2.0.1: {} + ast-types@0.16.1: + dependencies: + tslib: 2.8.1 + astral-regex@2.0.0: {} astring@1.9.0: {} @@ -10526,6 +11988,11 @@ snapshots: at-least-node@1.0.0: {} + automation-events@7.1.15: + dependencies: + '@babel/runtime': 7.28.6 + tslib: 2.8.1 + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -10699,6 +12166,8 @@ snapshots: big-integer@1.6.52: {} + binary-extensions@2.3.0: {} + body-parser@1.20.4: dependencies: bytes: 3.1.2 @@ -10823,6 +12292,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.6.2: {} + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -10833,6 +12304,41 @@ snapshots: charenc@0.0.2: {} + cheerio-select@2.1.0: + dependencies: + boolbase: 1.0.0 + css-select: 5.2.2 + css-what: 6.2.2 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + + cheerio@1.2.0: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + encoding-sniffer: 0.2.1 + htmlparser2: 10.1.0 + parse5: 7.3.0 + parse5-htmlparser2-tree-adapter: 7.1.0 + parse5-parser-stream: 7.1.2 + undici: 7.24.1 + whatwg-mimetype: 4.0.0 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -10841,7 +12347,7 @@ snapshots: chrome-launcher@0.15.2: dependencies: - '@types/node': 25.3.2 + '@types/node': 25.5.0 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -10850,7 +12356,7 @@ snapshots: chromium-edge-launcher@0.2.0: dependencies: - '@types/node': 25.3.2 + '@types/node': 25.5.0 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -10941,6 +12447,9 @@ snapshots: core-util-is: 1.0.3 esprima: 4.0.1 + comment-parser@1.4.5: + optional: true + commondir@1.0.1: {} component-type@1.2.2: {} @@ -10974,6 +12483,8 @@ snapshots: transitivePeerDependencies: - supports-color + consola@3.4.2: {} + content-disposition@0.5.4: dependencies: safe-buffer: 5.2.1 @@ -10982,6 +12493,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie-es@2.0.0: {} + cookie-signature@1.0.7: {} cookie@0.7.2: {} @@ -11016,6 +12529,10 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crossws@0.4.4(srvx@0.11.9): + optionalDependencies: + srvx: 0.11.9 + crypt@0.0.2: {} crypto-random-string@2.0.0: {} @@ -11069,6 +12586,10 @@ snapshots: dateformat@4.6.3: {} + dayjs@1.11.20: {} + + db0@0.3.4: {} + debug@2.6.9: dependencies: ms: 2.0.0 @@ -11137,6 +12658,8 @@ snapshots: diff@7.0.0: {} + diff@8.0.3: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -11188,7 +12711,7 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - eas-cli@18.0.6(@types/node@25.3.2)(typescript@5.9.3): + eas-cli@18.0.6(@types/node@25.5.0)(typescript@5.9.3): dependencies: '@expo/apple-utils': 2.1.13 '@expo/code-signing-certificates': 0.0.5 @@ -11204,8 +12727,8 @@ snapshots: '@expo/package-manager': 1.9.10 '@expo/pkcs12': 0.1.3 '@expo/plist': 0.2.0 - '@expo/plugin-help': 5.1.23(@types/node@25.3.2)(typescript@5.9.3) - '@expo/plugin-warn-if-update-available': 2.5.1(@types/node@25.3.2)(typescript@5.9.3) + '@expo/plugin-help': 5.1.23(@types/node@25.5.0)(typescript@5.9.3) + '@expo/plugin-warn-if-update-available': 2.5.1(@types/node@25.5.0)(typescript@5.9.3) '@expo/prebuild-config': 8.0.17 '@expo/results': 1.0.0 '@expo/rudder-sdk-node': 1.1.1 @@ -11213,7 +12736,7 @@ snapshots: '@expo/steps': 18.0.2 '@expo/timeago.js': 1.0.0 '@oclif/core': 1.26.2 - '@oclif/plugin-autocomplete': 2.3.10(@types/node@25.3.2)(typescript@5.9.3) + '@oclif/plugin-autocomplete': 2.3.10(@types/node@25.5.0)(typescript@5.9.3) '@segment/ajv-human-errors': 2.16.0(ajv@8.11.0) '@urql/core': 4.0.11(graphql@16.8.1) '@urql/exchange-retry': 1.2.0(graphql@16.8.1) @@ -11291,6 +12814,10 @@ snapshots: dependencies: '@blgc/utils': 0.0.61 + ecsify@0.0.19: + dependencies: + '@blgc/utils': 0.0.61 + ee-first@1.1.1: {} ejs@3.1.10: @@ -11307,6 +12834,11 @@ snapshots: encodeurl@2.0.0: {} + encoding-sniffer@0.2.1: + dependencies: + iconv-lite: 0.6.3 + whatwg-encoding: 3.1.1 + end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -11318,8 +12850,18 @@ snapshots: entities@4.5.0: {} + entities@6.0.1: {} + + entities@7.0.1: {} + env-paths@2.2.0: {} + env-runner@0.1.6: + dependencies: + crossws: 0.4.4(srvx@0.11.9) + httpxy: 0.3.1 + srvx: 0.11.9 + env-string@1.0.1: {} envinfo@7.11.0: {} @@ -11490,12 +13032,12 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-expo@55.0.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3): + eslint-config-expo@55.0.0(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.57.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3): dependencies: '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.3(jiti@2.6.1) - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.57.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.3(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1)) eslint-plugin-expo: 1.0.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.3(jiti@2.6.1)) @@ -11511,6 +13053,14 @@ snapshots: dependencies: eslint: 9.39.3(jiti@2.6.1) + eslint-import-context@0.1.9(unrs-resolver@1.11.1): + dependencies: + get-tsconfig: 4.13.6 + stable-hash-x: 0.2.0 + optionalDependencies: + unrs-resolver: 1.11.1 + optional: true + eslint-import-resolver-node@0.3.9: dependencies: debug: 3.2.7 @@ -11519,7 +13069,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.57.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.3(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3(supports-color@8.1.1) @@ -11531,6 +13081,7 @@ snapshots: unrs-resolver: 1.11.1 optionalDependencies: eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)) + eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.57.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.3(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -11541,7 +13092,7 @@ snapshots: '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.3(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.57.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.3(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -11554,6 +13105,25 @@ snapshots: - supports-color - typescript + eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.57.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.3(jiti@2.6.1)): + dependencies: + '@typescript-eslint/types': 8.57.0 + comment-parser: 1.4.5 + debug: 4.4.3(supports-color@8.1.1) + eslint: 9.39.3(jiti@2.6.1) + eslint-import-context: 0.1.9(unrs-resolver@1.11.1) + is-glob: 4.0.3 + minimatch: 10.2.4 + semver: 7.7.4 + stable-hash-x: 0.2.0 + unrs-resolver: 1.11.1 + optionalDependencies: + '@typescript-eslint/utils': 8.57.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + eslint-import-resolver-node: 0.3.9 + transitivePeerDependencies: + - supports-color + optional: true + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 @@ -12173,6 +13743,8 @@ snapshots: fetch-retry@4.1.1: {} + fflate@0.8.2: {} + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -12423,6 +13995,10 @@ snapshots: golden-fleece@1.0.9: {} + goober@2.1.18(csstype@3.2.3): + dependencies: + csstype: 3.2.3 + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -12438,6 +14014,13 @@ snapshots: graphql@16.8.1: {} + h3@2.0.1-rc.16(crossws@0.4.4(srvx@0.11.9)): + dependencies: + rou3: 0.8.1 + srvx: 0.11.9 + optionalDependencies: + crossws: 0.4.4(srvx@0.11.9) + has-bigints@1.1.0: {} has-flag@3.0.0: {} @@ -12539,10 +14122,19 @@ snapshots: hono@4.12.3: {} + hookable@6.0.1: {} + hosted-git-info@7.0.2: dependencies: lru-cache: 10.4.3 + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + http-call@5.3.0: dependencies: content-type: 1.0.5 @@ -12576,6 +14168,8 @@ snapshots: transitivePeerDependencies: - supports-color + httpxy@0.3.1: {} + human-signals@8.0.1: {} hyperlinker@1.0.0: {} @@ -12586,6 +14180,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + idb@8.0.3: {} ieee754@1.2.1: @@ -12666,6 +14264,10 @@ snapshots: dependencies: has-bigints: 1.1.0 + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + is-boolean-object@1.2.2: dependencies: call-bound: 1.0.4 @@ -12798,6 +14400,8 @@ snapshots: isbot@5.1.35: {} + isbot@5.1.36: {} + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -12838,7 +14442,7 @@ snapshots: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 25.3.2 + '@types/node': 25.5.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -12848,7 +14452,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 25.3.2 + '@types/node': 25.5.0 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -12875,7 +14479,7 @@ snapshots: jest-mock@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 25.3.2 + '@types/node': 25.5.0 jest-util: 29.7.0 jest-regex-util@29.6.3: {} @@ -12883,7 +14487,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 25.3.2 + '@types/node': 25.5.0 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -12900,7 +14504,7 @@ snapshots: jest-worker@29.7.0: dependencies: - '@types/node': 25.3.2 + '@types/node': 25.5.0 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -12996,6 +14600,11 @@ snapshots: lan-network@0.2.0: {} + launch-editor@2.13.1: + dependencies: + picocolors: 1.1.1 + shell-quote: 1.8.3 + leven@3.1.0: {} levn@0.4.1: @@ -13108,6 +14717,10 @@ snapshots: dependencies: react: 19.2.4 + lucide-react@0.577.0(react@19.2.4): + dependencies: + react: 19.2.4 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -13243,6 +14856,8 @@ snapshots: merge2@1.4.1: {} + meshoptimizer@1.0.1: {} + methods@1.1.2: {} metro-babel-transformer@0.83.3: @@ -13925,8 +15540,61 @@ snapshots: negotiator@1.0.0: {} + nf3@0.3.11: {} + nice-try@1.0.5: {} + nitro@3.0.260311-beta(chokidar@4.0.3)(dotenv@16.4.7)(jiti@2.6.1)(lru-cache@11.2.6)(rollup@4.59.0)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): + dependencies: + consola: 3.4.2 + crossws: 0.4.4(srvx@0.11.9) + db0: 0.3.4 + env-runner: 0.1.6 + h3: 2.0.1-rc.16(crossws@0.4.4(srvx@0.11.9)) + hookable: 6.0.1 + nf3: 0.3.11 + ocache: 0.1.2 + ofetch: 2.0.0-alpha.3 + ohash: 2.0.11 + rolldown: 1.0.0-rc.9 + srvx: 0.11.9 + unenv: 2.0.0-rc.24 + unstorage: 2.0.0-alpha.6(chokidar@4.0.3)(db0@0.3.4)(lru-cache@11.2.6)(ofetch@2.0.0-alpha.3) + optionalDependencies: + dotenv: 16.4.7 + jiti: 2.6.1 + rollup: 4.59.0 + vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@libsql/client' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - better-sqlite3 + - chokidar + - drizzle-orm + - idb-keyval + - ioredis + - lru-cache + - miniflare + - mongodb + - mysql2 + - sqlite3 + - uploadthing + node-exports-info@1.6.0: dependencies: array.prototype.flatmap: 1.3.3 @@ -14034,6 +15702,14 @@ snapshots: obug@2.1.1: {} + ocache@0.1.2: + dependencies: + ohash: 2.0.11 + + ofetch@2.0.0-alpha.3: {} + + ohash@2.0.11: {} + on-finished@2.3.0: dependencies: ee-first: 1.1.1 @@ -14151,6 +15827,19 @@ snapshots: dependencies: pngjs: 3.4.0 + parse5-htmlparser2-tree-adapter@7.1.0: + dependencies: + domhandler: 5.0.3 + parse5: 7.3.0 + + parse5-parser-stream@7.1.2: + dependencies: + parse5: 7.3.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parseurl@1.3.3: {} password-prompt@1.1.3: @@ -14521,6 +16210,8 @@ snapshots: react-refresh@0.14.2: {} + react-refresh@0.18.0: {} + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): dependencies: react: 19.2.4 @@ -14540,6 +16231,11 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + react-resizable-panels@4.7.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-router-hono-server@2.25.0(@hono/node-server@1.19.9(hono@4.12.3))(@react-router/dev@7.13.1(@react-router/serve@7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.1)(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2))(@types/react@19.2.14)(hono@4.12.3)(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: '@drizzle-team/brocli': 0.11.0 @@ -14582,8 +16278,20 @@ snapshots: string_decoder: 1.3.0 optional: true + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + readdirp@4.1.2: {} + recast@0.23.11: + dependencies: + ast-types: 0.16.1 + esprima: 4.0.1 + source-map: 0.6.1 + tiny-invariant: 1.3.3 + tslib: 2.8.1 + rechoir@0.6.2: dependencies: resolve: 1.22.11 @@ -14756,6 +16464,27 @@ snapshots: dependencies: glob: 10.5.0 + rolldown@1.0.0-rc.9: + dependencies: + '@oxc-project/types': 0.115.0 + '@rolldown/pluginutils': 1.0.0-rc.9 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.9 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.9 + '@rolldown/binding-darwin-x64': 1.0.0-rc.9 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.9 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.9 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.9 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.9 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.9 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.9 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.9 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.9 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.9 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.9 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.9 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.9 + rollup-plugin-dts@6.3.0(rollup@4.59.0)(typescript@5.9.3): dependencies: magic-string: 0.30.21 @@ -14824,6 +16553,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 + rou3@0.8.1: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -14898,6 +16629,12 @@ snapshots: serialize-error@2.1.0: {} + seroval-plugins@1.5.1(seroval@1.5.1): + dependencies: + seroval: 1.5.1 + + seroval@1.5.1: {} + serve-static@1.16.3: dependencies: encodeurl: 2.0.0 @@ -15056,6 +16793,12 @@ snapshots: slugify@1.6.6: {} + solid-js@1.9.11: + dependencies: + csstype: 3.2.3 + seroval: 1.5.1 + seroval-plugins: 1.5.1(seroval@1.5.1) + sort-object-keys@2.1.0: {} sort-package-json@3.6.0: @@ -15087,6 +16830,11 @@ snapshots: sprintf-js@1.0.3: {} + srvx@0.11.9: {} + + stable-hash-x@0.2.0: + optional: true + stable-hash@0.0.5: {} stack-utils@2.0.6: @@ -15101,6 +16849,12 @@ snapshots: dependencies: type-fest: 0.7.1 + standardized-audio-context@25.3.77: + dependencies: + '@babel/runtime': 7.28.6 + automation-events: 7.1.15 + tslib: 2.8.1 + statuses@1.5.0: {} statuses@2.0.2: {} @@ -15313,8 +17067,14 @@ snapshots: dependencies: any-promise: 1.3.0 + three@0.183.2: {} + throat@5.0.0: {} + tiny-invariant@1.3.3: {} + + tiny-warning@1.0.3: {} + tinybench@2.9.0: {} tinyexec@1.0.2: {} @@ -15334,6 +17094,11 @@ snapshots: toidentifier@1.0.1: {} + tone@15.1.22: + dependencies: + standardized-audio-context: 25.3.77 + tslib: 2.8.1 + toqr@0.1.1: {} tr46@0.0.3: {} @@ -15355,14 +17120,14 @@ snapshots: '@ts-morph/common': 0.11.1 code-block-writer: 10.1.1 - ts-node@10.9.2(@types/node@25.3.2)(typescript@5.9.3): + ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 25.3.2 + '@types/node': 25.5.0 acorn: 8.16.0 acorn-walk: 8.3.5 arg: 4.1.3 @@ -15501,6 +17266,8 @@ snapshots: ua-parser-js@1.0.41: {} + ufo@1.6.3: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -15510,6 +17277,12 @@ snapshots: undici-types@7.18.2: {} + undici@7.24.1: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + unicode-canonical-property-names-ecmascript@2.0.1: {} unicode-match-property-ecmascript@2.0.0: @@ -15575,6 +17348,13 @@ snapshots: pathe: 2.0.3 picomatch: 4.0.3 + unplugin@2.3.11: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.16.0 + picomatch: 4.0.3 + webpack-virtual-modules: 0.6.2 + unrs-resolver@1.11.1: dependencies: napi-postinstall: 0.3.4 @@ -15599,6 +17379,13 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + unstorage@2.0.0-alpha.6(chokidar@4.0.3)(db0@0.3.4)(lru-cache@11.2.6)(ofetch@2.0.0-alpha.3): + optionalDependencies: + chokidar: 4.0.3 + db0: 0.3.4 + lru-cache: 11.2.6 + ofetch: 2.0.0-alpha.3 + untildify@4.0.0: {} update-browserslist-db@1.2.3(browserslist@4.28.1): @@ -15694,6 +17481,27 @@ snapshots: - tsx - yaml + vite-node@3.2.4(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3(supports-color@8.1.1) + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: debug: 4.4.3(supports-color@8.1.1) @@ -15704,6 +17512,16 @@ snapshots: - supports-color - typescript + vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): + dependencies: + debug: 4.4.3(supports-color@8.1.1) + globrex: 0.1.2 + tsconfck: 3.1.6(typescript@5.9.3) + vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - supports-color + - typescript + vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.3 @@ -15721,10 +17539,31 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vitest@4.0.18(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.59.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.5.0 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.1 + terser: 5.46.0 + tsx: 4.21.0 + yaml: 2.8.2 + + vitefu@1.1.2(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): + optionalDependencies: + vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + + vitest@4.0.18(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -15741,10 +17580,10 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 25.3.2 + '@types/node': 25.5.0 transitivePeerDependencies: - jiti - less @@ -15772,8 +17611,16 @@ snapshots: webidl-conversions@3.0.1: {} + webpack-virtual-modules@0.6.2: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + whatwg-fetch@3.6.20: {} + whatwg-mimetype@4.0.0: {} + whatwg-url-minimum@0.1.1: {} whatwg-url@5.0.0: @@ -15884,6 +17731,13 @@ snapshots: sax: 1.4.4 xmlbuilder: 11.0.1 + xmlbuilder2@4.0.3: + dependencies: + '@oozcitak/dom': 2.0.2 + '@oozcitak/infra': 2.0.2 + '@oozcitak/util': 10.0.0 + js-yaml: 4.1.1 + xmlbuilder@11.0.1: {} xmlbuilder@14.0.0: {}