265 lines
8.1 KiB
Swift
265 lines
8.1 KiB
Swift
// PropertiesPanel.swift
|
|
// Right-side properties inspector, driven by SceneGraph selection.
|
|
|
|
import SwiftUI
|
|
|
|
struct PropertiesPanel: View {
|
|
@Binding var isExpanded: Bool
|
|
let sceneGraph: SceneGraph
|
|
|
|
var body: some View {
|
|
if isExpanded {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
panelHeader
|
|
|
|
if let obj = sceneGraph.selectedObject {
|
|
// Transform section
|
|
PropertySection(title: "Transform", icon: "move.3d") {
|
|
TransformEditor(transform: obj.transform)
|
|
}
|
|
|
|
// Mesh info (if mesh type)
|
|
if let mesh = obj.meshHandle {
|
|
PropertySection(title: "Mesh", icon: "cube") {
|
|
InfoRow(label: "Vertices", value: "\(mesh.vertexCount / 3 * 3)")
|
|
InfoRow(label: "Edges", value: "\(mesh.edgeCount)")
|
|
InfoRow(label: "Faces", value: "\(mesh.faceCount)")
|
|
}
|
|
}
|
|
|
|
// Object info
|
|
PropertySection(title: "Object", icon: "info.circle") {
|
|
InfoRow(label: "Type", value: obj.type.rawValue)
|
|
InfoRow(label: "Visible", value: obj.isVisible ? "Yes" : "No")
|
|
}
|
|
|
|
// Modifiers (placeholder)
|
|
PropertySection(title: "Modifiers", icon: "wrench") {
|
|
Text("No modifiers")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
} else {
|
|
Text("No selection")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.padding(.top, 20)
|
|
}
|
|
}
|
|
.padding(12)
|
|
}
|
|
.frame(width: 260)
|
|
.background(.ultraThinMaterial)
|
|
}
|
|
}
|
|
|
|
private var panelHeader: some View {
|
|
HStack {
|
|
if let obj = sceneGraph.selectedObject {
|
|
Image(systemName: obj.type.icon)
|
|
.foregroundStyle(.orange)
|
|
Text(obj.name)
|
|
.font(.subheadline.weight(.semibold))
|
|
} else {
|
|
Image(systemName: "questionmark.square.dashed")
|
|
.foregroundStyle(.secondary)
|
|
Text("Properties")
|
|
.font(.subheadline.weight(.semibold))
|
|
}
|
|
Spacer()
|
|
Button {
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
isExpanded = false
|
|
}
|
|
} label: {
|
|
Image(systemName: "sidebar.trailing")
|
|
.font(.caption)
|
|
}
|
|
.tint(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Outliner
|
|
|
|
struct OutlinerPanel: View {
|
|
let sceneGraph: SceneGraph
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack {
|
|
Text("Scene Collection")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.top, 8)
|
|
|
|
ScrollView {
|
|
LazyVStack(spacing: 0) {
|
|
ForEach(sceneGraph.rootObjects) { obj in
|
|
OutlinerRow(object: obj, sceneGraph: sceneGraph)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.background(.ultraThinMaterial)
|
|
}
|
|
}
|
|
|
|
struct OutlinerRow: View {
|
|
let object: SceneObject
|
|
let sceneGraph: SceneGraph
|
|
|
|
var body: some View {
|
|
Button {
|
|
sceneGraph.select(object)
|
|
} label: {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: object.children.isEmpty ? "circle.fill" : "chevron.right")
|
|
.font(.system(size: 7))
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 12)
|
|
|
|
Image(systemName: object.type.icon)
|
|
.font(.caption2)
|
|
.foregroundStyle(object.isSelected ? .orange : .secondary)
|
|
|
|
Text(object.name)
|
|
.font(.caption)
|
|
.foregroundStyle(object.isSelected ? .primary : .secondary)
|
|
|
|
Spacer()
|
|
|
|
// Visibility toggle
|
|
Button {
|
|
object.isVisible.toggle()
|
|
} label: {
|
|
Image(systemName: object.isVisible ? "eye" : "eye.slash")
|
|
.font(.system(size: 10))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 5)
|
|
.background(object.isSelected ? Color.orange.opacity(0.12) : .clear)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
// MARK: - Transform Editor
|
|
|
|
struct TransformEditor: View {
|
|
let transform: Transform
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
TransformRow(
|
|
label: "Location",
|
|
x: transform.position.x,
|
|
y: transform.position.y,
|
|
z: transform.position.z
|
|
)
|
|
TransformRow(
|
|
label: "Rotation",
|
|
x: transform.rotation.x * 180 / .pi,
|
|
y: transform.rotation.y * 180 / .pi,
|
|
z: transform.rotation.z * 180 / .pi
|
|
)
|
|
TransformRow(
|
|
label: "Scale",
|
|
x: transform.scale.x,
|
|
y: transform.scale.y,
|
|
z: transform.scale.z
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Property sub-views
|
|
|
|
struct PropertySection<Content: View>: View {
|
|
let title: String
|
|
let icon: String
|
|
@ViewBuilder let content: Content
|
|
@State private var expanded = true
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Button {
|
|
withAnimation(.easeInOut(duration: 0.15)) {
|
|
expanded.toggle()
|
|
}
|
|
} label: {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: expanded ? "chevron.down" : "chevron.right")
|
|
.font(.system(size: 9))
|
|
Image(systemName: icon)
|
|
.font(.caption2)
|
|
Text(title)
|
|
.font(.caption.weight(.semibold))
|
|
Spacer()
|
|
}
|
|
.foregroundStyle(.primary)
|
|
}
|
|
|
|
if expanded {
|
|
content
|
|
.padding(.leading, 16)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct TransformRow: View {
|
|
let label: String
|
|
let x: Float
|
|
let y: Float
|
|
let z: Float
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(label)
|
|
.font(.system(size: 10))
|
|
.foregroundStyle(.secondary)
|
|
HStack(spacing: 4) {
|
|
field("X", value: x, color: .red)
|
|
field("Y", value: y, color: .green)
|
|
field("Z", value: z, color: .blue)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func field(_ axis: String, value: Float, color: Color) -> some View {
|
|
HStack(spacing: 2) {
|
|
Text(axis)
|
|
.font(.system(size: 9, weight: .bold))
|
|
.foregroundStyle(color)
|
|
Text(String(format: "%.3f", value))
|
|
.font(.system(size: 10, design: .monospaced))
|
|
}
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 3)
|
|
.background(Color.primary.opacity(0.06), in: RoundedRectangle(cornerRadius: 3))
|
|
}
|
|
}
|
|
|
|
struct InfoRow: View {
|
|
let label: String
|
|
let value: String
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Text(label)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text(value)
|
|
.font(.caption.weight(.medium).monospacedDigit())
|
|
}
|
|
}
|
|
}
|