From 1e0ffdf5edb47ffeef99c51bcc8e18a2a7fb51f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Stompo=CC=81r?= Date: Thu, 22 Jan 2026 21:10:24 +0100 Subject: [PATCH] Add arcball rotation and reset --- .../Manipulation/CameraController.swift | 20 ++++++- Chess/Common/Scene/SceneBuilder.swift | 2 +- .../ViewControllers/GameViewController.swift | 58 ++++++++++++++++++- 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/Chess/Common/Manipulation/CameraController.swift b/Chess/Common/Manipulation/CameraController.swift index 3517b50..075836b 100644 --- a/Chess/Common/Manipulation/CameraController.swift +++ b/Chess/Common/Manipulation/CameraController.swift @@ -14,12 +14,28 @@ class CameraController { let transform = camera.animator.transform(coordinateSpace: camera.animation).rotation let newOrientation = simd_quatf(angle: Float(angleDegress).radians, axis: [0, 1, 0]) let animation = PNKeyframeAnimation(keyFrames: [transform, newOrientation * transform], - times: [0, 2], - maximumTime: 4) + times: [0, 1], + maximumTime: 2) let acs = PNAnimatedCoordinateSpace(translation: translation, rotation: animation, scale: PNAnimatedFloat3.defaultScale) camera.animator.chronometer.reset() camera.animation = acs } + func rotate(camera: PNAnimatedCameraNode, quatf: simd_quatf) { + let translation = PNAnimatedFloat3.static(from: [0, 0.41, 5]) + let transform = camera.animator.transform(coordinateSpace: camera.animation).rotation + let animation = PNKeyframeAnimation(keyFrames: [transform * quatf], + times: [0], + maximumTime: 0) + let acs = PNAnimatedCoordinateSpace(translation: translation, + rotation: animation, + scale: PNAnimatedFloat3.defaultScale) + camera.animator.chronometer.reset() + camera.animation = acs + } + func set(camera: PNAnimatedCameraNode, transformation: simd_float4x4) { + camera.animator.chronometer.reset() + camera.animation = .static(from: transformation) + } } diff --git a/Chess/Common/Scene/SceneBuilder.swift b/Chess/Common/Scene/SceneBuilder.swift index 56673b8..c928412 100644 --- a/Chess/Common/Scene/SceneBuilder.swift +++ b/Chess/Common/Scene/SceneBuilder.swift @@ -30,7 +30,7 @@ class SceneBuilder { let fields = loadBoardFields(mahogany: mahoganyMaterial, sapele: sapeleMaterial) all.add(children: pieces, boardPiece, fields) scene.rootNode.add(child: all) - scene.environmentMap = device.makeTextureSolidCube(color: [1, 1, 1, 1]) + scene.environmentMap = device.makeTextureSolidCube(color: [0, 0, 0, 1]) scene.directionalLights.append(PNIDirectionalLight(color: [1, 1, 1], intensity: 1.5, direction: simd_float3(0, -1, 0).normalized, diff --git a/Chess/macOS/ViewControllers/GameViewController.swift b/Chess/macOS/ViewControllers/GameViewController.swift index 3ce7187..e60acd3 100644 --- a/Chess/macOS/ViewControllers/GameViewController.swift +++ b/Chess/macOS/ViewControllers/GameViewController.swift @@ -9,7 +9,7 @@ import ModelIO import MetalKit import PNShared -class GameViewController: NSViewController, GameDelegate { +class GameViewController: NSViewController, GameDelegate, NSGestureRecognizerDelegate { @IBOutlet private weak var info: NSTextField! private var engine: PNEngine! private var engineView: PNView! @@ -20,6 +20,7 @@ class GameViewController: NSViewController, GameDelegate { private let cameraController = CameraController() private var manipulator: SceneManipulator! private var state = GameState.initial + private var last: NSPoint = .zero private func setupNotifications() { NotificationCenter.default.publisher(for: .persistanceDeleteAll) .sink { notification in @@ -52,8 +53,53 @@ class GameViewController: NSViewController, GameDelegate { } .store(in: &cancellables) } + func rotateArcball(from start: SIMD3, + to end: SIMD3) -> simd_quatf { + let axis = cross(start, end) + let dotp = max(min(dot(start, end), 1.0), -1.0) // clamp to avoid NaN + let angle = acos(dotp) + + return simd_quatf(angle: angle, axis: normalize(axis)) + } + func mapToSphere(nx: Float, ny: Float) -> simd_float3 { + let len2 = nx*nx + ny*ny + if len2 <= 1.0 { + return normalize(SIMD3(nx, ny, sqrt(1.0 - len2))) + } else { + return normalize(SIMD3(nx, ny, 0)) + } + } + @objc + func handlePan(recognizer: NSPanGestureRecognizer) { + let translation = recognizer.translation(in: view) + let tNormalized = NSPoint(x: translation.x / view.bounds.width, + y: translation.y / view.bounds.height) + if recognizer.state == .began { + last = tNormalized + } + let start = mapToSphere(nx: Float(last.x), ny: Float(last.y)) + let end = mapToSphere(nx: Float(tNormalized.x), ny: Float(tNormalized.y)) + let distance = length(end - start) + if (distance < 1e-3) { + return + } + print("s: \(start), e: \(end)") + nodeInteractor.forEach(node: engine.scene.rootNode, { node in + guard let node = node.data as? PNAnimatedCameraNode else { + return + } + let arc = rotateArcball(from: end, to: start) + cameraController.rotate(camera: node, quatf: arc) + }) + last = tNormalized + } override func viewDidLoad() { super.viewDidLoad() + + let panGestureRecognizer = NSPanGestureRecognizer(target: self, action: #selector(handlePan(recognizer: ))) + panGestureRecognizer.allowedTouchTypes = [.direct] + view.addGestureRecognizer(panGestureRecognizer) + setupNotifications() info.alphaValue = 0 engineView = view.subviews[0] as? PNView @@ -80,6 +126,16 @@ class GameViewController: NSViewController, GameDelegate { } cameraController.rotate(camera: node, angleDegress: minus ? -45 : 45) }) + case "r": + let rotation = simd_quatf(angle: Float(45).radians, axis: [0, 1, 0]) * + simd_quatf(angle: Float(45).radians, axis: [-1, 0, 0]) + let translation = simd_float4x4.translation(vector: [0, 0.41, 5]) + nodeInteractor.forEach(node: engine.scene.rootNode, { node in + guard let node = node.data as? PNAnimatedCameraNode else { + return + } + cameraController.set(camera: node, transformation: translation * rotation.rotationMatrix) + }) default: return false }