114 lines
3.7 KiB
Swift
114 lines
3.7 KiB
Swift
|
|
// GPCanvasView.swift
|
||
|
|
// Composites the Metal viewport with a PencilCaptureOverlay on top.
|
||
|
|
// Pencil strokes are projected into 3D and drawn by the renderer.
|
||
|
|
|
||
|
|
import SwiftUI
|
||
|
|
import simd
|
||
|
|
|
||
|
|
struct GPCanvasView: View {
|
||
|
|
let renderer: ViewportRenderer
|
||
|
|
@State private var gpData = GPObjectData()
|
||
|
|
@State private var activeStroke: GPStroke?
|
||
|
|
@State private var brushColor: Color = .black
|
||
|
|
@State private var brushWidth: Float = 0.05
|
||
|
|
|
||
|
|
var body: some View {
|
||
|
|
GeometryReader { geo in
|
||
|
|
ZStack {
|
||
|
|
// 3D viewport (renders scene + finalized GP strokes)
|
||
|
|
ViewportPane(renderer: renderer, label: "GP Canvas")
|
||
|
|
|
||
|
|
// Pencil input overlay
|
||
|
|
PencilCaptureOverlay(
|
||
|
|
onStrokeBegan: {
|
||
|
|
beginStroke()
|
||
|
|
},
|
||
|
|
onSample: { sample in
|
||
|
|
addSample(sample, viewportSize: geo.size)
|
||
|
|
},
|
||
|
|
onStrokeEnded: {
|
||
|
|
finalizeStroke()
|
||
|
|
}
|
||
|
|
)
|
||
|
|
.allowsHitTesting(true)
|
||
|
|
|
||
|
|
// Drawing indicator
|
||
|
|
if activeStroke != nil {
|
||
|
|
VStack {
|
||
|
|
HStack {
|
||
|
|
Spacer()
|
||
|
|
Label("Drawing", systemImage: "pencil.tip")
|
||
|
|
.font(.caption2)
|
||
|
|
.foregroundStyle(.orange)
|
||
|
|
.padding(.horizontal, 10)
|
||
|
|
.padding(.vertical, 4)
|
||
|
|
.background(.black.opacity(0.6), in: Capsule())
|
||
|
|
.padding(8)
|
||
|
|
}
|
||
|
|
Spacer()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Stroke lifecycle
|
||
|
|
|
||
|
|
private func beginStroke() {
|
||
|
|
let stroke = GPStroke()
|
||
|
|
stroke.color = colorToSIMD(brushColor)
|
||
|
|
stroke.baseWidth = brushWidth
|
||
|
|
activeStroke = stroke
|
||
|
|
}
|
||
|
|
|
||
|
|
private func addSample(_ sample: PencilSample, viewportSize: CGSize) {
|
||
|
|
guard let stroke = activeStroke,
|
||
|
|
let device = renderer.device else { return }
|
||
|
|
|
||
|
|
let projector = StrokeProjector(
|
||
|
|
viewMatrix: renderer.camera.viewMatrix,
|
||
|
|
projectionMatrix: MathUtils.perspective(
|
||
|
|
fovYRadians: .pi / 4,
|
||
|
|
aspect: Float(viewportSize.width / viewportSize.height),
|
||
|
|
near: 0.1, far: 500
|
||
|
|
),
|
||
|
|
viewportSize: SIMD2<Float>(
|
||
|
|
Float(viewportSize.width),
|
||
|
|
Float(viewportSize.height)
|
||
|
|
),
|
||
|
|
drawingPlaneNormal: drawingPlaneNormal(),
|
||
|
|
drawingPlanePoint: renderer.camera.target
|
||
|
|
)
|
||
|
|
|
||
|
|
stroke.addSample(sample, projector: projector)
|
||
|
|
|
||
|
|
// Live tessellation for preview
|
||
|
|
stroke.tessellate(device: device)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func finalizeStroke() {
|
||
|
|
guard let stroke = activeStroke,
|
||
|
|
stroke.points.count >= 2 else {
|
||
|
|
activeStroke = nil
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Commit to the active layer
|
||
|
|
gpData.activeLayer?.strokes.append(stroke)
|
||
|
|
activeStroke = nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Helpers
|
||
|
|
|
||
|
|
/// Drawing plane faces the camera (billboard mode).
|
||
|
|
private func drawingPlaneNormal() -> SIMD3<Float> {
|
||
|
|
normalize(renderer.camera.eye - renderer.camera.target)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func colorToSIMD(_ color: Color) -> SIMD4<Float> {
|
||
|
|
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||
|
|
UIColor(color).getRed(&r, green: &g, blue: &b, alpha: &a)
|
||
|
|
return SIMD4<Float>(Float(r), Float(g), Float(b), Float(a))
|
||
|
|
}
|
||
|
|
}
|