SceneExplorer
A read-only viewer for a scene file. Point it at a glTF or USD scene and look around it, part by part.
- Run
swift run --package-path Examples Example-3D-Geometry-SceneExplorer- Beside it
- scene.gltf, stage.usda
- Source
- Examples/3D/Geometry/SceneExplorer/Sketch.swift
import Ollin
import Foundation
/// The scene explorer: a read-only lens on a scene file.
///
/// Point it at a glTF or USD scene dropped beside the sketch and look around it
/// the way the material explorer looks at a finish: orbit it or adopt the file's
/// own camera, switch the lighting preset, backdrop, fog, and shadows around it,
/// and toggle the file's authored lights one by one. Step through the parts with
/// the arrow keys (or the Part stepper) to highlight or isolate one: the caption
/// names it, a wireframe box shows its bounds, and anything the import can't
/// carry exactly (a mesh with several materials, a transform with a shear in
/// it) is said on the part that carries it, right where the eye is.
///
/// The explorer never moves a node and never writes a file. To take a scene
/// apart as code, `ollin new --from-scene <file>` writes it out as a sketch
/// whose source is yours to edit.
///
/// Point it at any scene of your own by setting `OLLIN_SCENE` to the file's
/// path; the File parameter then switches back to the bundled stages.
@main
final class SceneExplorer: Sketch {
enum SceneFile: CaseIterable, ParamOption {
case gltfStage, usdStage
var resource: (name: String, ext: String) {
switch self {
case .gltfStage: ("scene", "gltf")
case .usdStage: ("stage", "usda")
}
}
}
enum PartMode: CaseIterable, ParamOption {
case all, highlight, isolate
}
enum CameraMode: CaseIterable, ParamOption {
case orbit, file
}
enum Backdrop: CaseIterable, ParamOption {
case none, studio, city, courtyard, forest, interior, night, sunrise, sunset, sky
var environment: Environment? {
switch self {
case .none: nil
case .studio: .studio
case .city: .city
case .courtyard: .courtyard
case .forest: .forest
case .interior: .interior
case .night: .night
case .sunrise: .sunrise
case .sunset: .sunset
case .sky: .sky()
}
}
}
// The file, and how to look at it.
@Param(group: "Scene") var file: SceneFile = .gltfStage
@Param(group: "Scene") var view: CameraMode = .orbit
// The part under the eye: 0 is the whole scene, 1... steps the named parts.
@Param(0...200, group: "Part") var part = 0
@Param(group: "Part") var mode: PartMode = .highlight
@Param(group: "Part") var showBounds = true
// The file's own lights, one switch each (switches past the file's count are inert).
@Param(group: "Lights") var light1 = true
@Param(group: "Lights") var light2 = true
@Param(group: "Lights") var light3 = true
@Param(group: "Lights") var light4 = true
// The stage around it, the material explorer's block.
@Param(group: "Stage") var mood: LightingPreset = .studio
@Param(group: "Stage") var backdrop: Backdrop = .none
@Param(group: "Stage") var shadows = true
@Param(group: "Stage") var haze = false
private var scene: Scene?
private var loadedFile: SceneFile?
private var ownSceneShown = false
/// Every node with a mesh, flattened depth-first, each with the chain of
/// nodes from the root down to it. The chain is what places the part in
/// world space when it draws alone.
private var parts: [(name: String, chain: [SceneNode])] = []
override func setup() {
reload()
}
private func reload() {
if let path = ProcessInfo.processInfo.environment["OLLIN_SCENE"], !ownSceneShown {
scene = try? Scene(path: path)
ownSceneShown = true
} else {
let r = file.resource
scene = try? Scene(resource: r.name, withExtension: r.ext, in: .module)
}
loadedFile = file
parts = []
guard let scene else { return }
var flat: [(String, [SceneNode])] = []
func walk(_ node: SceneNode, _ path: [SceneNode]) {
let chain = path + [node]
if node.mesh != nil {
let label = node.name.isEmpty ? "part \(flat.count + 1)" : node.name
flat.append((label, chain))
}
for child in node.children { walk(child, chain) }
}
for node in scene.nodes { walk(node, []) }
parts = flat
}
/// A scene holding just this chain: meshes stripped above the leaf, other
/// children pruned, so the leaf draws exactly where the full walk puts it.
private func isolated(_ chain: [SceneNode]) -> Scene {
var wrapped = chain[chain.count - 1]
wrapped.children = []
for i in stride(from: chain.count - 2, through: 0, by: -1) {
var parent = chain[i]
parent.mesh = nil
parent.children = [wrapped]
wrapped = parent
}
return Scene(nodes: [wrapped])
}
/// The whole scene with one part's mesh removed, the backdrop of a highlight.
private func without(_ chain: [SceneNode]) -> Scene {
guard var scene else { return Scene() }
func strip(_ nodes: inout [SceneNode], _ depth: Int) {
for i in nodes.indices {
if depth < chain.count, nodes[i].name == chain[depth].name {
if depth == chain.count - 1 {
nodes[i].mesh = nil
} else {
strip(&nodes[i].children, depth + 1)
}
}
}
}
strip(&scene.nodes, 0)
return scene
}
override func keyPressed() {
let count = parts.count
guard count > 0 else { return }
if keyCode == .rightArrow { part = (part + 1) % (count + 1) }
if keyCode == .leftArrow { part = (part + count) % (count + 1) }
}
override func draw() {
if loadedFile != file { reload(); part = 0 }
background(Color(white: 0.05))
guard let scene else {
drawCaption("no scene file: drop one beside the sketch (scene.gltf / stage.usda)")
return
}
// The camera: the file's own, or a free orbit sized to the scene.
let bounds = scene.bounds
let center = bounds.center
let radius = max(bounds.size.length, 0.001)
if view == .file, let fileCamera = scene.camera {
camera(fileCamera)
} else {
cameraShowcase(.autoOrbit(), target: center, radius: radius * 1.6,
elevation: 0.22, fieldOfView: .pi / 4)
}
lightingPreset(mood)
if let environment = backdrop.environment { self.environment(environment) }
castShadows(shadows)
if haze { fog(Color(white: 0.55), density: 0.4 / radius) }
// The file's own lights, toggled one by one.
let switches = [light1, light2, light3, light4]
for (i, l) in scene.lights.enumerated() where i < switches.count && switches[i] {
light(l)
}
let picked = part >= 1 && part <= parts.count ? parts[part - 1] : nil
switch (mode, picked) {
case (.isolate, .some(let p)):
fill(.white)
drawScene(isolated(p.chain))
case (.highlight, .some(let p)):
fill(Color(white: 0.78))
drawScene(without(p.chain))
fill(.white)
drawScene(isolated(p.chain))
default:
fill(.white)
drawScene(scene)
}
// The picked part's bounds, and what the import wants said about it.
if let p = picked {
if showBounds {
let cage = isolated(p.chain).bounds.padded(by: radius * 0.015)
let plo = cage.min, phi = cage.max
// The light rig is frame state, not stack state, so the cage is
// drawn lit; a bright fill keeps it reading as a marker.
withState {
// Brighter than any lit surface, so the bloom threshold
// below catches the cage and nothing else.
fill(Color(red: 2.9, green: 2.3, blue: 3.5))
let edge = radius * 0.0024
let xs = [plo.x, phi.x], ys = [plo.y, phi.y], zs = [plo.z, phi.z]
for y in ys { for z in zs {
drawCapsule(from: Vector3(plo.x, y, z), to: Vector3(phi.x, y, z), radius: edge)
} }
for x in xs { for z in zs {
drawCapsule(from: Vector3(x, plo.y, z), to: Vector3(x, phi.y, z), radius: edge)
} }
for x in xs { for y in ys {
drawCapsule(from: Vector3(x, y, plo.z), to: Vector3(x, y, phi.z), radius: edge)
} }
}
postProcess(.bloom(threshold: 1.15, amount: 0.9, radius: 8))
}
let leaf = p.chain[p.chain.count - 1]
var notes: [String] = []
if leaf.wearsSeveralMaterials { notes.append("has several materials, draws in its first") }
if !leaf.placementIsExact { notes.append("transform carries a shear") }
let suffix = notes.isEmpty ? "" : " · " + notes.joined(separator: "; ")
drawCaption("part \(part)/\(parts.count): \(p.name)\(suffix)")
} else {
let counts = "\(parts.count) parts · \(scene.lights.count) lights"
drawCaption("\(scene.name ?? file.resource.name) · \(counts) · arrows pick a part")
}
}
}