diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3975f5d..6777988 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,3 +46,13 @@ jobs: RUNTIME_PLATFORM="${{ matrix.platform }}" \ DEVICE_NAME="${{ matrix.device_name }}" | xcbeautify fi + + build-examples: + name: Build examples (iOS/visionOS/WatchOS/macOS) + runs-on: macos-26 + steps: + - uses: actions/checkout@v4 + - name: Build All Examples + run: | + set -o pipefail + make build-examples | xcbeautify diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index ddd9d76..911f2f8 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -18,6 +18,8 @@ 06F0BD812AAD82120089488C /* VRMSceneKit in Frameworks */ = {isa = PBXBuildFile; productRef = 06F0BD802AAD82120089488C /* VRMSceneKit */; }; 9FD10CB0B1951449373631D5 /* VRMRealityKit in Frameworks */ = {isa = PBXBuildFile; productRef = A1B2C3D4E5F60718293A4B5F /* VRMRealityKit */; }; A1B2C3D4E5F60718293A4B60 /* VRMRealityKit in Frameworks */ = {isa = PBXBuildFile; productRef = A1B2C3D4E5F60718293A4B5F /* VRMRealityKit */; }; + AF000002AF000002AF000002 /* VRM1_Constraint_Twist_Sample.vrm in Resources */ = {isa = PBXBuildFile; fileRef = AF000001AF000001AF000001 /* VRM1_Constraint_Twist_Sample.vrm */; }; + AF000003AF000003AF000003 /* VRM1_Constraint_Twist_Sample.vrm in Resources */ = {isa = PBXBuildFile; fileRef = AF000001AF000001AF000001 /* VRM1_Constraint_Twist_Sample.vrm */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -61,6 +63,7 @@ 06F0BD6C2AAD81A30089488C /* WatchExample Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "WatchExample Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 245725D72146F47A003AA5D7 /* VRMExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VRMExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 96FABF60E748F3EF7D574461 /* VisionExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VisionExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; + AF000001AF000001AF000001 /* VRM1_Constraint_Twist_Sample.vrm */ = {isa = PBXFileReference; lastKnownFileType = file; name = VRM1_Constraint_Twist_Sample.vrm; path = ../../Tests/VRMKitTests/Assets/VRM1_Constraint_Twist_Sample.vrm; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -146,6 +149,7 @@ isa = PBXGroup; children = ( 06E116512F277D1700D74CA4 /* AliciaSolid.vrm */, + AF000001AF000001AF000001 /* VRM1_Constraint_Twist_Sample.vrm */, ); path = Models; sourceTree = ""; @@ -340,6 +344,7 @@ buildActionMask = 2147483647; files = ( 06E116522F277D1800D74CA4 /* AliciaSolid.vrm in Resources */, + AF000002AF000002AF000002 /* VRM1_Constraint_Twist_Sample.vrm in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -355,6 +360,7 @@ buildActionMask = 2147483647; files = ( 06E116542F277ED600D74CA4 /* AliciaSolid.vrm in Resources */, + AF000003AF000003AF000003 /* VRM1_Constraint_Twist_Sample.vrm in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Example/Example/RealityKitViewController.swift b/Example/Example/RealityKitViewController.swift index 50f90c4..e831bc9 100644 --- a/Example/Example/RealityKitViewController.swift +++ b/Example/Example/RealityKitViewController.swift @@ -15,13 +15,15 @@ final class RealityKitViewController: UIViewController, UIGestureRecognizerDeleg private var orbitPitch: Float = -0.1 private var orbitDistance: Float = 2 private var orbitTarget = SIMD3(0, 0.8, 0) + private var currentExpression: RKExpression = .neutral override func viewDidLoad() { super.viewDidLoad() title = "RealityKit" view.backgroundColor = .black setUpARView() - loadVRM() + setUpUI() + loadVRM(model: .alicia) } private func setUpARView() { @@ -42,11 +44,51 @@ final class RealityKitViewController: UIViewController, UIGestureRecognizerDeleg setUpGestures() } - private func loadVRM() { + private func setUpUI() { + let items = VRMExampleModel.allCases.map { $0.displayName } + let segmentedControl = UISegmentedControl(items: items) + segmentedControl.selectedSegmentIndex = 0 + segmentedControl.addTarget(self, action: #selector(segmentChanged(_:)), for: .valueChanged) + segmentedControl.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(segmentedControl) + + let expressionItems = RKExpression.allCases.map { $0.displayName } + let expressionSegmentedControl = UISegmentedControl(items: expressionItems) + expressionSegmentedControl.selectedSegmentIndex = 0 + expressionSegmentedControl.addTarget(self, action: #selector(expressionSegmentChanged(_:)), for: .valueChanged) + expressionSegmentedControl.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(expressionSegmentedControl) + + NSLayoutConstraint.activate([ + segmentedControl.centerXAnchor.constraint(equalTo: view.centerXAnchor), + segmentedControl.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -50), + expressionSegmentedControl.centerXAnchor.constraint(equalTo: view.centerXAnchor), + expressionSegmentedControl.bottomAnchor.constraint(equalTo: segmentedControl.topAnchor, constant: -20) + ]) + } + + @objc private func segmentChanged(_ sender: UISegmentedControl) { + let model = VRMExampleModel.allCases[sender.selectedSegmentIndex] + loadVRM(model: model) + } + + @objc private func expressionSegmentChanged(_ sender: UISegmentedControl) { + let expression = RKExpression.allCases[sender.selectedSegmentIndex] + loadedEntity?.setBlendShape(value: 0.0, for: .preset(currentExpression.preset)) + currentExpression = expression + loadedEntity?.setBlendShape(value: 1.0, for: .preset(currentExpression.preset)) + } + + private func loadVRM(model: VRMExampleModel) { guard let arView = arView else { return } + if let loadedEntity = loadedEntity { + loadedEntity.entity.removeFromParent() + self.loadedEntity = nil + } + do { - let loader = try VRMEntityLoader(named: "AliciaSolid.vrm") + let loader = try VRMEntityLoader(named: model.rawValue) let vrmEntity = try loader.loadEntity() let anchor = AnchorEntity(world: .zero) @@ -72,10 +114,12 @@ final class RealityKitViewController: UIViewController, UIGestureRecognizerDeleg if let rightShoulder { rightShoulder.transform.rotation = rightShoulder.transform.rotation * shoulderRotation } - vrmEntity.setBlendShape(value: 1.0, for: .custom("><")) + vrmEntity.setBlendShape(value: 1.0, for: .preset(currentExpression.preset)) loadedEntity = vrmEntity + let rotationOffset = model.initialRotation + var time: TimeInterval = 0 updateSubscription = arView.scene.subscribe(to: SceneEvents.Update.self) { [weak self] event in guard let loadedEntity = self?.loadedEntity else { return } @@ -92,7 +136,7 @@ final class RealityKitViewController: UIViewController, UIGestureRecognizerDeleg angle = -0.5 + 0.5 * progress } - loadedEntity.entity.transform.rotation = simd_quatf(angle: angle, axis: SIMD3(0, 1, 0)) + loadedEntity.entity.transform.rotation = simd_quatf(angle: rotationOffset + angle, axis: SIMD3(0, 1, 0)) loadedEntity.update(at: event.deltaTime) } @@ -205,3 +249,22 @@ final class RealityKitViewController: UIViewController, UIGestureRecognizerDeleg return true } } + +@available(iOS 18.0, *) +private enum RKExpression: String, CaseIterable { + case neutral, joy, angry, sorrow, fun + + var preset: BlendShapePreset { + switch self { + case .neutral: return .neutral + case .joy: return .joy + case .angry: return .angry + case .sorrow: return .sorrow + case .fun: return .fun + } + } + + var displayName: String { + return rawValue.capitalized + } +} diff --git a/Example/Example/ViewController.swift b/Example/Example/ViewController.swift index 7f7b668..0c2a038 100644 --- a/Example/Example/ViewController.swift +++ b/Example/Example/ViewController.swift @@ -4,6 +4,45 @@ internal import VRMKit internal import VRMSceneKit @available(*, deprecated, message: "Deprecated. Use VRMRealityKit instead.") +enum VRMExampleModel: String, CaseIterable, Identifiable { + case alicia = "AliciaSolid.vrm" + case vrm1 = "VRM1_Constraint_Twist_Sample.vrm" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .alicia: return "Alicia" + case .vrm1: return "VRM 1.0" + } + } + + var initialRotation: Float { + switch self { + case .alicia: return 0 + case .vrm1: return .pi + } + } +} + +enum Expression: String, CaseIterable { + case neutral, joy, angry, sorrow, fun + + var preset: BlendShapePreset { + switch self { + case .neutral: return .neutral + case .joy: return .joy + case .angry: return .angry + case .sorrow: return .sorrow + case .fun: return .fun + } + } + + var displayName: String { + return rawValue.capitalized + } +} + class ViewController: UIViewController { @IBOutlet private weak var scnView: SCNView! { @@ -15,17 +54,66 @@ class ViewController: UIViewController { } } + private var vrmNode: VRMNode? + private var currentExpression: Expression = .neutral + override func viewDidLoad() { super.viewDidLoad() + setupUI() + loadVRM(model: .alicia) + } + + private func setupUI() { + let items = VRMExampleModel.allCases.map { $0.displayName } + let segmentedControl = UISegmentedControl(items: items) + segmentedControl.selectedSegmentIndex = 0 + segmentedControl.addTarget(self, action: #selector(segmentChanged(_:)), for: .valueChanged) + segmentedControl.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(segmentedControl) + + let expressionItems = Expression.allCases.map { $0.displayName } + let expressionSegmentedControl = UISegmentedControl(items: expressionItems) + expressionSegmentedControl.selectedSegmentIndex = 0 + expressionSegmentedControl.addTarget(self, action: #selector(expressionSegmentChanged(_:)), for: .valueChanged) + expressionSegmentedControl.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(expressionSegmentedControl) + + NSLayoutConstraint.activate([ + segmentedControl.centerXAnchor.constraint(equalTo: view.centerXAnchor), + segmentedControl.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -50), + + expressionSegmentedControl.centerXAnchor.constraint(equalTo: view.centerXAnchor), + expressionSegmentedControl.bottomAnchor.constraint(equalTo: segmentedControl.topAnchor, constant: -20) + ]) + } + @objc private func segmentChanged(_ sender: UISegmentedControl) { + let model = VRMExampleModel.allCases[sender.selectedSegmentIndex] + loadVRM(model: model) + } + + @objc private func expressionSegmentChanged(_ sender: UISegmentedControl) { + let expression = Expression.allCases[sender.selectedSegmentIndex] + vrmNode?.setBlendShape(value: 0.0, for: .preset(currentExpression.preset)) + currentExpression = expression + vrmNode?.setBlendShape(value: 1.0, for: .preset(currentExpression.preset)) + } + + private func loadVRM(model: VRMExampleModel) { do { - let loader = try VRMSceneLoader(named: "AliciaSolid.vrm") + let loader = try VRMSceneLoader(named: model.rawValue) let scene = try loader.loadScene() setupScene(scene) scnView.scene = scene scnView.delegate = self let node = scene.vrmNode - node.setBlendShape(value: 1.0, for: .custom("><")) + self.vrmNode = node + + let rotationOffset = CGFloat(model.initialRotation) + node.eulerAngles = SCNVector3(0, rotationOffset, 0) + + node.setBlendShape(value: 1.0, for: .preset(currentExpression.preset)) + node.humanoid.node(for: .neck)?.eulerAngles = SCNVector3(0, 0, 20 * CGFloat.pi / 180) node.humanoid.node(for: .leftShoulder)?.eulerAngles = SCNVector3(0, 0, 40 * CGFloat.pi / 180) node.humanoid.node(for: .rightShoulder)?.eulerAngles = SCNVector3(0, 0, 40 * CGFloat.pi / 180) diff --git a/Example/MacExample/ContentView.swift b/Example/MacExample/ContentView.swift index 6272875..2338b3d 100644 --- a/Example/MacExample/ContentView.swift +++ b/Example/MacExample/ContentView.swift @@ -13,14 +13,23 @@ internal import Combine struct ContentView: View { @State private var viewModel = ContentViewModel() + @State private var selectedModel: MacExampleModel = .alicia var body: some View { VStack { + Picker("Model", selection: $selectedModel) { + ForEach(MacExampleModel.allCases) { model in + Text(model.displayName).tag(model) + } + } + .pickerStyle(.segmented) + .padding([.top, .horizontal]) + RealityView { content in content.add(viewModel.rootEntity) } - .task { - await viewModel.loadEntity() + .task(id: selectedModel) { + await viewModel.loadEntity(model: selectedModel) } .onReceive(viewModel.updateTimer) { _ in viewModel.update() @@ -44,16 +53,22 @@ final class ContentViewModel { private var vrmEntity: VRMEntity? private var time: TimeInterval = 0 private var lastUpdateTime: Date? + private var currentModel: MacExampleModel = .alicia let updateTimer = Timer.publish(every: 1.0 / 60.0, on: .main, in: .common).autoconnect() - func loadEntity() async { + func loadEntity(model: MacExampleModel) async { do { - let loader = try VRMEntityLoader(named: "AliciaSolid.vrm") + if let vrmEntity { + vrmEntity.entity.removeFromParent() + self.vrmEntity = nil + } + + let loader = try VRMEntityLoader(named: model.rawValue) let vrmEntity = try loader.loadEntity() vrmEntity.entity.transform.translation = SIMD3(0, -1, 0) - vrmEntity.entity.transform.rotation = simd_quatf(angle: .pi, axis: SIMD3(0, 1, 0)) + vrmEntity.entity.transform.rotation = simd_quatf(angle: model.initialRotation, axis: SIMD3(0, 1, 0)) rootEntity.addChild(vrmEntity.entity) // Adjust pose @@ -75,6 +90,7 @@ final class ContentViewModel { vrmEntity.setBlendShape(value: 1.0, for: .custom("><")) self.vrmEntity = vrmEntity + self.currentModel = model self.lastUpdateTime = Date() } catch { errorMessage = error.localizedDescription @@ -102,11 +118,33 @@ final class ContentViewModel { angle = -0.5 + 0.5 * progress } - vrmEntity.entity.transform.rotation = simd_quatf(angle: .pi + angle, axis: SIMD3(0, 1, 0)) + vrmEntity.entity.transform.rotation = simd_quatf(angle: currentModel.initialRotation + angle, + axis: SIMD3(0, 1, 0)) vrmEntity.update(at: deltaTime) } } +enum MacExampleModel: String, CaseIterable, Identifiable { + case alicia = "AliciaSolid.vrm" + case vrm1 = "VRM1_Constraint_Twist_Sample.vrm" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .alicia: return "Alicia" + case .vrm1: return "VRM 1.0" + } + } + + var initialRotation: Float { + switch self { + case .alicia: return .pi + case .vrm1: return 0 + } + } +} + #Preview { ContentView() } diff --git a/Example/VisionExample/ContentView.swift b/Example/VisionExample/ContentView.swift index 0f66009..7f809b8 100644 --- a/Example/VisionExample/ContentView.swift +++ b/Example/VisionExample/ContentView.swift @@ -8,10 +8,19 @@ struct MainView: View { @Environment(\.dismissImmersiveSpace) private var dismissImmersiveSpace var body: some View { + @Bindable var appModel = appModel VStack(spacing: 20) { Text("VRM Example") .font(.largeTitle) + Picker("Model", selection: $appModel.selectedModelName) { + ForEach(AppModel.ModelName.allCases, id: \.self) { model in + Text(model.displayName).tag(model) + } + } + .pickerStyle(.segmented) + .disabled(appModel.immersiveSpaceState == .inTransition) + Button { Task { switch appModel.immersiveSpaceState { @@ -38,6 +47,7 @@ struct MainView: View { } struct ImmersiveView: View { + @Environment(AppModel.self) private var appModel @State private var viewModel = ImmersiveViewModel() var body: some View { @@ -45,7 +55,12 @@ struct ImmersiveView: View { content.add(viewModel.rootEntity) } .task { - await viewModel.loadEntity() + await viewModel.loadEntity(model: appModel.selectedModelName) + } + .onChange(of: appModel.selectedModelName) { _, newValue in + Task { + await viewModel.loadEntity(model: newValue) + } } .onReceive(viewModel.updateTimer) { _ in viewModel.update() @@ -62,15 +77,28 @@ final class ImmersiveViewModel { private var time: TimeInterval = 0 private var lastUpdateTime: Date? + private var baseRotation: Float = 0 + let updateTimer = Timer.publish(every: 1.0 / 60.0, on: .main, in: .common).autoconnect() - func loadEntity() async { + func loadEntity(model: AppModel.ModelName) async { + let modelName = model.rawValue + + // Clean up previous + if let current = vrmEntity { + current.entity.removeFromParent() + vrmEntity = nil + } + + // Alicia (VRM0) needs 180 degree rotation to face camera, VRM1 samples often don't + baseRotation = model.initialRotation + do { - let loader = try VRMEntityLoader(named: "AliciaSolid.vrm") + let loader = try VRMEntityLoader(named: modelName) let vrmEntity = try loader.loadEntity() vrmEntity.entity.transform.translation = SIMD3(0, 0, -1.5) - vrmEntity.entity.transform.rotation = simd_quatf(angle: .pi, axis: SIMD3(0, 1, 0)) + vrmEntity.entity.transform.rotation = simd_quatf(angle: baseRotation, axis: SIMD3(0, 1, 0)) rootEntity.addChild(vrmEntity.entity) // Adjust pose @@ -119,7 +147,7 @@ final class ImmersiveViewModel { angle = -0.5 + 0.5 * progress } - vrmEntity.entity.transform.rotation = simd_quatf(angle: .pi + angle, axis: SIMD3(0, 1, 0)) + vrmEntity.entity.transform.rotation = simd_quatf(angle: baseRotation + angle, axis: SIMD3(0, 1, 0)) vrmEntity.update(at: deltaTime) } } diff --git a/Example/VisionExample/VisionExampleApp.swift b/Example/VisionExample/VisionExampleApp.swift index 3a36214..9d05b6a 100644 --- a/Example/VisionExample/VisionExampleApp.swift +++ b/Example/VisionExample/VisionExampleApp.swift @@ -33,4 +33,26 @@ final class AppModel { enum ImmersiveSpaceState { case closed, inTransition, open } + enum ModelName: String, CaseIterable, Identifiable { + case alicia = "AliciaSolid.vrm" + case vrm1 = "VRM1_Constraint_Twist_Sample.vrm" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .alicia: return "Alicia" + case .vrm1: return "VRM 1.0" + } + } + + var initialRotation: Float { + switch self { + case .alicia: return 0 + case .vrm1: return .pi + } + } + } + + var selectedModelName: ModelName = .alicia } diff --git a/Example/WatchExample Watch App/ContentView.swift b/Example/WatchExample Watch App/ContentView.swift index 5eddc70..9180570 100644 --- a/Example/WatchExample Watch App/ContentView.swift +++ b/Example/WatchExample Watch App/ContentView.swift @@ -5,11 +5,24 @@ struct ContentView: View { @StateObject private var viewModel = ViewModel() var body: some View { - SceneView( - scene: viewModel.scene, - delegate: viewModel.renderer - ) - .ignoresSafeArea() + TabView { + SceneView( + scene: viewModel.scene, + delegate: viewModel.renderer + ) + .ignoresSafeArea() + + VStack { + Text("Select Model") + Picker("Model", selection: $viewModel.selectedModelName) { + ForEach(ViewModel.ModelName.allCases) { model in + Text(model.displayName).tag(model) + } + } + .pickerStyle(.wheel) + } + } + .tabViewStyle(.page) .onAppear { viewModel.loadModelIfNeeded() } diff --git a/Example/WatchExample Watch App/ViewModel.swift b/Example/WatchExample Watch App/ViewModel.swift index fa51eb2..58577d7 100644 --- a/Example/WatchExample Watch App/ViewModel.swift +++ b/Example/WatchExample Watch App/ViewModel.swift @@ -11,18 +11,50 @@ final class Renderer: NSObject, SCNSceneRendererDelegate { @available(*, deprecated, message: "Deprecated. But watchOS can't use RealityKit...") final class ViewModel: ObservableObject { + enum ModelName: String, CaseIterable, Identifiable { + case alicia = "AliciaSolid.vrm" + case vrm1 = "VRM1_Constraint_Twist_Sample.vrm" + var id: String { rawValue } + + var displayName: String { + switch self { + case .alicia: return "Alicia" + case .vrm1: return "VRM 1.0" + } + } + + var initialRotation: CGFloat { + switch self { + case .alicia: return 0 + case .vrm1: return .pi + } + } + } + + @Published var selectedModelName: ModelName = .alicia { + didSet { + // Reload when selection changes + loadModel(model: selectedModelName) + } + } @Published private(set) var scene: VRMScene? let renderer = Renderer() func loadModelIfNeeded() { guard scene == nil else { return } + loadModel(model: selectedModelName) + } + private func loadModel(model: ModelName) { do { - let loader = try VRMSceneLoader(named: "AliciaSolid.vrm") + let loader = try VRMSceneLoader(named: model.rawValue) let scene = try loader.loadScene() setupScene(scene) let node = scene.vrmNode + let rotationOffset = model.initialRotation + node.eulerAngles = SCNVector3(0, rotationOffset, 0) + node.humanoid.node(for: .leftShoulder)?.eulerAngles = SCNVector3(0, 0, 40 * CGFloat.pi / 180) node.humanoid.node(for: .rightShoulder)?.eulerAngles = SCNVector3(0, 0, 40 * CGFloat.pi / -180) diff --git a/Makefile b/Makefile index cef0844..868c653 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,36 @@ PLATFORM_MATRIX ?= \ "watchOS,watchOS Simulator,Watch" \ "xrOS,visionOS Simulator,Apple Vision Pro" +.PHONY: test test-package-platform build-examples build-example-ios build-example-vision build-example-macos build-example-watch + +EXAMPLE_PROJECT ?= Example/Example.xcodeproj +IOS_SIM_DEST ?= generic/platform=iOS Simulator +VISIONOS_SIM_DEST ?= generic/platform=visionOS Simulator +MACOS_DEST ?= platform=macOS +WATCHOS_SIM_DEST ?= generic/platform=watchOS Simulator + +build-examples: build-example-ios build-example-vision build-example-macos build-example-watch + +build-example-ios: + @set -e; \ + echo "==> Building VRMExample (iOS Simulator)"; \ + xcodebuild -project "$(EXAMPLE_PROJECT)" -scheme VRMExample -destination "$(IOS_SIM_DEST)" build + +build-example-vision: + @set -e; \ + echo "==> Building VisionExample (visionOS Simulator)"; \ + xcodebuild -project "$(EXAMPLE_PROJECT)" -scheme VisionExample -destination "$(VISIONOS_SIM_DEST)" build + +build-example-macos: + @set -e; \ + echo "==> Building MacExample (macOS)"; \ + xcodebuild -project "$(EXAMPLE_PROJECT)" -scheme MacExample -destination "$(MACOS_DEST)" build + +build-example-watch: + @set -e; \ + echo "==> Building WatchExample (watchOS Simulator)"; \ + xcodebuild -project "$(EXAMPLE_PROJECT)" -scheme "WatchExample Watch App" -destination "$(WATCHOS_SIM_DEST)" build + test: @set -e; \ for entry in $(PLATFORM_MATRIX); do \ diff --git a/Sources/VRMKit/VRM/Material.swift b/Sources/VRMKit/VRM/Material.swift index a0f84e5..0d46895 100644 --- a/Sources/VRMKit/VRM/Material.swift +++ b/Sources/VRMKit/VRM/Material.swift @@ -97,10 +97,15 @@ extension GLTF { public struct MaterialExtensions: Codable { public let materialsMToon: MaterialsMToon? + public let materialsUnlit: MaterialsUnlit? private enum CodingKeys: String, CodingKey { case materialsMToon = "VRMC_materials_mtoon" + case materialsUnlit = "KHR_materials_unlit" } + + /// KHR_materials_unlit extension marker (empty object in glTF) + public struct MaterialsUnlit: Codable {} public struct MaterialsMToon: Codable { public let specVersion: String diff --git a/Sources/VRMKit/VRM/Node.swift b/Sources/VRMKit/VRM/Node.swift index ea4860b..7302b94 100644 --- a/Sources/VRMKit/VRM/Node.swift +++ b/Sources/VRMKit/VRM/Node.swift @@ -72,15 +72,9 @@ extension GLTF { public let extras: CodableAny? public enum RollAxis: String, Codable { - case x - case y - case z - - private enum CodingKeys: String, CodingKey { - case x = "X" - case y = "Y" - case z = "Z" - } + case x = "X" + case y = "Y" + case z = "Z" } } @@ -92,21 +86,12 @@ extension GLTF { public let extras: CodableAny? public enum AimAxis: String, Codable { - case positiveX - case negativeX - case positiveY - case negativeY - case positiveZ - case negativeZ - - private enum CodingKeys: String, CodingKey { - case positiveX = "PositiveX" - case negativeX = "NegativeX" - case positiveY = "PositiveY" - case negativeY = "NegativeY" - case positiveZ = "PositiveZ" - case negativeZ = "NegativeZ" - } + case positiveX = "PositiveX" + case negativeX = "NegativeX" + case positiveY = "PositiveY" + case negativeY = "NegativeY" + case positiveZ = "PositiveZ" + case negativeZ = "NegativeZ" } } diff --git a/Sources/VRMKit/VRM/VRM.swift b/Sources/VRMKit/VRM/VRM.swift index a4a09d5..89e5e14 100644 --- a/Sources/VRMKit/VRM/VRM.swift +++ b/Sources/VRMKit/VRM/VRM.swift @@ -17,18 +17,50 @@ public struct VRM: VRMFile { let rawExtensions = try gltf.jsonData.extensions ??? .keyNotFound("extensions") let extensions = try rawExtensions.value as? [String: [String: Any]] ??? .dataInconsistent("extension type mismatch") - let vrm = try extensions["VRM"] ??? .keyNotFound("VRM") let decoder = DictionaryDecoder() - meta = try decoder.decode(Meta.self, from: try vrm["meta"] ??? .keyNotFound("meta")) - version = vrm["version"] as? String - materialProperties = try decoder.decode([MaterialProperty].self, from: try vrm["materialProperties"] ??? .keyNotFound("materialProperties")) - humanoid = try decoder.decode(Humanoid.self, from: try vrm["humanoid"] ??? .keyNotFound("humanoid")) - blendShapeMaster = try decoder.decode(BlendShapeMaster.self, from: try vrm["blendShapeMaster"] ??? .keyNotFound("blendShapeMaster")) - firstPerson = try decoder.decode(FirstPerson.self, from: try vrm["firstPerson"] ??? .keyNotFound("firstPerson")) - secondaryAnimation = try decoder.decode(SecondaryAnimation.self, from: try vrm["secondaryAnimation"] ??? .keyNotFound("secondaryAnimation")) - - materialPropertyNameMap = materialProperties.reduce(into: [:]) { $0[$1.name] = $1 } + + if extensions.keys.contains("VRMC_vrm") { + // VRM 1.0 Support + let vrm1 = try VRM1(data: data) + + // Version + version = vrm1.specVersion + + // Meta + meta = Meta(vrm1: vrm1.meta) + + // Humanoid + humanoid = Humanoid(vrm1: vrm1.humanoid) + + // BlendShapeMaster + blendShapeMaster = BlendShapeMaster(vrm1: vrm1.expressions, gltf: gltf) + + // FirstPerson + firstPerson = FirstPerson(vrm1: vrm1.firstPerson, lookAt: vrm1.lookAt) + + // SecondaryAnimation (SpringBone) + secondaryAnimation = SecondaryAnimation(vrm1: vrm1.springBone) + + // MaterialProperties (MToon) + materialProperties = try VRM.migrateMaterials(gltf: gltf, vrm1: vrm1) + + materialPropertyNameMap = materialProperties.reduce(into: [:]) { $0[$1.name] = $1 } + + } else { + // VRM 0.x Support + let vrm = try extensions["VRM"] ??? .keyNotFound("VRM") + + meta = try decoder.decode(Meta.self, from: try vrm["meta"] ??? .keyNotFound("meta")) + version = vrm["version"] as? String + materialProperties = try decoder.decode([MaterialProperty].self, from: try vrm["materialProperties"] ??? .keyNotFound("materialProperties")) + humanoid = try decoder.decode(Humanoid.self, from: try vrm["humanoid"] ??? .keyNotFound("humanoid")) + blendShapeMaster = try decoder.decode(BlendShapeMaster.self, from: try vrm["blendShapeMaster"] ??? .keyNotFound("blendShapeMaster")) + firstPerson = try decoder.decode(FirstPerson.self, from: try vrm["firstPerson"] ??? .keyNotFound("firstPerson")) + secondaryAnimation = try decoder.decode(SecondaryAnimation.self, from: try vrm["secondaryAnimation"] ??? .keyNotFound("secondaryAnimation")) + + materialPropertyNameMap = materialProperties.reduce(into: [:]) { $0[$1.name] = $1 } + } } } diff --git a/Sources/VRMKit/VRM/VRM1.swift b/Sources/VRMKit/VRM/VRM1.swift index e9dbf75..7c10607 100644 --- a/Sources/VRMKit/VRM/VRM1.swift +++ b/Sources/VRMKit/VRM/VRM1.swift @@ -325,11 +325,11 @@ extension VRM1 { public struct Joint: Codable { public let node: Int - public let hitRadius: Double - public let stiffness: Double - public let gravityPower: Double - public let gravityDir: [Double] - public let dragForce: Double + public let hitRadius: Double? + public let stiffness: Double? + public let gravityPower: Double? + public let gravityDir: [Double]? + public let dragForce: Double? public let extensions: CodableAny? public let extras: CodableAny? } diff --git a/Sources/VRMKit/VRM/VRMMigration.swift b/Sources/VRMKit/VRM/VRMMigration.swift new file mode 100644 index 0000000..7b7f0b6 --- /dev/null +++ b/Sources/VRMKit/VRM/VRMMigration.swift @@ -0,0 +1,553 @@ +import Foundation + +// MARK: - Migration Logic for VRM 1.0 -> 0.x + +public extension VRM.Meta { + init(vrm1: VRM1.Meta) { + self.init( + title: vrm1.name, + author: vrm1.authors.joined(separator: ", "), // VRM1 authors is [String] + contactInformation: vrm1.contactInformation, + reference: vrm1.references?.joined(separator: ", "), + texture: vrm1.thumbnailImage, + version: vrm1.version, + allowedUserName: { + // VRM1 avatarPermission -> VRM0 allowedUserName + // VRM0 expects: OnlyAuthor / ExplicitlyLicensedPerson / Everyone + switch vrm1.avatarPermission { + case .onlyAuthor: return "OnlyAuthor" + case .onlySeparatelyLicensedPerson: return "ExplicitlyLicensedPerson" + case .everyone: return "Everyone" + case .none: return "Everyone" + } + }(), + violentUssageName: vrm1.allowExcessivelyViolentUsage == true ? "Allow" : "Disallow", + sexualUssageName: vrm1.allowExcessivelySexualUsage == true ? "Allow" : "Disallow", + commercialUssageName: vrm1.commercialUsage?.rawValue, + otherPermissionUrl: vrm1.otherLicenseUrl, + licenseName: vrm1.licenseUrl, + otherLicenseUrl: vrm1.otherLicenseUrl + ) + } +} + +public extension VRM.Humanoid { + init(vrm1: VRM1.Humanoid) { + var bones: [HumanBone] = [] + + let mirror = Mirror(reflecting: vrm1.humanBones) + for child in mirror.children { + guard let label = child.label, + let humanBone1 = child.value as? VRM1.Humanoid.HumanBones.HumanBone?, + let node = humanBone1?.node else { continue } + let boneName = label + bones.append(HumanBone(bone: boneName, node: node, useDefaultValues: true)) + } + + self.init( + armStretch: 0.05, // Default/Unknown + feetSpacing: 0, + hasTranslationDoF: false, + legStretch: 0.05, + lowerArmTwist: 0.5, + lowerLegTwist: 0.5, + upperArmTwist: 0.5, + upperLegTwist: 0.5, + humanBones: bones + ) + } +} + +public extension VRM.BlendShapeMaster { + init(vrm1: VRM1.Expressions?, gltf: BinaryGLTF) { + guard let expressions = vrm1 else { + self.init(blendShapeGroups: []) + return + } + + var groups: [BlendShapeGroup] = [] + let decoder = DictionaryDecoder() + + func addGroup(name: String, presetName: String, expression: VRM1.Expressions.Expression) { + let binds: [VRM.BlendShapeMaster.BlendShapeGroup.Bind] = (expression.morphTargetBinds?.compactMap { (bind) -> VRM.BlendShapeMaster.BlendShapeGroup.Bind? in + let meshIndex: Int? + if let nodes = gltf.jsonData.nodes, + nodes.indices.contains(bind.node) { + meshIndex = nodes[bind.node].mesh + } else if let meshes = gltf.jsonData.meshes, + meshes.indices.contains(bind.node) { + // Fallback: treat bind.node as a mesh index if nodes are unavailable. + meshIndex = bind.node + } else { + meshIndex = nil + } + guard let meshIndex else { return nil } + return VRM.BlendShapeMaster.BlendShapeGroup.Bind( + index: bind.index, + mesh: meshIndex, + weight: bind.weight * 100.0 + ) + }) ?? [] + + // VRM1 materialColor/textureTransform -> VRM0 materialValues + let colorValues: [VRM.BlendShapeMaster.BlendShapeGroup.MaterialValueBind] = (expression.materialColorBinds?.compactMap { bind -> VRM.BlendShapeMaster.BlendShapeGroup.MaterialValueBind? in + guard let materials = gltf.jsonData.materials, bind.material < materials.count else { return nil } + // VRM1 bind refers to material by index. Resolve the name from GLTF. + let materialName = materials[bind.material].name ?? "" + return VRM.BlendShapeMaster.BlendShapeGroup.MaterialValueBind( + materialName: materialName, + propertyName: bind.type.rawValue, + targetValue: bind.targetValue + ) + }) ?? [] + + let textureValues: [VRM.BlendShapeMaster.BlendShapeGroup.MaterialValueBind] = (expression.textureTransformBinds?.compactMap { bind -> VRM.BlendShapeMaster.BlendShapeGroup.MaterialValueBind? in + guard let materials = gltf.jsonData.materials, bind.material < materials.count else { return nil } + let materialName = materials[bind.material].name ?? "" + + let scale = bind.scale ?? [1.0, 1.0] + let offset = bind.offset ?? [0.0, 0.0] + let sx = scale.count > 0 ? scale[0] : 1.0 + let sy = scale.count > 1 ? scale[1] : 1.0 + let ox = offset.count > 0 ? offset[0] : 0.0 + let oy = offset.count > 1 ? offset[1] : 0.0 + // glTF(top-left) -> Unity(bottom-left) + let flippedOy = 1.0 - oy - sy + + return VRM.BlendShapeMaster.BlendShapeGroup.MaterialValueBind( + materialName: materialName, + propertyName: "_MainTex_ST", + targetValue: [sx, sy, ox, flippedOy] + ) + }) ?? [] + + let materialValues = colorValues + textureValues + + groups.append(BlendShapeGroup( + binds: binds, + materialValues: materialValues, + name: name, + presetName: presetName, + _isBinary: expression.isBinary + )) + } + + // VRM1 preset expressions -> VRM0 BlendShapeGroup presetName mapping. + let preset = expressions.preset + addGroup(name: "Happy", presetName: "joy", expression: preset.happy) + addGroup(name: "Angry", presetName: "angry", expression: preset.angry) + addGroup(name: "Sad", presetName: "sorrow", expression: preset.sad) + addGroup(name: "Relaxed", presetName: "fun", expression: preset.relaxed) + addGroup(name: "Surprised", presetName: "unknown", expression: preset.surprised) // VRM0 doesn't have surprised + + // VRM0 presets: neutral, a, i, u, e, o, blink, joy, angry, sorrow, fun, lookup, lookdown, lookleft, lookright, blink_l, blink_r. + addGroup(name: "A", presetName: "a", expression: preset.aa) + addGroup(name: "I", presetName: "i", expression: preset.ih) + addGroup(name: "U", presetName: "u", expression: preset.ou) + addGroup(name: "E", presetName: "e", expression: preset.ee) + addGroup(name: "O", presetName: "o", expression: preset.oh) + addGroup(name: "Blink", presetName: "blink", expression: preset.blink) + addGroup(name: "Blink_L", presetName: "blink_l", expression: preset.blinkLeft) + addGroup(name: "Blink_R", presetName: "blink_r", expression: preset.blinkRight) + + addGroup(name: "LookUp", presetName: "lookup", expression: preset.lookUp) + addGroup(name: "LookDown", presetName: "lookdown", expression: preset.lookDown) + addGroup(name: "LookLeft", presetName: "lookleft", expression: preset.lookLeft) + addGroup(name: "LookRight", presetName: "lookright", expression: preset.lookRight) + addGroup(name: "Neutral", presetName: "neutral", expression: preset.neutral) + + // VRM1 custom expressions + if let customMap = expressions.custom?.value as? [String: Any] { + for name in customMap.keys.sorted() { + guard let raw = customMap[name] as? [String: Any], + let expression = try? decoder.decode(VRM1.Expressions.Expression.self, from: raw) else { + continue + } + addGroup(name: name, presetName: "unknown", expression: expression) + } + } + + self.init(blendShapeGroups: groups) + } +} + +public extension VRM.FirstPerson { + init(vrm1: VRM1.FirstPerson?, lookAt: VRM1.LookAt?) { + // VRM 1.0 FirstPerson + let meshAnnotations: [MeshAnnotation] = vrm1?.meshAnnotations.map { + MeshAnnotation(firstPersonFlag: $0.type.rawValue, mesh: $0.node) + } ?? [] + + // LookAt + let lookAtTypeName: LookAtType + switch lookAt?.type { + case .bone: lookAtTypeName = .bone + case .expression: lookAtTypeName = .blendShape + case .none: lookAtTypeName = .none + } + + // VRM 1.0 LookAt offsetFromHeadBone + let offset = lookAt?.offsetFromHeadBone ?? [0, 0, 0] + let vec3 = VRM.Vector3(x: offset[0], y: offset[1], z: offset[2]) + + self.init( + firstPersonBone: -1, // Deprecated/Unknown in VRM1? VRM1 uses Head bone usually. + // VRM0 expected an index. VRM1 doesn't specify explicit firstPersonBone index in FirstPerson struct. + // It relies on Humanoid.head. + firstPersonBoneOffset: vec3, + meshAnnotations: meshAnnotations, + lookAtTypeName: lookAtTypeName + ) + } +} + +public extension VRM.SecondaryAnimation { + init(vrm1: VRM1.SpringBone?) { + guard let sb = vrm1 else { + self.init(boneGroups: [], colliderGroups: []) + return + } + var vrm0ColliderGroups: [ColliderGroup] = [] + + // Resolve all VRM 1.0 colliders + if let vrm1Colliders = sb.colliders { + // Group by node + var collidersByNode: [Int: [ColliderGroup.Collider]] = [:] + + for collider in vrm1Colliders { + let nodeIndex = collider.node + var vrm0Collider: ColliderGroup.Collider? + + if let sphere = collider.shape.sphere { + vrm0Collider = ColliderGroup.Collider( + offset: VRM.Vector3(x: sphere.offset[0], y: sphere.offset[1], z: sphere.offset[2]), + radius: sphere.radius + ) + } else if let capsule = collider.shape.capsule { + // Approximate capsule as sphere (head) + vrm0Collider = ColliderGroup.Collider( + offset: VRM.Vector3(x: capsule.offset[0], y: capsule.offset[1], z: capsule.offset[2]), + radius: capsule.radius + ) + } + + if let c = vrm0Collider { + collidersByNode[nodeIndex, default: []].append(c) + } + } + + for (nodeIndex, colliders) in collidersByNode { + vrm0ColliderGroups.append(ColliderGroup(node: nodeIndex, colliders: colliders)) + } + } + + // Convert Springs (BoneGroups) + var boneGroups: [BoneGroup] = [] + if let springs = sb.springs { + for spring in springs { + // Determine `colliderGroups` valid for this Spring. + // These are shared for all split groups derived from this Spring. + var referencedNodeIndices: Set = [] + if let groupIndices = spring.colliderGroups, let vrm1Groups = sb.colliderGroups { + for groupIdx in groupIndices { + if groupIdx >= 0 && groupIdx < vrm1Groups.count { + let group = vrm1Groups[groupIdx] + for colliderIdx in group.colliders { + if let colliders = sb.colliders, colliderIdx >= 0 && colliderIdx < colliders.count { + let collider = colliders[colliderIdx] + referencedNodeIndices.insert(collider.node) + } + } + } + } + } + + // Find indices of vrm0ColliderGroups that correspond to these nodes + let vrm0ColliderGroupIndices: [Int] = vrm0ColliderGroups.enumerated().compactMap { index, group in + return referencedNodeIndices.contains(group.node) ? index : nil + } + + struct PhysicsParams: Equatable { + let dragForce: Double + let gravityDir: [Double] + let gravityPower: Double + let hitRadius: Double + let stiffness: Double + } + + var currentJoints: [Int] = [] + var currentParams: PhysicsParams? + + for joint in spring.joints { + let params = PhysicsParams( + dragForce: joint.dragForce ?? 0.5, + gravityDir: joint.gravityDir ?? [0, -1, 0], + gravityPower: joint.gravityPower ?? 0, + hitRadius: joint.hitRadius ?? 0.02, + stiffness: joint.stiffness ?? 1.0 + ) + + if let current = currentParams, current == params { + // Same parameters, add to current group. + currentJoints.append(joint.node) + } else { + // Parameters changed or first joint. + if let current = currentParams, !currentJoints.isEmpty { + // Close previous group + boneGroups.append(BoneGroup( + bones: currentJoints, + center: spring.center ?? -1, + colliderGroups: vrm0ColliderGroupIndices, + comment: spring.name, + dragForce: current.dragForce, + gravityDir: VRM.Vector3(x: current.gravityDir[0], y: current.gravityDir[1], z: current.gravityDir[2]), + gravityPower: current.gravityPower, + hitRadius: current.hitRadius, + stiffiness: current.stiffness + )) + } + + // Start new group + currentParams = params + currentJoints = [joint.node] + } + } + + // Close the last group + if let current = currentParams, !currentJoints.isEmpty { + boneGroups.append(BoneGroup( + bones: currentJoints, + center: spring.center ?? -1, + colliderGroups: vrm0ColliderGroupIndices, + comment: spring.name, + dragForce: current.dragForce, + gravityDir: VRM.Vector3(x: current.gravityDir[0], y: current.gravityDir[1], z: current.gravityDir[2]), + gravityPower: current.gravityPower, + hitRadius: current.hitRadius, + stiffiness: current.stiffness + )) + } + } + } + + self.init(boneGroups: boneGroups, colliderGroups: vrm0ColliderGroups) + } +} + +public extension VRM { + static func migrateMaterials(gltf: BinaryGLTF, vrm1: VRM1) throws -> [MaterialProperty] { + guard let materials = gltf.jsonData.materials else { return [] } + + var properties: [MaterialProperty] = [] + + for material in materials { + let name = material.name ?? "" + + // Check for VRMC_materials_mtoon extension + if let mtoon = material.extensions?.materialsMToon { + + // Map MToon parameters to VRM 0.x MaterialProperty + var floatProperties: [String: Double] = [:] + let keywordMap: [String: Bool] = [:] + var textureProperties: [String: Int] = [:] + var vectorProperties: [String: [Double]] = [:] // VRM0.x expects [Double] (array of 4) + + // ShadeColor + if let shadeColorFactor = mtoon.shadeColorFactor { + vectorProperties["_ShadeColor"] = shadeColorFactor + (shadeColorFactor.count == 3 ? [1.0] : []) + } + + // Color (BaseColor) + if let pbr = material.pbrMetallicRoughness { + let baseColor = pbr.baseColorFactor + vectorProperties["_Color"] = [Double(baseColor.r), Double(baseColor.g), Double(baseColor.b), Double(baseColor.a)] + } + + // ShadingShift + if let shadingShiftFactor = mtoon.shadingShiftFactor { + floatProperties["_ShadingShift"] = shadingShiftFactor + } + + // ShadingToony + if let shadingToonyFactor = mtoon.shadingToonyFactor { + floatProperties["_ShadingToony"] = shadingToonyFactor + } + + // GiEqualization + if let giEqualizationFactor = mtoon.giEqualizationFactor { + floatProperties["_GiEqualization"] = giEqualizationFactor + } + + // RimColor + if let parametricRimColorFactor = mtoon.parametricRimColorFactor { + vectorProperties["_RimColor"] = parametricRimColorFactor + (parametricRimColorFactor.count == 3 ? [1.0] : []) + } + if let parametricRimFresnelPowerFactor = mtoon.parametricRimFresnelPowerFactor { + floatProperties["_RimFresnelPower"] = parametricRimFresnelPowerFactor + } + if let parametricRimLiftFactor = mtoon.parametricRimLiftFactor { + floatProperties["_RimLift"] = parametricRimLiftFactor + } + + // Outline + if let outlineWidthMode = mtoon.outlineWidthMode { + let mode: Double + switch outlineWidthMode { + case .worldCoordinates: mode = 1 + case .screenCoordinates: mode = 2 + default: mode = 0 + } + floatProperties["_OutlineWidthMode"] = mode + } + if let outlineWidthFactor = mtoon.outlineWidthFactor { + floatProperties["_OutlineWidth"] = outlineWidthFactor + } + if let outlineColorFactor = mtoon.outlineColorFactor { + vectorProperties["_OutlineColor"] = outlineColorFactor + (outlineColorFactor.count == 3 ? [1.0] : []) + } + if let outlineLightingMixFactor = mtoon.outlineLightingMixFactor { + floatProperties["_OutlineLightingMix"] = outlineLightingMixFactor + } + + // Texture references + if let index = mtoon.shadeMultiplyTexture?.index { + textureProperties["_ShadeTexture"] = index + } + if let index = mtoon.matcapTexture?.index { + textureProperties["_SphereAdd"] = index // MatCap + } + if let index = mtoon.rimMultiplyTexture?.index { + textureProperties["_RimTexture"] = index + } + if let index = mtoon.outlineWidthMultiplyTexture?.index { + textureProperties["_OutlineWidthTexture"] = index + } + + // MainTex (BaseColorTexture) + if let pbr = material.pbrMetallicRoughness, let baseTex = pbr.baseColorTexture { + textureProperties["_MainTex"] = baseTex.index + } + // BumpMap (NormalTexture) + if let normalTex = material.normalTexture { + textureProperties["_BumpMap"] = normalTex.index + } + // EmissionMap (EmissiveTexture) + if let emissiveTex = material.emissiveTexture { + textureProperties["_EmissionMap"] = emissiveTex.index + } + + properties.append(MaterialProperty( + name: name, + shader: "VRM/MToon", + renderQueue: 2000, + floatProperties: CodableAny(floatProperties), + keywordMap: keywordMap, + tagMap: [:], + textureProperties: textureProperties, + vectorProperties: CodableAny(vectorProperties) + )) + } else { + // Standard PBR or Unlit (KHR_materials_unlit) + var floatProperties: [String: Double] = [:] + var keywordMap: [String: Bool] = [:] + var textureProperties: [String: Int] = [:] + var vectorProperties: [String: [Double]] = [:] + var tagMap: [String: String] = [:] + + // Check for KHR_materials_unlit extension + let isUnlit = material.extensions?.materialsUnlit != nil + + // Determine shader based on material type + let shader: String + var renderQueue = 2000 + + // Alpha mode handling + switch material.alphaMode { + case .OPAQUE: + renderQueue = 2000 + tagMap["RenderType"] = "Opaque" + case .MASK: + renderQueue = 2450 + tagMap["RenderType"] = "TransparentCutout" + floatProperties["_Cutoff"] = Double(material.alphaCutoff) + case .BLEND: + renderQueue = 3000 + tagMap["RenderType"] = "Transparent" + } + + if isUnlit { + shader = "VRM/UnlitTexture" + } else { + shader = "Standard" // Unity Standard shader for PBR + } + + // PBR properties + if let pbr = material.pbrMetallicRoughness { + // BaseColor -> _Color + let baseColor = pbr.baseColorFactor + vectorProperties["_Color"] = [Double(baseColor.r), Double(baseColor.g), Double(baseColor.b), Double(baseColor.a)] + + // Metallic + floatProperties["_Metallic"] = Double(pbr.metallicFactor) + + // Roughness -> Glossiness (inverted) + // Unity Standard uses Smoothness (1 - roughness) + floatProperties["_Glossiness"] = Double(1.0 - pbr.roughnessFactor) + + // BaseColorTexture -> _MainTex + if let baseTex = pbr.baseColorTexture { + textureProperties["_MainTex"] = baseTex.index + } + + // MetallicRoughnessTexture -> _MetallicGlossMap + if let mrTex = pbr.metallicRoughnessTexture { + textureProperties["_MetallicGlossMap"] = mrTex.index + } + } + + // Normal map + if let normalTex = material.normalTexture { + textureProperties["_BumpMap"] = normalTex.index + floatProperties["_BumpScale"] = Double(normalTex.scale) + keywordMap["_NORMALMAP"] = true + } + + // Occlusion map + if let occTex = material.occlusionTexture { + textureProperties["_OcclusionMap"] = occTex.index + floatProperties["_OcclusionStrength"] = Double(occTex.strength) + } + + // Emissive + let emissive = material.emissiveFactor + vectorProperties["_EmissionColor"] = [Double(emissive.r), Double(emissive.g), Double(emissive.b), 1.0] + if emissive.r > 0 || emissive.g > 0 || emissive.b > 0 { + keywordMap["_EMISSION"] = true + } + + if let emissiveTex = material.emissiveTexture { + textureProperties["_EmissionMap"] = emissiveTex.index + keywordMap["_EMISSION"] = true + } + + // DoubleSided + if material.doubleSided { + floatProperties["_Cull"] = 0 // Off + } + + properties.append(MaterialProperty( + name: name, + shader: shader, + renderQueue: renderQueue, + floatProperties: CodableAny(floatProperties), + keywordMap: keywordMap, + tagMap: tagMap, + textureProperties: textureProperties, + vectorProperties: CodableAny(vectorProperties) + )) + } + } + + return properties + } +} diff --git a/Sources/VRMRealityKit/VRMEntityLoader.swift b/Sources/VRMRealityKit/VRMEntityLoader.swift index a4cf6ba..d44a67c 100644 --- a/Sources/VRMRealityKit/VRMEntityLoader.swift +++ b/Sources/VRMRealityKit/VRMEntityLoader.swift @@ -399,7 +399,10 @@ open class VRMEntityLoader { return vrm.materialPropertyNameMap[name] }() let shaderName = materialProperty?.shader.lowercased() - let useUnlit = shaderName?.contains("unlit") == true + // VRM shaders (MToon, Unlit variants) are not PBR, so use UnlitMaterial for consistent rendering + // This matches SceneKit's behavior which uses lightingModel = .constant + let isMToon = shaderName?.contains("mtoon") == true + let useUnlit = shaderName?.contains("unlit") == true || isMToon || materialProperty != nil let hasAlphaPremultiply = materialProperty?.keywordMap["_ALPHAPREMULTIPLY_ON"] == true let hasAlphaBlend = materialProperty?.keywordMap["_ALPHABLEND_ON"] == true let hasAlphaTest = materialProperty?.keywordMap["_ALPHATEST_ON"] == true @@ -763,19 +766,25 @@ open class VRMEntityLoader { let accessor = try gltf.load(\.accessors)[index] let (componentsPerVector, bytesPerComponent, vectorSize) = accessor.components() - let (bufferView, dataStride): (Data, Int) = try { + let baseData: Data = try { if let bufferViewIndex = accessor.bufferView { let bufferView = try self.bufferView(withBufferViewIndex: bufferViewIndex) - return (bufferView.bufferView, bufferView.stride ?? vectorSize) - } else { - return (Data(count: vectorSize * accessor.count), vectorSize) + let dataStride = bufferView.stride ?? vectorSize + return bufferView.bufferView.subdata(offset: accessor.byteOffset, + size: vectorSize, + stride: dataStride, + count: accessor.count) } + return Data(count: vectorSize * accessor.count) }() - let data = bufferView.subdata(offset: accessor.byteOffset, - size: vectorSize, - stride: dataStride, - count: accessor.count) + var data = baseData + if let sparse = accessor.sparse { + try applySparse(sparse: sparse, + accessorCount: accessor.count, + vectorSize: vectorSize, + data: &data) + } let slice = AccessorSlice( data: data, @@ -789,6 +798,68 @@ open class VRMEntityLoader { return slice } + private func applySparse(sparse: GLTF.Accessor.Sparse, + accessorCount: Int, + vectorSize: Int, + data: inout Data) throws { + guard sparse.count > 0 else { return } + let indices = try sparseIndices(sparse: sparse) + let values = try sparseValues(sparse: sparse, vectorSize: vectorSize) + let count = min(indices.count, sparse.count) + data.withUnsafeMutableBytes { rawDst in + guard let dst = rawDst.bindMemory(to: UInt8.self).baseAddress else { return } + values.withUnsafeBytes { rawSrc in + guard let src = rawSrc.bindMemory(to: UInt8.self).baseAddress else { return } + for i in 0..= 0, index < accessorCount else { continue } + let dstPos = index * vectorSize + let srcPos = i * vectorSize + memcpy(dst.advanced(by: dstPos), src.advanced(by: srcPos), vectorSize) + } + } + } + } + + private func sparseIndices(sparse: GLTF.Accessor.Sparse) throws -> [Int] { + let bufferView = try self.bufferView(withBufferViewIndex: sparse.indices.bufferView) + let bytesPerIndex = bytes(of: sparse.indices.componentType) + let stride = bufferView.stride ?? bytesPerIndex + let indexData = bufferView.bufferView.subdata(offset: sparse.indices.byteOffset, + size: bytesPerIndex, + stride: stride, + count: sparse.count) + var indices: [Int] = [] + indices.reserveCapacity(sparse.count) + indexData.withUnsafeBytes { raw in + guard let base = raw.baseAddress else { return } + for i in 0.. Data { + let bufferView = try self.bufferView(withBufferViewIndex: sparse.values.bufferView) + let stride = bufferView.stride ?? vectorSize + return bufferView.bufferView.subdata(offset: sparse.values.byteOffset, + size: vectorSize, + stride: stride, + count: sparse.count) + } + private func vector2s(_ accessorIndex: Int) throws -> [SIMD2] { let slice = try accessorSlice(accessorIndex) guard slice.componentsPerVector == 2 else { diff --git a/Sources/VRMSceneKit/GLTF2SCN/SCNGeometrySource+GLTF.swift b/Sources/VRMSceneKit/GLTF2SCN/SCNGeometrySource+GLTF.swift index 59b89f4..415dfc8 100644 --- a/Sources/VRMSceneKit/GLTF2SCN/SCNGeometrySource+GLTF.swift +++ b/Sources/VRMSceneKit/GLTF2SCN/SCNGeometrySource+GLTF.swift @@ -5,23 +5,117 @@ import SceneKit extension SCNGeometrySource { convenience init(accessor: GLTF.Accessor, semantic: SCNGeometrySource.Semantic, loader: VRMSceneLoader) throws { let (componentsPerVector, bytesPerComponent, vectorSize) = accessor.components() + if let sparse = accessor.sparse { + var data = try baseDataForSparse(accessor: accessor, vectorSize: vectorSize, loader: loader) + try applySparse(sparse: sparse, + accessorCount: accessor.count, + vectorSize: vectorSize, + loader: loader, + data: &data) + self.init(data: data, + semantic: semantic, + vectorCount: accessor.count, + usesFloatComponents: accessor.componentType == .float, + componentsPerVector: componentsPerVector, + bytesPerComponent: bytesPerComponent, + dataOffset: 0, + dataStride: vectorSize) + } else { + let (bufferView, dataStride): (Data, Int) = try { + if let bufferViewIndex = accessor.bufferView { + let bufferView = try loader.bufferView(withBufferViewIndex: bufferViewIndex) + return (bufferView.bufferView, bufferView.stride ?? vectorSize) + } else { + return (Data(count: vectorSize * accessor.count), vectorSize) + } + }() - let (bufferView, dataStride): (Data, Int) = try { - if let bufferViewIndex = accessor.bufferView { - let bufferView = try loader.bufferView(withBufferViewIndex: bufferViewIndex) - return (bufferView.bufferView, bufferView.stride ?? vectorSize) - } else { - return (Data(count: vectorSize * accessor.count), vectorSize) + self.init(data: bufferView, + semantic: semantic, + vectorCount: accessor.count, + usesFloatComponents: accessor.componentType == .float, + componentsPerVector: componentsPerVector, + bytesPerComponent: bytesPerComponent, + dataOffset: accessor.byteOffset, + dataStride: dataStride) + } + } +} + +private func baseDataForSparse(accessor: GLTF.Accessor, + vectorSize: Int, + loader: VRMSceneLoader) throws -> Data { + if let bufferViewIndex = accessor.bufferView { + let bufferView = try loader.bufferView(withBufferViewIndex: bufferViewIndex) + let dataStride = bufferView.stride ?? vectorSize + return bufferView.bufferView.subdata(offset: accessor.byteOffset, + size: vectorSize, + stride: dataStride, + count: accessor.count) + } + return Data(count: vectorSize * accessor.count) +} + +private func applySparse(sparse: GLTF.Accessor.Sparse, + accessorCount: Int, + vectorSize: Int, + loader: VRMSceneLoader, + data: inout Data) throws { + guard sparse.count > 0 else { return } + let indices = try sparseIndices(sparse: sparse, loader: loader) + let values = try sparseValues(sparse: sparse, vectorSize: vectorSize, loader: loader) + let count = min(indices.count, sparse.count) + data.withUnsafeMutableBytes { rawDst in + guard let dst = rawDst.bindMemory(to: UInt8.self).baseAddress else { return } + values.withUnsafeBytes { rawSrc in + guard let src = rawSrc.bindMemory(to: UInt8.self).baseAddress else { return } + for i in 0..= 0, index < accessorCount else { continue } + let dstPos = index * vectorSize + let srcPos = i * vectorSize + memcpy(dst.advanced(by: dstPos), src.advanced(by: srcPos), vectorSize) } - }() + } + } +} - self.init(data: bufferView, - semantic: semantic, - vectorCount: accessor.count, - usesFloatComponents: accessor.componentType == .float, - componentsPerVector: componentsPerVector, - bytesPerComponent: bytesPerComponent, - dataOffset: accessor.byteOffset, - dataStride: dataStride) +private func sparseIndices(sparse: GLTF.Accessor.Sparse, loader: VRMSceneLoader) throws -> [Int] { + let bufferView = try loader.bufferView(withBufferViewIndex: sparse.indices.bufferView) + let bytesPerIndex = bytes(of: sparse.indices.componentType) + let stride = bufferView.stride ?? bytesPerIndex + let indexData = bufferView.bufferView.subdata(offset: sparse.indices.byteOffset, + size: bytesPerIndex, + stride: stride, + count: sparse.count) + var indices: [Int] = [] + indices.reserveCapacity(sparse.count) + indexData.withUnsafeBytes { raw in + guard let base = raw.baseAddress else { return } + for i in 0.. Data { + let bufferView = try loader.bufferView(withBufferViewIndex: sparse.values.bufferView) + let stride = bufferView.stride ?? vectorSize + return bufferView.bufferView.subdata(offset: sparse.values.byteOffset, + size: vectorSize, + stride: stride, + count: sparse.count) } diff --git a/Tests/VRMKitTests/Assets/VRM1_Constraint_Twist_Sample.vrm b/Tests/VRMKitTests/Assets/VRM1_Constraint_Twist_Sample.vrm new file mode 100644 index 0000000..8ee8717 Binary files /dev/null and b/Tests/VRMKitTests/Assets/VRM1_Constraint_Twist_Sample.vrm differ diff --git a/Tests/VRMKitTests/VRM1MigrationTests.swift b/Tests/VRMKitTests/VRM1MigrationTests.swift new file mode 100644 index 0000000..ba6ef52 --- /dev/null +++ b/Tests/VRMKitTests/VRM1MigrationTests.swift @@ -0,0 +1,93 @@ +import Testing +import VRMKit +import Foundation + +struct VRM1MigrationTests { + + @Test("Meta: VRM1 -> VRM0") + func migrationMetaVRM1toVRM0() throws { + let vrm0 = try VRM(data: Resources.seedSan.data) + + #expect(vrm0.meta.title == "Seed-san") + #expect(vrm0.meta.author == "VirtualCast, Inc.") + #expect(vrm0.meta.version == "1") + #expect(vrm0.meta.texture == 14) + #expect(vrm0.meta.allowedUserName == "Everyone") + #expect(vrm0.meta.violentUssageName == "Allow") + #expect(vrm0.meta.sexualUssageName == "Allow") + #expect(vrm0.meta.commercialUssageName == "corporation") + #expect(vrm0.meta.licenseName == "https://vrm.dev/licenses/1.0/") + } + + @Test("Humanoid: VRM1 -> VRM0") + func migrationHumanoidVRM1toVRM0() throws { + let vrm0 = try VRM(data: Resources.seedSan.data) + + #expect(vrm0.humanoid.humanBones.count == 51) + #expect(vrm0.humanoid.humanBones.first { $0.bone == "hips" }?.node == 3) + #expect(vrm0.humanoid.humanBones.first { $0.bone == "head" }?.node == 45) + } + + @Test("BlendShape: VRM1 -> VRM0") + func migrationBlendShapeVRM1toVRM0() throws { + let vrm0 = try VRM(data: Resources.seedSan.data) + + #expect(vrm0.blendShapeMaster.blendShapeGroups.count == 18) + + #expect(vrm0.blendShapeMaster.blendShapeGroups.first { $0.name == "Happy" }?.presetName == "joy") + #expect(vrm0.blendShapeMaster.blendShapeGroups.first { $0.name == "Angry" }?.presetName == "angry") + #expect(vrm0.blendShapeMaster.blendShapeGroups.first { $0.name == "Sad" }?.presetName == "sorrow") + #expect(vrm0.blendShapeMaster.blendShapeGroups.first { $0.name == "Relaxed" }?.presetName == "fun") + #expect(vrm0.blendShapeMaster.blendShapeGroups.first { $0.name == "Surprised" }?.presetName == "unknown") + } + + @Test("FirstPerson: VRM1 -> VRM0") + func migrationFirstPersonVRM1toVRM0() throws { + let vrm0 = try VRM(data: Resources.seedSan.data) + + #expect(vrm0.firstPerson.meshAnnotations.count == 5) + #expect(vrm0.firstPerson.firstPersonBone == -1) + #expect(vrm0.firstPerson.lookAtTypeName == .blendShape) + } + + @Test("SpringBone: VRM1 -> VRM0") + func migrationSecondaryAnimationVRM1toVRM0() throws { + let vrm0 = try VRM(data: Resources.seedSan.data) + + #expect(vrm0.secondaryAnimation.colliderGroups.count == 6) + + #expect(vrm0.secondaryAnimation.colliderGroups.contains { $0.node == 4 && $0.colliders.count == 1 }) + #expect(vrm0.secondaryAnimation.colliderGroups.contains { $0.node == 5 && $0.colliders.count == 3 }) + } + + @Test("Material: MToon VRM1 -> VRM0") + func migrationMaterialVRM1toVRM0() throws { + let vrm0 = try VRM(data: Resources.seedSan.data) + + #expect(vrm0.materialProperties.count == 17) + + // Material 0 (MToon) + let mtoon0 = vrm0.materialProperties[0] + #expect(mtoon0.shader == "VRM/MToon") + + if let floatProps = mtoon0.floatProperties.value as? [String: Double] { + #expect(floatProps["_ShadingToony"] == 0.95) + #expect(floatProps["_ShadingShift"] == -0.05) + } else { + Issue.record("floatProperties type mismatch") + } + + // _ShadeColor is vector + if let vectorProps = mtoon0.vectorProperties.value as? [String: [Double]] { + if let shadeColor = vectorProps["_ShadeColor"] { + #expect(shadeColor.count == 4) + // Approximate equality for double + #expect(abs(shadeColor[0] - 0.301212043) < 0.0001) + } else { + Issue.record("_ShadeColor missing") + } + } else { + Issue.record("vectorProperties type mismatch") + } + } +} diff --git a/Tests/VRMKitTests/VRM1Tests.swift b/Tests/VRMKitTests/VRM1Tests.swift index 4f5cb52..55e97a7 100644 --- a/Tests/VRMKitTests/VRM1Tests.swift +++ b/Tests/VRMKitTests/VRM1Tests.swift @@ -466,62 +466,62 @@ class VRM1Tests: XCTestCase { XCTAssertEqual(vrm.springBone?.springs?[0].colliderGroups?[0], 0) XCTAssertEqual(vrm.springBone?.springs?[0].joints.count, 7) XCTAssertEqual(vrm.springBone?.springs?[0].joints[0].dragForce, 1) - XCTAssertEqual(vrm.springBone?.springs?[0].joints[0].gravityDir.count, 3) - XCTAssertEqual(vrm.springBone?.springs?[0].joints[0].gravityDir[0], 0) - XCTAssertEqual(vrm.springBone?.springs?[0].joints[0].gravityDir[1], -1) - XCTAssertEqual(vrm.springBone?.springs?[0].joints[0].gravityDir[2], 0) + XCTAssertEqual(vrm.springBone?.springs?[0].joints[0].gravityDir?.count, 3) + XCTAssertEqual(vrm.springBone?.springs?[0].joints[0].gravityDir?[0], 0) + XCTAssertEqual(vrm.springBone?.springs?[0].joints[0].gravityDir?[1], -1) + XCTAssertEqual(vrm.springBone?.springs?[0].joints[0].gravityDir?[2], 0) XCTAssertEqual(vrm.springBone?.springs?[0].joints[0].gravityPower, 0) XCTAssertEqual(vrm.springBone?.springs?[0].joints[0].hitRadius, 0.02) XCTAssertEqual(vrm.springBone?.springs?[0].joints[0].node, 75) XCTAssertEqual(vrm.springBone?.springs?[0].joints[0].stiffness, 4) XCTAssertEqual(vrm.springBone?.springs?[0].joints[1].dragForce, 1) - XCTAssertEqual(vrm.springBone?.springs?[0].joints[1].gravityDir.count, 3) - XCTAssertEqual(vrm.springBone?.springs?[0].joints[1].gravityDir[0], 0) - XCTAssertEqual(vrm.springBone?.springs?[0].joints[1].gravityDir[1], -1) + XCTAssertEqual(vrm.springBone?.springs?[0].joints[1].gravityDir?.count, 3) + XCTAssertEqual(vrm.springBone?.springs?[0].joints[1].gravityDir?[0], 0) + XCTAssertEqual(vrm.springBone?.springs?[0].joints[1].gravityDir?[1], -1) XCTAssertEqual(vrm.springBone?.springs?[0].joints[1].gravityPower, 0) XCTAssertEqual(vrm.springBone?.springs?[0].joints[1].hitRadius, 0.02) XCTAssertEqual(vrm.springBone?.springs?[0].joints[1].node, 76) XCTAssertEqual(vrm.springBone?.springs?[0].joints[1].stiffness, 4) XCTAssertEqual(vrm.springBone?.springs?[0].joints[2].dragForce, 1) - XCTAssertEqual(vrm.springBone?.springs?[0].joints[2].gravityDir.count, 3) - XCTAssertEqual(vrm.springBone?.springs?[0].joints[2].gravityDir[0], 0) - XCTAssertEqual(vrm.springBone?.springs?[0].joints[2].gravityDir[1], -1) - XCTAssertEqual(vrm.springBone?.springs?[0].joints[2].gravityDir[2], 0) + XCTAssertEqual(vrm.springBone?.springs?[0].joints[2].gravityDir?.count, 3) + XCTAssertEqual(vrm.springBone?.springs?[0].joints[2].gravityDir?[0], 0) + XCTAssertEqual(vrm.springBone?.springs?[0].joints[2].gravityDir?[1], -1) + XCTAssertEqual(vrm.springBone?.springs?[0].joints[2].gravityDir?[2], 0) XCTAssertEqual(vrm.springBone?.springs?[0].joints[2].gravityPower, 0) XCTAssertEqual(vrm.springBone?.springs?[0].joints[2].hitRadius, 0.02) XCTAssertEqual(vrm.springBone?.springs?[0].joints[2].node, 77) XCTAssertEqual(vrm.springBone?.springs?[0].joints[2].stiffness, 3) XCTAssertEqual(vrm.springBone?.springs?[0].joints[3].dragForce, 1) - XCTAssertEqual(vrm.springBone?.springs?[0].joints[3].gravityDir.count, 3) - XCTAssertEqual(vrm.springBone?.springs?[0].joints[3].gravityDir[0], 0) - XCTAssertEqual(vrm.springBone?.springs?[0].joints[3].gravityDir[1], -1) - XCTAssertEqual(vrm.springBone?.springs?[0].joints[3].gravityDir[2], 0) + XCTAssertEqual(vrm.springBone?.springs?[0].joints[3].gravityDir?.count, 3) + XCTAssertEqual(vrm.springBone?.springs?[0].joints[3].gravityDir?[0], 0) + XCTAssertEqual(vrm.springBone?.springs?[0].joints[3].gravityDir?[1], -1) + XCTAssertEqual(vrm.springBone?.springs?[0].joints[3].gravityDir?[2], 0) XCTAssertEqual(vrm.springBone?.springs?[0].joints[3].gravityPower, 0) XCTAssertEqual(vrm.springBone?.springs?[0].joints[3].hitRadius, 0.02) XCTAssertEqual(vrm.springBone?.springs?[0].joints[3].node, 78) XCTAssertEqual(vrm.springBone?.springs?[0].joints[3].stiffness, 3) XCTAssertEqual(vrm.springBone?.springs?[0].joints[4].dragForce, 1) - XCTAssertEqual(vrm.springBone?.springs?[0].joints[4].gravityDir.count, 3) - XCTAssertEqual(vrm.springBone?.springs?[0].joints[4].gravityDir[0], 0) - XCTAssertEqual(vrm.springBone?.springs?[0].joints[4].gravityDir[1], -1) - XCTAssertEqual(vrm.springBone?.springs?[0].joints[4].gravityDir[2], 0) + XCTAssertEqual(vrm.springBone?.springs?[0].joints[4].gravityDir?.count, 3) + XCTAssertEqual(vrm.springBone?.springs?[0].joints[4].gravityDir?[0], 0) + XCTAssertEqual(vrm.springBone?.springs?[0].joints[4].gravityDir?[1], -1) + XCTAssertEqual(vrm.springBone?.springs?[0].joints[4].gravityDir?[2], 0) XCTAssertEqual(vrm.springBone?.springs?[0].joints[4].hitRadius, 0.02) XCTAssertEqual(vrm.springBone?.springs?[0].joints[4].node, 79) XCTAssertEqual(vrm.springBone?.springs?[0].joints[4].stiffness, 2) XCTAssertEqual(vrm.springBone?.springs?[0].joints[5].dragForce, 1) - XCTAssertEqual(vrm.springBone?.springs?[0].joints[5].gravityDir.count, 3) - XCTAssertEqual(vrm.springBone?.springs?[0].joints[5].gravityDir[0], 0) - XCTAssertEqual(vrm.springBone?.springs?[0].joints[5].gravityDir[1], -1) - XCTAssertEqual(vrm.springBone?.springs?[0].joints[5].gravityDir[2], 0) + XCTAssertEqual(vrm.springBone?.springs?[0].joints[5].gravityDir?.count, 3) + XCTAssertEqual(vrm.springBone?.springs?[0].joints[5].gravityDir?[0], 0) + XCTAssertEqual(vrm.springBone?.springs?[0].joints[5].gravityDir?[1], -1) + XCTAssertEqual(vrm.springBone?.springs?[0].joints[5].gravityDir?[2], 0) XCTAssertEqual(vrm.springBone?.springs?[0].joints[5].gravityPower, 0) XCTAssertEqual(vrm.springBone?.springs?[0].joints[5].hitRadius, 0.02) XCTAssertEqual(vrm.springBone?.springs?[0].joints[5].node, 80) XCTAssertEqual(vrm.springBone?.springs?[0].joints[5].stiffness, 2) XCTAssertEqual(vrm.springBone?.springs?[0].joints[6].dragForce, 1) - XCTAssertEqual(vrm.springBone?.springs?[0].joints[6].gravityDir.count, 3) - XCTAssertEqual(vrm.springBone?.springs?[0].joints[6].gravityDir[0], 0) - XCTAssertEqual(vrm.springBone?.springs?[0].joints[6].gravityDir[1], -1) - XCTAssertEqual(vrm.springBone?.springs?[0].joints[6].gravityDir[2], 0) + XCTAssertEqual(vrm.springBone?.springs?[0].joints[6].gravityDir?.count, 3) + XCTAssertEqual(vrm.springBone?.springs?[0].joints[6].gravityDir?[0], 0) + XCTAssertEqual(vrm.springBone?.springs?[0].joints[6].gravityDir?[1], -1) + XCTAssertEqual(vrm.springBone?.springs?[0].joints[6].gravityDir?[2], 0) XCTAssertEqual(vrm.springBone?.springs?[0].joints[6].gravityPower, 0) XCTAssertEqual(vrm.springBone?.springs?[0].joints[6].hitRadius, 0.02) XCTAssertEqual(vrm.springBone?.springs?[0].joints[6].node, 81) @@ -531,19 +531,19 @@ class VRM1Tests: XCTestCase { XCTAssertEqual(vrm.springBone?.springs?[1].center, 3) XCTAssertEqual(vrm.springBone?.springs?[1].joints.count, 2) XCTAssertEqual(vrm.springBone?.springs?[1].joints[0].dragForce, 1) - XCTAssertEqual(vrm.springBone?.springs?[1].joints[0].gravityDir.count, 3) - XCTAssertEqual(vrm.springBone?.springs?[1].joints[0].gravityDir[0], 0) - XCTAssertEqual(vrm.springBone?.springs?[1].joints[0].gravityDir[1], -1) - XCTAssertEqual(vrm.springBone?.springs?[1].joints[0].gravityDir[2], 0) + XCTAssertEqual(vrm.springBone?.springs?[1].joints[0].gravityDir?.count, 3) + XCTAssertEqual(vrm.springBone?.springs?[1].joints[0].gravityDir?[0], 0) + XCTAssertEqual(vrm.springBone?.springs?[1].joints[0].gravityDir?[1], -1) + XCTAssertEqual(vrm.springBone?.springs?[1].joints[0].gravityDir?[2], 0) XCTAssertEqual(vrm.springBone?.springs?[1].joints[0].gravityPower, 0) XCTAssertEqual(vrm.springBone?.springs?[1].joints[0].hitRadius, 0.01) XCTAssertEqual(vrm.springBone?.springs?[1].joints[0].node, 47) XCTAssertEqual(vrm.springBone?.springs?[1].joints[0].stiffness, 1.2) XCTAssertEqual(vrm.springBone?.springs?[1].joints[1].dragForce, 1) - XCTAssertEqual(vrm.springBone?.springs?[1].joints[1].gravityDir.count, 3) - XCTAssertEqual(vrm.springBone?.springs?[1].joints[1].gravityDir[0], 0) - XCTAssertEqual(vrm.springBone?.springs?[1].joints[1].gravityDir[1], -1) - XCTAssertEqual(vrm.springBone?.springs?[1].joints[1].gravityDir[2], 0) + XCTAssertEqual(vrm.springBone?.springs?[1].joints[1].gravityDir?.count, 3) + XCTAssertEqual(vrm.springBone?.springs?[1].joints[1].gravityDir?[0], 0) + XCTAssertEqual(vrm.springBone?.springs?[1].joints[1].gravityDir?[1], -1) + XCTAssertEqual(vrm.springBone?.springs?[1].joints[1].gravityDir?[2], 0) XCTAssertEqual(vrm.springBone?.springs?[1].joints[1].gravityPower, 0) XCTAssertEqual(vrm.springBone?.springs?[1].joints[1].hitRadius, 0.01) XCTAssertEqual(vrm.springBone?.springs?[1].joints[1].node, 48) @@ -553,18 +553,18 @@ class VRM1Tests: XCTestCase { XCTAssertEqual(vrm.springBone?.springs?[2].center, 3) XCTAssertEqual(vrm.springBone?.springs?[2].joints.count, 2) XCTAssertEqual(vrm.springBone?.springs?[2].joints[0].dragForce, 1) - XCTAssertEqual(vrm.springBone?.springs?[2].joints[0].gravityDir.count, 3) - XCTAssertEqual(vrm.springBone?.springs?[2].joints[0].gravityDir[0], 0) - XCTAssertEqual(vrm.springBone?.springs?[2].joints[0].gravityDir[1], -1) - XCTAssertEqual(vrm.springBone?.springs?[2].joints[0].gravityDir[2], 0) + XCTAssertEqual(vrm.springBone?.springs?[2].joints[0].gravityDir?.count, 3) + XCTAssertEqual(vrm.springBone?.springs?[2].joints[0].gravityDir?[0], 0) + XCTAssertEqual(vrm.springBone?.springs?[2].joints[0].gravityDir?[1], -1) + XCTAssertEqual(vrm.springBone?.springs?[2].joints[0].gravityDir?[2], 0) XCTAssertEqual(vrm.springBone?.springs?[2].joints[0].hitRadius, 0.01) XCTAssertEqual(vrm.springBone?.springs?[2].joints[0].node, 50) XCTAssertEqual(vrm.springBone?.springs?[2].joints[0].stiffness, 1.2) XCTAssertEqual(vrm.springBone?.springs?[2].joints[1].dragForce, 1) - XCTAssertEqual(vrm.springBone?.springs?[2].joints[1].gravityDir.count, 3) - XCTAssertEqual(vrm.springBone?.springs?[2].joints[1].gravityDir[0], 0) - XCTAssertEqual(vrm.springBone?.springs?[2].joints[1].gravityDir[1], -1) - XCTAssertEqual(vrm.springBone?.springs?[2].joints[1].gravityDir[2], 0) + XCTAssertEqual(vrm.springBone?.springs?[2].joints[1].gravityDir?.count, 3) + XCTAssertEqual(vrm.springBone?.springs?[2].joints[1].gravityDir?[0], 0) + XCTAssertEqual(vrm.springBone?.springs?[2].joints[1].gravityDir?[1], -1) + XCTAssertEqual(vrm.springBone?.springs?[2].joints[1].gravityDir?[2], 0) XCTAssertEqual(vrm.springBone?.springs?[2].joints[1].hitRadius, 0.01) XCTAssertEqual(vrm.springBone?.springs?[2].joints[1].node, 51) XCTAssertEqual(vrm.springBone?.springs?[2].joints[1].stiffness, 1.2) @@ -573,19 +573,19 @@ class VRM1Tests: XCTestCase { XCTAssertEqual(vrm.springBone?.springs?[3].center, 3) XCTAssertEqual(vrm.springBone?.springs?[3].joints.count, 2) XCTAssertEqual(vrm.springBone?.springs?[3].joints[0].dragForce, 1) - XCTAssertEqual(vrm.springBone?.springs?[3].joints[0].gravityDir.count, 3) - XCTAssertEqual(vrm.springBone?.springs?[3].joints[0].gravityDir[0], 0) - XCTAssertEqual(vrm.springBone?.springs?[3].joints[0].gravityDir[1], -1) - XCTAssertEqual(vrm.springBone?.springs?[3].joints[0].gravityDir[2], 0) + XCTAssertEqual(vrm.springBone?.springs?[3].joints[0].gravityDir?.count, 3) + XCTAssertEqual(vrm.springBone?.springs?[3].joints[0].gravityDir?[0], 0) + XCTAssertEqual(vrm.springBone?.springs?[3].joints[0].gravityDir?[1], -1) + XCTAssertEqual(vrm.springBone?.springs?[3].joints[0].gravityDir?[2], 0) XCTAssertEqual(vrm.springBone?.springs?[3].joints[0].gravityPower, 0) XCTAssertEqual(vrm.springBone?.springs?[3].joints[0].hitRadius, 0.01) XCTAssertEqual(vrm.springBone?.springs?[3].joints[0].node, 53) XCTAssertEqual(vrm.springBone?.springs?[3].joints[0].stiffness, 1.2) XCTAssertEqual(vrm.springBone?.springs?[3].joints[1].dragForce, 1) - XCTAssertEqual(vrm.springBone?.springs?[3].joints[1].gravityDir.count, 3) - XCTAssertEqual(vrm.springBone?.springs?[3].joints[1].gravityDir[0], 0) - XCTAssertEqual(vrm.springBone?.springs?[3].joints[1].gravityDir[1], -1) - XCTAssertEqual(vrm.springBone?.springs?[3].joints[1].gravityDir[2], 0) + XCTAssertEqual(vrm.springBone?.springs?[3].joints[1].gravityDir?.count, 3) + XCTAssertEqual(vrm.springBone?.springs?[3].joints[1].gravityDir?[0], 0) + XCTAssertEqual(vrm.springBone?.springs?[3].joints[1].gravityDir?[1], -1) + XCTAssertEqual(vrm.springBone?.springs?[3].joints[1].gravityDir?[2], 0) XCTAssertEqual(vrm.springBone?.springs?[3].joints[1].hitRadius, 0.01) XCTAssertEqual(vrm.springBone?.springs?[3].joints[1].node, 54) XCTAssertEqual(vrm.springBone?.springs?[3].joints[1].stiffness, 1.2) @@ -594,19 +594,19 @@ class VRM1Tests: XCTestCase { XCTAssertEqual(vrm.springBone?.springs?[4].center, 3) XCTAssertEqual(vrm.springBone?.springs?[4].joints.count, 2) XCTAssertEqual(vrm.springBone?.springs?[4].joints[0].dragForce, 1) - XCTAssertEqual(vrm.springBone?.springs?[4].joints[0].gravityDir.count, 3) - XCTAssertEqual(vrm.springBone?.springs?[4].joints[0].gravityDir[0], 0) - XCTAssertEqual(vrm.springBone?.springs?[4].joints[0].gravityDir[1], -1) - XCTAssertEqual(vrm.springBone?.springs?[4].joints[0].gravityDir[2], 0) + XCTAssertEqual(vrm.springBone?.springs?[4].joints[0].gravityDir?.count, 3) + XCTAssertEqual(vrm.springBone?.springs?[4].joints[0].gravityDir?[0], 0) + XCTAssertEqual(vrm.springBone?.springs?[4].joints[0].gravityDir?[1], -1) + XCTAssertEqual(vrm.springBone?.springs?[4].joints[0].gravityDir?[2], 0) XCTAssertEqual(vrm.springBone?.springs?[4].joints[0].gravityPower, 0) XCTAssertEqual(vrm.springBone?.springs?[4].joints[0].hitRadius, 0.01) XCTAssertEqual(vrm.springBone?.springs?[4].joints[0].node, 56) XCTAssertEqual(vrm.springBone?.springs?[4].joints[0].stiffness, 1.2) XCTAssertEqual(vrm.springBone?.springs?[4].joints[1].dragForce, 1) - XCTAssertEqual(vrm.springBone?.springs?[4].joints[1].gravityDir.count, 3) - XCTAssertEqual(vrm.springBone?.springs?[4].joints[1].gravityDir[0], 0) - XCTAssertEqual(vrm.springBone?.springs?[4].joints[1].gravityDir[1], -1) - XCTAssertEqual(vrm.springBone?.springs?[4].joints[1].gravityDir[2], 0) + XCTAssertEqual(vrm.springBone?.springs?[4].joints[1].gravityDir?.count, 3) + XCTAssertEqual(vrm.springBone?.springs?[4].joints[1].gravityDir?[0], 0) + XCTAssertEqual(vrm.springBone?.springs?[4].joints[1].gravityDir?[1], -1) + XCTAssertEqual(vrm.springBone?.springs?[4].joints[1].gravityDir?[2], 0) XCTAssertEqual(vrm.springBone?.springs?[4].joints[1].gravityPower, 0) XCTAssertEqual(vrm.springBone?.springs?[4].joints[1].hitRadius, 0.01) XCTAssertEqual(vrm.springBone?.springs?[4].joints[1].node, 57) @@ -616,19 +616,19 @@ class VRM1Tests: XCTestCase { XCTAssertEqual(vrm.springBone?.springs?[5].center, 3) XCTAssertEqual(vrm.springBone?.springs?[5].joints.count, 2) XCTAssertEqual(vrm.springBone?.springs?[5].joints[0].dragForce, 1) - XCTAssertEqual(vrm.springBone?.springs?[5].joints[0].gravityDir.count, 3) - XCTAssertEqual(vrm.springBone?.springs?[5].joints[0].gravityDir[0], 0) - XCTAssertEqual(vrm.springBone?.springs?[5].joints[0].gravityDir[1], -1) - XCTAssertEqual(vrm.springBone?.springs?[5].joints[0].gravityDir[2], 0) + XCTAssertEqual(vrm.springBone?.springs?[5].joints[0].gravityDir?.count, 3) + XCTAssertEqual(vrm.springBone?.springs?[5].joints[0].gravityDir?[0], 0) + XCTAssertEqual(vrm.springBone?.springs?[5].joints[0].gravityDir?[1], -1) + XCTAssertEqual(vrm.springBone?.springs?[5].joints[0].gravityDir?[2], 0) XCTAssertEqual(vrm.springBone?.springs?[5].joints[0].gravityPower, 0) XCTAssertEqual(vrm.springBone?.springs?[5].joints[0].hitRadius, 0.01) XCTAssertEqual(vrm.springBone?.springs?[5].joints[0].node, 59) XCTAssertEqual(vrm.springBone?.springs?[5].joints[0].stiffness, 1.2) XCTAssertEqual(vrm.springBone?.springs?[5].joints[1].dragForce, 1) - XCTAssertEqual(vrm.springBone?.springs?[5].joints[1].gravityDir.count, 3) - XCTAssertEqual(vrm.springBone?.springs?[5].joints[1].gravityDir[0], 0) - XCTAssertEqual(vrm.springBone?.springs?[5].joints[1].gravityDir[1], -1) - XCTAssertEqual(vrm.springBone?.springs?[5].joints[1].gravityDir[2], 0) + XCTAssertEqual(vrm.springBone?.springs?[5].joints[1].gravityDir?.count, 3) + XCTAssertEqual(vrm.springBone?.springs?[5].joints[1].gravityDir?[0], 0) + XCTAssertEqual(vrm.springBone?.springs?[5].joints[1].gravityDir?[1], -1) + XCTAssertEqual(vrm.springBone?.springs?[5].joints[1].gravityDir?[2], 0) XCTAssertEqual(vrm.springBone?.springs?[5].joints[1].hitRadius, 0.01) XCTAssertEqual(vrm.springBone?.springs?[5].joints[1].node, 60) XCTAssertEqual(vrm.springBone?.springs?[5].joints[1].stiffness, 1.2) @@ -637,19 +637,19 @@ class VRM1Tests: XCTestCase { XCTAssertEqual(vrm.springBone?.springs?[6].center, 3) XCTAssertEqual(vrm.springBone?.springs?[6].joints.count, 2) XCTAssertEqual(vrm.springBone?.springs?[6].joints[0].dragForce, 1) - XCTAssertEqual(vrm.springBone?.springs?[6].joints[0].gravityDir.count, 3) - XCTAssertEqual(vrm.springBone?.springs?[6].joints[0].gravityDir[0], 0) - XCTAssertEqual(vrm.springBone?.springs?[6].joints[0].gravityDir[1], -1) - XCTAssertEqual(vrm.springBone?.springs?[6].joints[0].gravityDir[2], 0) + XCTAssertEqual(vrm.springBone?.springs?[6].joints[0].gravityDir?.count, 3) + XCTAssertEqual(vrm.springBone?.springs?[6].joints[0].gravityDir?[0], 0) + XCTAssertEqual(vrm.springBone?.springs?[6].joints[0].gravityDir?[1], -1) + XCTAssertEqual(vrm.springBone?.springs?[6].joints[0].gravityDir?[2], 0) XCTAssertEqual(vrm.springBone?.springs?[6].joints[0].gravityPower, 0) XCTAssertEqual(vrm.springBone?.springs?[6].joints[0].hitRadius, 0.01) XCTAssertEqual(vrm.springBone?.springs?[6].joints[0].node, 62) XCTAssertEqual(vrm.springBone?.springs?[6].joints[0].stiffness, 1.2) XCTAssertEqual(vrm.springBone?.springs?[6].joints[1].dragForce, 1) - XCTAssertEqual(vrm.springBone?.springs?[6].joints[1].gravityDir.count, 3) - XCTAssertEqual(vrm.springBone?.springs?[6].joints[1].gravityDir[0], 0) - XCTAssertEqual(vrm.springBone?.springs?[6].joints[1].gravityDir[1], -1) - XCTAssertEqual(vrm.springBone?.springs?[6].joints[1].gravityDir[2], 0) + XCTAssertEqual(vrm.springBone?.springs?[6].joints[1].gravityDir?.count, 3) + XCTAssertEqual(vrm.springBone?.springs?[6].joints[1].gravityDir?[0], 0) + XCTAssertEqual(vrm.springBone?.springs?[6].joints[1].gravityDir?[1], -1) + XCTAssertEqual(vrm.springBone?.springs?[6].joints[1].gravityDir?[2], 0) XCTAssertEqual(vrm.springBone?.springs?[6].joints[1].gravityPower, 0) XCTAssertEqual(vrm.springBone?.springs?[6].joints[1].hitRadius, 0.01) XCTAssertEqual(vrm.springBone?.springs?[6].joints[1].node, 63) @@ -659,19 +659,19 @@ class VRM1Tests: XCTestCase { XCTAssertEqual(vrm.springBone?.springs?[7].center, 3) XCTAssertEqual(vrm.springBone?.springs?[7].joints.count, 2) XCTAssertEqual(vrm.springBone?.springs?[7].joints[0].dragForce, 1) - XCTAssertEqual(vrm.springBone?.springs?[7].joints[0].gravityDir.count, 3) - XCTAssertEqual(vrm.springBone?.springs?[7].joints[0].gravityDir[0], 0) - XCTAssertEqual(vrm.springBone?.springs?[7].joints[0].gravityDir[1], -1) - XCTAssertEqual(vrm.springBone?.springs?[7].joints[0].gravityDir[2], 0) + XCTAssertEqual(vrm.springBone?.springs?[7].joints[0].gravityDir?.count, 3) + XCTAssertEqual(vrm.springBone?.springs?[7].joints[0].gravityDir?[0], 0) + XCTAssertEqual(vrm.springBone?.springs?[7].joints[0].gravityDir?[1], -1) + XCTAssertEqual(vrm.springBone?.springs?[7].joints[0].gravityDir?[2], 0) XCTAssertEqual(vrm.springBone?.springs?[7].joints[0].gravityPower, 0) XCTAssertEqual(vrm.springBone?.springs?[7].joints[0].hitRadius, 0.01) XCTAssertEqual(vrm.springBone?.springs?[7].joints[0].node, 65) XCTAssertEqual(vrm.springBone?.springs?[7].joints[0].stiffness, 1.2) XCTAssertEqual(vrm.springBone?.springs?[7].joints[1].dragForce, 1) - XCTAssertEqual(vrm.springBone?.springs?[7].joints[1].gravityDir.count, 3) - XCTAssertEqual(vrm.springBone?.springs?[7].joints[1].gravityDir[0], 0) - XCTAssertEqual(vrm.springBone?.springs?[7].joints[1].gravityDir[1], -1) - XCTAssertEqual(vrm.springBone?.springs?[7].joints[1].gravityDir[2], 0) + XCTAssertEqual(vrm.springBone?.springs?[7].joints[1].gravityDir?.count, 3) + XCTAssertEqual(vrm.springBone?.springs?[7].joints[1].gravityDir?[0], 0) + XCTAssertEqual(vrm.springBone?.springs?[7].joints[1].gravityDir?[1], -1) + XCTAssertEqual(vrm.springBone?.springs?[7].joints[1].gravityDir?[2], 0) XCTAssertEqual(vrm.springBone?.springs?[7].joints[1].gravityPower, 0) XCTAssertEqual(vrm.springBone?.springs?[7].joints[1].hitRadius, 0.01) XCTAssertEqual(vrm.springBone?.springs?[7].joints[1].node, 66) @@ -682,64 +682,64 @@ class VRM1Tests: XCTestCase { XCTAssertEqual(vrm.springBone?.springs?[8].colliderGroups?[0], 1) XCTAssertEqual(vrm.springBone?.springs?[8].joints.count, 7) XCTAssertEqual(vrm.springBone?.springs?[8].joints[0].dragForce, 1) - XCTAssertEqual(vrm.springBone?.springs?[8].joints[0].gravityDir.count, 3) - XCTAssertEqual(vrm.springBone?.springs?[8].joints[0].gravityDir[0], 0) - XCTAssertEqual(vrm.springBone?.springs?[8].joints[0].gravityDir[1], -1) - XCTAssertEqual(vrm.springBone?.springs?[8].joints[0].gravityDir[2], 0) + XCTAssertEqual(vrm.springBone?.springs?[8].joints[0].gravityDir?.count, 3) + XCTAssertEqual(vrm.springBone?.springs?[8].joints[0].gravityDir?[0], 0) + XCTAssertEqual(vrm.springBone?.springs?[8].joints[0].gravityDir?[1], -1) + XCTAssertEqual(vrm.springBone?.springs?[8].joints[0].gravityDir?[2], 0) XCTAssertEqual(vrm.springBone?.springs?[8].joints[0].gravityPower, 0) XCTAssertEqual(vrm.springBone?.springs?[8].joints[0].hitRadius, 0.02) XCTAssertEqual(vrm.springBone?.springs?[8].joints[0].node, 37) XCTAssertEqual(vrm.springBone?.springs?[8].joints[0].stiffness, 4) XCTAssertEqual(vrm.springBone?.springs?[8].joints[1].dragForce, 1) - XCTAssertEqual(vrm.springBone?.springs?[8].joints[1].gravityDir.count, 3) - XCTAssertEqual(vrm.springBone?.springs?[8].joints[1].gravityDir[0], 0) - XCTAssertEqual(vrm.springBone?.springs?[8].joints[1].gravityDir[1], -1) - XCTAssertEqual(vrm.springBone?.springs?[8].joints[1].gravityDir[2], 0) + XCTAssertEqual(vrm.springBone?.springs?[8].joints[1].gravityDir?.count, 3) + XCTAssertEqual(vrm.springBone?.springs?[8].joints[1].gravityDir?[0], 0) + XCTAssertEqual(vrm.springBone?.springs?[8].joints[1].gravityDir?[1], -1) + XCTAssertEqual(vrm.springBone?.springs?[8].joints[1].gravityDir?[2], 0) XCTAssertEqual(vrm.springBone?.springs?[8].joints[1].gravityPower, 0) XCTAssertEqual(vrm.springBone?.springs?[8].joints[1].hitRadius, 0.02) XCTAssertEqual(vrm.springBone?.springs?[8].joints[1].node, 38) XCTAssertEqual(vrm.springBone?.springs?[8].joints[1].stiffness, 4) XCTAssertEqual(vrm.springBone?.springs?[8].joints[2].dragForce, 1) - XCTAssertEqual(vrm.springBone?.springs?[8].joints[2].gravityDir.count, 3) - XCTAssertEqual(vrm.springBone?.springs?[8].joints[2].gravityDir[0], 0) - XCTAssertEqual(vrm.springBone?.springs?[8].joints[2].gravityDir[1], -1) - XCTAssertEqual(vrm.springBone?.springs?[8].joints[2].gravityDir[2], 0) + XCTAssertEqual(vrm.springBone?.springs?[8].joints[2].gravityDir?.count, 3) + XCTAssertEqual(vrm.springBone?.springs?[8].joints[2].gravityDir?[0], 0) + XCTAssertEqual(vrm.springBone?.springs?[8].joints[2].gravityDir?[1], -1) + XCTAssertEqual(vrm.springBone?.springs?[8].joints[2].gravityDir?[2], 0) XCTAssertEqual(vrm.springBone?.springs?[8].joints[2].gravityPower, 0) XCTAssertEqual(vrm.springBone?.springs?[8].joints[2].hitRadius, 0.02) XCTAssertEqual(vrm.springBone?.springs?[8].joints[2].node, 39) XCTAssertEqual(vrm.springBone?.springs?[8].joints[2].stiffness, 4) XCTAssertEqual(vrm.springBone?.springs?[8].joints[3].dragForce, 1) - XCTAssertEqual(vrm.springBone?.springs?[8].joints[3].gravityDir.count, 3) - XCTAssertEqual(vrm.springBone?.springs?[8].joints[3].gravityDir[0], 0) - XCTAssertEqual(vrm.springBone?.springs?[8].joints[3].gravityDir[1], -1) - XCTAssertEqual(vrm.springBone?.springs?[8].joints[3].gravityDir[2], 0) + XCTAssertEqual(vrm.springBone?.springs?[8].joints[3].gravityDir?.count, 3) + XCTAssertEqual(vrm.springBone?.springs?[8].joints[3].gravityDir?[0], 0) + XCTAssertEqual(vrm.springBone?.springs?[8].joints[3].gravityDir?[1], -1) + XCTAssertEqual(vrm.springBone?.springs?[8].joints[3].gravityDir?[2], 0) XCTAssertEqual(vrm.springBone?.springs?[8].joints[3].gravityPower, 0) XCTAssertEqual(vrm.springBone?.springs?[8].joints[3].hitRadius, 0.02) XCTAssertEqual(vrm.springBone?.springs?[8].joints[3].node, 40) XCTAssertEqual(vrm.springBone?.springs?[8].joints[3].stiffness, 4) XCTAssertEqual(vrm.springBone?.springs?[8].joints[4].dragForce, 1) - XCTAssertEqual(vrm.springBone?.springs?[8].joints[4].gravityDir.count, 3) - XCTAssertEqual(vrm.springBone?.springs?[8].joints[4].gravityDir[0], 0) - XCTAssertEqual(vrm.springBone?.springs?[8].joints[4].gravityDir[1], -1) - XCTAssertEqual(vrm.springBone?.springs?[8].joints[4].gravityDir[2], 0) + XCTAssertEqual(vrm.springBone?.springs?[8].joints[4].gravityDir?.count, 3) + XCTAssertEqual(vrm.springBone?.springs?[8].joints[4].gravityDir?[0], 0) + XCTAssertEqual(vrm.springBone?.springs?[8].joints[4].gravityDir?[1], -1) + XCTAssertEqual(vrm.springBone?.springs?[8].joints[4].gravityDir?[2], 0) XCTAssertEqual(vrm.springBone?.springs?[8].joints[4].gravityPower, 0) XCTAssertEqual(vrm.springBone?.springs?[8].joints[4].hitRadius, 0.02) XCTAssertEqual(vrm.springBone?.springs?[8].joints[4].node, 41) XCTAssertEqual(vrm.springBone?.springs?[8].joints[4].stiffness, 4) XCTAssertEqual(vrm.springBone?.springs?[8].joints[5].dragForce, 1) - XCTAssertEqual(vrm.springBone?.springs?[8].joints[5].gravityDir.count, 3) - XCTAssertEqual(vrm.springBone?.springs?[8].joints[5].gravityDir[0], 0) - XCTAssertEqual(vrm.springBone?.springs?[8].joints[5].gravityDir[1], -1) - XCTAssertEqual(vrm.springBone?.springs?[8].joints[5].gravityDir[2], 0) + XCTAssertEqual(vrm.springBone?.springs?[8].joints[5].gravityDir?.count, 3) + XCTAssertEqual(vrm.springBone?.springs?[8].joints[5].gravityDir?[0], 0) + XCTAssertEqual(vrm.springBone?.springs?[8].joints[5].gravityDir?[1], -1) + XCTAssertEqual(vrm.springBone?.springs?[8].joints[5].gravityDir?[2], 0) XCTAssertEqual(vrm.springBone?.springs?[8].joints[5].gravityPower, 0) XCTAssertEqual(vrm.springBone?.springs?[8].joints[5].hitRadius, 0.06) XCTAssertEqual(vrm.springBone?.springs?[8].joints[5].node, 42) XCTAssertEqual(vrm.springBone?.springs?[8].joints[5].stiffness, 4) XCTAssertEqual(vrm.springBone?.springs?[8].joints[6].dragForce, 1) - XCTAssertEqual(vrm.springBone?.springs?[8].joints[6].gravityDir.count, 3) - XCTAssertEqual(vrm.springBone?.springs?[8].joints[6].gravityDir[0], 0) - XCTAssertEqual(vrm.springBone?.springs?[8].joints[6].gravityDir[1], -1) - XCTAssertEqual(vrm.springBone?.springs?[8].joints[6].gravityDir[2], 0) + XCTAssertEqual(vrm.springBone?.springs?[8].joints[6].gravityDir?.count, 3) + XCTAssertEqual(vrm.springBone?.springs?[8].joints[6].gravityDir?[0], 0) + XCTAssertEqual(vrm.springBone?.springs?[8].joints[6].gravityDir?[1], -1) + XCTAssertEqual(vrm.springBone?.springs?[8].joints[6].gravityDir?[2], 0) XCTAssertEqual(vrm.springBone?.springs?[8].joints[6].gravityPower, 0) XCTAssertEqual(vrm.springBone?.springs?[8].joints[6].hitRadius, 0.02) XCTAssertEqual(vrm.springBone?.springs?[8].joints[6].node, 43)