'use client'
import { useEffect, useRef } from 'react'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'
/**
* Real Estate Machine — Interactive 3D Deal Pipeline
*
* A brokerage run by AI agents, built as one machine you can spin:
* lead → valuation → listing → offers → closing across five stations on a metal plate.
* Four modes (Assembled, Cutaway, Stations, One deal), five cameras, hover and click
* stations. Procedural three.js: no models, no images.
*
* Adapted from the "Agentic Factory" 3D machine (same engine, camera, modes and timing);
* every station, screen, prop and line of copy is rebuilt for real estate.
*
* Fills its box: give it a height (default 100vh). Dependencies: react, three.
* The embed prop hides the panels for a landing-page hero: on a frame wider than 900 px
* the machine moves to the right 58 % and leaves the left side for your headline.
* Control it with window.__machine: setMode, setCamera, focusStation, play, pause.
*/
export type RealEstateMachine3DProps = {
/** Height of the scene box, e.g. 720 or '100vh'. Default '100vh'. */
height?: number | string
className?: string
/** Your brokerage or product name, shown in the top bar, footer and on the engraved plate. */
brand?: string
/** Clean hero mode: no panels, the machine on the right of a wide frame. */
embed?: boolean
/** A station was clicked: 'leads' | 'valuation' | 'listing' | 'offers' | 'closing'. */
onStation?: (id: StationId) => void
/** The first real frame is drawn. */
onReady?: () => void
}
export default function RealEstateMachine3D({
height = '100vh',
className,
brand = 'Agentic Realty',
embed = false,
onStation,
onReady,
}: RealEstateMachine3DProps) {
const rootRef = useRef(null)
const handlers = useRef({ onStation, onReady })
useEffect(() => {
handlers.current = { onStation, onReady }
}, [onStation, onReady])
useEffect(() => {
const root = rootRef.current
if (!root) return
let dispose: (() => void) | undefined
let cancelled = false
// The in-scene screens are painted on canvases: wait for fonts first.
document.fonts.ready.then(() => {
if (cancelled) return
dispose = initMachineScene(root, getComputedStyle(root).fontFamily, {
embedded: embed,
brand,
onStation: (id) => handlers.current.onStation?.(id),
onReady: () => handlers.current.onReady?.(),
})
})
return () => {
cancelled = true
dispose?.()
}
}, [embed, brand])
return (
)
}
// ---------------------------------------------------------------------------
// The scene
// ---------------------------------------------------------------------------
export type MachineMode = 'assembled' | 'cutaway' | 'stations' | 'order'
export type MachineCamera = 'overview' | 'side' | 'top' | 'station' | 'flight'
export type StationId = 'leads' | 'valuation' | 'listing' | 'offers' | 'closing'
export type MachineApi = {
setMode: (name: string) => boolean
focusStation: (id: string) => boolean
setCamera: (name: string) => boolean
play: () => boolean
pause: () => boolean
}
declare global {
interface Window {
__machine?: MachineApi
__machineDebug?: { getState: () => Record }
}
}
export type MachineSceneOptions = {
/** Clean hero: panels hidden, wheel and touch scroll the page. */
embedded: boolean
brand: string
onStation?: (id: StationId) => void
onReady?: () => void
}
// One deal, start to finish. Every screen in the scene tells the same story.
const DEAL = {
seller: 'Maria Lopez',
address: '412 Oak Ridge Dr',
estimate: 742000,
listPrice: 749000,
offer: 755000,
commissionRate: 0.025,
}
const money = (n: number) => '$' + Math.round(n).toLocaleString('en-US')
function initMachineScene(
root: HTMLElement,
fontFamily: string,
options: MachineSceneOptions
): () => void {
const cleanups: Array<() => void> = []
const frameWidth = () => root.clientWidth
const frameHeight = () => root.clientHeight
const listen = (target: EventTarget, type: string, handler: (event: never) => void) => {
const fn = handler as unknown as EventListener
target.addEventListener(type, fn)
cleanups.push(() => target.removeEventListener(type, fn))
}
function $(id: string): T {
const el = root.querySelector(`#${id}`)
if (!el) throw new Error(`Scene markup is missing #${id}`)
return el
}
function showError(message?: string) {
$('loading').classList.add('done')
$('error').style.display = 'block'
if (message) $('error').querySelector('p')!.textContent = message
}
const dispose = () => {
for (const fn of cleanups.reverse()) fn()
cleanups.length = 0
}
try {
const TAU = Math.PI * 2
const embedded = options.embedded
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches
// Brass for keys, plaques and anything that means money moved; sage for "done".
const C = {
brass: '#e0a14a',
brassDeep: '#8a5f24',
sage: '#9fbf9a',
ink: '#141a22',
paper: '#f2efe8',
screen: '#0f1722',
row: '#1b2633',
muted: '#8796a3',
}
const commission = DEAL.offer * DEAL.commissionRate
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(33, frameWidth() / frameHeight(), 0.1, 150)
let renderer: THREE.WebGLRenderer
try {
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
powerPreference: 'high-performance',
})
} catch (e) {
showError(
'WebGL is not available. Turn on hardware acceleration in your browser settings and reload the page.'
)
throw e
}
renderer.setClearColor(0x000000, 0)
renderer.setPixelRatio(Math.min(devicePixelRatio, frameWidth() < 900 ? 1.5 : 1.75))
renderer.setSize(frameWidth(), frameHeight())
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 1.12
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFShadowMap
renderer.localClippingEnabled = true
$('scene').appendChild(renderer.domElement)
const controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = 0.065
controls.enablePan = false
controls.minDistance = 7
controls.maxDistance = 55
controls.minPolarAngle = 0.09
controls.maxPolarAngle = Math.PI * 0.475
controls.rotateSpeed = 0.48
controls.zoomSpeed = 0.7
if (embedded) {
controls.enableZoom = false
if (matchMedia('(pointer:coarse)').matches) controls.enableRotate = false
}
const pmrem = new THREE.PMREMGenerator(renderer),
room = new RoomEnvironment()
const env = pmrem.fromScene(room, 0.04)
scene.environment = env.texture
scene.environmentIntensity = 0.62
room.dispose()
pmrem.dispose()
scene.add(new THREE.HemisphereLight(0xdbe5f4, 0x221d17, 2))
const key = new THREE.DirectionalLight(0xfff1d8, 4.2)
key.position.set(-4, 12, 7)
key.castShadow = true
key.shadow.mapSize.set(2048, 2048)
Object.assign(key.shadow.camera, {
left: -10,
right: 10,
top: 9,
bottom: -9,
near: 0.5,
far: 35,
})
key.shadow.normalBias = 0.035
key.shadow.bias = -0.0002
key.shadow.radius = 4
scene.add(key)
const rim = new THREE.DirectionalLight(0xc4d4ed, 3.1)
rim.position.set(3, 7, -8)
scene.add(rim)
const warm = new THREE.PointLight(0xffc56a, 26, 20, 2)
warm.position.set(-4, 5, 3)
scene.add(warm)
const front = new THREE.DirectionalLight(0xffffff, 1)
front.position.set(5, 3, 10)
scene.add(front)
const mat = (
color: number,
metalness = 0.1,
roughness = 0.4,
extra: THREE.MeshStandardMaterialParameters = {}
) => new THREE.MeshStandardMaterial({ color, metalness, roughness, ...extra })
const M = {
body: mat(0x2b3340, 0.75, 0.29),
base: mat(0x252c37, 0.85, 0.32),
edge: mat(0x6f7a88, 0.85, 0.24),
chrome: mat(0xc3cad0, 0.92, 0.18),
dark: mat(0x111720, 0.45, 0.38),
rubber: mat(0x0b0f15, 0.1, 0.6),
amber: mat(0xe0a14a, 0.62, 0.26),
ivory: mat(0xe3e0d6, 0.45, 0.28),
copper: mat(0xb98d55, 0.85, 0.3),
roof: mat(0x3b4452, 0.3, 0.55),
lawn: mat(0x5d7a58, 0.05, 0.75),
light: mat(0xe0a14a, 0.2, 0.25, { emissive: 0xe0a14a, emissiveIntensity: 1.5 }),
whiteLight: mat(0xfff3d7, 0.1, 0.3, { emissive: 0xffe2a8, emissiveIntensity: 1.8 }),
green: mat(0xc6d9a1, 0.1, 0.3, { emissive: 0x91b364, emissiveIntensity: 0.7 }),
}
type Vec3 = [number, number, number]
type Material = THREE.Material
const geometries = new Map()
function boxGeo(w: number, h: number, d: number, r = 0.04) {
const k = `b${w},${h},${d},${r}`
if (!geometries.has(k))
geometries.set(
k,
r
? new RoundedBoxGeometry(w, h, d, 2, Math.min(r, w / 3, h / 3, d / 3))
: new THREE.BoxGeometry(w, h, d)
)
return geometries.get(k)!
}
function box(
parent: THREE.Object3D,
w: number,
h: number,
d: number,
x: number,
y: number,
z: number,
m: Material = M.body,
r = 0.04
) {
const o = new THREE.Mesh(boxGeo(w, h, d, r), m)
o.position.set(x, y, z)
o.castShadow = true
o.receiveShadow = true
parent.add(o)
return o
}
function cyl(
parent: THREE.Object3D,
r: number,
h: number,
x: number,
y: number,
z: number,
m: Material = M.chrome,
r2: number = r,
segments = 24
) {
const k = `c${r},${r2},${h},${segments}`
if (!geometries.has(k)) geometries.set(k, new THREE.CylinderGeometry(r, r2, h, segments))
const o = new THREE.Mesh(geometries.get(k)!, m)
o.position.set(x, y, z)
o.castShadow = true
o.receiveShadow = true
parent.add(o)
return o
}
function tube(parent: THREE.Object3D, pts: Vec3[], r: number, m: Material = M.chrome) {
const curve = new THREE.CatmullRomCurve3(pts.map((p) => new THREE.Vector3(...p)))
const o = new THREE.Mesh(
new THREE.TubeGeometry(curve, Math.max(12, pts.length * 7), r, 8, false),
m
)
o.castShadow = true
parent.add(o)
return o
}
function screw(parent: THREE.Object3D, x: number, y: number, z: number) {
cyl(parent, 0.055, 0.026, x, y, z, M.chrome, undefined, 12)
box(parent, 0.068, 0.005, 0.009, x, y + 0.014, z, M.dark, 0)
}
// A small gabled house: the one object every station in this machine is about.
function house(
parent: THREE.Object3D,
s: number,
x: number,
y: number,
z: number,
wall: Material = M.ivory
) {
const g = new THREE.Group()
g.position.set(x, y, z)
g.scale.setScalar(s)
parent.add(g)
box(g, 0.5, 0.34, 0.4, 0, 0.17, 0, wall, 0.02)
const gable = box(g, 0.28, 0.28, 0.38, 0, 0.34, 0, wall, 0.01)
gable.rotation.z = Math.PI / 4
for (const side of [-1, 1]) {
const slope = box(g, 0.35, 0.035, 0.48, side * 0.136, 0.43, 0, M.roof, 0.01)
slope.rotation.z = -side * 0.62
}
box(g, 0.06, 0.15, 0.06, 0.14, 0.5, -0.09, wall, 0.01)
box(g, 0.09, 0.16, 0.012, 0, 0.08, 0.203, M.dark, 0.004)
for (const wx of [-0.15, 0.15]) box(g, 0.09, 0.08, 0.012, wx, 0.21, 0.203, M.whiteLight, 0.004)
return g
}
type Draw = (ctx: CanvasRenderingContext2D, w: number, h: number) => void
function canvasTexture(w: number, h: number, draw: Draw) {
const c = document.createElement('canvas')
c.width = w
c.height = h
const ctx = c.getContext('2d')!
draw(ctx, w, h)
const t = new THREE.CanvasTexture(c)
t.colorSpace = THREE.SRGBColorSpace
t.anisotropy = Math.min(4, renderer.capabilities.getMaxAnisotropy())
return { texture: t, canvas: c, ctx }
}
function print(
ctx: CanvasRenderingContext2D,
txt: string,
x: number,
y: number,
size = 20,
color = C.paper,
weight = 500
) {
ctx.fillStyle = color
ctx.font = `${weight} ${size}px ${fontFamily}`
ctx.fillText(txt, x, y)
}
function roundRect(
c: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
fill: string
) {
c.fillStyle = fill
c.beginPath()
c.roundRect(x, y, w, h, r)
c.fill()
}
// A listing photo at dusk, painted procedurally: sky, lawn, house, lit windows, a tree.
function paintHousePhoto(c: CanvasRenderingContext2D, x: number, y: number, w: number, h: number) {
c.save()
c.beginPath()
c.roundRect(x, y, w, h, Math.min(w, h) * 0.05)
c.clip()
const sky = c.createLinearGradient(0, y, 0, y + h)
sky.addColorStop(0, '#6f8fae')
sky.addColorStop(0.62, '#f1c58a')
c.fillStyle = sky
c.fillRect(x, y, w, h)
c.fillStyle = '#4f6b4a'
c.fillRect(x, y + h * 0.74, w, h * 0.26)
const hx = x + w * 0.28,
hy = y + h * 0.42,
hw = w * 0.5,
hh = h * 0.34
c.fillStyle = '#e9e4d8'
c.fillRect(hx, hy, hw, hh)
c.fillStyle = '#353e4b'
c.beginPath()
c.moveTo(hx - hw * 0.08, hy + 2)
c.lineTo(hx + hw / 2, hy - hh * 0.62)
c.lineTo(hx + hw * 1.08, hy + 2)
c.closePath()
c.fill()
c.fillStyle = '#ffd27a'
for (const f of [0.12, 0.66]) c.fillRect(hx + hw * f, hy + hh * 0.22, hw * 0.2, hh * 0.3)
c.fillStyle = '#3a2c22'
c.fillRect(hx + hw * 0.42, hy + hh * 0.45, hw * 0.16, hh * 0.55)
c.fillStyle = '#3c5a3c'
c.beginPath()
c.arc(x + w * 0.15, y + h * 0.6, h * 0.17, 0, TAU)
c.fill()
c.fillRect(x + w * 0.145, y + h * 0.66, w * 0.012, h * 0.1)
c.restore()
}
function screenMaterial(texture: THREE.Texture) {
return new THREE.MeshBasicMaterial({ map: texture, toneMapped: false })
}
type Painted = THREE.Texture | { texture: THREE.Texture }
function screen(
parent: THREE.Object3D,
w: number,
h: number,
x: number,
y: number,
z: number,
tex: Painted
) {
const map = 'texture' in tex ? tex.texture : tex
const o = new THREE.Mesh(new THREE.PlaneGeometry(w, h), screenMaterial(map))
o.position.set(x, y, z)
parent.add(o)
return o
}
const machine = new THREE.Group()
scene.add(machine)
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(70, 70),
new THREE.ShadowMaterial({ opacity: 0.23 })
)
floor.rotation.x = -Math.PI / 2
floor.position.y = -0.43
floor.receiveShadow = true
scene.add(floor)
const shadow = canvasTexture(128, 128, (c, w, h) => {
const g = c.createRadialGradient(64, 64, 12, 64, 64, 64)
g.addColorStop(0, 'rgba(0,0,0,.8)')
g.addColorStop(0.55, 'rgba(0,0,0,.45)')
g.addColorStop(1, 'rgba(0,0,0,0)')
c.fillStyle = g
c.fillRect(0, 0, w, h)
})
const contact = new THREE.Mesh(
new THREE.PlaneGeometry(18, 13),
new THREE.MeshBasicMaterial({
map: shadow.texture,
transparent: true,
depthWrite: false,
opacity: 0.65,
})
)
contact.rotation.x = -Math.PI / 2
contact.position.y = -0.415
scene.add(contact)
// A chamfered, layered platform with an engraved plate and captive screws.
box(machine, 12.8, 0.38, 8.25, 0, -0.09, 0, M.base, 0.17)
box(machine, 12.6, 0.055, 8.08, 0, 0.13, 0, M.edge, 0.11)
box(machine, 12.49, 0.09, 7.96, 0, 0.19, 0, M.body, 0.1)
box(machine, 12.55, 0.027, 8.02, 0, -0.19, 0, M.dark, 0.06)
box(machine, 11.9, 0.026, 0.032, 0, -0.17, 4.115, M.light, 0.01)
for (const x of [-5.6, 5.6])
for (const z of [-3.35, 3.35]) {
cyl(machine, 0.39, 0.25, x, -0.31, z, M.rubber)
cyl(machine, 0.29, 0.09, x, -0.4, z, M.dark)
screw(machine, x, 0.253, z)
}
for (const x of [-6.02, 6.02]) for (const z of [-3.73, 3.73]) screw(machine, x, 0.255, z)
const engraving = canvasTexture(1536, 176, (c, w, h) => {
c.fillStyle = '#212833'
c.fillRect(0, 0, w, h)
c.strokeStyle = '#4a5562'
c.lineWidth = 2
c.strokeRect(2, 2, w - 4, h - 4)
const name = options.brand.toUpperCase()
print(c, name, 45, 79, 40, '#dcd8cc', 650)
const nameWidth = c.measureText(name).width
print(c, '· lead · value · list · offer · close', 45 + nameWidth + 30, 79, 32, '#b3bcc4', 450)
print(c, 'AGENTIC REAL ESTATE / FROM FIRST CALL TO CLOSING', 47, 133, 19, '#6f7d8a', 500)
print(c, 'No. 001', 1360, 130, 23, C.brass)
})
const plate = screen(machine, 7.35, 0.84, -0.4, 0.25, 3.51, engraving)
plate.rotation.x = -Math.PI / 2
for (let i = 0; i < 16; i++)
box(machine, 0.015, 0.009, 0.11 + (i % 4) * 0.035, -5.6 + i * 0.09, 0.249, 3.5, M.edge, 0)
// Faint survey marks around the machine, like a plot on a site plan.
const drafting = new THREE.Group()
scene.add(drafting)
const lineMat = new THREE.LineBasicMaterial({
color: 0x6a7684,
transparent: true,
opacity: 0.12,
})
const draftingPoints = []
for (const r of [7.5, 8.1])
for (let i = 0; i < 120; i++)
for (const j of [i, i + 1]) {
const a = (j / 120) * TAU
draftingPoints.push(new THREE.Vector3(Math.cos(a) * r, -0.4, Math.sin(a) * r * 0.72))
}
for (let i = 0; i < 52; i++) {
const a = (i / 52) * TAU,
r = 8.1
draftingPoints.push(
new THREE.Vector3(Math.cos(a) * r, -0.395, Math.sin(a) * r * 0.72),
new THREE.Vector3(
Math.cos(a) * (r + (i % 4 === 0 ? 0.16 : 0.07)),
-0.395,
Math.sin(a) * (r + (i % 4 === 0 ? 0.16 : 0.07)) * 0.72
)
)
}
drafting.add(
new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints(draftingPoints), lineMat)
)
// The five stations, in plate order. `step` is the station's place in the deal
// (lead, valuation, listing, offer, close); `output` is what it hands on.
type StationDef = {
id: StationId
name: string
step: number
output: string
pos: Vec3
desc: string
}
type Station = StationDef & {
group: THREE.Group
base: THREE.Vector3
glowMat: THREE.MeshStandardMaterial
label: HTMLDivElement
index: number
anchor: THREE.Vector3
}
const definitions: StationDef[] = [
{
id: 'valuation',
name: 'Valuation',
step: 2,
output: 'price',
pos: [-4.15, 0.29, -0.65],
desc: 'Pulls comps, scans the property and sets a price that sells.',
},
{
id: 'listing',
name: 'Listing Studio',
step: 3,
output: 'listing',
pos: [-1.65, 0.29, -2.03],
desc: 'Photos, listing copy and syndication, live in minutes.',
},
{
id: 'offers',
name: 'Showings & Offers',
step: 4,
output: 'offer',
pos: [1.5, 0.29, -2.08],
desc: 'Books showings, compares every offer and flags the strongest one.',
},
{
id: 'leads',
name: 'Lead Desk',
step: 1,
output: 'lead',
pos: [4.03, 0.29, 0.12],
desc: 'Captures every inquiry, qualifies it and follows up the same minute.',
},
{
id: 'closing',
name: 'Closing',
step: 5,
output: 'commission',
pos: [0.93, 0.29, 1.85],
desc: 'Escrow funded. Keys handed over. Commission paid.',
},
]
const stations: Station[] = [],
cutPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 10)
function shell(m: T): T {
const s = m.clone() as T
s.clippingPlanes = [cutPlane]
s.clipShadows = true
s.side = THREE.DoubleSide
return s
}
const S = {
body: shell(M.body),
ivory: shell(M.ivory),
amber: shell(M.amber),
}
const gears: Array<{ g: THREE.Group }> = []
function gear(parent: THREE.Object3D, x: number, y: number, z: number, r = 0.3) {
const g = new THREE.Group()
g.position.set(x, y, z)
parent.add(g)
cyl(g, r, 0.09, 0, 0, 0, M.copper)
cyl(g, r * 0.66, 0.105, 0, 0, 0, M.dark)
cyl(g, r * 0.22, 0.14, 0, 0, 0, M.chrome)
for (let i = 0; i < 12; i++) {
const a = (i / 12) * TAU,
b = box(g, r * 0.26, 0.085, r * 0.2, Math.cos(a) * r, 0, Math.sin(a) * r, M.copper, 0.008)
b.rotation.y = -a
}
g.userData.moving = true
gears.push({ g })
return g
}
definitions.forEach((d, i) => {
const group = new THREE.Group()
group.position.fromArray(d.pos)
machine.add(group)
const glowMat = M.light.clone()
glowMat.emissiveIntensity = 0.5
box(group, 2.05, 0.12, 1.78, 0, 0.03, 0, M.dark, 0.1)
box(group, 1.97, 0.03, 1.7, 0, 0.12, 0, glowMat, 0.09)
box(group, 2.03, 0.17, 1.75, 0, 0.215, 0, M.body, 0.1)
for (const x of [-0.85, 0.85]) for (const z of [-0.7, 0.7]) screw(group, x, 0.311, z)
// Internal mechanics become visible when the shell is cut at deck height.
gear(group, -0.35, 0.45, 0, 0.27)
gear(group, 0.22, 0.45, 0.12, 0.21)
box(group, 0.6, 0.15, 0.36, 0.52, 0.47, -0.33, M.dark)
for (let j = 0; j < 6; j++)
box(group, 0.025, 0.16, 0.37, 0.3 + j * 0.08, 0.47, -0.33, M.edge, 0.004)
tube(
group,
[
[-0.7, 0.4, -0.4],
[-0.55, 0.48, 0.4],
[0.35, 0.45, 0.6],
[0.7, 0.58, 0.23],
],
0.023,
M.light
)
const plaque = canvasTexture(512, 116, (c) => {
c.fillStyle = '#121820'
c.fillRect(0, 0, 512, 116)
print(c, String(d.step).padStart(2, '0'), 24, 76, 42, C.brass, 550)
print(c, d.name.toUpperCase(), 111, 73, d.name.length > 12 ? 27 : 35, '#d7d9d7', 550)
})
screen(group, 1.54, 0.345, 0, 0.27, 0.891, plaque)
const label = document.createElement('div')
label.className = 'station-label'
label.innerHTML = ``
$('labels').appendChild(label)
cleanups.push(() => label.remove())
stations.push({
...d,
group,
base: new THREE.Vector3(...d.pos),
glowMat,
label,
index: i,
anchor: new THREE.Vector3(0, 2.5, 0),
})
})
// ── 02 VALUATION: a gantry scanner sweeps a model house on a turntable ──
const valuation = stations[0].group
box(valuation, 1.74, 0.62, 1.33, 0, 0.65, -0.05, S.ivory, 0.13)
box(valuation, 1.5, 0.1, 1.16, 0, 0.99, -0.04, S.body, 0.025)
for (const x of [-0.68, 0.68]) {
cyl(valuation, 0.065, 2.1, x, 1.55, -0.18, M.chrome)
box(valuation, 0.22, 2.2, 0.22, x, 1.55, -0.44, S.ivory, 0.035)
}
box(valuation, 1.82, 0.27, 0.4, 0, 2.66, -0.35, S.amber, 0.045)
box(valuation, 1.55, 0.06, 0.06, 0, 2.5, -0.115, M.chrome, 0.01)
const turntable = new THREE.Group()
turntable.position.set(0, 1.05, 0.05)
turntable.userData.moving = true
valuation.add(turntable)
cyl(turntable, 0.52, 0.05, 0, 0, 0, M.dark, undefined, 40)
cyl(turntable, 0.47, 0.02, 0, 0.035, 0, M.chrome, undefined, 40)
cyl(turntable, 0.42, 0.012, 0, 0.05, 0, M.lawn, undefined, 40)
house(turntable, 1.05, 0, 0.056, 0)
const scanner = new THREE.Group()
valuation.add(scanner)
scanner.position.set(0, 2.24, -0.08)
scanner.userData.moving = true
box(scanner, 0.42, 0.3, 0.4, 0, 0, 0, M.body, 0.05)
cyl(scanner, 0.1, 0.12, 0, -0.2, 0.03, M.chrome, 0.05)
box(scanner, 0.24, 0.045, 0.022, 0, 0.07, 0.21, M.light, 0.01)
const laserMat = new THREE.MeshBasicMaterial({
color: 0xffc56a,
transparent: true,
opacity: 0.55,
depthWrite: false,
})
const laser = cyl(scanner, 0.01, 0.5, 0, -0.5, 0.03, laserMat, 0.05, 10)
laser.castShadow = false
tube(
valuation,
[
[-0.68, 2.45, -0.35],
[-0.42, 2.9, -0.4],
[0.25, 2.88, -0.4],
[0.35, 2.38, -0.12],
],
0.032,
M.dark
)
// The live estimate: digits settle as comps come in, then the cycle restarts.
const valTex = canvasTexture(384, 640, () => {})
function drawValuation(t: number) {
const c = valTex.ctx,
w = 384,
h = 640
c.fillStyle = C.screen
c.fillRect(0, 0, w, h)
const cycle = t % 6,
settle = Math.max(0, 1 - cycle / 3)
print(c, 'AVM ESTIMATE', 26, 48, 19, C.muted, 600)
print(c, DEAL.address, 26, 78, 21, '#d6dbd9', 500)
const value = DEAL.estimate + Math.round((Math.sin(t * 11) * settle * 14000) / 100) * 100
print(c, money(value), 24, 152, 50, settle > 0 ? C.paper : C.brass, 650)
print(c, `confidence ±${(2.1 + settle * 6).toFixed(1)}%`, 26, 188, 17, C.muted)
print(c, 'COMPS · 0.5 MI · 90 DAYS', 26, 245, 14, C.muted, 600)
const comps: Array<[string, number]> = [
['418 Oak Ridge', 736],
['27 Larkspur Ct', 751],
['903 Juniper Ln', 728],
['55 Fernhill Way', 760],
['140 Alder St', 744],
]
comps.forEach(([street, k], i) => {
const y = 268 + i * 58
const on = cycle * 2 > i
roundRect(c, 20, y, 344, 46, 6, C.row)
print(c, street, 34, y + 29, 16, on ? '#d6dbd9' : '#5d6b78')
print(c, `$${k}K`, 290, y + 29, 16, on ? C.sage : '#5d6b78', 600)
c.fillStyle = on ? C.brass : '#2c3a48'
c.fillRect(170, y + 21, ((k - 700) / 70) * 100, 5)
})
c.fillStyle = C.brass
c.fillRect(20, 600, 344 * Math.min(1, cycle / 3), 4)
valTex.texture.needsUpdate = true
}
drawValuation(0)
const valScreen = new THREE.Group()
valuation.add(valScreen)
valScreen.position.set(-1.0, 1.62, 0.18)
valScreen.rotation.y = 0.42
box(valScreen, 0.62, 1.04, 0.055, 0, 0, 0, M.dark, 0.035)
screen(valScreen, 0.56, 0.94, 0, 0, 0.031, valTex)
cyl(valuation, 0.035, 0.42, -0.95, 0.99, 0.12, M.chrome)
// ── 03 LISTING STUDIO: camera on a tripod, a softbox flash, a publishing console ──
const listing = stations[1].group
box(listing, 1.9, 0.66, 1.36, 0, 0.67, -0.04, S.body, 0.11)
const consoleTop = new THREE.Group()
consoleTop.position.set(0, 1.01, -0.08)
consoleTop.rotation.x = -0.32
listing.add(consoleTop)
box(consoleTop, 1.76, 0.12, 1.27, 0, 0, 0, S.ivory, 0.04)
const listingTex = canvasTexture(640, 340, () => {})
function drawListing(t: number) {
const c = listingTex.ctx
c.fillStyle = C.screen
c.fillRect(0, 0, 640, 340)
paintHousePhoto(c, 20, 20, 268, 186)
print(c, 'JUST LISTED', 312, 52, 18, C.brass, 650)
print(c, money(DEAL.listPrice), 310, 104, 42, C.paper, 650)
print(c, '4 bd · 3 ba · 2,180 sqft', 312, 140, 19, '#c6ccc9')
print(c, DEAL.address, 312, 172, 17, C.muted)
;['MLS', 'Portals', 'Social'].forEach((ch, i) => {
const y = 226 + i * 36,
p = Math.min(1, Math.max(0, (t * 0.18 - i * 0.22) % 1.4))
print(c, ch, 22, y + 18, 16, '#c6ccc9', 550)
c.fillStyle = '#26323f'
c.fillRect(110, y + 9, 400, 7)
c.fillStyle = p >= 1 ? C.sage : C.brass
c.fillRect(110, y + 9, p * 400, 7)
print(c, p >= 1 ? 'LIVE' : 'SENDING', 530, y + 18, 13, p >= 1 ? C.sage : C.muted, 650)
})
listingTex.texture.needsUpdate = true
}
drawListing(0)
const ls = screen(consoleTop, 1.53, 0.79, 0, 0.067, -0.17, listingTex)
ls.rotation.x = -Math.PI / 2
const toggles: THREE.Mesh[] = []
for (let i = 0; i < 4; i++) {
const x = -0.57 + i * 0.38
cyl(consoleTop, 0.074, 0.035, x, 0.1, 0.45, M.dark)
const t = cyl(consoleTop, 0.025, 0.15, x, 0.19, 0.45, M.chrome)
t.rotation.x = -0.4
t.userData.moving = true
toggles.push(t)
}
// Tripod and camera at the back right, aimed at the console's photo.
const camX = 0.72,
camZ = -0.5
for (let k = 0; k < 3; k++) {
const a = (k / 3) * TAU + 0.4
tube(
listing,
[
[camX + Math.cos(a) * 0.26, 1.0, camZ + Math.sin(a) * 0.2],
[camX + Math.cos(a) * 0.13, 1.3, camZ + Math.sin(a) * 0.1],
[camX, 1.58, camZ],
],
0.018,
M.chrome
)
}
box(listing, 0.32, 0.22, 0.2, camX, 1.7, camZ, M.dark, 0.03)
box(listing, 0.1, 0.06, 0.08, camX - 0.08, 1.84, camZ, M.dark, 0.015)
const lens = cyl(listing, 0.075, 0.16, camX, 1.7, camZ + 0.17, M.chrome)
lens.rotation.x = Math.PI / 2
// Softbox flash on a stand, back left; it fires on the beat.
const flashMat = M.whiteLight.clone()
cyl(listing, 0.028, 1.1, -0.72, 1.45, -0.55, M.chrome)
box(listing, 0.44, 0.34, 0.1, -0.72, 2.05, -0.55, M.dark, 0.03)
box(listing, 0.38, 0.28, 0.02, -0.72, 2.05, -0.495, flashMat, 0.01)
cyl(listing, 0.075, 0.06, 0.82, 1.0, 0.52, M.green)
for (let i = 0; i < 7; i++)
box(listing, 0.55, 0.027, 0.02, 0, 0.46 + i * 0.05, 0.655, M.dark, 0.003)
// ── 04 SHOWINGS & OFFERS: the offer board, a For Sale sign, an offer card in the air ──
const offers = stations[2].group
box(offers, 1.35, 0.18, 0.88, 0, 0.44, 0, S.ivory, 0.045)
cyl(offers, 0.095, 1.04, 0, 0.93, -0.31, M.chrome)
box(offers, 0.56, 0.91, 0.14, 0, 0.99, -0.34, S.body, 0.04)
box(offers, 2.42, 1.72, 0.2, 0, 1.94, -0.17, S.ivory, 0.08)
box(offers, 2.28, 1.59, 0.1, 0, 1.94, -0.044, M.dark, 0.045)
const boardTex = canvasTexture(896, 592, (c, w, h) => {
c.fillStyle = C.screen
c.fillRect(0, 0, w, h)
paintHousePhoto(c, 28, 28, 250, 330)
print(c, 'SHOWINGS', 28, 398, 14, C.muted, 600)
print(c, '18', 28, 446, 44, C.paper, 650)
print(c, 'this week', 92, 444, 16, C.muted)
print(c, 'SAVES', 28, 492, 14, C.muted, 600)
print(c, '241', 28, 540, 44, C.paper, 650)
print(c, 'OFFERS', 310, 62, 20, C.muted, 650)
print(c, DEAL.address, 310, 96, 26, C.paper, 600)
print(c, `Listed at ${money(DEAL.listPrice)}`, 310, 128, 18, C.muted)
const rows: Array<[string, number, string, boolean]> = [
['Offer A', DEAL.offer, 'Cash · 14-day close · no contingencies', true],
['Offer B', 748500, 'Conventional · 30 days · inspection', false],
['Offer C', 741000, 'FHA · 45 days · appraisal, inspection', false],
]
rows.forEach(([name, amount, terms, best], i) => {
const y = 160 + i * 128
roundRect(c, 304, y, 564, 110, 10, best ? '#2a2417' : C.row)
if (best) {
c.strokeStyle = C.brass
c.lineWidth = 3
c.beginPath()
c.roundRect(304, y, 564, 110, 10)
c.stroke()
}
print(c, name, 328, y + 40, 20, best ? C.brass : '#c6ccc9', 600)
print(c, money(amount), 328, y + 84, 36, C.paper, 650)
print(c, terms, 560, y + 84, 15, C.muted)
if (best) {
roundRect(c, 744, y + 18, 104, 32, 16, C.brass)
print(c, 'STRONGEST', 757, y + 40, 14, C.ink, 700)
}
})
print(c, `${money(DEAL.offer - DEAL.listPrice)} over asking`, 310, 568, 18, C.sage, 600)
})
screen(offers, 2.16, 1.43, 0, 1.95, 0.011, boardTex)
box(offers, 0.21, 0.019, 0.008, 0, 1.149, 0.013, M.light, 0.003)
// For Sale yard sign, front left.
cyl(offers, 0.022, 1.05, -0.84, 0.82, 0.6, M.ivory)
box(offers, 0.5, 0.035, 0.035, -0.62, 1.32, 0.6, M.ivory, 0.008)
for (const sx of [-0.78, -0.46]) cyl(offers, 0.006, 0.08, sx, 1.27, 0.6, M.chrome)
box(offers, 0.46, 0.3, 0.02, -0.62, 1.09, 0.6, M.ivory, 0.01)
const signTex = canvasTexture(256, 168, (c, w, h) => {
c.fillStyle = C.brass
c.fillRect(0, 0, w, h)
c.fillStyle = C.ink
c.fillRect(10, 10, w - 20, h - 20)
print(c, 'FOR', 26, 70, 40, C.brass, 750)
print(c, 'SALE', 26, 120, 52, C.paper, 750)
print(c, options.brand.toUpperCase().slice(0, 22), 26, 146, 12, C.muted, 600)
})
screen(offers, 0.44, 0.28, -0.62, 1.09, 0.611, signTex)
const flyOffer = new THREE.Group()
flyOffer.userData.moving = true
offers.add(flyOffer)
box(flyOffer, 0.59, 0.77, 0.045, 0.94, 0.81, 0.55, M.ivory, 0.025)
const offerCardTex = canvasTexture(220, 290, (c, w, h) => {
c.fillStyle = C.paper
c.fillRect(0, 0, w, h)
print(c, 'OFFER', 18, 40, 22, '#2a3038', 700)
print(c, 'ACCEPTED', 18, 68, 22, '#2a3038', 700)
c.fillStyle = C.brass
c.fillRect(18, 88, 184, 4)
print(c, '$755K', 18, 150, 50, '#2a3038', 700)
print(c, 'Cash · 14 days', 18, 184, 17, '#6b7480')
c.strokeStyle = '#2a3038'
c.lineWidth = 2.5
c.beginPath()
c.moveTo(22, 246)
c.bezierCurveTo(48, 206, 62, 270, 92, 236)
c.bezierCurveTo(112, 214, 128, 262, 176, 232)
c.stroke()
print(c, 'signed by buyer', 18, 274, 13, '#8a919a')
})
screen(flyOffer, 0.55, 0.72, 0.94, 0.81, 0.575, offerCardTex)
// ── 01 LEAD DESK: the inbox where every deal starts ──
const leads = stations[3].group
box(leads, 1.75, 1.77, 1.02, 0, 1.24, -0.13, S.body, 0.12)
box(leads, 1.58, 0.13, 1.09, 0, 2.16, -0.13, S.amber, 0.04)
box(leads, 1.47, 1.38, 0.055, 0, 1.31, 0.405, M.dark, 0.025)
const leadsTex = canvasTexture(480, 460, (c, w, h) => {
c.fillStyle = C.screen
c.fillRect(0, 0, w, h)
print(c, 'NEW LEADS', 30, 51, 27, '#e6e6dc', 600)
print(c, 'Today · 3 new · avg reply 42 sec', 30, 81, 16, C.muted)
const people: Array<[string, string, string]> = [
[DEAL.seller, 'Seller · 3 bd home', C.brass],
['James Park', 'Buyer · pre-approved', '#b9c4a8'],
['Dana Reyes', 'Seller · wants a CMA', '#7f97a3'],
]
people.forEach(([n, note, color], i) => {
const y = 110 + i * 100
roundRect(c, 22, y, 436, 83, 8, C.row)
c.fillStyle = color
c.beginPath()
c.arc(58, y + 40, 19, 0, TAU)
c.fill()
print(c, n[0], 51, y + 47, 20, C.ink, 650)
print(c, n, 93, y + 33, 23, '#e6e8dc', 550)
print(c, note, 93, y + 58, 15, C.muted)
print(c, ['NEW', 'TOUR SAT', 'CMA SENT'][i], 360, y + 47, 13, i ? C.muted : C.brass, 700)
})
})
screen(leads, 1.31, 1.255, 0, 1.37, 0.44, leadsTex)
box(leads, 1.27, 0.075, 0.29, 0, 0.57, 0.55, M.chrome, 0.02)
for (let i = 0; i < 3; i++)
box(leads, 1.15, 0.021, 0.12, 0, 0.58 + i * 0.15, -0.35, M.copper, 0.006)
tube(
leads,
[
[0.69, 0.43, -0.2],
[0.89, 0.65, -0.2],
[0.89, 1.8, -0.2],
[0.65, 1.99, -0.2],
],
0.033,
M.chrome
)
const incoming = new THREE.Group()
incoming.userData.moving = true
leads.add(incoming)
box(incoming, 0.86, 0.42, 0.043, 0, 0, 0, M.light, 0.035)
const nameTex = canvasTexture(432, 204, (c, w, h) => {
c.fillStyle = C.brass
c.fillRect(0, 0, w, h)
c.fillStyle = C.ink
c.beginPath()
c.arc(58, 98, 32, 0, TAU)
c.fill()
print(c, 'M', 42, 111, 32, C.brass, 650)
print(c, DEAL.seller, 108, 91, 36, C.ink, 650)
print(c, 'Seller lead +', 108, 138, 23, '#4d3814', 550)
})
screen(incoming, 0.82, 0.39, 0, 0, 0.026, nameTex)
// ── 05 CLOSING: escrow terminal, closing statement, the keys drop into the tray ──
const closing = stations[4].group
box(closing, 1.91, 0.63, 1.32, 0, 0.65, -0.06, S.ivory, 0.12)
box(closing, 1.96, 0.19, 1.38, 0, 0.42, -0.04, S.body, 0.04)
box(closing, 1.37, 0.09, 0.038, 0, 0.44, 0.66, M.dark, 0.01)
box(closing, 0.37, 0.042, 0.03, 0, 0.45, 0.687, M.chrome, 0.009)
box(closing, 1.02, 0.25, 0.91, -0.33, 1.04, -0.03, S.body, 0.045)
for (let row = 0; row < 3; row++)
for (let col = 0; col < 3; col++)
box(
closing,
0.19,
0.085,
0.17,
-0.62 + col * 0.27,
1.21,
0.27 - row * 0.24,
col === 2 && row === 2 ? M.amber : M.ivory,
0.022
)
box(closing, 0.64, 0.6, 0.52, 0.58, 1.04, -0.15, S.body, 0.045)
box(closing, 0.48, 0.07, 0.09, 0.59, 1.37, -0.1, M.dark, 0.01)
cyl(closing, 0.06, 0.65, -0.35, 1.6, -0.56, M.chrome)
box(closing, 1.13, 0.46, 0.19, -0.35, 1.98, -0.56, S.body, 0.045)
const escrowTex = canvasTexture(512, 176, (c) => {
c.fillStyle = '#10201a'
c.fillRect(0, 0, 512, 176)
print(c, 'CLOSED · COMMISSION PAID', 22, 44, 21, C.sage, 600)
print(c, '+ ' + money(commission), 24, 128, 62, '#eceec9', 600)
})
screen(closing, 1.015, 0.349, -0.35, 1.98, -0.459, escrowTex)
const statement = new THREE.Group()
statement.position.set(0.59, 1.37, -0.1)
statement.userData.moving = true
closing.add(statement)
const statementTex = canvasTexture(280, 540, (c, w, h) => {
c.fillStyle = C.paper
c.fillRect(0, 0, w, h)
print(c, 'CLOSING', 25, 50, 24, '#2f3a40', 700)
print(c, 'STATEMENT', 25, 80, 24, '#2f3a40', 700)
print(c, DEAL.address, 25, 110, 16, '#5d6870')
c.strokeStyle = '#8a9189'
c.setLineDash([5, 6])
c.beginPath()
c.moveTo(22, 132)
c.lineTo(258, 132)
c.stroke()
c.setLineDash([])
print(c, 'Sale price', 25, 168, 17, '#5d6870')
print(c, money(DEAL.offer), 25, 196, 22, '#2f3a40', 650)
print(c, `Commission ${DEAL.commissionRate * 100}%`, 25, 236, 17, '#5d6870')
print(c, money(commission), 25, 264, 22, '#2f3a40', 650)
print(c, 'RECORDED', 25, 322, 28, C.brassDeep, 750)
c.strokeStyle = '#2f3a40'
c.lineWidth = 2
c.beginPath()
c.moveTo(28, 400)
c.bezierCurveTo(60, 352, 80, 420, 118, 380)
c.bezierCurveTo(140, 356, 160, 410, 236, 372)
c.stroke()
c.fillStyle = '#aab0aa'
c.fillRect(25, 418, 230, 1.5)
print(c, 'Seller signature', 25, 442, 13, '#8a919a')
print(c, 'Escrow No. 0001', 25, 500, 16, '#5d6870')
})
const sp = screen(statement, 0.41, 0.83, 0, 0.415, 0.01, statementTex)
sp.material.side = THREE.DoubleSide
statement.rotation.x = -0.16
// House keys on a brass ring: they fall into the tray when the deal records.
const keys = new THREE.Group()
keys.userData.moving = true
closing.add(keys)
const ring = new THREE.Mesh(new THREE.TorusGeometry(0.11, 0.016, 8, 32), M.chrome)
keys.add(ring)
for (const [tilt, m] of [
[-0.35, M.amber],
[0.4, M.chrome],
] as Array<[number, Material]>) {
const k = new THREE.Group()
k.position.set(0, -0.1, 0)
k.rotation.z = tilt
keys.add(k)
const bow = new THREE.Mesh(new THREE.TorusGeometry(0.065, 0.024, 8, 24), m)
bow.position.y = -0.06
k.add(bow)
box(k, 0.045, 0.3, 0.025, 0, -0.27, 0, m, 0.008)
for (let j = 0; j < 3; j++) box(k, 0.05, 0.03, 0.022, 0.04, -0.32 + j * 0.06 - (j % 2) * 0.01, 0, m, 0.004)
}
const tag = new THREE.Group()
tag.position.set(0.1, -0.16, 0.01)
tag.rotation.z = 0.15
keys.add(tag)
box(tag, 0.14, 0.2, 0.012, 0, 0, 0, M.ivory, 0.01)
const tagTex = canvasTexture(96, 136, (c) => {
c.fillStyle = C.paper
c.fillRect(0, 0, 96, 136)
print(c, '412', 14, 62, 30, C.ink, 700)
print(c, 'OAK', 14, 98, 24, C.brassDeep, 700)
})
const tagFace = screen(tag, 0.13, 0.185, 0, 0, 0.007, tagTex)
tagFace.material.side = THREE.DoubleSide
cyl(closing, 0.33, 0.09, 1.04, 0.39, 0.55, M.dark)
cyl(closing, 0.26, 0.025, 1.04, 0.445, 0.55, M.copper)
// Conveyor: a closed spline, instanced treads, continuous rails and phase-locked cargo.
const path = new THREE.CatmullRomCurve3(
[
[-3.95, 0.84, 0.65],
[-3.1, 0.84, -0.12],
[-1.45, 0.84, -0.79],
[1.32, 0.84, -0.8],
[3.3, 0.84, 0.19],
[3.43, 0.84, 1.21],
[1.35, 0.84, 2.7],
[-1.4, 0.84, 2.52],
[-3.54, 0.84, 1.65],
].map((p) => new THREE.Vector3(...(p as Vec3))),
true,
'catmullrom',
0.25
)
const belt = new THREE.Group()
machine.add(belt)
const frameMesh = new THREE.Mesh(new THREE.TubeGeometry(path, 150, 0.35, 8, true), M.dark)
frameMesh.scale.y = 0.3
frameMesh.position.y = 0.51
belt.add(frameMesh)
const beltCount = 148,
beltSlats = new THREE.InstancedMesh(boxGeo(0.135, 0.065, 0.63, 0.012), M.body, beltCount)
beltSlats.receiveShadow = true
beltSlats.castShadow = false
belt.add(beltSlats)
beltSlats.instanceMatrix.setUsage(THREE.DynamicDrawUsage)
const dummy = new THREE.Object3D(),
pVec = new THREE.Vector3(),
tVec = new THREE.Vector3()
function updateBelt(t: number) {
for (let i = 0; i < beltCount; i++) {
const u = (i / beltCount + t * 0.012) % 1
path.getPointAt(u, pVec)
path.getTangentAt(u, tVec)
dummy.position.copy(pVec)
dummy.rotation.set(0, -Math.atan2(tVec.z, tVec.x), 0)
dummy.updateMatrix()
beltSlats.setMatrixAt(i, dummy.matrix)
}
beltSlats.instanceMatrix.needsUpdate = true
}
updateBelt(0)
for (const side of [-1, 1]) {
const pts = []
for (let i = 0; i <= 160; i++) {
path.getPointAt(i / 160, pVec)
path.getTangentAt(i / 160, tVec)
pts.push(
pVec.clone().add(new THREE.Vector3(-tVec.z * 0.36 * side, 0.09, tVec.x * 0.36 * side))
)
}
const railPath = new THREE.CatmullRomCurve3(pts)
belt.add(new THREE.Mesh(new THREE.TubeGeometry(railPath, 160, 0.028, 6, false), M.chrome))
}
for (let i = 0; i < 16; i++) {
const p = path.getPointAt(i / 16)
cyl(belt, 0.045, 0.43, p.x, 0.53, p.z, M.chrome)
}
// Bronze lines link the station footings independently from the conveyor.
const pipes = new THREE.Group()
machine.add(pipes)
for (let i = 0; i < 4; i++) {
const a = stations[i].base,
b = stations[i + 1].base
const points = [
a.clone().add(new THREE.Vector3(0.4, 0.12, 0)),
a
.clone()
.lerp(b, 0.35)
.add(new THREE.Vector3(0, 0.1, -0.6)),
a
.clone()
.lerp(b, 0.65)
.add(new THREE.Vector3(0, 0.1, -0.6)),
b.clone().add(new THREE.Vector3(-0.4, 0.12, 0)),
]
const curve = new THREE.CatmullRomCurve3(points)
pipes.add(new THREE.Mesh(new THREE.TubeGeometry(curve, 30, 0.054, 8, false), M.copper))
}
// The cargo card changes face at each station: lead → CMA → listing → offer → statement.
const leadCardTex = canvasTexture(256, 352, (c, w, h) => {
c.fillStyle = C.brass
c.fillRect(0, 0, w, h)
print(c, 'NEW', 21, 48, 24, C.ink, 700)
print(c, 'LEAD', 21, 79, 24, C.ink, 700)
c.strokeStyle = '#6e4d1c'
c.lineWidth = 2
c.strokeRect(23, 112, 210, 139)
print(c, 'Maria', 42, 172, 34, C.ink, 600)
print(c, 'Lopez', 42, 214, 34, C.ink, 600)
print(c, 'SELLER / 01', 23, 320, 17, '#5a3f15', 600)
})
const cmaTex = canvasTexture(256, 352, (c, w, h) => {
c.fillStyle = '#eeeae0'
c.fillRect(0, 0, w, h)
print(c, 'CMA', 24, 47, 24, '#2f3a40', 700)
print(c, 'Suggested list', 24, 82, 15, '#7d8580')
print(c, money(DEAL.listPrice), 24, 120, 30, '#2f3a40', 700)
for (let i = 0; i < 5; i++) {
c.fillStyle = i === 2 ? C.brass : '#aeb2a5'
c.fillRect(24, 150 + i * 26, 70 + ((i * 37) % 5) * 26, 12)
}
print(c, 'DONE ✓', 24, 327, 17, '#5d7a52', 700)
})
const listingCardTex = canvasTexture(256, 352, (c, w, h) => {
c.fillStyle = C.paper
c.fillRect(0, 0, w, h)
paintHousePhoto(c, 14, 14, 228, 170)
print(c, 'JUST LISTED', 20, 222, 18, C.brassDeep, 700)
print(c, money(DEAL.listPrice), 20, 262, 30, '#2f3a40', 700)
print(c, '4 bd · 3 ba', 20, 296, 17, '#6b7480')
print(c, '2,180 sqft', 20, 322, 17, '#6b7480')
})
const packetTextures = [leadCardTex, cmaTex, listingCardTex, offerCardTex, statementTex]
const packets: Array<{
group: THREE.Group
faces: THREE.Mesh[]
halo: THREE.Mesh
stage: number
phase: number
}> = []
for (let i = 0; i < 6; i++) {
const g = new THREE.Group()
g.userData.moving = true
machine.add(g)
box(g, 0.47, 0.71, 0.04, 0, 0, 0, M.ivory, 0.022)
const faces = packetTextures.map((tex) => {
const s = screen(g, 0.43, 0.665, 0, 0, 0.024, tex)
s.material.side = THREE.DoubleSide
s.visible = false
return s
})
const halo = new THREE.Mesh(
new THREE.RingGeometry(0.4, 0.414, 40),
new THREE.MeshBasicMaterial({
color: 0xe0a14a,
transparent: true,
opacity: 0.8,
side: THREE.DoubleSide,
depthWrite: false,
})
)
halo.rotation.x = -Math.PI / 2
halo.position.y = -0.37
g.add(halo)
halo.visible = false
packets.push({ group: g, faces, halo, stage: -1, phase: 0 })
}
// Find ports by arc length, so the travelling deal arrives at the actual station.
function nearestPort(x: number, z: number) {
let best = 0,
dist = Infinity
for (let i = 0; i < 300; i++) {
const p = path.getPointAt(i / 300),
d = (p.x - x) ** 2 + (p.z - z) ** 2
if (d < dist) {
dist = d
best = i / 300
}
}
return best
}
const listingU = nearestPort(-1.65, -0.7)
const offersU = nearestPort(1.5, -0.7)
const closingU = nearestPort(0.93, 2.65)
// Merge static geometry per parent/material. Moving assemblies remain separate.
function compact(group: THREE.Object3D) {
for (const child of [...group.children]) if ((child as THREE.Group).isGroup) compact(child)
const buckets = new Map[]>()
for (const object of group.children) {
const child = object as THREE.Mesh & {
isInstancedMesh?: boolean
}
if (
!child.isMesh ||
child.isInstancedMesh ||
child.userData.moving ||
Array.isArray(child.material)
)
continue
const key = child.material.uuid
if (!buckets.has(key)) buckets.set(key, [])
buckets.get(key)!.push(child)
}
for (const list of buckets.values()) {
if (list.length < 2) continue
const geos = list.map((m) => {
m.updateMatrix()
const g = m.geometry.index ? m.geometry.toNonIndexed() : m.geometry.clone()
return g.applyMatrix4(m.matrix)
})
const merged = mergeGeometries(geos, false)
for (const geo of geos) geo.dispose()
if (!merged) continue
const mesh = new THREE.Mesh(merged, list[0].material)
mesh.castShadow = list.some((x) => x.castShadow)
mesh.receiveShadow = list.some((x) => x.receiveShadow)
for (const m of list) group.remove(m)
group.add(mesh)
}
}
;[incoming, statement, keys, flyOffer, scanner, turntable].forEach(
(g) => (g.userData.moving = true)
)
compact(machine)
stations.forEach((s) =>
s.group.traverse((o) => {
o.userData.station = s.id
})
)
const pickables = stations.map((s) => s.group)
let mode: MachineMode = 'assembled',
cameraMode: MachineCamera = 'overview',
playing = !reduceMotion,
simTime = 0,
spread = 0,
selected: string = 'valuation',
hovered: string | null = null
let width = frameWidth(),
height = frameHeight(),
mobile = width <= 900,
lastInteraction = performance.now(),
dragging = false,
wasDragged = false,
downX = 0,
downY = 0
let cameraAnimating = true,
flightTime = 0,
lastDraw = -1,
visible = true,
contextLost = false
const desiredPosition = new THREE.Vector3(),
desiredTarget = new THREE.Vector3(0, 1, 0)
const viewDirection = new THREE.Vector3(10.5, 10.8, 17).normalize()
let baseDistance = 25,
sized = false,
readySent = false
// Wait for a real size: a zero-size frame would put NaN into the camera.
function layoutCamera() {
if (!frameWidth() || !frameHeight()) return
const first = !sized
sized = true
width = frameWidth()
height = frameHeight()
mobile = width <= 900
renderer.setSize(width, height)
camera.aspect = width / height
// In a wide landing hero, a shifted frustum centers the machine in the right 58%.
camera.setViewOffset(
width,
height,
mobile || !embedded ? 0 : -width * 0.21,
mobile || embedded ? 0 : height * 0.025,
width,
height
)
const aspect = width / height
const availableWidth = mobile ? 0.91 : Math.min(0.55, aspect > 2 ? 0.54 : 0.57)
const horizontalFit =
17.3 / (2 * Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) * aspect * availableWidth)
const verticalFit =
11.5 / (2 * Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) * (embedded ? 0.85 : 0.62))
baseDistance =
(Math.max(horizontalFit, verticalFit) * (mobile ? 0.97 : 1)) /
(embedded ? (mobile ? 1.15 : 1.45) : 1)
controls.maxDistance = Math.max(55, baseDistance * 1.6)
camera.updateProjectionMatrix()
setCameraGoal()
cameraAnimating = true
if (first) {
camera.position.copy(desiredPosition)
controls.target.copy(desiredTarget)
controls.update()
cameraAnimating = false
}
}
function setCameraGoal() {
const expand = mode === 'stations' ? 1.2 : 1
if (cameraMode === 'station') {
const s = stations.find((s) => s.id === selected)!
desiredTarget.copy(s.group.position).add(new THREE.Vector3(0, 1.25, 0))
desiredPosition.copy(desiredTarget).addScaledVector(viewDirection, mobile ? 9 : 12)
} else {
desiredTarget.set(0, 1, 0)
const distance = baseDistance * expand
if (cameraMode === 'side')
desiredPosition.set(13, 5, 20).normalize().multiplyScalar(distance).add(desiredTarget)
else if (cameraMode === 'top') desiredPosition.set(0.01, distance, 0.8).add(desiredTarget)
else desiredPosition.copy(viewDirection).multiplyScalar(distance).add(desiredTarget)
}
}
function syncButtons() {
root
.querySelectorAll('[data-mode]')
.forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.mode === mode)))
root
.querySelectorAll('[data-camera]')
.forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.camera === cameraMode)))
$('journey').classList.toggle('visible', mode === 'order')
}
const modeAliases: Record = {
Assembled: 'assembled',
Cutaway: 'cutaway',
Stations: 'stations',
'One deal': 'order',
assembled: 'assembled',
cutaway: 'cutaway',
stations: 'stations',
order: 'order',
deal: 'order',
}
const cameraAliases: Record = {
Overview: 'overview',
Side: 'side',
Top: 'top',
Station: 'station',
Flight: 'flight',
overview: 'overview',
side: 'side',
top: 'top',
station: 'station',
flight: 'flight',
}
function setMode(name: string) {
if (!Object.prototype.hasOwnProperty.call(modeAliases, name)) return false
mode = modeAliases[name]
lastInteraction = performance.now()
if (mode === 'order') {
simTime = 0
lastDraw = -1
play()
cameraMode = 'overview'
} else if (cameraMode === 'station') cameraMode = 'overview'
setCameraGoal()
cameraAnimating = true
syncButtons()
return true
}
function focusStation(id: string) {
const s = stations.find((s) => s.id === id)
if (!s) return false
selected = id
if (mode === 'order') mode = 'assembled'
cameraMode = 'station'
lastInteraction = performance.now()
setCameraGoal()
cameraAnimating = true
syncButtons()
return true
}
function setCamera(name: string) {
if (!Object.prototype.hasOwnProperty.call(cameraAliases, name)) return false
cameraMode = cameraAliases[name]
if (mode === 'order') mode = 'assembled'
lastInteraction = performance.now()
flightTime = 0
setCameraGoal()
cameraAnimating = true
syncButtons()
return true
}
function syncPlayback() {
const b = $('play')
b.setAttribute('aria-label', playing ? 'Pause the animation' : 'Resume the animation')
b.setAttribute('aria-pressed', String(!playing))
b.innerHTML = playing
? ''
: ''
$('status').classList.toggle('paused', !playing)
$('status-text').textContent = playing ? 'Pipeline running' : 'Pipeline paused'
}
function play() {
playing = true
syncPlayback()
return true
}
function pause() {
playing = false
syncPlayback()
return true
}
const api: MachineApi = { setMode, focusStation, setCamera, play, pause }
window.__machine = api
cleanups.push(() => {
if (window.__machine === api) delete window.__machine
})
root
.querySelectorAll('[data-mode]')
.forEach((b) => listen(b, 'click', () => setMode(b.dataset.mode ?? '')))
root
.querySelectorAll('[data-camera]')
.forEach((b) => listen(b, 'click', () => setCamera(b.dataset.camera ?? '')))
listen($('play'), 'click', () => (playing ? pause() : play()))
syncPlayback()
layoutCamera()
camera.position.copy(desiredPosition)
controls.target.copy(desiredTarget)
controls.update()
cameraAnimating = false
listen(window, 'resize', layoutCamera)
const resizeObserver = new ResizeObserver(() => layoutCamera())
resizeObserver.observe(root)
cleanups.push(() => resizeObserver.disconnect())
controls.addEventListener('start', () => {
dragging = true
cameraAnimating = false
lastInteraction = performance.now()
})
controls.addEventListener('end', () => {
dragging = false
lastInteraction = performance.now()
})
controls.addEventListener('change', () => {
if (dragging) lastInteraction = performance.now()
})
const raycaster = new THREE.Raycaster(),
pointer = new THREE.Vector2(),
tooltip = $('tooltip')
// Pointer position relative to the component, not the window.
function local(e: PointerEvent) {
const r = root.getBoundingClientRect()
return { x: e.clientX - r.left, y: e.clientY - r.top }
}
function hitStation(x: number, y: number) {
pointer.set((x / width) * 2 - 1, (-y / height) * 2 + 1)
raycaster.setFromCamera(pointer, camera)
const hits = raycaster.intersectObjects(pickables, true)
return hits.length ? stations.find((s) => s.id === hits[0].object.userData.station) : null
}
listen(renderer.domElement, 'pointermove', (e: PointerEvent) => {
const p = local(e)
if (Math.hypot(p.x - downX, p.y - downY) > 5) wasDragged = true
if (dragging) return
const s = hitStation(p.x, p.y)
hovered = s ? s.id : null
renderer.domElement.style.cursor = s ? 'pointer' : 'grab'
tooltip.classList.toggle('visible', !!s)
if (s) {
tooltip.querySelector('strong')!.innerHTML =
`${String(s.step).padStart(2, '0')}${s.name.replace('&', '&')}`
tooltip.querySelector('p')!.textContent = s.desc
tooltip.style.left = Math.min(width - 255, Math.max(10, p.x + 16)) + 'px'
tooltip.style.top = Math.max(10, Math.min(height - 95, p.y - 65)) + 'px'
}
})
listen(renderer.domElement, 'pointerleave', () => {
hovered = null
tooltip.classList.remove('visible')
})
listen(renderer.domElement, 'pointerdown', (e: PointerEvent) => {
const p = local(e)
downX = p.x
downY = p.y
wasDragged = false
tooltip.classList.remove('visible')
})
listen(renderer.domElement, 'pointerup', (e: PointerEvent) => {
if (wasDragged) return
const p = local(e)
const s = hitStation(p.x, p.y)
if (!s) return
focusStation(s.id)
options.onStation?.(s.id)
})
listen(document, 'visibilitychange', () => {
visible = !document.hidden
lastFrame = performance.now()
})
const observer = new IntersectionObserver(
(entries) => {
visible = entries[0].isIntersecting && !document.hidden
lastFrame = performance.now()
},
{ threshold: 0.01 }
)
observer.observe(renderer.domElement)
cleanups.push(() => observer.disconnect())
listen(renderer.domElement, 'webglcontextlost', (e: Event) => {
e.preventDefault()
contextLost = true
showError(
'The graphics context was interrupted. The scene comes back on its own once WebGL is available again.'
)
})
listen(renderer.domElement, 'webglcontextrestored', () => {
contextLost = false
$('error').style.display = 'none'
lastFrame = performance.now()
})
const scratch = new THREE.Vector3(),
anchor = new THREE.Vector3()
const journeySteps = [
['New lead', 'Lead Desk · Maria wants to sell'],
['Pricing the home', 'Valuation · comps in, estimate out'],
['Going live', 'Listing Studio · photos, copy, MLS'],
['Offers coming in', 'Showings & Offers · the strongest one accepted'],
['Closed', `Closing · keys handed over, ${money(commission)} in`],
]
let prevJourney = -1,
lastFrame = performance.now(),
frameCount = 0,
measureTime = 0,
pixelRatio = renderer.getPixelRatio()
let rafId = 0
function animate(now: number) {
rafId = requestAnimationFrame(animate)
const dt = Math.max(0, Math.min((now - lastFrame) / 1000, 0.045))
lastFrame = now
if (!visible || contextLost) return
if (playing) {
simTime += dt
flightTime += dt
}
const t = simTime,
beat = (t / 4) % 1,
tact = (t * TAU) / 4,
smooth = 1 - Math.exp(-dt * 5)
spread = THREE.MathUtils.lerp(spread, mode === 'stations' ? 1 : 0, smooth)
cutPlane.constant = THREE.MathUtils.lerp(
cutPlane.constant,
mode === 'cutaway' ? 0.99 : 10,
smooth
)
stations.forEach((s, i) => {
s.group.position.copy(s.base)
s.group.position.x *= 1 + spread * 0.29
s.group.position.z *= 1 + spread * 0.37
s.group.position.y += spread * (i % 2 ? 0.32 : 0.55)
const pulse = Math.pow(Math.max(0, Math.sin(tact - i * 0.9)), 7)
s.glowMat.emissiveIntensity = THREE.MathUtils.lerp(
s.glowMat.emissiveIntensity,
hovered === s.id || (cameraMode === 'station' && selected === s.id)
? 3.5
: 0.55 + pulse * 0.65,
smooth
)
})
belt.scale.set(1 + spread * 0.12, 1, 1 + spread * 0.15)
pipes.scale.set(1 + spread * 0.25, 1, 1 + spread * 0.3)
gears.forEach(({ g }, i) => (g.rotation.y = t * (i % 2 ? -1 : 1) * 1.1))
// Valuation: the house turns slowly under a scanner that sweeps across it.
turntable.rotation.y = t * 0.45
scanner.position.x = Math.sin(tact) * 0.42
laserMat.opacity = 0.35 + Math.abs(Math.sin(t * 7)) * 0.3
// Listing: the softbox fires once per beat.
flashMat.emissiveIntensity = 0.5 + Math.pow(Math.max(0, 1 - ((t * 0.5) % 1) * 6), 2) * 6
flyOffer.position.y = Math.sin(tact) * 0.04
const orderPhase = (t / 24) % 1,
closeBeat =
mode === 'order' ? THREE.MathUtils.clamp((orderPhase - 0.88) / 0.12, 0, 1) : beat
statement.visible = keys.visible = mode !== 'order' || orderPhase > 0.88
statement.scale.y = 0.2 + Math.min(1, closeBeat * 1.5) * 0.8
const fall = Math.min(1, closeBeat * 1.7) ** 2
keys.position.set(1.04, 2.85 - fall * 2.2, 0.55)
keys.rotation.y = t * 2.4
keys.rotation.z = Math.sin(t * 5) * 0.12 * (1 - fall)
keys.scale.setScalar(closeBeat > 0.9 ? 1 - (closeBeat - 0.9) * 8 : 1)
const incomingBeat = mode === 'order' ? Math.min(1, orderPhase / 0.15) : beat
incoming.visible = mode !== 'order' || orderPhase < 0.15
incoming.position.set(
Math.sin(incomingBeat * Math.PI) * 0.24,
2.9 - incomingBeat * 1.9,
1.1 - incomingBeat * 0.55
)
incoming.rotation.z = Math.sin(incomingBeat * Math.PI) * -0.14
incoming.scale.setScalar(Math.min(1, incomingBeat * 8 + 0.15, (1 - incomingBeat) * 7 + 0.1))
toggles.forEach((o, i) => (o.rotation.x = Math.sin(tact + i) > 0 ? -0.4 : 0.4))
if (t - lastDraw > 1 / 18 || lastDraw < 0) {
drawValuation(t)
drawListing(t)
updateBelt(t)
lastDraw = t
}
packets.forEach((packet, i) => {
const phase = (t / 24 + i / 6) % 1
packet.phase = phase
let stage
if (phase < 0.15) {
// The lead arcs from the Lead Desk over to Valuation.
stage = 0
const f = phase / 0.15
packet.group.position.copy(stations[3].group.position).add(new THREE.Vector3(0, 1.6, 0.6))
scratch.copy(stations[0].group.position).add(new THREE.Vector3(0, 1.3, 0.65))
packet.group.position.lerp(scratch, f)
packet.group.position.y += Math.sin(f * Math.PI) * 2
packet.group.rotation.set(0, Math.sin(f * Math.PI) * 0.28, Math.sin(f * Math.PI) * -0.1)
} else {
const u = ((phase - 0.15) / 0.85) * closingU
path.getPointAt(u, packet.group.position)
packet.group.position.x *= 1 + spread * 0.12
packet.group.position.z *= 1 + spread * 0.15
packet.group.position.y += 0.43
packet.group.rotation.set(0, 0.18, 0)
stage = u < listingU * 0.7 ? 1 : u < offersU * 0.96 ? 2 : u < closingU * 0.83 ? 3 : 4
}
if (packet.stage !== stage) {
packet.faces.forEach((f, j) => (f.visible = j === stage))
packet.stage = stage
}
packet.group.visible = mode !== 'order' || i === 0
packet.halo.visible = mode === 'order' && i === 0
packet.group.scale.setScalar(mode === 'order' ? 1.35 : 1)
if (phase > 0.94) packet.group.scale.multiplyScalar(Math.max(0.05, (1 - phase) / 0.06))
})
if (mode === 'order') {
const packet = packets[0],
step = packet.stage
if (step !== prevJourney) {
$('journey-title').textContent = journeySteps[step][0]
$('journey-detail').textContent = journeySteps[step][1]
prevJourney = step
}
$('journey-progress').style.width = packet.phase * 100 + '%'
if (!dragging && playing) {
desiredTarget.copy(packet.group.position)
desiredPosition.copy(desiredTarget).addScaledVector(viewDirection, mobile ? 10 : 15)
cameraAnimating = true
}
} else if (cameraMode === 'flight' && playing && !dragging) {
const a = flightTime * 0.12,
d = baseDistance * (mode === 'stations' ? 1.2 : 1)
desiredTarget.set(0, 1, 0)
desiredPosition
.set(
Math.sin(a + 0.55) * d * 0.83,
d * (0.5 + Math.sin(a * 0.7) * 0.09),
Math.cos(a + 0.55) * d * 0.83
)
.add(desiredTarget)
cameraAnimating = true
} else if (cameraMode === 'station' && cameraAnimating) setCameraGoal()
if (cameraAnimating && !dragging) {
const speed = 1 - Math.exp(-dt * (mode === 'order' ? 2.2 : 3))
camera.position.lerp(desiredPosition, speed)
controls.target.lerp(desiredTarget, speed)
if (
cameraMode !== 'flight' &&
mode !== 'order' &&
camera.position.distanceTo(desiredPosition) < 0.015 &&
controls.target.distanceTo(desiredTarget) < 0.015
)
cameraAnimating = false
}
controls.autoRotate =
playing &&
!reduceMotion &&
!dragging &&
!cameraAnimating &&
mode !== 'order' &&
cameraMode === 'overview' &&
now - lastInteraction > 6500
controls.autoRotateSpeed = 0.24
controls.update(dt)
// Project station callouts after the camera update, clamped inside the frame.
stations.forEach((s, i) => {
const show = mode === 'stations'
s.label.classList.toggle('visible', show)
if (!show) return
anchor.copy(s.group.position).add(s.anchor).project(camera)
const offsets = mobile
? [
[-30, 25],
[-27, -50],
[15, -65],
[16, -1],
[-5, 52],
]
: [
[-48, -14],
[-8, -64],
[30, -12],
[30, 20],
[-12, 40],
]
let x = (anchor.x * 0.5 + 0.5) * width - 40 + offsets[i][0],
y = (-anchor.y * 0.5 + 0.5) * height - 52 + offsets[i][1]
x = THREE.MathUtils.clamp(
x,
mobile || !embedded ? 9 : width * 0.425,
width - (mobile ? 140 : 190)
)
y = THREE.MathUtils.clamp(y, 130, height - 200)
s.label.style.transform = `translate(${x}px,${y}px)`
})
renderer.render(scene, camera)
if (!readySent && sized) {
readySent = true
options.onReady?.()
}
// Reduce only raster resolution on sustained slow devices; geometry and timing stay identical.
if (playing) {
frameCount++
measureTime += dt
if (measureTime > 4) {
if (frameCount / measureTime < 43 && pixelRatio > 1) {
pixelRatio = Math.max(1, pixelRatio - 0.25)
renderer.setPixelRatio(pixelRatio)
}
frameCount = 0
measureTime = 0
}
}
}
rafId = requestAnimationFrame(animate)
cleanups.push(() => {
cancelAnimationFrame(rafId)
controls.dispose()
const textures = new Set()
scene.traverse((object) => {
const mesh = object as THREE.Mesh
if (mesh.geometry) mesh.geometry.dispose()
const list = mesh.material
? Array.isArray(mesh.material)
? mesh.material
: [mesh.material]
: []
for (const material of list) {
for (const value of Object.values(material))
if (value instanceof THREE.Texture) textures.add(value)
material.dispose()
}
})
geometries.forEach((g) => g.dispose())
textures.forEach((t) => t.dispose())
env.dispose()
renderer.dispose()
renderer.forceContextLoss()
renderer.domElement.remove()
})
renderer.compile(scene, camera)
$('loading').classList.add('done')
$('error').style.display = 'none'
Object.defineProperty(window, '__machineDebug', {
value: {
getState: () => ({
mode,
camera: cameraMode,
playing,
time: simTime,
spread,
cutHeight: cutPlane.constant,
width,
height,
embedded,
drawCalls: renderer.info.render.calls,
triangles: renderer.info.render.triangles,
pixelRatio: renderer.getPixelRatio(),
}),
},
configurable: true,
})
cleanups.push(() => {
delete window.__machineDebug
})
} catch (error) {
console.error('Machine: failed to start', error)
showError()
}
return dispose
}
// ---------------------------------------------------------------------------
// Styles, scoped to .real-estate-machine-3d. Override --rem-font to change the face
// (the in-scene screens pick it up too).
// ---------------------------------------------------------------------------
const STYLES = String.raw`
.real-estate-machine-3d {
--bg: #07090d;
--white: #f2efe8;
--accent: #e0a14a;
--muted: #7f8893;
--line: rgba(242, 239, 232, 0.11);
color-scheme: dark;
position: relative;
width: 100%;
margin: 0;
overflow: hidden;
contain: layout;
background: var(--bg);
font-family: var(--rem-font, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif);
color: var(--white);
font-size: 13px;
-webkit-font-smoothing: antialiased;
}
.real-estate-machine-3d, .real-estate-machine-3d * { box-sizing: border-box; }
.real-estate-machine-3d button, .real-estate-machine-3d a { -webkit-tap-highlight-color: transparent; }
.real-estate-machine-3d button { font: inherit; color: inherit; cursor: pointer; }
.real-estate-machine-3d button:focus-visible, .real-estate-machine-3d a:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 5px;
}
.real-estate-machine-3d #scene { position: absolute; inset: 0; touch-action: none; outline: none; }
.real-estate-machine-3d #scene canvas { display: block; width: 100%; height: 100%; }
.real-estate-machine-3d .vignette {
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(ellipse at 50% 48%, transparent 30%, rgba(0, 0, 0, 0.12) 64%, rgba(0, 0, 0, 0.65) 100%);
}
.real-estate-machine-3d .topbar {
position: absolute;
inset: 28px 32px auto;
display: flex;
justify-content: space-between;
align-items: center;
pointer-events: none;
}
.real-estate-machine-3d .identity { display: flex; gap: 13px; align-items: center; }
.real-estate-machine-3d .mark {
width: 30px;
height: 30px;
border: 1px solid #5a4828;
border-radius: 9px;
display: grid;
place-items: center;
color: var(--accent);
background: #1a1710;
}
.real-estate-machine-3d .identity strong { display: block; font-weight: 600; letter-spacing: -0.2px; font-size: 13px; }
.real-estate-machine-3d .identity small {
display: block;
margin-top: 5px;
font-size: 9px;
letter-spacing: 1.55px;
color: #6f7882;
text-transform: uppercase;
}
.real-estate-machine-3d .status { display: flex; align-items: center; gap: 8px; font-size: 10px; color: #a3a9a8; }
.real-estate-machine-3d .status i {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 10px #e0a14a60;
}
.real-estate-machine-3d .status.paused i { background: #777; box-shadow: none; }
.real-estate-machine-3d .status span:last-child {
font-variant-numeric: tabular-nums;
color: #5f6871;
margin-left: 12px;
}
.real-estate-machine-3d .scene-heading {
position: absolute;
top: 110px;
left: 24%;
right: 24%;
pointer-events: none;
display: flex;
align-items: center;
gap: 12px;
font-size: 9px;
letter-spacing: 1.8px;
color: #929a9f;
text-transform: uppercase;
}
.real-estate-machine-3d .scene-heading:before { content: ''; width: 22px; height: 1px; background: var(--accent); }
.real-estate-machine-3d .scene-heading .index { margin-left: auto; color: #4b545e; letter-spacing: 1px; }
.real-estate-machine-3d .coordinates {
position: absolute;
top: 150px;
right: 33px;
color: #4f5862;
writing-mode: vertical-rl;
font: 9px ui-monospace, Menlo, SFMono-Regular, monospace;
letter-spacing: 1.5px;
pointer-events: none;
}
.real-estate-machine-3d .controls {
position: absolute;
bottom: 79px;
left: 26px;
right: 26px;
display: flex;
align-items: center;
flex-direction: column;
gap: 16px;
z-index: 5;
}
.real-estate-machine-3d .mode-bar {
display: flex;
gap: 3px;
align-items: center;
padding: 5px;
border: 1px solid #ffffff13;
border-radius: 12px;
background: rgba(22, 27, 34, 0.87);
box-shadow: 0 8px 30px #0003;
backdrop-filter: blur(16px);
}
.real-estate-machine-3d .mode-bar button {
border: 0;
background: none;
color: #a0a7ad;
white-space: nowrap;
padding: 11px 15px;
display: flex;
gap: 8px;
align-items: center;
font-size: 11px;
font-weight: 500;
border-radius: 8px;
transition: background 0.2s, color 0.2s;
}
.real-estate-machine-3d .mode-bar button:hover { background: #ffffff08; color: var(--white); }
.real-estate-machine-3d .mode-bar button[aria-pressed='true'] {
background: var(--accent);
color: #1f1708;
box-shadow: 0 2px 12px #e0a14a14;
}
.real-estate-machine-3d svg { flex-shrink: 0; display: block; }
.real-estate-machine-3d .mode-bar svg { width: 14px; height: 14px; }
.real-estate-machine-3d .camera-row { display: flex; align-items: center; justify-content: center; gap: 4px; }
.real-estate-machine-3d .camera-row .caption { font-size: 9px; letter-spacing: 1.4px; color: #525c67; margin-right: 9px; }
.real-estate-machine-3d .camera-row button {
border: 0;
background: transparent;
color: #6c7580;
padding: 5px 9px;
font-size: 10px;
transition: color 0.2s;
}
.real-estate-machine-3d .camera-row button:hover,
.real-estate-machine-3d .camera-row button[aria-pressed='true'] { color: var(--white); }
.real-estate-machine-3d .camera-row button[aria-pressed='true']:after {
content: '';
display: block;
width: 3px;
height: 3px;
background: var(--accent);
border-radius: 50%;
margin: 5px auto -8px;
}
.real-estate-machine-3d .camera-row .divider { width: 1px; height: 13px; background: var(--line); margin: 0 8px; }
.real-estate-machine-3d .camera-row #play { padding: 5px; width: 25px; height: 25px; display: grid; place-items: center; }
.real-estate-machine-3d .footer {
position: absolute;
bottom: 25px;
left: 32px;
right: 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
pointer-events: none;
}
.real-estate-machine-3d .wordmark { font-size: 11px; color: #a2a7a4; }
.real-estate-machine-3d .wordmark span { color: #6a7179; }
.real-estate-machine-3d .hint { display: flex; align-items: center; gap: 7px; color: #616a74; font-size: 10px; }
.real-estate-machine-3d .hint svg { color: #8a9095; }
.real-estate-machine-3d .footer-center {
position: absolute;
left: 50%;
transform: translateX(-50%);
font: 9px ui-monospace, Menlo, SFMono-Regular, monospace;
letter-spacing: 1.5px;
color: #3e4650;
}
.real-estate-machine-3d #labels { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.real-estate-machine-3d .station-label {
position: absolute;
left: 0;
top: 0;
opacity: 0;
transition: opacity 0.3s;
will-change: transform;
pointer-events: none;
min-width: 138px;
}
.real-estate-machine-3d .station-label.visible { opacity: 1; }
.real-estate-machine-3d .station-label .stem {
height: 26px;
width: 1px;
background: linear-gradient(#e0a14a00, #e0a14a70);
margin: 0 0 0 10px;
}
.real-estate-machine-3d .station-label .label-card {
background: #12161cee;
backdrop-filter: blur(10px);
border: 1px solid #e0a14a40;
border-radius: 6px;
padding: 10px 13px;
box-shadow: 0 4px 20px #0003;
}
.real-estate-machine-3d .station-label .label-title {
font-weight: 600;
font-size: 12px;
display: flex;
gap: 10px;
align-items: center;
white-space: nowrap;
}
.real-estate-machine-3d .station-label .label-title span { color: var(--accent); font: 9px ui-monospace, Menlo, monospace; }
.real-estate-machine-3d .station-label .label-meta { font-size: 9px; color: #8b939c; margin: 6px 0 0 24px; white-space: nowrap; }
.real-estate-machine-3d #tooltip {
position: absolute;
pointer-events: none;
z-index: 10;
opacity: 0;
transition: opacity 0.15s;
padding: 11px 14px;
border: 1px solid #ffffff1a;
background: #151a21ed;
backdrop-filter: blur(12px);
box-shadow: 0 8px 32px #0005;
border-radius: 7px;
max-width: 240px;
}
.real-estate-machine-3d #tooltip.visible { opacity: 1; }
.real-estate-machine-3d #tooltip strong { font-size: 12px; font-weight: 600; }
.real-estate-machine-3d #tooltip strong span { font-size: 10px; color: var(--accent); margin-right: 8px; }
.real-estate-machine-3d #tooltip p { font-size: 10px; color: #99a1a9; margin: 6px 0 0; line-height: 1.5; }
.real-estate-machine-3d #journey {
position: absolute;
left: 24%;
right: 24%;
top: 148px;
opacity: 0;
transform: translateY(-5px);
transition: 0.4s;
pointer-events: none;
display: flex;
align-items: center;
gap: 13px;
}
.real-estate-machine-3d #journey.visible { opacity: 1; transform: none; }
.real-estate-machine-3d .journey-icon {
width: 28px;
height: 28px;
border: 1px solid #e0a14a40;
border-radius: 50%;
display: grid;
place-items: center;
color: var(--accent);
}
.real-estate-machine-3d #journey strong { font-size: 11px; font-weight: 500; }
.real-estate-machine-3d #journey small { display: block; font-size: 9px; margin-top: 4px; color: #7c858e; }
.real-estate-machine-3d #journey .track { height: 2px; background: #ffffff0b; flex: 1; margin-left: 8px; }
.real-estate-machine-3d #journey .track i { display: block; height: 100%; background: var(--accent); width: 0; }
.real-estate-machine-3d #loading {
position: absolute;
left: 50%;
top: 48%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
gap: 12px;
color: #8a9096;
font-size: 11px;
transition: opacity 0.4s;
z-index: 20;
}
.real-estate-machine-3d #loading i {
height: 18px;
width: 18px;
border-radius: 50%;
border: 1px solid #e0a14a22;
border-top-color: var(--accent);
animation: real-estate-machine-3d-spin 1s linear infinite;
}
@keyframes real-estate-machine-3d-spin { to { transform: rotate(360deg); } }
.real-estate-machine-3d #loading.done { opacity: 0; pointer-events: none; }
.real-estate-machine-3d #error {
position: absolute;
left: 27.5%;
right: 27.5%;
top: 40%;
border: 1px solid #e0a14a30;
background: #171b21;
padding: 24px;
border-radius: 12px;
display: none;
font-size: 13px;
line-height: 1.7;
}
.real-estate-machine-3d #error strong { color: var(--accent); font-weight: 500; }
.real-estate-machine-3d #error p { color: #9ba2a9; margin: 8px 0 0; }
.real-estate-machine-3d #error button {
border: 1px solid #ffffff22;
background: #232830;
padding: 8px 14px;
border-radius: 5px;
margin-top: 14px;
}
.real-estate-machine-3d.embed .debug-ui { display: none !important; }
.real-estate-machine-3d.embed .vignette {
background: radial-gradient(ellipse at 71% 48%, transparent 30%, rgba(0, 0, 0, 0.1) 65%, rgba(0, 0, 0, 0.55));
}
.real-estate-machine-3d.embed .footer { justify-content: flex-end; }
.real-estate-machine-3d.embed .wordmark { opacity: 0.65; }
@media (min-width: 901px) {
.real-estate-machine-3d.embed #loading { left: 71%; }
.real-estate-machine-3d.embed #error { left: 48%; right: 7%; }
}
@media (min-width: 1600px) {
.real-estate-machine-3d .topbar { inset: 38px 46px auto; }
.real-estate-machine-3d .scene-heading { top: 142px; }
.real-estate-machine-3d .controls { bottom: 100px; gap: 20px; }
.real-estate-machine-3d .mode-bar button { padding: 13px 20px; font-size: 12px; }
.real-estate-machine-3d .camera-row button { font-size: 11px; padding: 5px 12px; }
.real-estate-machine-3d .footer { bottom: 35px; left: 46px; right: 46px; }
.real-estate-machine-3d #journey { top: 182px; }
.real-estate-machine-3d .coordinates { right: 47px; top: 185px; }
}
@media (max-width: 900px) {
.real-estate-machine-3d .topbar { inset: 24px 22px auto; }
.real-estate-machine-3d .identity strong { font-size: 12px; }
.real-estate-machine-3d .identity small { font-size: 8px; letter-spacing: 1.1px; }
.real-estate-machine-3d .status { font-size: 9px; }
.real-estate-machine-3d .status span:last-child { display: none; }
.real-estate-machine-3d .scene-heading { left: 22px; right: 22px; top: 105px; font-size: 8px; letter-spacing: 1.3px; }
.real-estate-machine-3d .coordinates { display: none; }
.real-estate-machine-3d .controls { left: 12px; right: 12px; bottom: 84px; gap: 18px; }
.real-estate-machine-3d .mode-bar { gap: 1px; padding: 4px; border-radius: 10px; }
.real-estate-machine-3d .mode-bar button { padding: 11px 10px; font-size: 10px; gap: 6px; }
.real-estate-machine-3d .mode-bar svg { width: 12px; height: 12px; }
.real-estate-machine-3d .camera-row { gap: 0; }
.real-estate-machine-3d .camera-row .caption { font-size: 8px; margin-right: 6px; }
.real-estate-machine-3d .camera-row button { padding: 5px 8px; font-size: 9px; }
.real-estate-machine-3d .camera-row .divider { margin: 0 5px; }
.real-estate-machine-3d .footer { bottom: 25px; left: 22px; right: 22px; align-items: flex-end; }
.real-estate-machine-3d .hint { font-size: 9px; max-width: 175px; line-height: 1.6; }
.real-estate-machine-3d .footer-center { display: none; }
.real-estate-machine-3d .vignette { background: radial-gradient(ellipse at 50% 47%, transparent 20%, #00000080 100%); }
.real-estate-machine-3d #error { left: 7%; right: 7%; top: 35%; }
.real-estate-machine-3d #journey { left: 24px; right: 24px; top: 143px; }
.real-estate-machine-3d .station-label { min-width: 100px; }
.real-estate-machine-3d .station-label .label-card { padding: 7px 9px; }
.real-estate-machine-3d .station-label .label-title { font-size: 10px; gap: 7px; }
.real-estate-machine-3d .station-label .label-meta { font-size: 8px; margin-left: 0; }
.real-estate-machine-3d .station-label .stem { height: 17px; }
.real-estate-machine-3d.embed .vignette { background: radial-gradient(ellipse at center, transparent 30%, #00000060 100%); }
}
@media (max-width: 360px) {
.real-estate-machine-3d .mode-bar button { padding: 10px 7px; }
.real-estate-machine-3d .mode-bar svg { display: none; }
.real-estate-machine-3d .camera-row .caption { display: none; }
.real-estate-machine-3d .status { display: none; }
}
@media (max-height: 570px) {
.real-estate-machine-3d .topbar { top: 16px; }
.real-estate-machine-3d .scene-heading { top: 70px; }
.real-estate-machine-3d .controls { bottom: 50px; gap: 8px; }
.real-estate-machine-3d .footer { bottom: 14px; }
.real-estate-machine-3d #journey { top: 96px; }
.real-estate-machine-3d .mode-bar button { padding-top: 8px; padding-bottom: 8px; }
}
@media (prefers-reduced-motion: reduce) {
.real-estate-machine-3d, .real-estate-machine-3d * { transition: none !important; }
.real-estate-machine-3d #loading i { animation: none; }
}
`
{brand}
Lead-to-close engine
Pipeline running
DEAL 001
The deal machine your agents run 5 STATIONS / 1 PIPELINE
PROTOTYPE 01 — FROM FIRST CALL TO CLOSING
New lead
Lead Desk · Maria wants to sell
Opening escrow
Could not start the 3D scene
Check that WebGL is turned on in your browser.
${String(d.step).padStart(2, '0')}${d.name.replace('&', '&')}
Step ${d.step} · ${d.output}
