From 734f7095b00ccf9fe62e0044170bf241f731611b Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Tue, 30 Jun 2026 23:16:43 -0800 Subject: [PATCH] slab/menubar: real 3D low-poly rock sigils (cached sprite sheets) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each prompt is now an actual 3D low-poly mesh — a displaced, subdivided icosahedron (hashed subdivision, lumpiness, and anisotropic squash so forms span crystals, eggs, slabs, boulders) wrapped in a strata texture and lit by the time-of-day sun. Performant by pre-rendering: the tumble is rendered offscreen once per (seed, sun) into a sprite sheet, then played back with a cheap Core Animation keyframe — ~9% opt-in vs ~50% for live per-window SceneKit. Rock plays a chunky downsampled/nearest copy; the shadow plays the crisp high-res silhouette, offset by the sun. Matte material, smoother (54-frame) tumble, dominant colours + neon spotting, per-5-min sun. Co-Authored-By: Claude Opus 4.8 --- .../SlabMenubar/PromptSigilOverlay.swift | 216 +++++++++++++----- .../Sources/SlabMenubar/SigilMesh.swift | 121 ++++++++++ .../Sources/SlabMenubar/SigilRenderer.swift | 99 ++++++++ .../Sources/SlabMenubar/SigilRockFrames.swift | 99 ++++++++ 4 files changed, 483 insertions(+), 52 deletions(-) create mode 100644 slab/menubar-swift/Sources/SlabMenubar/SigilMesh.swift create mode 100644 slab/menubar-swift/Sources/SlabMenubar/SigilRockFrames.swift diff --git a/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift b/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift index d1f35efc79..2424b40499 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift @@ -1,6 +1,7 @@ import AppKit import CoreGraphics import ApplicationServices +import SceneKit /// Private (but stable, widely-used by window managers) bridge from an AX /// window element to its CGWindowID — lets a kAXWindowMoved callback map the @@ -8,6 +9,32 @@ import ApplicationServices @_silgen_name("_AXUIElementGetWindow") private func _AXUIElementGetWindow(_ element: AXUIElement, _ windowID: UnsafeMutablePointer) -> AXError +/// The global sun that lights every stone, driven by the local clock: it rises +/// in the east, arcs overhead at midday, sets in the west, and drops long +/// shadows near dawn/dusk. Deliberately simple (a daylight arc from local time, +/// no ephemeris) — enough for the light to feel like it belongs to the room's +/// time of day. `hx` is horizontal position (+1 east/right … −1 west/left), +/// `elevation` height (0 horizon … 1 overhead), `intensity` strength, and +/// `drop` the flat shadow offset (screen points, away from the sun). +enum Sun { + static func light(at date: Date) -> (hx: CGFloat, elevation: CGFloat, intensity: CGFloat, drop: CGSize) { + let c = Calendar.current.dateComponents([.hour, .minute], from: date) + let h = CGFloat(c.hour ?? 12) + CGFloat(c.minute ?? 0) / 60 + let sunrise: CGFloat = 6.5, sunset: CGFloat = 19.5 + let hx: CGFloat, e: CGFloat, intensity: CGFloat + if h < sunrise || h > sunset { + hx = 0; e = 0.12; intensity = 0.32 // night: dim, near-overhead + } else { + let t = (h - sunrise) / (sunset - sunrise) // 0 dawn … 1 dusk + hx = cos(t * .pi) // +1 east → −1 west + e = sin(t * .pi) // 0 horizon … 1 noon + intensity = 0.55 + 0.45 * e + } + let len: CGFloat = 2 + (1 - e) * 4 // longer shadow when low + return (hx, e, intensity, CGSize(width: -hx * len, height: -len)) + } +} + /// One borderless, click-through badge window holding a session's per-prompt /// sigil, parked top-right under the title bar of its terminal window. The /// sigil image is still (shape + strata = which prompt); the badge spins it @@ -26,11 +53,10 @@ final class PromptSigilOverlay { /// CGWindowID. let tty: String - /// Seed + appearance currently drawn; re-rendered when the prompt changes - /// or the system flips light/dark (rocks are themed) — never on a status - /// change (status is motion + halo colour). - var renderedSeed: UInt64 = 0 - var renderedDark: Bool? + /// Key of the sprite sheet currently installed (seed : dark : sun-minute), + /// so the controller re-renders the frames only when the rock or the sun + /// actually changes — never on a status change (status is motion + halo). + var frameKey: String = "" private var motion: (period: Double, clockwise: Bool)? /// Spring-follow state: `target` is where the terminal wants the badge, /// `current` is where it's drawn. `advance` eases current → target each @@ -40,74 +66,132 @@ final class PromptSigilOverlay { private var currentOrigin: NSPoint? let size: CGFloat = 56 + /// Padding around the rock inside the window so the offset drop shadow has + /// room and isn't clipped by the window edge. + private let pad: CGFloat = 9 + /// Fixed global sun: where the highlight/shadow come from (screen-space, + /// shared by every stone). Down-right shadow ⇒ light from the upper-left. + private let shadowDrop = CGSize(width: 3, height: -3) private let window: NSWindow - private let spin = CALayer() + private let rockLayer = CALayer() // plays the pre-rendered rotation frames + private let shadowLayer = CALayer() // solid status colour, masked to the rock silhouette + private let shadowMask = CALayer() // plays the same frames → the shadow's tumbling shape + private var boxCenter = CGPoint.zero + /// Two pre-rendered sprite sheets of one full turn: `rockFrames` is the + /// chunky low-res copy the rock plays; `shadowFrames` is the crisp high-res + /// silhouette the shadow plays. + private var rockFrames: [CGImage] = [] + private var shadowFrames: [CGImage] = [] init(sessionId: String, tty: String) { self.sessionId = sessionId self.tty = tty - let initial = NSRect(x: -2000, y: -2000, width: size, height: size) + let box = size + 2 * pad + let initial = NSRect(x: -2000, y: -2000, width: box, height: box) window = NSWindow(contentRect: initial, styleMask: [.borderless], backing: .buffered, defer: false) window.isOpaque = false window.backgroundColor = .clear window.hasShadow = false window.ignoresMouseEvents = true - // Normal level + per-window ordering = composites with the terminal - // stack (occluded when another window covers the terminal). Dropping - // canJoinAllSpaces keeps it on the terminal's Space. window.level = .normal window.collectionBehavior = [.fullScreenAuxiliary] - let view = NSView(frame: NSRect(origin: .zero, size: initial.size)) - view.wantsLayer = true - spin.frame = CGRect(x: 0, y: 0, width: size, height: size) - spin.anchorPoint = CGPoint(x: 0.5, y: 0.5) - spin.position = CGPoint(x: size / 2, y: size / 2) - spin.contentsGravity = .resizeAspect - spin.contentsScale = 2 - // Flat, hard-edged status-coloured drop shadow — NO blur (radius 0), a - // crisp offset silhouette in the session's status colour. Set per status - // by the controller; this is the default. - spin.shadowColor = NSColor.systemGray.cgColor - spin.shadowOpacity = 1.0 - spin.shadowRadius = 0 - spin.shadowOffset = CGSize(width: 2.5, height: -2.5) - view.layer?.addSublayer(spin) - window.contentView = view + // Container: a flat status-colour drop-shadow disc as a backing + // sublayer, with the rock-frame layer on top — the disc peeks out on + // the sun-opposite side as a hard drop shadow. + let container = NSView(frame: NSRect(origin: .zero, size: initial.size)) + container.wantsLayer = true + container.layer?.masksToBounds = false + boxCenter = CGPoint(x: box / 2, y: box / 2) + + // Shadow: a solid status-colour block masked to the rock's silhouette + // (the same tumbling frames), offset by the sun. So it's the rock's + // actual shape turning — not a circle. Position offset applied in + // setLighting. + shadowLayer.frame = CGRect(x: pad, y: pad, width: size, height: size) + shadowLayer.position = boxCenter + shadowLayer.backgroundColor = NSColor.systemGray.cgColor + shadowLayer.opacity = 0.9 + shadowMask.frame = CGRect(x: 0, y: 0, width: size, height: size) + shadowMask.contentsGravity = .resizeAspect + // Crisp silhouette: the shadow plays the high-res frames, smoothed. + shadowMask.magnificationFilter = .linear + shadowMask.minificationFilter = .linear + shadowMask.contentsScale = 2 + shadowLayer.mask = shadowMask + container.layer?.addSublayer(shadowLayer) + + rockLayer.frame = CGRect(x: pad, y: pad, width: size, height: size) + rockLayer.contentsGravity = .resizeAspect + // Low-res frames scaled up with nearest-neighbour → chunky low-poly + // pixels, and cheap. + rockLayer.magnificationFilter = .nearest + rockLayer.minificationFilter = .nearest + rockLayer.contentsScale = 1 + container.layer?.addSublayer(rockLayer) + + window.contentView = container } - func setImage(_ image: NSImage) { spin.contents = image } + /// Install freshly rendered sprite sheets (chunky rock + crisp shadow) and + /// (re)start playback. + func setFrames(rock: [CGImage], shadow: [CGImage]) { + rockFrames = rock + shadowFrames = shadow + rockLayer.contents = rock.first + shadowMask.contents = shadow.first + applyPlayback() + } - /// Tint the crisp halo with the session's status colour. No-op when - /// unchanged so we don't churn the layer. + /// Tint the flat drop-shadow disc with the session's status colour. private var shadowColor: NSColor? func setShadowColor(_ color: NSColor) { guard shadowColor != color else { return } shadowColor = color - spin.shadowColor = color.cgColor + shadowLayer.backgroundColor = color.cgColor + } + + /// The global sun's direction is baked into the frames (re-rendered by the + /// controller when it moves); here we only slide the flat shadow to the + /// sun-opposite `drop`. + private var appliedDrop: CGSize? + func setLighting(drop: CGSize) { + if appliedDrop != drop { + appliedDrop = drop + shadowLayer.position = CGPoint(x: boxCenter.x + drop.width, + y: boxCenter.y + drop.height) + } } - /// Apply a status-driven spin. No-op when unchanged so the rotation never - /// stutters. On change we continue from the current presentation angle at - /// the new speed/direction, so a status flip reads as the rock smoothly - /// speeding up / reversing rather than snapping to zero. + /// Status-driven tumble. Cheap: a discrete keyframe animation cycling the + /// cached frames — direction picks the frame order, period sets the turn + /// time. Playback is server-side, so a slow tumble barely touches the CPU. func setMotion(period: Double, clockwise: Bool) { if let m = motion, m.period == period, m.clockwise == clockwise { return } motion = (period, clockwise) - let current = (spin.presentation()?.value(forKeyPath: "transform.rotation.z") as? CGFloat) - ?? (spin.value(forKeyPath: "transform.rotation.z") as? CGFloat) ?? 0 - spin.setValue(current, forKeyPath: "transform.rotation.z") - let anim = CABasicAnimation(keyPath: "transform.rotation.z") - // rotation.z is positive counter-clockwise, so clockwise subtracts. - anim.fromValue = current - anim.toValue = current + (clockwise ? -2 * .pi : 2 * .pi) - anim.duration = period - anim.repeatCount = .infinity - anim.isRemovedOnCompletion = false - anim.timingFunction = CAMediaTimingFunction(name: .linear) - spin.add(anim, forKey: "spin") + applyPlayback() + } + + private func applyPlayback() { + guard rockFrames.count > 1, let (period, cw) = motion else { + rockLayer.removeAnimation(forKey: "tumble") + shadowMask.removeAnimation(forKey: "tumble") + return + } + func tumble(_ vals: [CGImage]) -> CAKeyframeAnimation { + let a = CAKeyframeAnimation(keyPath: "contents") + a.values = cw ? vals : vals.reversed() + a.calculationMode = .discrete + a.duration = period + a.repeatCount = .infinity + a.isRemovedOnCompletion = false + return a + } + // Rock + shadow play their own sheets at the same timing → in sync. + rockLayer.add(tumble(rockFrames), forKey: "tumble") + shadowMask.add(tumble(shadowFrames), forKey: "tumble") } /// This badge's CGWindowID (valid once it's been ordered on-screen), so the @@ -120,8 +204,10 @@ final class PromptSigilOverlay { /// snaps (no spring from off-screen). No z-order touched here. func place(bounds b: (CGFloat, CGFloat, CGFloat, CGFloat), screenHeight: CGFloat) { let titleBar: CGFloat = 30, rightInset: CGFloat = 10 - let originX = b.0 + b.2 - rightInset - size - let originY = screenHeight - (b.1 + titleBar + size) + // Window is padded around the rock; shift origin by -pad so the rock + // itself (centred in the window) still lands at the top-right spot. + let originX = b.0 + b.2 - rightInset - size - pad + let originY = screenHeight - (b.1 + titleBar + size + pad) let t = NSPoint(x: originX, y: originY) targetOrigin = t if currentOrigin == nil { // first appearance: snap there @@ -200,6 +286,14 @@ final class PromptSigilOverlayController { private var observerInstalled = false /// Live AX observers per terminal app pid (kept alive by this reference). private var axObservers: [pid_t: AXObserver] = [:] + /// Global-sun state, recomputed each wall-clock minute so the whole wall's + /// light tracks the time of day together. + private var sunMinute = -1 + private var sun: (hx: CGFloat, elevation: CGFloat, intensity: CGFloat, drop: CGSize) = + (0.4, 0.7, 0.9, CGSize(width: -2.4, height: -2.4)) + /// Serial queue for the offscreen 3D frame renders — one rock at a time so + /// a per-minute sun change never spins up 7 Metal renderers at once. + private let renderQueue = DispatchQueue(label: "computer.slab.sigil-frames", qos: .userInitiated) /// Non-capturing AX callback → route on main. Lexically inside the class so /// it may reach the private singleton; captures nothing local, so it @@ -341,6 +435,16 @@ final class PromptSigilOverlayController { let liveIds = Set(live.map { $0.sessionId }) let dark = AppDelegate.isDarkAppearance() + // Recompute the global sun every 5 minutes — the sun moves slowly, and + // each change re-renders every rock's sprite sheet, so we don't want to + // pay that every minute. The whole wall re-lights together. + let now = Date() + let bucket = Int(now.timeIntervalSince1970 / 300) + if bucket != sunMinute { + sunMinute = bucket + sun = Sun.light(at: now) + } + var membershipChanged = false for (sid, ov) in overlays where !liveIds.contains(sid) { ov.close(); overlays.removeValue(forKey: sid); membershipChanged = true @@ -361,14 +465,22 @@ final class PromptSigilOverlayController { // "yes", blank); the prompt makes the rock re-form as the session // moves to a new prompt. let seed = SigilRenderer.seed(for: s.sessionId + "\u{1}" + s.subject) - if ov.renderedSeed != seed || ov.renderedDark != dark { - ov.renderedSeed = seed - ov.renderedDark = dark - ov.setImage(SigilRenderer.image(seed: seed, dark: dark, size: ov.size)) + // Re-render the sprite sheet only when the rock or the sun moved. + let key = "\(seed):\(dark):\(sunMinute)" + if ov.frameKey != key { + ov.frameKey = key + let (hx, e, inten) = (sun.hx, sun.elevation, sun.intensity) + renderQueue.async { [weak ov] in + let hi = SigilRockFrames.render( + seed: seed, dark: dark, sunHx: hx, sunElevation: e, sunIntensity: inten) + let lo = hi.map { SigilRockFrames.downsample($0, to: 30) } + DispatchQueue.main.async { ov?.setFrames(rock: lo, shadow: hi) } + } } let (period, cw) = motion(for: s.state) ov.setMotion(period: period, clockwise: cw) ov.setShadowColor(statusColor(for: s.state)) + ov.setLighting(drop: sun.drop) } if membershipChanged { needsRebind = true } diff --git a/slab/menubar-swift/Sources/SlabMenubar/SigilMesh.swift b/slab/menubar-swift/Sources/SlabMenubar/SigilMesh.swift new file mode 100644 index 0000000000..5c8208baec --- /dev/null +++ b/slab/menubar-swift/Sources/SlabMenubar/SigilMesh.swift @@ -0,0 +1,121 @@ +import SceneKit +import simd + +/// A unique low-poly rock mesh per prompt: a once-subdivided icosahedron whose +/// vertices are pushed in/out by seeded noise (so it's a lumpy boulder, not a +/// ball), emitted as flat-shaded facets with spherical UVs — so a horizontally +/// banded strata texture wraps around it as latitude layers. Double-sided with +/// outward-forced normals, so it renders correctly regardless of winding. +/// +/// 80 facets — cheap to build and to draw; the whole rock is a handful of KB. +enum SigilMesh { + private struct RNG { + var s: UInt64 + init(_ seed: UInt64) { s = seed == 0 ? 0x9e37_79b9_7f4a_7c15 : seed } + mutating func next() -> UInt64 { + s = s &+ 0x9e37_79b9_7f4a_7c15 + var z = s + z = (z ^ (z >> 30)) &* 0xbf58_476d_1ce4_e5b9 + z = (z ^ (z >> 27)) &* 0x94d0_49bb_1331_11eb + return z ^ (z >> 31) + } + mutating func unit() -> Float { Float(next() >> 40) / Float(1 << 24) } + } + + static func rock(seed: UInt64) -> SCNGeometry { + var rng = RNG(seed) + let t = Float((1.0 + 5.0.squareRoot()) / 2.0) + + var verts: [SIMD3] = [ + SIMD3(-1, t, 0), SIMD3(1, t, 0), SIMD3(-1, -t, 0), SIMD3(1, -t, 0), + SIMD3(0, -1, t), SIMD3(0, 1, t), SIMD3(0, -1, -t), SIMD3(0, 1, -t), + SIMD3(t, 0, -1), SIMD3(t, 0, 1), SIMD3(-t, 0, -1), SIMD3(-t, 0, 1), + ].map { simd_normalize($0) } + + var faces: [(Int, Int, Int)] = [ + (0,11,5),(0,5,1),(0,1,7),(0,7,10),(0,10,11), + (1,5,9),(5,11,4),(11,10,2),(10,7,6),(7,1,8), + (3,9,4),(3,4,2),(3,2,6),(3,6,8),(3,8,9), + (4,9,5),(2,4,11),(6,2,10),(8,6,7),(9,8,1), + ] + + // Subdivision is hashed: ~a third stay at 20 raw faces (chunky, angular + // crystals), the rest subdivide once to 80 (rounder boulders) — two + // clearly different form families. + let passes = rng.unit() < 0.34 ? 0 : 1 + var midCache: [UInt64: Int] = [:] + func midpoint(_ a: Int, _ b: Int) -> Int { + let key = a < b ? UInt64(a) << 32 | UInt64(b) : UInt64(b) << 32 | UInt64(a) + if let m = midCache[key] { return m } + verts.append(simd_normalize((verts[a] + verts[b]) * 0.5)) + let idx = verts.count - 1 + midCache[key] = idx + return idx + } + for _ in 0..(0.66 + 0.64 * rng.unit(), + 0.66 + 0.64 * rng.unit(), + 0.66 + 0.64 * rng.unit()) + let displaced: [SIMD3] = verts.map { v in + (v * (1 + (rng.unit() - 0.5) * 2 * amp)) * scale + } + + func uv(_ p: SIMD3) -> CGPoint { + let d = simd_normalize(p) + let u = 0.5 + atan2(d.z, d.x) / (2 * .pi) + let v = 0.5 - asin(max(-1, min(1, d.y))) / .pi + return CGPoint(x: CGFloat(u), y: CGFloat(v)) + } + + var positions: [SCNVector3] = [] + var normals: [SCNVector3] = [] + var uvs: [CGPoint] = [] + var indices: [Int32] = [] + positions.reserveCapacity(faces.count * 3) + + for (a, b, c) in faces { + let pa = displaced[a], pb = displaced[b], pc = displaced[c] + var n = simd_normalize(simd_cross(pb - pa, pc - pa)) + if simd_dot(n, (pa + pb + pc) / 3) < 0 { n = -n } // force outward + var ua = uv(pa), ub = uv(pb), uc = uv(pc) + // Seam fix: a facet straddling u=0/1 gets its low u's pushed up. + let lo = min(ua.x, ub.x, uc.x), hi = max(ua.x, ub.x, uc.x) + if hi - lo > 0.5 { + if ua.x < 0.5 { ua.x += 1 } + if ub.x < 0.5 { ub.x += 1 } + if uc.x < 0.5 { uc.x += 1 } + } + let base = Int32(positions.count) + let nv = SCNVector3(CGFloat(n.x), CGFloat(n.y), CGFloat(n.z)) + for (p, tex) in [(pa, ua), (pb, ub), (pc, uc)] { + positions.append(SCNVector3(CGFloat(p.x), CGFloat(p.y), CGFloat(p.z))) + normals.append(nv) + uvs.append(tex) + } + indices += [base, base + 1, base + 2] + } + + let geo = SCNGeometry( + sources: [ + SCNGeometrySource(vertices: positions), + SCNGeometrySource(normals: normals), + SCNGeometrySource(textureCoordinates: uvs), + ], + elements: [SCNGeometryElement(indices: indices, primitiveType: .triangles)] + ) + return geo + } +} diff --git a/slab/menubar-swift/Sources/SlabMenubar/SigilRenderer.swift b/slab/menubar-swift/Sources/SlabMenubar/SigilRenderer.swift index a2e00dbb82..a89112b772 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/SigilRenderer.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/SigilRenderer.swift @@ -212,6 +212,105 @@ enum SigilRenderer { return img } + /// A full-rect strata texture for wrapping onto the 3D rock mesh — same + /// hashed beds / neon spots / gashes as the flat sigil, but drawn edge to + /// edge with FLAT (un-dipped) horizontal bands so the left/right edges + /// match and the texture tiles seamlessly around the rock's longitude; the + /// bands then read as latitude strata layers on the sphere. + static func texture(seed: UInt64, dark: Bool, side: CGFloat = 128) -> NSImage { + guard let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: Int(side), pixelsHigh: Int(side), + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0 + ) else { return NSImage(size: NSSize(width: side, height: side)) } + rep.size = NSSize(width: side, height: side) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) + defer { NSGraphicsContext.restoreGraphicsState() } + + var rng = SplitMix64(seed) + let satMood: CGFloat = 0.70 + 0.30 * rng.unit() + let briMood: CGFloat = dark ? (0.34 + 0.24 * rng.unit()) : (0.66 + 0.26 * rng.unit()) + let h0 = rng.unit() + // Wide hue fan → rocks span a real chunk of the wheel (more multi-hued, + // more tellable apart) rather than close monochrome bands. + let fan = 0.14 + 0.62 * rng.unit() + func hue(_ i: Int) -> CGFloat { (h0 + fan * CGFloat(i)).truncatingRemainder(dividingBy: 1) } + let veinHue = (h0 + 0.5).truncatingRemainder(dividingBy: 1) + + // Fewer beds, one boosted to dominate → each rock has a clear dominant + // colour with a couple of supporting areas. + let bedCount = rng.int(2, 5) + var bedHSB: [(h: CGFloat, s: CGFloat, b: CGFloat)] = [] + var bedEdge: [CGFloat] = [] + var weights: [CGFloat] = (0.. Int { + for i in 0.. CGFloat = { _ in 0 } + // Thick bands → big bold areas of colour (not fine grain). A little + // brightness drift keeps each block from being dead-flat; neon streaks + // are rare so they punctuate rather than fleck. + let striation = max(2.0, side * 0.05) + var y: CGFloat = 0 + while y < side { + let bed = bedHSB[bedIndex(y / side)] + let jb = (rng.unit() - 0.5) * 0.08 + let col = NSColor(deviceHue: bed.h, saturation: bed.s, + brightness: max(0.10, min(1, bed.b + jb)), alpha: 1) + fillBand(yLo: y, yHi: y + striation + 0.5, xL: 0, xR: side, wave: flat, color: col) + y += striation + } + _ = veinHue + + // Spotting: scattered bright / neon speckles (mineral inclusions) over + // the big colour areas — lively, and strong per-rock identity. + let spots = rng.int(40, 110) + for _ in 0..(0.25, 1.0, 0.12)) + + /// Render `frameCount` rotation frames for `seed`, themed to `dark` and lit + /// by the given sun. Runs offscreen (safe off the main thread); returns + /// transparent-background CGImages ready for a CALayer. + static func render( + seed: UInt64, dark: Bool, + sunHx: CGFloat, sunElevation: CGFloat, sunIntensity: CGFloat, + frameCount: Int = 54, px: CGFloat = 96 + ) -> [CGImage] { + guard let device = device else { return [] } + let renderer = SCNRenderer(device: device, options: nil) + let scene = SCNScene() + renderer.scene = scene + renderer.autoenablesDefaultLighting = false + + let cam = SCNCamera() + cam.usesOrthographicProjection = true + cam.orthographicScale = 1.85 // margin for stretched/craggy forms + cam.zNear = 0.1 + cam.zFar = 100 + let camNode = SCNNode() + camNode.camera = cam + camNode.position = SCNVector3(0, 0, 6) + scene.rootNode.addChildNode(camNode) + renderer.pointOfView = camNode + + let geo = SigilMesh.rock(seed: seed) + let mat = SCNMaterial() + mat.diffuse.contents = SigilRenderer.texture(seed: seed, dark: dark) + mat.diffuse.wrapS = .repeat + mat.diffuse.wrapT = .clamp + mat.lightingModel = .blinn + mat.specular.contents = NSColor(white: 0.22, alpha: 1) // matte, barely glossy + mat.shininess = 0.06 + mat.isDoubleSided = true + geo.firstMaterial = mat + let rock = SCNNode(geometry: geo) + scene.rootNode.addChildNode(rock) + + let sun = SCNLight() + sun.type = .directional + sun.intensity = 650 + 700 * sunIntensity + let sunNode = SCNNode() + sunNode.light = sun + let d = SCNVector3(sunHx, 0.3 + 0.7 * sunElevation, 0.85) + sunNode.position = SCNVector3(d.x * 10, d.y * 10, d.z * 10) + sunNode.look(at: SCNVector3(0, 0, 0)) + scene.rootNode.addChildNode(sunNode) + + let ambNode = SCNNode() + let amb = SCNLight() + amb.type = .ambient + amb.intensity = 340 + ambNode.light = amb + scene.rootNode.addChildNode(ambNode) + + let size = CGSize(width: px, height: px) + var out: [CGImage] = [] + out.reserveCapacity(frameCount) + for i in 0.. CGImage { + let cs = CGColorSpaceCreateDeviceRGB() + guard let ctx = CGContext( + data: nil, width: side, height: side, bitsPerComponent: 8, bytesPerRow: 0, + space: cs, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return img } + ctx.interpolationQuality = .none + ctx.clear(CGRect(x: 0, y: 0, width: side, height: side)) + ctx.draw(img, in: CGRect(x: 0, y: 0, width: side, height: side)) + return ctx.makeImage() ?? img + } +} -- 2.54.0