import SceneKit import UIKit /// Renders the actual catalog model once per variant, shared by all project rows. @MainActor final class TankThumbnailRenderer { static let shared = TankThumbnailRenderer() private let cache = NSCache() func image(for definition: TankDefinition) -> UIImage? { let key = definition.modelPath as NSString if let image = cache.object(forKey: key) { return image } guard let url = Bundle.main.url(forResource: definition.modelResourceName, withExtension: definition.modelFileExtension), let source = try? SCNScene(url: url, options: [.convertToYUp: true]) else { return nil } let scene = SCNScene() let model = SCNNode() for child in source.rootNode.childNodes { model.addChildNode(child.clone()) } // Bundle scenes may carry studio lights; use one consistent thumbnail setup. model.enumerateChildNodes { node, _ in node.light = nil node.camera = nil } // Underground catalog models need an additional quarter-turn after Y-up conversion. if definition.placement == .belowGround { model.eulerAngles.x = -.pi / 2 } let container = SCNNode() container.addChildNode(model) scene.rootNode.addChildNode(container) // Measure in the parent's coordinate system, including the corrected orientation. let bounds = container.boundingBox let center = SCNVector3((bounds.min.x + bounds.max.x) / 2, (bounds.min.y + bounds.max.y) / 2, (bounds.min.z + bounds.max.z) / 2) let extent = SCNVector3(bounds.max.x - bounds.min.x, bounds.max.y - bounds.min.y, bounds.max.z - bounds.min.z) let radius = max(sqrt(extent.x * extent.x + extent.y * extent.y + extent.z * extent.z) / 2, 0.01) let camera = SCNNode() camera.camera = SCNCamera() camera.camera?.usesOrthographicProjection = true camera.camera?.orthographicScale = Double(radius * 0.64) camera.camera?.zNear = Double(radius * 0.01) camera.camera?.zFar = Double(radius * 20) camera.position = SCNVector3(center.x + radius * 0.65, center.y + radius * 0.7, center.z + radius * 3) camera.look(at: center) scene.rootNode.addChildNode(camera) let ambient = SCNNode() ambient.light = SCNLight() ambient.light?.type = .ambient ambient.light?.intensity = 300 scene.rootNode.addChildNode(ambient) let light = SCNNode() light.light = SCNLight() light.light?.type = .omni light.light?.intensity = 800 light.position = SCNVector3(center.x - radius, center.y + radius * 3, center.z + radius * 2) scene.rootNode.addChildNode(light) let renderer = SCNRenderer(device: nil, options: nil) renderer.scene = scene renderer.pointOfView = camera let image = renderer.snapshot(atTime: 0, with: CGSize(width: 600, height: 360), antialiasingMode: .multisampling4X) cache.setObject(image, forKey: key) return image } }