263 lines
8.9 KiB
Swift
263 lines
8.9 KiB
Swift
// UVPanels.swift
|
|
// UV Editor viewport and Texture Bake settings panel.
|
|
|
|
import SwiftUI
|
|
|
|
// MARK: - UV Editor View
|
|
|
|
struct UVEditorView: View {
|
|
@State private var uvZoom: CGFloat = 1.0
|
|
@State private var uvOffset: CGSize = .zero
|
|
@State private var showGrid = true
|
|
@State private var uvMode: UVEditMode = .vertex
|
|
|
|
var body: some View {
|
|
GeometryReader { geo in
|
|
ZStack {
|
|
// UV background (checkerboard)
|
|
Canvas { context, size in
|
|
let cellSize: CGFloat = 20 * uvZoom
|
|
let cols = Int(size.width / cellSize) + 2
|
|
let rows = Int(size.height / cellSize) + 2
|
|
|
|
for row in 0..<rows {
|
|
for col in 0..<cols {
|
|
let isLight = (row + col) % 2 == 0
|
|
let rect = CGRect(
|
|
x: CGFloat(col) * cellSize + uvOffset.width.truncatingRemainder(dividingBy: cellSize * 2),
|
|
y: CGFloat(row) * cellSize + uvOffset.height.truncatingRemainder(dividingBy: cellSize * 2),
|
|
width: cellSize,
|
|
height: cellSize
|
|
)
|
|
context.fill(
|
|
Path(rect),
|
|
with: .color(isLight ? Color(white: 0.25) : Color(white: 0.20))
|
|
)
|
|
}
|
|
}
|
|
|
|
// UV space bounds (0,0 to 1,1 mapped to view)
|
|
let uvRect = CGRect(
|
|
x: uvOffset.width,
|
|
y: uvOffset.height,
|
|
width: size.width * uvZoom,
|
|
height: size.height * uvZoom
|
|
)
|
|
context.stroke(
|
|
Path(uvRect),
|
|
with: .color(.orange.opacity(0.4)),
|
|
lineWidth: 1
|
|
)
|
|
}
|
|
.gesture(uvDragGesture)
|
|
.gesture(uvZoomGesture)
|
|
|
|
// Toolbar overlay
|
|
VStack {
|
|
HStack(spacing: 8) {
|
|
Text("UV Editor")
|
|
.font(.caption2.weight(.medium))
|
|
.foregroundStyle(.white.opacity(0.5))
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(.black.opacity(0.3), in: RoundedRectangle(cornerRadius: 4))
|
|
|
|
Spacer()
|
|
|
|
// Edit mode picker
|
|
Picker("Mode", selection: $uvMode) {
|
|
ForEach(UVEditMode.allCases) { mode in
|
|
Image(systemName: mode.icon)
|
|
.tag(mode)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.frame(width: 120)
|
|
}
|
|
.padding(8)
|
|
Spacer()
|
|
}
|
|
|
|
// Placeholder instruction
|
|
if true {
|
|
VStack {
|
|
Spacer()
|
|
Text("Select a mesh to view UVs")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.padding(.bottom, 20)
|
|
}
|
|
}
|
|
}
|
|
.background(Color(white: 0.18))
|
|
}
|
|
}
|
|
|
|
private var uvDragGesture: some Gesture {
|
|
DragGesture()
|
|
.onChanged { value in
|
|
uvOffset = value.translation
|
|
}
|
|
}
|
|
|
|
private var uvZoomGesture: some Gesture {
|
|
MagnifyGesture()
|
|
.onChanged { value in
|
|
uvZoom = max(0.1, min(value.magnification, 5.0))
|
|
}
|
|
}
|
|
}
|
|
|
|
enum UVEditMode: String, CaseIterable, Identifiable {
|
|
case vertex = "Vertex"
|
|
case edge = "Edge"
|
|
case face = "Face"
|
|
case island = "Island"
|
|
|
|
var id: String { rawValue }
|
|
|
|
var icon: String {
|
|
switch self {
|
|
case .vertex: "circle.fill"
|
|
case .edge: "line.diagonal"
|
|
case .face: "square.fill"
|
|
case .island: "square.on.square"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Texture Bake Panel
|
|
|
|
struct TextureBakePanel: View {
|
|
@State private var bakeType: BakeType = .diffuse
|
|
@State private var resolution: Int = 1024
|
|
@State private var margin: Int = 16
|
|
@State private var selectedOnly = false
|
|
@State private var isBaking = false
|
|
@State private var progress: Double = 0
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
// Header
|
|
HStack {
|
|
Image(systemName: "square.grid.3x3.topleft.filled")
|
|
.foregroundStyle(.orange)
|
|
Text("Texture Bake")
|
|
.font(.subheadline.weight(.semibold))
|
|
}
|
|
.padding(.bottom, 4)
|
|
|
|
// Bake type
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Bake Type")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Picker("Type", selection: $bakeType) {
|
|
ForEach(BakeType.allCases) { type in
|
|
Text(type.rawValue).tag(type)
|
|
}
|
|
}
|
|
.pickerStyle(.menu)
|
|
.tint(.orange)
|
|
}
|
|
|
|
// Resolution
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Resolution")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Picker("Resolution", selection: $resolution) {
|
|
Text("512").tag(512)
|
|
Text("1024").tag(1024)
|
|
Text("2048").tag(2048)
|
|
Text("4096").tag(4096)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
}
|
|
|
|
// Margin
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Margin: \(margin)px")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Slider(value: Binding(
|
|
get: { Double(margin) },
|
|
set: { margin = Int($0) }
|
|
), in: 0...64, step: 1)
|
|
.tint(.orange)
|
|
}
|
|
|
|
Toggle("Selected Only", isOn: $selectedOnly)
|
|
.font(.caption)
|
|
|
|
Divider()
|
|
|
|
// Target engine
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Target Engine")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
HStack(spacing: 8) {
|
|
engineTag("Unity")
|
|
engineTag("Unreal")
|
|
engineTag("Godot")
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
|
|
// Bake button
|
|
Button {
|
|
isBaking = true
|
|
progress = 0
|
|
// Simulate progress
|
|
Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { timer in
|
|
progress += 0.02
|
|
if progress >= 1.0 {
|
|
timer.invalidate()
|
|
isBaking = false
|
|
}
|
|
}
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: isBaking ? "hourglass" : "bolt.fill")
|
|
Text(isBaking ? "Baking..." : "Bake")
|
|
}
|
|
.font(.subheadline.weight(.medium))
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 8)
|
|
.background(isBaking ? Color.gray : Color.orange, in: RoundedRectangle(cornerRadius: 6))
|
|
.foregroundStyle(.white)
|
|
}
|
|
.disabled(isBaking)
|
|
|
|
if isBaking {
|
|
ProgressView(value: progress)
|
|
.tint(.orange)
|
|
}
|
|
}
|
|
.padding(12)
|
|
}
|
|
.background(.ultraThinMaterial)
|
|
}
|
|
|
|
private func engineTag(_ name: String) -> some View {
|
|
Text(name)
|
|
.font(.caption2.weight(.medium))
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 4)
|
|
.background(Color.primary.opacity(0.08), in: Capsule())
|
|
}
|
|
}
|
|
|
|
enum BakeType: String, CaseIterable, Identifiable {
|
|
case diffuse = "Diffuse"
|
|
case normal = "Normal"
|
|
case ao = "AO"
|
|
case roughness = "Roughness"
|
|
case emission = "Emission"
|
|
case combined = "Combined"
|
|
|
|
var id: String { rawValue }
|
|
}
|