196 lines
5.9 KiB
Swift
196 lines
5.9 KiB
Swift
// 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)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|