Skip to content

Commit 3680d43

Browse files
Improve mobile STL arch preview controls
Enhance the iOS Teeth preview so uploaded and dev-sample STL arches are easier to inspect on mobile. Changes included: - Add an arch segmented control for Both, Upper, and Lower views when STL geometry is available. - Add a zoom slider that adjusts the SceneKit camera distance while keeping camera controls enabled. - Improve STL visibility with brighter ivory material, stronger ambient/directional lighting, and denser triangle preview sampling. - Recenter STL meshes using recursive child bounds before scaling and rotating, fixing the upper-right drift seen in the phone preview. - Fall back to first/second STL selection for upper/lower views when uploaded file names do not include arch hints. Validation: - git diff --check - xcodebuild build -project OpenSourceOrthoLite.xcodeproj -scheme OpenSourceOrthoLite -destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.5'
1 parent 2b4b282 commit 3680d43

1 file changed

Lines changed: 129 additions & 18 deletions

File tree

mobile/ios/App/Screens.swift

Lines changed: 129 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,8 @@ struct TeethAndTimeView: View {
232232
@EnvironmentObject private var model: LiteFlowViewModel
233233
@State private var stage = 0.0
234234
@State private var showDemoSample = false
235+
@State private var previewArch: DentalPreviewArch = .both
236+
@State private var zoom = 1.0
235237

236238
private var hasSelectedStl: Bool {
237239
model.scans.contains { $0.modality.lowercased() == "stl" }
@@ -244,10 +246,30 @@ struct TeethAndTimeView: View {
244246
DentalScenePreview(
245247
stage: stage,
246248
scans: model.previewScans,
249+
previewArch: previewArch,
250+
zoom: zoom,
247251
hasSelectedStl: hasSelectedStl
248252
)
249253
.frame(height: 340)
250254
.clipShape(RoundedRectangle(cornerRadius: 12))
255+
if hasSelectedStl {
256+
Picker("Arch", selection: $previewArch) {
257+
ForEach(DentalPreviewArch.allCases, id: \.self) { arch in
258+
Text(arch.title).tag(arch)
259+
}
260+
}
261+
.pickerStyle(.segmented)
262+
HStack(spacing: 12) {
263+
Text("Zoom")
264+
.font(.caption)
265+
.foregroundStyle(.secondary)
266+
Slider(value: $zoom, in: 0.75...2.4, step: 0.05)
267+
Text(String(format: "%.1fx", zoom))
268+
.font(.caption.monospacedDigit())
269+
.foregroundStyle(.secondary)
270+
.frame(width: 42, alignment: .trailing)
271+
}
272+
}
251273
if !hasSelectedStl {
252274
VStack(spacing: 8) {
253275
Button {
@@ -482,12 +504,14 @@ struct PrintAndSendView: View {
482504
struct DentalScenePreview: View {
483505
var stage: Double
484506
var scans: [PreviewScan]
507+
var previewArch: DentalPreviewArch = .both
508+
var zoom: Double = 1
485509
var hasSelectedStl: Bool = false
486510

487511
var body: some View {
488512
ZStack(alignment: .bottomLeading) {
489513
SceneView(
490-
scene: DentalPreviewScene.make(stage: stage, scans: scans),
514+
scene: DentalPreviewScene.make(stage: stage, scans: scans, previewArch: previewArch, zoom: zoom),
491515
options: [.allowsCameraControl, .autoenablesDefaultLighting]
492516
)
493517
Text(previewCaption)
@@ -513,30 +537,63 @@ struct DentalScenePreview: View {
513537
}
514538
}
515539

540+
enum DentalPreviewArch: CaseIterable {
541+
case both
542+
case upper
543+
case lower
544+
545+
var title: String {
546+
switch self {
547+
case .both: return "Both"
548+
case .upper: return "Upper"
549+
case .lower: return "Lower"
550+
}
551+
}
552+
553+
func includes(_ scan: PreviewScan) -> Bool {
554+
guard scan.modality.lowercased() == "stl" else { return false }
555+
let name = scan.fileName.lowercased()
556+
switch self {
557+
case .both:
558+
return true
559+
case .upper:
560+
return name.contains("upper") || name.contains("maxillary")
561+
case .lower:
562+
return name.contains("lower") || name.contains("mandibular")
563+
}
564+
}
565+
}
566+
516567
private enum DentalPreviewScene {
517-
static func make(stage: Double, scans: [PreviewScan]) -> SCNScene {
568+
static func make(stage: Double, scans: [PreviewScan], previewArch: DentalPreviewArch, zoom: Double) -> SCNScene {
518569
let scene = SCNScene()
519570
scene.background.contents = UIColor.systemBackground
520571

521572
let camera = SCNCamera()
522-
camera.fieldOfView = 45
573+
camera.fieldOfView = 36
523574
let cameraNode = SCNNode()
524575
cameraNode.camera = camera
525-
cameraNode.position = SCNVector3(0, 0, 9)
576+
cameraNode.position = SCNVector3(0, 0, Float(7.2 / Swift.max(0.75, zoom)))
526577
scene.rootNode.addChildNode(cameraNode)
527578

579+
let ambient = SCNLight()
580+
ambient.type = .ambient
581+
ambient.intensity = 560
582+
let ambientNode = SCNNode()
583+
ambientNode.light = ambient
584+
scene.rootNode.addChildNode(ambientNode)
585+
528586
let light = SCNLight()
529-
light.type = .omni
530-
light.intensity = 900
587+
light.type = .directional
588+
light.intensity = 1_350
531589
let lightNode = SCNNode()
532590
lightNode.light = light
533-
lightNode.position = SCNVector3(0, 3, 6)
591+
lightNode.eulerAngles = SCNVector3(-0.6, 0.25, 0)
592+
lightNode.position = SCNVector3(0, 4, 6)
534593
scene.rootNode.addChildNode(lightNode)
535594

536-
let stlNodes = scans
537-
.filter { $0.modality.lowercased() == "stl" }
538-
.prefix(2)
539-
.compactMap(STLSceneMesh.node)
595+
let selectedScans = selectedScans(from: scans, previewArch: previewArch)
596+
let stlNodes = selectedScans.compactMap(STLSceneMesh.node)
540597
if stlNodes.isEmpty {
541598
addSampleDentalCast(to: scene, stage: stage)
542599
} else {
@@ -551,18 +608,66 @@ private enum DentalPreviewScene {
551608
return scene
552609
}
553610

611+
private static func selectedScans(from scans: [PreviewScan], previewArch: DentalPreviewArch) -> [PreviewScan] {
612+
let stlScans = Array(scans.filter { $0.modality.lowercased() == "stl" }.prefix(2))
613+
guard previewArch != .both else { return stlScans }
614+
615+
let namedMatches = stlScans.filter(previewArch.includes)
616+
if !namedMatches.isEmpty {
617+
return namedMatches
618+
}
619+
guard stlScans.count > 1 else { return stlScans }
620+
return previewArch == .upper ? [stlScans[0]] : [stlScans[1]]
621+
}
622+
554623
private static func centerAndScale(_ node: SCNNode) {
555-
let bounds = node.boundingBox
624+
guard let bounds = recursiveBounds(for: node) else { return }
556625
let min = bounds.min
557626
let maxPoint = bounds.max
558627
let center = SCNVector3((min.x + maxPoint.x) / 2, (min.y + maxPoint.y) / 2, (min.z + maxPoint.z) / 2)
559628
let span = Swift.max(maxPoint.x - min.x, Swift.max(maxPoint.y - min.y, maxPoint.z - min.z))
560-
let scale = span > 0 ? 4.8 / span : 1
561-
node.position = SCNVector3(-center.x * scale, -center.y * scale, -center.z * scale)
629+
let scale = span > 0 ? 6.2 / span : 1
630+
for child in node.childNodes {
631+
child.position = SCNVector3(
632+
child.position.x - center.x,
633+
child.position.y - center.y,
634+
child.position.z - center.z
635+
)
636+
}
637+
node.position = SCNVector3Zero
562638
node.scale = SCNVector3(scale, scale, scale)
563639
node.eulerAngles.x = -.pi / 2
564640
}
565641

642+
private static func recursiveBounds(for node: SCNNode) -> (min: SCNVector3, max: SCNVector3)? {
643+
var result: (min: SCNVector3, max: SCNVector3)?
644+
for child in node.childNodes {
645+
let bounds = child.boundingBox
646+
let corners = [
647+
SCNVector3(bounds.min.x, bounds.min.y, bounds.min.z),
648+
SCNVector3(bounds.min.x, bounds.min.y, bounds.max.z),
649+
SCNVector3(bounds.min.x, bounds.max.y, bounds.min.z),
650+
SCNVector3(bounds.min.x, bounds.max.y, bounds.max.z),
651+
SCNVector3(bounds.max.x, bounds.min.y, bounds.min.z),
652+
SCNVector3(bounds.max.x, bounds.min.y, bounds.max.z),
653+
SCNVector3(bounds.max.x, bounds.max.y, bounds.min.z),
654+
SCNVector3(bounds.max.x, bounds.max.y, bounds.max.z),
655+
]
656+
for corner in corners {
657+
let converted = child.convertPosition(corner, to: node)
658+
if let current = result {
659+
result = (
660+
min: SCNVector3(Swift.min(current.min.x, converted.x), Swift.min(current.min.y, converted.y), Swift.min(current.min.z, converted.z)),
661+
max: SCNVector3(Swift.max(current.max.x, converted.x), Swift.max(current.max.y, converted.y), Swift.max(current.max.z, converted.z))
662+
)
663+
} else {
664+
result = (converted, converted)
665+
}
666+
}
667+
}
668+
return result
669+
}
670+
566671
private static func addSampleDentalCast(to scene: SCNScene, stage: Double) {
567672
let parent = SCNNode()
568673
parent.eulerAngles.x = -0.08
@@ -643,7 +748,7 @@ private enum DentalPreviewScene {
643748
}
644749

645750
private enum STLSceneMesh {
646-
private static let trianglePreviewLimit = 80_000
751+
private static let trianglePreviewLimit = 120_000
647752
private static var geometryCache: [String: SCNGeometry] = [:]
648753
private static let cacheLock = NSLock()
649754

@@ -666,9 +771,15 @@ private enum STLSceneMesh {
666771
bytesPerIndex: MemoryLayout<Int32>.size
667772
)
668773
let geometry = SCNGeometry(sources: [source], elements: [element])
669-
geometry.firstMaterial?.diffuse.contents = UIColor(red: 0.94, green: 0.88, blue: 0.74, alpha: 1.0)
670-
geometry.firstMaterial?.specular.contents = UIColor.white
671-
geometry.firstMaterial?.isDoubleSided = true
774+
let material = SCNMaterial()
775+
material.diffuse.contents = UIColor(red: 0.99, green: 0.96, blue: 0.82, alpha: 1.0)
776+
material.ambient.contents = UIColor(red: 0.62, green: 0.56, blue: 0.36, alpha: 1.0)
777+
material.specular.contents = UIColor(white: 0.9, alpha: 1.0)
778+
material.emission.contents = UIColor(red: 0.06, green: 0.05, blue: 0.02, alpha: 1.0)
779+
material.shininess = 0.45
780+
material.lightingModel = .blinn
781+
material.isDoubleSided = true
782+
geometry.materials = [material]
672783
storeGeometry(geometry, for: cacheKey)
673784
return SCNNode(geometry: geometry)
674785
}

0 commit comments

Comments
 (0)