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..9f57e2c 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + 062780292F320E6B00FFBDAC /* AliciaSolid.vrm in Resources */ = {isa = PBXBuildFile; fileRef = 06E116512F277D1700D74CA4 /* AliciaSolid.vrm */; }; + 0627802A2F320F4800FFBDAC /* VRM1_Constraint_Twist_Sample.vrm in Resources */ = {isa = PBXBuildFile; fileRef = AF000001AF000001AF000001 /* VRM1_Constraint_Twist_Sample.vrm */; }; + 0627802B2F320F6D00FFBDAC /* AliciaSolid.vrm in Resources */ = {isa = PBXBuildFile; fileRef = 06E116512F277D1700D74CA4 /* AliciaSolid.vrm */; }; + 0627802C2F320F6F00FFBDAC /* VRM1_Constraint_Twist_Sample.vrm in Resources */ = {isa = PBXBuildFile; fileRef = AF000001AF000001AF000001 /* VRM1_Constraint_Twist_Sample.vrm */; }; 067C5C502AAD6D8B00F8FBB3 /* VRMKit in Frameworks */ = {isa = PBXBuildFile; productRef = 067C5C4F2AAD6D8B00F8FBB3 /* VRMKit */; }; 067C5C522AAD6D8B00F8FBB3 /* VRMSceneKit in Frameworks */ = {isa = PBXBuildFile; productRef = 067C5C512AAD6D8B00F8FBB3 /* VRMSceneKit */; }; 06E1164E2F277C6D00D74CA4 /* VRMKit in Frameworks */ = {isa = PBXBuildFile; productRef = 06E1164D2F277C6D00D74CA4 /* VRMKit */; }; @@ -18,6 +22,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 +67,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 +153,7 @@ isa = PBXGroup; children = ( 06E116512F277D1700D74CA4 /* AliciaSolid.vrm */, + AF000001AF000001AF000001 /* VRM1_Constraint_Twist_Sample.vrm */, ); path = Models; sourceTree = ""; @@ -340,6 +348,7 @@ buildActionMask = 2147483647; files = ( 06E116522F277D1800D74CA4 /* AliciaSolid.vrm in Resources */, + AF000002AF000002AF000002 /* VRM1_Constraint_Twist_Sample.vrm in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -347,6 +356,8 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 062780292F320E6B00FFBDAC /* AliciaSolid.vrm in Resources */, + 0627802A2F320F4800FFBDAC /* VRM1_Constraint_Twist_Sample.vrm in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -355,6 +366,7 @@ buildActionMask = 2147483647; files = ( 06E116542F277ED600D74CA4 /* AliciaSolid.vrm in Resources */, + AF000003AF000003AF000003 /* VRM1_Constraint_Twist_Sample.vrm in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -362,6 +374,8 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0627802B2F320F6D00FFBDAC /* AliciaSolid.vrm in Resources */, + 0627802C2F320F6F00FFBDAC /* VRM1_Constraint_Twist_Sample.vrm in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Example/Example/Info.plist b/Example/Example/Info.plist index 89d7858..c53186c 100644 --- a/Example/Example/Info.plist +++ b/Example/Example/Info.plist @@ -39,5 +39,7 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + UIUserInterfaceStyle + Dark 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..1fb4a42 100644 --- a/Example/Example/ViewController.swift +++ b/Example/Example/ViewController.swift @@ -3,7 +3,45 @@ import SceneKit 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 +53,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..150e5ba 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 .pi + case .vrm1: return 0 + } + } + } + + 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..adb7eee 100644 --- a/Sources/VRMKit/VRM/VRM.swift +++ b/Sources/VRMKit/VRM/VRM.swift @@ -1,158 +1,96 @@ import Foundation -public struct VRM: VRMFile { - public let gltf: BinaryGLTF - public let meta: Meta - public let version: String? - public let materialProperties: [MaterialProperty] - public let humanoid: Humanoid - public let blendShapeMaster: BlendShapeMaster - public let firstPerson: FirstPerson - public let secondaryAnimation: SecondaryAnimation - - public let materialPropertyNameMap: [String: MaterialProperty] +/// VRM data, supporting both VRM0 and VRM1 formats +public enum VRM { + case v0(VRM0) + case v1(VRM1) public init(data: Data) throws { - gltf = try BinaryGLTF(data: data) - + let gltf = try BinaryGLTF(data: data) 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") { + self = .v1(try VRM1(data: data)) + } else { + self = .v0(try VRM0(data: data)) + } } -} - -public extension VRM { - struct Meta: Codable { - public let title: String? - public let author: String? - public let contactInformation: String? - public let reference: String? - public let texture: Int? - public let version: String? - public let allowedUserName: String? - public let violentUssageName: String? - public let sexualUssageName: String? - public let commercialUssageName: String? - public let otherPermissionUrl: String? + // MARK: - Common Interface - public let licenseName: String? - public let otherLicenseUrl: String? + /// The underlying BinaryGLTF data + public var gltf: BinaryGLTF { + switch self { + case .v0(let vrm): return vrm.gltf + case .v1(let vrm): return vrm.gltf + } } - struct MaterialProperty: Codable { - public let name: String - public let shader: String - public let renderQueue: Int - public let floatProperties: CodableAny - public let keywordMap: [String: Bool] - public let tagMap: [String: String] - public let textureProperties: [String: Int] - public let vectorProperties: CodableAny + /// VRM spec version string + public var specVersion: String { + switch self { + case .v0(let vrm): return vrm.version ?? "0.x" + case .v1(let vrm): return vrm.specVersion + } } - struct Humanoid: Codable { - public let armStretch: Double - public let feetSpacing: Double - public let hasTranslationDoF: Bool - public let legStretch: Double - public let lowerArmTwist: Double - public let lowerLegTwist: Double - public let upperArmTwist: Double - public let upperLegTwist: Double - public let humanBones: [HumanBone] + // MARK: - VRM0 Format interfaces (for current migration period) + // In the future, these will be replaced with VRM1 native types - public struct HumanBone: Codable { - public let bone: String - public let node: Int - public let useDefaultValues: Bool + /// Meta information (VRM0 format) + public var meta: VRM0.Meta { + switch self { + case .v0(let vrm): return vrm.meta + case .v1(let vrm): return VRM0.Meta(vrm1: vrm.meta) } } - struct BlendShapeMaster: Codable { - public let blendShapeGroups: [BlendShapeGroup] - public struct BlendShapeGroup: Codable { - public let binds: [Bind]? - public let materialValues: [MaterialValueBind]? - public let name: String - public let presetName: String - let _isBinary: Bool? - public var isBinary: Bool { return _isBinary ?? false } - private enum CodingKeys: String, CodingKey { - case binds - case materialValues - case name - case presetName - case _isBinary = "isBinary" - } - public struct Bind: Codable { - public let index: Int - public let mesh: Int - public let weight: Double - } - public struct MaterialValueBind: Codable { - public let materialName: String - public let propertyName: String - public let targetValue: [Double] - } + /// Humanoid bone mapping (VRM0 format) + public var humanoid: VRM0.Humanoid { + switch self { + case .v0(let vrm): return vrm.humanoid + case .v1(let vrm): return VRM0.Humanoid(vrm1: vrm.humanoid) } } - struct FirstPerson: Codable { - public let firstPersonBone: Int - public let firstPersonBoneOffset: Vector3 - public let meshAnnotations: [MeshAnnotation] - public let lookAtTypeName: LookAtType - - public struct MeshAnnotation: Codable { - public let firstPersonFlag: String - public let mesh: Int + /// Material properties (VRM0 format) + public var materialProperties: [VRM0.MaterialProperty] { + switch self { + case .v0(let vrm): return vrm.materialProperties + case .v1(let vrm): return VRM0(migratedFrom: vrm).materialProperties } - public enum LookAtType: String, Codable { - case none = "None" - case bone = "Bone" - case blendShape = "BlendShape" + } + + /// Material property name map (VRM0 format) + public var materialPropertyNameMap: [String: VRM0.MaterialProperty] { + switch self { + case .v0(let vrm): return vrm.materialPropertyNameMap + case .v1(let vrm): return VRM0(migratedFrom: vrm).materialPropertyNameMap } } - struct SecondaryAnimation: Codable { - public let boneGroups: [BoneGroup] - public let colliderGroups: [ColliderGroup] - public struct BoneGroup: Codable { - public let bones: [Int] - public let center: Int - public let colliderGroups: [Int] - public let comment: String? - public let dragForce: Double - public let gravityDir: Vector3 - public let gravityPower: Double - public let hitRadius: Double - public let stiffiness: Double + /// BlendShape master (VRM0 format) + public var blendShapeMaster: VRM0.BlendShapeMaster { + switch self { + case .v0(let vrm): return vrm.blendShapeMaster + case .v1(let vrm): return VRM0(migratedFrom: vrm).blendShapeMaster } - - public struct ColliderGroup: Codable { - public let node: Int - public let colliders: [Collider] - - public struct Collider: Codable { - public let offset: Vector3 - public let radius: Double - } + } + + /// First person settings (VRM0 format) + public var firstPerson: VRM0.FirstPerson { + switch self { + case .v0(let vrm): return vrm.firstPerson + case .v1(let vrm): return VRM0(migratedFrom: vrm).firstPerson } } - struct Vector3: Codable { - public let x, y, z: Double + /// Secondary animation / spring bones (VRM0 format) + public var secondaryAnimation: VRM0.SecondaryAnimation { + switch self { + case .v0(let vrm): return vrm.secondaryAnimation + case .v1(let vrm): return VRM0(migratedFrom: vrm).secondaryAnimation + } } } diff --git a/Sources/VRMKit/VRM/VRM0.swift b/Sources/VRMKit/VRM/VRM0.swift new file mode 100644 index 0000000..0f19ec0 --- /dev/null +++ b/Sources/VRMKit/VRM/VRM0.swift @@ -0,0 +1,176 @@ +import Foundation + +/// VRM 0.x format data structure +public struct VRM0 { + public let gltf: BinaryGLTF + public let meta: Meta + public let version: String? + public let materialProperties: [MaterialProperty] + public let humanoid: Humanoid + public let blendShapeMaster: BlendShapeMaster + public let firstPerson: FirstPerson + public let secondaryAnimation: SecondaryAnimation + + public let materialPropertyNameMap: [String: MaterialProperty] + + /// Initialize from VRM 0.x data + public init(data: Data) throws { + gltf = try BinaryGLTF(data: data) + + let rawExtensions = try gltf.jsonData.extensions ??? .keyNotFound("extensions") + let extensions = try rawExtensions.value as? [String: [String: Any]] ??? .dataInconsistent("extension type mismatch") + + // VRM 0.x must have "VRM" extension + 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 } + } + + /// Initialize by migrating from VRM 1.0 + public init(migratedFrom vrm1: VRM1) { + self.gltf = vrm1.gltf + self.version = vrm1.specVersion + self.meta = Meta(vrm1: vrm1.meta) + self.humanoid = Humanoid(vrm1: vrm1.humanoid) + self.blendShapeMaster = BlendShapeMaster(vrm1: vrm1.expressions, gltf: vrm1.gltf) + self.firstPerson = FirstPerson(vrm1: vrm1.firstPerson, lookAt: vrm1.lookAt) + self.secondaryAnimation = SecondaryAnimation(vrm1: vrm1.springBone) + self.materialProperties = (try? VRM0.migrateMaterials(gltf: vrm1.gltf, vrm1: vrm1)) ?? [] + self.materialPropertyNameMap = materialProperties.reduce(into: [:]) { $0[$1.name] = $1 } + } +} + +public extension VRM0 { + struct Meta: Codable { + public let title: String? + public let author: String? + public let contactInformation: String? + public let reference: String? + public let texture: Int? + public let version: String? + + public let allowedUserName: String? + public let violentUssageName: String? + public let sexualUssageName: String? + public let commercialUssageName: String? + public let otherPermissionUrl: String? + + public let licenseName: String? + public let otherLicenseUrl: String? + } + + struct MaterialProperty: Codable { + public let name: String + public let shader: String + public let renderQueue: Int + public let floatProperties: CodableAny + public let keywordMap: [String: Bool] + public let tagMap: [String: String] + public let textureProperties: [String: Int] + public let vectorProperties: CodableAny + } + + struct Humanoid: Codable { + public let armStretch: Double + public let feetSpacing: Double + public let hasTranslationDoF: Bool + public let legStretch: Double + public let lowerArmTwist: Double + public let lowerLegTwist: Double + public let upperArmTwist: Double + public let upperLegTwist: Double + public let humanBones: [HumanBone] + + public struct HumanBone: Codable { + public let bone: String + public let node: Int + public let useDefaultValues: Bool + } + } + + struct BlendShapeMaster: Codable { + public let blendShapeGroups: [BlendShapeGroup] + public struct BlendShapeGroup: Codable { + public let binds: [Bind]? + public let materialValues: [MaterialValueBind]? + public let name: String + public let presetName: String + let _isBinary: Bool? + public var isBinary: Bool { return _isBinary ?? false } + private enum CodingKeys: String, CodingKey { + case binds + case materialValues + case name + case presetName + case _isBinary = "isBinary" + } + public struct Bind: Codable { + public let index: Int + public let mesh: Int + public let weight: Double + } + public struct MaterialValueBind: Codable { + public let materialName: String + public let propertyName: String + public let targetValue: [Double] + } + } + } + + struct FirstPerson: Codable { + public let firstPersonBone: Int + public let firstPersonBoneOffset: Vector3 + public let meshAnnotations: [MeshAnnotation] + public let lookAtTypeName: LookAtType + + public struct MeshAnnotation: Codable { + public let firstPersonFlag: String + public let mesh: Int + } + public enum LookAtType: String, Codable { + case none = "None" + case bone = "Bone" + case blendShape = "BlendShape" + } + } + + struct SecondaryAnimation: Codable { + public let boneGroups: [BoneGroup] + public let colliderGroups: [ColliderGroup] + public struct BoneGroup: Codable { + public let bones: [Int] + public let center: Int + public let colliderGroups: [Int] + public let comment: String? + public let dragForce: Double + public let gravityDir: Vector3 + public let gravityPower: Double + public let hitRadius: Double + public let stiffiness: Double + } + + public struct ColliderGroup: Codable { + public let node: Int + public let colliders: [Collider] + + public struct Collider: Codable { + public let offset: Vector3 + public let radius: Double + } + } + } + + struct Vector3: Codable { + public let x, y, z: Double + } +} diff --git a/Sources/VRMKit/VRM/VRM1.swift b/Sources/VRMKit/VRM/VRM1.swift index e9dbf75..3824b12 100644 --- a/Sources/VRMKit/VRM/VRM1.swift +++ b/Sources/VRMKit/VRM/VRM1.swift @@ -1,6 +1,6 @@ import Foundation -public struct VRM1: VRMFile { +public struct VRM1 { public let gltf: BinaryGLTF public let specVersion: String public let meta: Meta @@ -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/VRMExtension.swift b/Sources/VRMKit/VRM/VRMExtension.swift index 3e6a92a..052ad00 100644 --- a/Sources/VRMKit/VRM/VRMExtension.swift +++ b/Sources/VRMKit/VRM/VRMExtension.swift @@ -1,6 +1,6 @@ import Foundation -public extension VRM.MaterialProperty { +public extension VRM0.MaterialProperty { var vrmShader: Shader? { return Shader(rawValue: shader) } diff --git a/Sources/VRMKit/VRM/VRMFileProtocol.swift b/Sources/VRMKit/VRM/VRMFileProtocol.swift deleted file mode 100644 index 13177b3..0000000 --- a/Sources/VRMKit/VRM/VRMFileProtocol.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Foundation - -public protocol VRMFile { - init(data: Data) throws -} - diff --git a/Sources/VRMKit/VRM/VRMMigration.swift b/Sources/VRMKit/VRM/VRMMigration.swift new file mode 100644 index 0000000..48dd9d6 --- /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 VRM0.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 VRM0.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 VRM0.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: [VRM0.BlendShapeMaster.BlendShapeGroup.Bind] = (expression.morphTargetBinds?.compactMap { (bind) -> VRM0.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 VRM0.BlendShapeMaster.BlendShapeGroup.Bind( + index: bind.index, + mesh: meshIndex, + weight: bind.weight * 100.0 + ) + }) ?? [] + + // VRM1 materialColor/textureTransform -> VRM0 materialValues + let colorValues: [VRM0.BlendShapeMaster.BlendShapeGroup.MaterialValueBind] = (expression.materialColorBinds?.compactMap { bind -> VRM0.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 VRM0.BlendShapeMaster.BlendShapeGroup.MaterialValueBind( + materialName: materialName, + propertyName: bind.type.rawValue, + targetValue: bind.targetValue + ) + }) ?? [] + + let textureValues: [VRM0.BlendShapeMaster.BlendShapeGroup.MaterialValueBind] = (expression.textureTransformBinds?.compactMap { bind -> VRM0.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 VRM0.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 VRM0.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 = VRM0.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 VRM0.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: VRM0.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: VRM0.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: VRM0.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: VRM0.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 VRM0 { + 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/VRMKit/VRMLoader.swift b/Sources/VRMKit/VRMLoader.swift index 710dbd9..6f41c54 100644 --- a/Sources/VRMKit/VRMLoader.swift +++ b/Sources/VRMKit/VRMLoader.swift @@ -22,22 +22,6 @@ open class VRMLoader { return try VRM(data: data) } - open func load(_ type: T.Type = T.self, named: String) throws -> T { - guard let url = Bundle.main.url(forResource: named, withExtension: nil) else { - throw URLError(.fileDoesNotExist) - } - return try load(type, withURL: url) - } - - open func load(_ type: T.Type = T.self, withURL url: URL) throws -> T { - let data = try Data(contentsOf: url) - return try load(type, withData: data) - } - - open func load(_ type: T.Type = T.self, withData data: Data) throws -> T { - return try T(data: data) - } - open func loadThumbnail(from vrm: VRM) throws -> VRMImage { guard let textureIndex = vrm.meta.texture, textureIndex >= 0 else { throw VRMError.thumbnailNotFound @@ -45,6 +29,13 @@ open class VRMLoader { return try loadImage(from: vrm.gltf, at: textureIndex) } + open func loadThumbnail(from vrm0: VRM0) throws -> VRMImage { + guard let textureIndex = vrm0.meta.texture, textureIndex >= 0 else { + throw VRMError.thumbnailNotFound + } + return try loadImage(from: vrm0.gltf, at: textureIndex) + } + open func loadThumbnail(from vrm1: VRM1) throws -> VRMImage { guard let imageIndex = vrm1.meta.thumbnailImage, imageIndex >= 0 else { throw VRMError.thumbnailNotFound diff --git a/Sources/VRMRealityKit/CustomType/Humanoid.swift b/Sources/VRMRealityKit/CustomType/Humanoid.swift index 5ebc350..c585b58 100644 --- a/Sources/VRMRealityKit/CustomType/Humanoid.swift +++ b/Sources/VRMRealityKit/CustomType/Humanoid.swift @@ -5,7 +5,7 @@ import RealityKit public final class Humanoid { var bones: [Bones: Entity] = [:] - func setUp(humanoid: VRM.Humanoid, nodes: [Entity?]) { + func setUp(humanoid: VRM0.Humanoid, nodes: [Entity?]) { bones = humanoid.humanBones.reduce(into: [:]) { result, humanBone in guard let bone = Bones(rawValue: humanBone.bone), let node = nodes[safe: humanBone.node] else { return } diff --git a/Sources/VRMRealityKit/CustomType/VRMEntitySpringBoneColliderGroup.swift b/Sources/VRMRealityKit/CustomType/VRMEntitySpringBoneColliderGroup.swift index c60fff2..082d8c8 100644 --- a/Sources/VRMRealityKit/CustomType/VRMEntitySpringBoneColliderGroup.swift +++ b/Sources/VRMRealityKit/CustomType/VRMEntitySpringBoneColliderGroup.swift @@ -8,7 +8,7 @@ final class VRMEntitySpringBoneColliderGroup { let node: Entity let colliders: [SphereCollider] - init(colliderGroup: VRM.SecondaryAnimation.ColliderGroup, loader: VRMEntityLoader) throws { + init(colliderGroup: VRM0.SecondaryAnimation.ColliderGroup, loader: VRMEntityLoader) throws { self.node = try loader.node(withNodeIndex: colliderGroup.node) self.colliders = colliderGroup.colliders.map(SphereCollider.init) } @@ -17,7 +17,7 @@ final class VRMEntitySpringBoneColliderGroup { let offset: SIMD3 let radius: Float - init(collider: VRM.SecondaryAnimation.ColliderGroup.Collider) { + init(collider: VRM0.SecondaryAnimation.ColliderGroup.Collider) { self.offset = SIMD3(Float(collider.offset.x), Float(collider.offset.y), Float(collider.offset.z)) self.radius = Float(collider.radius) } diff --git a/Sources/VRMRealityKit/VRMEntityLoader.swift b/Sources/VRMRealityKit/VRMEntityLoader.swift index a4cf6ba..c69ba2f 100644 --- a/Sources/VRMRealityKit/VRMEntityLoader.swift +++ b/Sources/VRMRealityKit/VRMEntityLoader.swift @@ -7,7 +7,7 @@ import VRMKit @available(iOS 18.0, macOS 15.0, visionOS 2.0, *) @MainActor open class VRMEntityLoader { - let vrm: VRM + public let vrm: VRM private let gltf: GLTF private let entityData: EntityData @@ -394,12 +394,16 @@ open class VRMEntityLoader { if let cache = try entityData.load(\.materials, index: index) { return cache } let gltfMaterial = try gltf.load(\.materials)[index] - let materialProperty: VRM.MaterialProperty? = { + let materialProperty: VRM0.MaterialProperty? = { guard let name = gltfMaterial.name else { return nil } return vrm.materialPropertyNameMap[name] }() let shaderName = materialProperty?.shader.lowercased() - let useUnlit = shaderName?.contains("unlit") == true + // 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 || gltfMaterial.extensions?.materialsMToon != nil + let isUnlit = shaderName?.contains("unlit") == true || gltfMaterial.extensions?.materialsUnlit != nil + let useUnlit = isMToon || isUnlit let hasAlphaPremultiply = materialProperty?.keywordMap["_ALPHAPREMULTIPLY_ON"] == true let hasAlphaBlend = materialProperty?.keywordMap["_ALPHABLEND_ON"] == true let hasAlphaTest = materialProperty?.keywordMap["_ALPHATEST_ON"] == true @@ -763,19 +767,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 +799,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/CustomType/Humanoid.swift b/Sources/VRMSceneKit/CustomType/Humanoid.swift index fcafb59..0e793d9 100644 --- a/Sources/VRMSceneKit/CustomType/Humanoid.swift +++ b/Sources/VRMSceneKit/CustomType/Humanoid.swift @@ -5,7 +5,7 @@ import SceneKit public final class Humanoid { var bones: [Bones: SCNNode] = [:] - func setUp(humanoid: VRM.Humanoid, nodes: [SCNNode?]) { + func setUp(humanoid: VRM0.Humanoid, nodes: [SCNNode?]) { bones = humanoid.humanBones.reduce(into: [:]) { result, humanBone in guard let bone = Bones(rawValue: humanBone.bone), let node = nodes[humanBone.node] else { return } diff --git a/Sources/VRMSceneKit/CustomType/VRMSpringBoneColliderGroup.swift b/Sources/VRMSceneKit/CustomType/VRMSpringBoneColliderGroup.swift index c885290..5320e8a 100644 --- a/Sources/VRMSceneKit/CustomType/VRMSpringBoneColliderGroup.swift +++ b/Sources/VRMSceneKit/CustomType/VRMSpringBoneColliderGroup.swift @@ -6,7 +6,7 @@ final class VRMSpringBoneColliderGroup { let node: SCNNode let colliders: [SphereCollider] - init(colliderGroup: VRM.SecondaryAnimation.ColliderGroup, loader: VRMSceneLoader) throws { + init(colliderGroup: VRM0.SecondaryAnimation.ColliderGroup, loader: VRMSceneLoader) throws { self.node = try loader.node(withNodeIndex: colliderGroup.node) self.colliders = colliderGroup.colliders.map(SphereCollider.init) } @@ -16,7 +16,7 @@ final class VRMSpringBoneColliderGroup { let offset: SIMD3 let radius: Float - init(collider: VRM.SecondaryAnimation.ColliderGroup.Collider) { + init(collider: VRM0.SecondaryAnimation.ColliderGroup.Collider) { self.offset = collider.offset.simd self.radius = Float(collider.radius) } diff --git a/Sources/VRMSceneKit/Extensions/SCNVector3+.swift b/Sources/VRMSceneKit/Extensions/SCNVector3+.swift index 918bd8d..420928a 100644 --- a/Sources/VRMSceneKit/Extensions/SCNVector3+.swift +++ b/Sources/VRMSceneKit/Extensions/SCNVector3+.swift @@ -1,7 +1,7 @@ import VRMKit import SceneKit -extension VRM.Vector3 { +extension VRM0.Vector3 { var simd: SIMD3 { SIMD3(x: Float(x), y: Float(y), z: Float(z)) } 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/Sources/VRMSceneKit/GLTF2SCN/SCNMaterial+GLTF.swift b/Sources/VRMSceneKit/GLTF2SCN/SCNMaterial+GLTF.swift index de446c6..859d52d 100644 --- a/Sources/VRMSceneKit/GLTF2SCN/SCNMaterial+GLTF.swift +++ b/Sources/VRMSceneKit/GLTF2SCN/SCNMaterial+GLTF.swift @@ -13,7 +13,7 @@ extension SCNMaterial { isLitPerPixel = false writesToDepthBuffer = material.alphaMode != .BLEND - var shader: VRM.MaterialProperty.Shader? + var shader: VRM0.MaterialProperty.Shader? if let name = name, let property = loader.vrm.materialPropertyNameMap[name] { shader = property.vrmShader diff --git a/Sources/VRMSceneKit/GLTF2SCN/UIImage+GLTF.swift b/Sources/VRMSceneKit/GLTF2SCN/UIImage+GLTF.swift index 7db99bf..53bded5 100644 --- a/Sources/VRMSceneKit/GLTF2SCN/UIImage+GLTF.swift +++ b/Sources/VRMSceneKit/GLTF2SCN/UIImage+GLTF.swift @@ -19,19 +19,4 @@ extension VRMImage { return image } - static func from(_ image: GLTF.Image, relativeTo rootDirectory: URL?, loader: VRM1SceneLoader) throws -> VRMImage { - let data: Data - if let uri = image.uri { - data = try Data(gltfUrlString: uri, relativeTo: rootDirectory) - } else if let bufferViewIndex = image.bufferView { - data = try loader.bufferView(withBufferViewIndex: bufferViewIndex).bufferView - } else { - throw VRMError._dataInconsistent("failed to load images") - } - - guard let image = VRMImage(data: data) else { - throw VRMError._dataInconsistent("failed to create image from data") - } - return image - } } diff --git a/Sources/VRMSceneKit/VRM1SceneLoader.swift b/Sources/VRMSceneKit/VRM1SceneLoader.swift deleted file mode 100644 index 564ebda..0000000 --- a/Sources/VRMSceneKit/VRM1SceneLoader.swift +++ /dev/null @@ -1,77 +0,0 @@ -import Foundation -import VRMKit -import SceneKit - -@available(*, deprecated, message: "Deprecated. Use VRMRealityKit instead.") -open class VRM1SceneLoader { - let vrm1: VRM1 - private let gltf: GLTF - private let sceneData: SceneData - private var rootDirectory: URL? = nil - - public init(vrm1: VRM1, rootDirectory: URL? = nil) { - self.vrm1 = vrm1 - self.gltf = vrm1.gltf.jsonData - self.rootDirectory = rootDirectory - self.sceneData = SceneData(vrm: gltf) - } - - public func loadThumbnail() throws -> VRMImage { - guard let imageIndex = vrm1.meta.thumbnailImage, imageIndex >= 0 else { - throw VRMError.thumbnailNotFound - } - - if let cache = try sceneData.load(\.images, index: imageIndex) { - return cache - } - - return try image(withImageIndex: imageIndex) - } - - func image(withImageIndex index: Int) throws -> VRMImage { - if let cache = try sceneData.load(\.images, index: index) { - return cache - } - - guard let gltfImages = gltf.images else { - throw VRMError.keyNotFound("images") - } - - guard index >= 0 && index < gltfImages.count, - let gltfImage = gltfImages[safe: index] else { - throw VRMError.dataInconsistent("Image index \(index) is out of bounds for \(gltfImages.count) images.") - } - - let image = try VRMImage.from(gltfImage, relativeTo: rootDirectory, loader: self) - sceneData.images[index] = image - return image - } - - func bufferView(withBufferViewIndex index: Int) throws -> (bufferView: Data, stride: Int?) { - if let cache = try sceneData.load(\.bufferViews, index: index) { - let gltfBufferView = try gltf.load(\.bufferViews)[index] - return (cache, gltfBufferView.byteStride) - } - let result = try vrm1.gltf.bufferViewData(at: index, relativeTo: rootDirectory) - sceneData.bufferViews[index] = result.data - return (result.data, result.stride) - } -} - -@available(*, deprecated, message: "Deprecated. Use VRMRealityKit instead.") -extension VRM1SceneLoader { - public convenience init(withURL url: URL, rootDirectory: URL? = nil) throws { - let vrm1 = try VRMLoader().load(VRM1.self, withURL: url) - self.init(vrm1: vrm1, rootDirectory: rootDirectory) - } - - public convenience init(named: String, rootDirectory: URL? = nil) throws { - let vrm1 = try VRMLoader().load(VRM1.self, named: named) - self.init(vrm1: vrm1, rootDirectory: rootDirectory) - } - - public convenience init(withData data: Data, rootDirectory: URL? = nil) throws { - let vrm1 = try VRMLoader().load(VRM1.self, withData: data) - self.init(vrm1: vrm1, rootDirectory: rootDirectory) - } -} 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/VRMTests.swift b/Tests/VRMKitTests/VRM0Tests.swift similarity index 94% rename from Tests/VRMKitTests/VRMTests.swift rename to Tests/VRMKitTests/VRM0Tests.swift index 1edc79e..dfe438c 100644 --- a/Tests/VRMKitTests/VRMTests.swift +++ b/Tests/VRMKitTests/VRM0Tests.swift @@ -1,14 +1,14 @@ import XCTest import VRMKit -class VRMTests: XCTestCase { +class VRM0Tests: XCTestCase { let vrm = try! VRM(data: Resources.aliciaSolid.data) - + override func setUp() { super.setUp() } - + func testMeta() { XCTAssertEqual(vrm.meta.title, "Alicia Solid") XCTAssertEqual(vrm.meta.author, "DWANGO Co., Ltd.") @@ -16,13 +16,13 @@ class VRMTests: XCTestCase { XCTAssertEqual(vrm.meta.reference, "") XCTAssertEqual(vrm.meta.texture, 6) XCTAssertEqual(vrm.meta.version, "1.0.0") - + XCTAssertEqual(vrm.meta.allowedUserName, "Everyone") XCTAssertEqual(vrm.meta.violentUssageName, "Disallow") XCTAssertEqual(vrm.meta.sexualUssageName, "Disallow") XCTAssertEqual(vrm.meta.commercialUssageName, "Allow") XCTAssertEqual(vrm.meta.otherPermissionUrl, "http://3d.nicovideo.jp/alicia/rule.html") - + XCTAssertEqual(vrm.meta.licenseName, "Other") XCTAssertEqual(vrm.meta.otherLicenseUrl, "http://3d.nicovideo.jp/alicia/rule.html") } @@ -87,7 +87,7 @@ class VRMTests: XCTestCase { XCTAssertEqual(target.hitRadius, 0.01) XCTAssertEqual(target.stiffiness, 0.65) } - + func testSecondaryAnimationColliderGroups() { let target = vrm.secondaryAnimation.colliderGroups[0] XCTAssertEqual(target.node, 34) @@ -96,4 +96,12 @@ class VRMTests: XCTestCase { XCTAssertEqual(target.colliders[0].offset.z, 0.0) XCTAssertEqual(target.colliders[0].radius, 0.09) } + + func testVRMVersionDetection() { + guard case .v0(let vrm0) = vrm else { + XCTFail("Expected VRM0") + return + } + XCTAssertEqual(vrm0.meta.title, "Alicia Solid") + } } diff --git a/Tests/VRMKitTests/VRM1MigrationTests.swift b/Tests/VRMKitTests/VRM1MigrationTests.swift new file mode 100644 index 0000000..85c0a5a --- /dev/null +++ b/Tests/VRMKitTests/VRM1MigrationTests.swift @@ -0,0 +1,106 @@ +import Testing +import VRMKit +import Foundation + +struct VRM1MigrationTests { + + @Test("Meta: VRM1 -> VRM0") + func migrationMetaVRM1toVRM0() throws { + let vrm = try VRM(data: Resources.seedSan.data) + + // VRM1 data accessed via VRM0 format migration + #expect(vrm.meta.title == "Seed-san") + #expect(vrm.meta.author == "VirtualCast, Inc.") + #expect(vrm.meta.version == "1") + #expect(vrm.meta.texture == 14) + #expect(vrm.meta.allowedUserName == "Everyone") + #expect(vrm.meta.violentUssageName == "Allow") + #expect(vrm.meta.sexualUssageName == "Allow") + #expect(vrm.meta.commercialUssageName == "corporation") + #expect(vrm.meta.licenseName == "https://vrm.dev/licenses/1.0/") + } + + @Test("Humanoid: VRM1 -> VRM0") + func migrationHumanoidVRM1toVRM0() throws { + let vrm = try VRM(data: Resources.seedSan.data) + + // VRM1 data accessed via VRM0 format migration + #expect(vrm.humanoid.humanBones.count == 51) + #expect(vrm.humanoid.humanBones.first { $0.bone == "hips" }?.node == 3) + #expect(vrm.humanoid.humanBones.first { $0.bone == "head" }?.node == 45) + } + + @Test("BlendShape: VRM1 -> VRM0") + func migrationBlendShapeVRM1toVRM0() throws { + let vrm = try VRM(data: Resources.seedSan.data) + + #expect(vrm.blendShapeMaster.blendShapeGroups.count == 18) + + #expect(vrm.blendShapeMaster.blendShapeGroups.first { $0.name == "Happy" }?.presetName == "joy") + #expect(vrm.blendShapeMaster.blendShapeGroups.first { $0.name == "Angry" }?.presetName == "angry") + #expect(vrm.blendShapeMaster.blendShapeGroups.first { $0.name == "Sad" }?.presetName == "sorrow") + #expect(vrm.blendShapeMaster.blendShapeGroups.first { $0.name == "Relaxed" }?.presetName == "fun") + #expect(vrm.blendShapeMaster.blendShapeGroups.first { $0.name == "Surprised" }?.presetName == "unknown") + } + + @Test("FirstPerson: VRM1 -> VRM0") + func migrationFirstPersonVRM1toVRM0() throws { + let vrm = try VRM(data: Resources.seedSan.data) + + #expect(vrm.firstPerson.meshAnnotations.count == 5) + #expect(vrm.firstPerson.firstPersonBone == -1) + #expect(vrm.firstPerson.lookAtTypeName == .blendShape) + } + + @Test("SpringBone: VRM1 -> VRM0") + func migrationSecondaryAnimationVRM1toVRM0() throws { + let vrm = try VRM(data: Resources.seedSan.data) + + #expect(vrm.secondaryAnimation.colliderGroups.count == 6) + + #expect(vrm.secondaryAnimation.colliderGroups.contains { $0.node == 4 && $0.colliders.count == 1 }) + #expect(vrm.secondaryAnimation.colliderGroups.contains { $0.node == 5 && $0.colliders.count == 3 }) + } + + @Test("Material: MToon VRM1 -> VRM0") + func migrationMaterialVRM1toVRM0() throws { + let vrm = try VRM(data: Resources.seedSan.data) + + #expect(vrm.materialProperties.count == 17) + + // Material 0 (MToon) + let mtoon0 = vrm.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") + } + } + + @Test("VRM1 Version Detection") + func versionDetection() throws { + let vrm = try VRM(data: Resources.seedSan.data) + + guard case .v1(let vrm1) = vrm else { + throw VRMError.dataInconsistent("Expected VRM1") + } + #expect(vrm.specVersion == "1.0") + #expect(vrm1.meta.name == "Seed-san") + } +} 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) diff --git a/Tests/VRMSceneKitTests/VRM1SceneKitTests.swift b/Tests/VRMSceneKitTests/VRM1SceneKitTests.swift index b915574..b882251 100644 --- a/Tests/VRMSceneKitTests/VRM1SceneKitTests.swift +++ b/Tests/VRMSceneKitTests/VRM1SceneKitTests.swift @@ -6,15 +6,18 @@ import Testing @Suite struct VRM1SceneLoaderTests { - func vrm1Loader() throws -> VRM1SceneLoader { + func vrmLoader() throws -> VRMSceneLoader { let url = try #require(Bundle.module.url(forResource: "Seed-san", withExtension: "vrm"), "Failed to load Seed-san.vrm resource from test bundle.") - return try VRM1SceneLoader(withURL: url) + return try VRMSceneLoader(withURL: url) } @Test func testLoadVRM1() throws { - let vrm1Loader = try vrm1Loader() - let vrm1 = vrm1Loader.vrm1 + let vrmLoader = try vrmLoader() + let vrm = vrmLoader.vrm + guard case .v1(let vrm1) = vrm else { + throw VRMError.dataInconsistent("Expected VRM1") + } let gltf = vrm1.gltf.jsonData #expect(vrm1.meta.name == "Seed-san") @@ -27,15 +30,15 @@ struct VRM1SceneLoaderTests { let scenes = try #require(gltf.scenes, "GLTF scenes should not be nil") #expect(scenes.map(\.nodes).map(\.?.count) == [7]) - let loadedThumbnail = try vrm1Loader.loadThumbnail() + let loadedThumbnail = try vrmLoader.loadThumbnail() let thumbnail = try #require(loadedThumbnail, "Thumbnail should be loadable and not nil.") #expect(thumbnail.size == CGSize(width: 512, height: 512)) } @Test func testBufferAccess() throws { - let vrm1Loader = try vrm1Loader() - let result = try vrm1Loader.bufferView(withBufferViewIndex: 0) + let vrmLoader = try vrmLoader() + let result = try vrmLoader.bufferView(withBufferViewIndex: 0) #expect(result.stride == nil) #expect(result.bufferView.count == 93840) }