// MultiViewportView.swift // Feature 18: Split the viewport into 1, 2, or 4 panes. // Each pane has its own camera. All share the same SceneGraph. import SwiftUI import Metal // MARK: - Layout modes enum ViewportLayout: String, CaseIterable, Identifiable { case single = "Single" case splitH = "Split Horizontal" case splitV = "Split Vertical" case quad = "Quad" var id: String { rawValue } var icon: String { switch self { case .single: "rectangle" case .splitH: "rectangle.split.2x1" case .splitV: "rectangle.split.1x2" case .quad: "rectangle.split.2x2" } } } // MARK: - Preset camera angles enum CameraPreset { case perspective, top, front, right func configure(_ camera: OrbitalCamera) { switch self { case .perspective: camera.azimuth = Float.pi * 0.25 camera.elevation = Float.pi * 0.2 camera.distance = 7.0 case .top: camera.azimuth = 0 camera.elevation = Float.pi * 0.499 camera.distance = 10.0 case .front: camera.azimuth = 0 camera.elevation = 0 camera.distance = 10.0 case .right: camera.azimuth = Float.pi * 0.5 camera.elevation = 0 camera.distance = 10.0 } } } // MARK: - Multi-Viewport View struct MultiViewportView: View { let renderers: [ViewportRenderer] let layout: ViewportLayout // Divider positions (0...1) @State private var hSplit: CGFloat = 0.5 @State private var vSplit: CGFloat = 0.5 private let dividerThickness: CGFloat = 2 private let dividerHitArea: CGFloat = 16 var body: some View { GeometryReader { geo in switch layout { case .single: singleLayout() case .splitH: splitHorizontal(size: geo.size) case .splitV: splitVertical(size: geo.size) case .quad: quadLayout(size: geo.size) } } .clipped() } // MARK: - Layouts @ViewBuilder private func singleLayout() -> some View { pane(index: 0, label: "Perspective") } @ViewBuilder private func splitHorizontal(size: CGSize) -> some View { let leftWidth = size.width * hSplit - dividerThickness * 0.5 let rightWidth = size.width * (1 - hSplit) - dividerThickness * 0.5 HStack(spacing: 0) { pane(index: 0, label: "Perspective") .frame(width: max(leftWidth, 40)) dividerView(axis: .vertical, size: size) pane(index: 1, label: "Right") .frame(width: max(rightWidth, 40)) } } @ViewBuilder private func splitVertical(size: CGSize) -> some View { let topHeight = size.height * vSplit - dividerThickness * 0.5 let bottomHeight = size.height * (1 - vSplit) - dividerThickness * 0.5 VStack(spacing: 0) { pane(index: 0, label: "Perspective") .frame(height: max(topHeight, 40)) dividerView(axis: .horizontal, size: size) pane(index: 1, label: "Front") .frame(height: max(bottomHeight, 40)) } } @ViewBuilder private func quadLayout(size: CGSize) -> some View { let leftW = size.width * hSplit - dividerThickness * 0.5 let rightW = size.width * (1 - hSplit) - dividerThickness * 0.5 let topH = size.height * vSplit - dividerThickness * 0.5 let botH = size.height * (1 - vSplit) - dividerThickness * 0.5 VStack(spacing: 0) { HStack(spacing: 0) { pane(index: 0, label: "Top") .frame(width: max(leftW, 40), height: max(topH, 40)) dividerView(axis: .vertical, size: size) pane(index: 1, label: "Perspective") .frame(width: max(rightW, 40), height: max(topH, 40)) } .frame(height: max(topH, 40)) dividerView(axis: .horizontal, size: size) HStack(spacing: 0) { pane(index: 2, label: "Front") .frame(width: max(leftW, 40), height: max(botH, 40)) dividerView(axis: .vertical, size: size) pane(index: 3, label: "Right") .frame(width: max(rightW, 40), height: max(botH, 40)) } .frame(height: max(botH, 40)) } } // MARK: - Pane helper @ViewBuilder private func pane(index: Int, label: String) -> some View { if index < renderers.count { ViewportPane(renderer: renderers[index], label: label) } else { Color(white: 0.15) .overlay(Text("No renderer").foregroundStyle(.secondary)) } } // MARK: - Draggable divider @ViewBuilder private func dividerView(axis: Axis, size: CGSize) -> some View { Rectangle() .fill(Color.white.opacity(0.15)) .frame( width: axis == .vertical ? dividerThickness : nil, height: axis == .horizontal ? dividerThickness : nil ) .contentShape(Rectangle().size( width: axis == .vertical ? dividerHitArea : size.width, height: axis == .horizontal ? dividerHitArea : size.height )) .gesture( DragGesture(minimumDistance: 1) .onChanged { value in switch axis { case .vertical: let newH = value.location.x / size.width hSplit = min(max(newH, 0.15), 0.85) case .horizontal: let newV = value.location.y / size.height vSplit = min(max(newV, 0.15), 0.85) } } ) } }