上一節「補充(21)」提供可調整貝茲曲線的繪圖板,有了這個工具,我們就可以控制花瓶的輪廓曲線,從古典「玉壺春瓶」到現代可口可樂「曲線瓶」,都可藉由簡單的貝茲曲線製作出來。
先來看一下程式執行影片(執行前請關閉「啟用結果」):
附帶一提,這個程式主要用2019年的 iMac 21” (Intel i5)開發,上面影片則用2018年第一代的 iPad Pro 11” (A12X Bionic)錄製,兩台設備都已超過5年,但跑起來還是很順,這對很多學生來說,實在是莫大福音。
這得歸功於 Apple 出色的作業系統(比 Windows/Linux 還贊)與硬體整合能力,而且 Swift 寫的程式效能也令人感到滿意,即便是計算量吃重的空間運算,舊設備仍游刃有餘,實在令人讚嘆。
以下是結合前兩節(6-8c、補充21)的程式碼,除了調整局部參數之外,主要增加 RealityView update: 一段程式和線框控制開關,請自行參考:
// 6-8d 手拉曲線花瓶
// Created by Heman Lu on 2025/03/24
// Tested with iMac 2019 (macOS 15.3.2) + Swift Playground 4.6.3
import SwiftUI
import RealityKit
// 花瓶右半輪廓曲線
struct 花瓶曲線: Shape {
let 厚度: CGFloat = 0.01 // Normalized to 0~1
let 底座: [CGPoint] = [ // Normalized to 0.0~1.0
CGPoint(x: 0, y: 0),
CGPoint(x: 0.3, y: 0),
CGPoint(x: 0.3, y: 0.02)
]
let 曲線: [CGPoint] // 4個正規化點座標(起點、控制點1、控制點2、終點)
func path(in 尺寸: CGRect) -> Path {
let 寬 = 尺寸.width
let 高 = 尺寸.height
var 畫筆 = Path()
// (1)畫底座
if 底座.count > 1 {
畫筆.addLines(底座)
}
if 曲線.count > 3 {
// (2)畫貝茲曲線
畫筆.addLine(to: 曲線[0])
畫筆.addCurve(
to: 曲線[3],
control1: 曲線[1],
control2: 曲線[2])
// (3) 畫瓶口轉折
畫筆.addLine(to: CGPoint(
x: 曲線[3].x - 厚度,
y: 曲線[3].y))
// (4)畫返回貝茲曲線
let 控制點1 = CGPoint(
x: 曲線[2].x - 厚度,
y: 曲線[2].y)
let 控制點2 = CGPoint(
x: 曲線[1].x - 厚度,
y: 曲線[1].y)
let 終點 = CGPoint(
x: 曲線[0].x - 厚度,
y: 曲線[0].y + 厚度)
畫筆.addCurve(to: 終點, control1: 控制點1, control2: 控制點2)
// (5)畫返回底座
畫筆.addLine(to: CGPoint(
x: 曲線[0].x - 厚度,
y: 曲線[0].y))
畫筆.addLine(to: CGPoint(
x: 0,
y: 曲線[0].y))
}
畫筆.closeSubpath()
// 恢復原尺寸
let 縮放矩陣 = CGAffineTransform(scaleX: 寬, y: 高)
return 畫筆.applying(縮放矩陣)
}
}
// 手拉曲線
struct 曲線繪圖板: View {
let 繪板寬: CGFloat
let 繪板高: CGFloat
@Binding var 手拉曲線: [CGPoint] // 4個正規化點座標(起點、控制點1、控制點2、終點)
@State var 手勢開始 = false
@State var 上次座標: CGPoint = .zero
@State var 目標索引: Int = 0 // 貝茲曲線的索引(0...3)
func 最近點索引(位置: CGPoint, 點陣列: [CGPoint]) -> Int {
var 索引: Int = 0
var 目前距離: CGFloat = .infinity
for i in 點陣列.indices {
let 兩點距離 = sqrt(
pow(點陣列[i].x - 位置.x, 2) +
pow(點陣列[i].y - 位置.y, 2)
)
if 兩點距離 < 目前距離 {
目前距離 = 兩點距離
索引 = i
}
}
return 索引
}
var 拖曳手勢: some Gesture {
DragGesture(minimumDistance: 5.0)
.onChanged { 參數 in
if 手勢開始 == false {
手勢開始 = true
let 轉換點座標 = 手拉曲線.map { 正規化座標 in
CGPoint(
x: 正規化座標.x * 繪板寬,
y: 繪板高 - 正規化座標.y * 繪板高
)
}
目標索引 = 最近點索引(位置: 參數.location, 點陣列: 轉換點座標)
if 目標索引 > -1 && 目標索引 < 4 { // 索引超過範圍會閃退
上次座標 = 手拉曲線[目標索引]
}
}
if 目標索引 > -1 && 目標索引 < 4 { // 索引超過範圍會閃退
手拉曲線[目標索引] = CGPoint(
x: 上次座標.x + 參數.translation.width / 繪板寬,
y: 上次座標.y - 參數.translation.height / 繪板高
)
}
if 目標索引 == 0 {
// To-do: 底座的座標要跟著變化
}
// print(貝茲曲線[目標索引])
}
.onEnded { 參數 in
// print(拖曳位移陣列)
手勢開始 = false
目標索引 = 0
}
}
var body: some View {
ZStack {
花瓶曲線(曲線: 手拉曲線)
.stroke(Color.primary)
.fill(Color.red)
.scaleEffect(y: -1)
Canvas { 圖層, 尺寸 in
let 寬 = 尺寸.width
let 高 = 尺寸.height
// let 中心 = CGPoint(x: 寬/2, y: 高/2)
let 上中 = CGPoint(x: 寬/2, y: 0)
let 下中 = CGPoint(x: 寬/2, y: 高)
let 左中 = CGPoint(x: 0, y: 高/2)
let 右中 = CGPoint(x: 寬, y: 高/2)
var 畫筆 = Path()
// (1) 畫外框與十字線
畫筆.addRect(CGRect(origin: .zero, size: 尺寸))
畫筆.move(to: 上中)
畫筆.addLine(to: 下中)
畫筆.move(to: 左中)
畫筆.addLine(to: 右中)
圖層.stroke(畫筆, with: .color(.gray), lineWidth: 1)
// (2) 畫控制點小圓
let 縮放矩陣 = CGAffineTransform(scaleX: 寬, y: 高)
var 實際座標: [CGPoint] = [] // 將貝茲曲線轉換到數學座標
畫筆 = Path()
for 點座標 in 手拉曲線 {
let 點座標轉換 = CGPoint(
x: 點座標.applying(縮放矩陣).x,
y: 高 - 點座標.applying(縮放矩陣).y
)
實際座標.append(點座標轉換)
// print("座標轉換:\\(點座標轉換)")
畫筆.addArc(
center: 點座標轉換,
radius: 7,
startAngle: .zero,
endAngle: .degrees(360),
clockwise: false)
}
圖層.fill(畫筆, with: .color(.primary))
// (3) 畫兩條控制點線段
畫筆 = Path()
if 實際座標.count > 3 {
畫筆.move(to: 實際座標[0])
畫筆.addLine(to: 實際座標[1])
畫筆.move(to: 實際座標[2])
畫筆.addLine(to: 實際座標[3])
}
圖層.stroke(畫筆, with: .color(.primary))
}
.gesture(拖曳手勢)
}
.frame(width: 繪板寬, height: 繪板高)
}
}
//import PlaygroundSupport
//PlaygroundPage.current.setLiveView(曲線繪圖板())
struct 手拉曲線花瓶: View {
@State var 輪廓曲線: [CGPoint] = [ //正規化(數學)座標,原點在左下角
CGPoint(x: 0.3, y: 0.02), // start point
CGPoint(x: 1.0, y: 0.2), // #1 control point
CGPoint(x: 0.01, y: 0.7), // #2 control point
CGPoint(x: 0.1, y: 1) // end point
]
@State var 線框開關: Bool = false
var body: some View {
ZStack(alignment: .bottomTrailing) {
RealityView { 內容 in
// 內容.add(座標軸()) // 共享程式6-6b
var 材質 = PhysicallyBasedMaterial()
材質.baseColor.tint = .tintColor
材質.roughness = 0.1
材質.metallic = 0.9
// 材質.blending = .transparent(opacity: 0.9)
// 材質.triangleFillMode = 線框開關 ? .lines : .fill
let 外框 = CGRect(x: 0.0, y: 0.0, width: 0.5, height: 0.8)
let 曲線 = 花瓶曲線(曲線: 輪廓曲線).path(in: 外框)
// 共享程式6-8c:製作花瓶()
if let 花瓶 = try? await MeshResource.製作花瓶(輪廓: 曲線) {
let 花瓶模型 = ModelEntity(mesh: 花瓶, materials: [材質])
花瓶模型.name = "花瓶"
花瓶模型.position.y = -0.3
內容.add(花瓶模型)
}
// 威尼斯清晨:<https://drive.usercontent.google.com/download?id=1y-8hbbZ5viZ86YubAJWgfRhguzJOCLxM>
if let 天空盒 = try? await EnvironmentResource(named: "威尼斯清晨") {
內容.environment = .skybox(天空盒)
}
} update: { 內容 in
print("Updating at \\(Date.now):\\(輪廓曲線)")
for 個體 in 內容.entities {
if let 模型 = 個體.findEntity(named: "花瓶") as? ModelEntity {
// print("模型找到了")
if var 材質 = 模型.model?.materials.first as? PhysicallyBasedMaterial {
// print("取代材質")
材質.triangleFillMode = 線框開關 ? .lines : .fill
模型.model?.materials = [材質]
}
let 外框 = CGRect(x: 0.0, y: 0.0, width: 0.5, height: 0.8)
let 曲線 = 花瓶曲線(曲線: 輪廓曲線).path(in: 外框)
Task {
if let 新造型 = try? await MeshResource.製作花瓶(輪廓: 曲線, 分段: 60) {
// print("更新外型網格")
模型.model?.mesh = 新造型
}
}
}
}
}
.realityViewCameraControls(.orbit)
HStack(alignment: .bottom) {
Text("""
(c)2025 Heman Lu
Programmed by Heman Lu with background "Venice Dawn 2" by Greg Zaal & Rico Cilliers
""")
.italic()
.font(.caption)
.foregroundStyle(.gray)
.padding()
Spacer()
Button("線框", systemImage: "globe") {
線框開關.toggle()
}
.buttonStyle(.borderedProminent)
.padding()
曲線繪圖板(繪板寬: 150, 繪板高: 200, 手拉曲線: $輪廓曲線)
.frame(width: 150, height: 200)
.padding()
}
}
}
}
import PlaygroundSupport
PlaygroundPage.current.setLiveView(手拉曲線花瓶())
下一節:圖學大師與他們的產地