Journey's modular platform for interactive 3D property experiences — reusable page types, components, and patterns assembled into bespoke applications per client.
◇
Interactive 3D
Splat base + GLB overlay with selectable unit layers. Tap a unit, get data.
□
Multi-Page App
Splash, 3D, gallery, location, favourites, share. Pick per project.
△
iPad-First
SwiftUI wrapper, AirPlay to screen. Moving away from PCs.
○
Sales Centre
Multi-device sync, physical model lighting, idle reset.
Typical Physical Setup
☐iPadon stand
→
◻Wall ScreenAirPlay / HDMI
+
▱Linux Boxif lighting
→
☼LightSwarmserial → LEDs
01
Framework & Key Features
Not every project uses everything. Selection is per client. Everything listed has shipped in production.
01
Splash
Brand, preload, enter
02
3D Explore
Building model, select units
03
Filter
Price, floor, type, status
04
Detail
Plans, tours, gallery
05
Compare
Favourites, side-by-side
06
Share
QR, PDF, email
3D Visualisation
Gaussian Splatting (photorealistic base)
GLB overlay model (selectable unit layers)
Orbit camera (pan, zoom, rotate)
Click-to-select units on 3D
Material highlight for selection state
Per-floor splats with transitions
Camera presets per view
Post-processing (CAS, bloom)
World-space labels
Auto-rotation when idle
Property Discovery
Unit finder with multi-criteria filters
Filter: floor, type, beds, price, status
Sortable unit table / card view
Unit detail panel (specs, price, area)
Floor plan viewer
Favourites / bookmarking
Side-by-side comparison
CRM API live availability
Before/after comparison slider
360° rotation viewer (image sequences)
Content & Sales
Photo/video gallery carousel
QR code sharing
Idle auto-reset
360° panoramic tours
Interactive maps (Mapbox/Leaflet)
PDF unit spec export
VR headset mode (WebXR)
Multi-device sync (Socket.IO)
Physical model lighting (LightSwarm)
Multi-language (Arabic/English)
02
How the 3D Page Works
The 3D experience is built from layers, not a single model.
Layer 1: Gaussian Splat (Base)
The photorealistic base. Point cloud data rendered in-browser via PlayCanvas GSplat. Looks like a photograph you can orbit, pan, and zoom around. You can navigate the splat freely (orbit camera with constraints), but you can't click on individual units within it — that's what the overlay layer is for.
Sources: Can be fully CG (rendered from Unreal or 3ds Max), drone photography, or a combination (CG building + drone context). The combo approach is the most common — CG for the building, drone for surrounding context.
Per-floor splats: For projects with floor plan exploration, a separate splat is generated per floor (cut-through showing that level). The viewer transitions between floor splats with a crossfade, keeping only ~3 in memory via LRU cache to avoid crashes.
Layer 2: GLB Overlay (Interactive)
A simplified 3D model (GLB/glTF) layered on top of the splat. This is what makes units selectable. Each unit is a separate mesh/layer in the GLB with an ID that maps to the unit data. When a user taps a unit, the system:
Raycasts against the GLB overlay (not the splat)
Identifies which mesh was hit → gets the unit ID
Pulls unit data from the accommodation schedule
Highlights the selected mesh (material swap)
Dims/fades unselected units
Shows the unit info panel with specs, pricing, plans
SVG alternative: When there are floor cut-throughs (seeing into the building from above), an SVG overlay projected onto the 3D scene works better than a GLB — the splat shows the interior, and the SVG provides the selectable unit outlines on top.
Layer 3: UI Markers & Labels
HTML elements positioned in 3D world-space (Element2d). Marker icons, unit labels, popup cards, location pins. These float above the 3D scene and respond to camera movement. Each marker has a 3D position [x,y,z] and can trigger camera animations on click.
Splat Pipeline INTERNAL
Source
CG / Drone / Both
→
Reconstruct
COLMAP SfM
→
Generate
PostShot
→
Convert
PLY → SOG
→
Output
.sog (15-30MB)
Floor cut-throughs: A separate splat per floor level (GF.sog, 1F.sog, 2F.sog... + ALL.sog). Source model sectioned at each floor height and run through the pipeline individually.
File sizes & memory: Each splat is typically 15–30MB. A project with 8 floors + an ALL view = ~280MB of splat assets total. The viewer keeps only ~3 floors in memory at once (LRU cache) to prevent WebGL crashes on iPad. Floor transitions crossfade between the outgoing and incoming splat. This is a real constraint — more floors = more assets to generate, host, and download on first load.
03
Page Types in Detail
Click to expand each page type.
‹
›
Splash / Intro
3D Residences / Masterplan
Floor Plans
Gallery
360° Tours
Location
Favourites & Comparison
Amenities
About / Investor
Share & Export
▶ Splash / Intro PAGE
First screen. Brand animation, asset preloading, transition into experience. Doubles as screensaver/idle state in kiosk mode — app returns here after configurable timeout (typically 2–20 min). Can include background video loop, parallax scroll gallery, or simple logo + enter button.
Logo animation (GSAP/Motion)
Asset preloading + progress
Enter CTA
Page transition animation
Background video loop
Parallax image gallery
Idle timeout auto-reset
Auth gate (Okta/Firebase)
▶ 3D Residences / Masterplan PAGE
The hero. Gaussian Splat base + GLB overlay with selectable unit layers. User taps a unit on the 3D model, it highlights, pulls unit data (from accommodation schedule), shows info panel. Filters narrow which units are visible/highlighted. Camera animates between views (overview, per-building, per-floor). For floor exploration: per-floor splats with crossfade transitions.
Splat base layer (photorealistic)
GLB overlay (selectable units)
Orbit camera with constraints
Click-to-select → unit data panel
Multi-criteria filtering
Unit table with sort
Favouriting from unit card
Camera presets per view
Per-floor splats with transition
Floor selector UI
SVG overlay instead of GLB (for cut-throughs / top-down)
CRM API for live status
Colour coding (available/sold/reserved)
3D markers + popups
Scale comparison models
Dynamic DPR (perf optimisation)
▶ Floor Plans PAGE
Per-unit floor plan display. 2D images, interactive SVGs with room labels, or 3D splat walkthroughs. Floor selector, pinch-to-zoom.
Spherical image viewer for interior/exterior panoramas. Drag to look around. Navigate between rooms via hotspots or buttons. Can extend to full VR headset mode (WebXR) or use device gyroscope on mobile.
Spherical rendering
Drag/touch to look around
Multi-scene navigation
Hotspot overlays
Gyroscope control
WebXR VR headset
▶ Location PAGE
Stylised branded map — not a raw satellite embed. POI markers are tappable, each opening to show image, description, distance, category.
Stylised/branded map
Property location pin
Points of interest markers
POI tap → popup with image + info
Category filters (dining, transport, parks, etc.)
Subtle animations on marker appear/select
Mapbox GL with custom style JSON
Custom aerial tile map (from renders/drone)
3D globe with flight path animation
Distance indicators from property
District/zone SVG overlays
Transport route highlighting
▶ Favourites & Comparison PAGE
Saved units list. Persistent via localStorage/Zustand. Side-by-side comparison (max 2). Export shortlist as PDF or share via QR.
▶ Amenities PAGE
Building/community amenities. List with imagery, category grouping, detail modals. Optionally interactive floor plan with hotspots or 3D camera fly-to per amenity.
Full run-through on every device. Data, images, 3D cached, network stable.
10
Launch
Launch Day
Sales centre opens. Dev on standby. Last-minute changes deploy in minutes.
11
Post-Launch
Adjustments
Real usage feedback. Refinements, data updates, content changes, maintenance.
05
Required Assets per Page
What's needed per page. Review at kick-off.
3D Residences / Masterplan
Must Have
Fully textured 3D model — with lighting adjusted. This is what gets processed into the Gaussian Splat (.sog) internally. Source: CG (Unreal/3ds Max), drone photogrammetry, or combination
Context source — surrounding environment. Usually from drone shoot, can also be CG. Combined with the building model to produce the final splat
3D GLB/glTF overlay model — simplified model with each unit as a separate named mesh/layer. Critical: mesh names in the GLB must exactly match the unit IDs in the accommodation schedule (e.g. mesh "1F-01" maps to unit "1F-01"). If these don't match, unit selection breaks silently
Accommodation schedule — the single source of truth for all unit data. Minimum fields: unit ID, unit type, floor, bedrooms, bathrooms, area (sqft/sqm), price, availability status. Typically provided as a spreadsheet and converted to JSON. Example structure per unit: { id: "1F-01", label: "1F1-01", floor: "1F", type: "2bed", beds: 2, baths: 1, area: 85, areaSqFt: "914.9", price: 450000, status: "available", category: "residential" }
If Floor Exploration
Per-floor splats — separate .sog per floor level (GF, 1F, 2F... + ALL). Generated by sectioning the 3D model at each floor height and running through the splat pipeline individually
Floor plan SVGs or images — top-down cut-through per floor, used as 2D overlay on the 3D scene or in the floor plan page
Marketing floor plans — per unit type, from the client's sales pack. Usually PDFs or high-res images
Splat source decision: Fully CG (from Unreal or Max) gives complete control but needs 3D team time. Drone gives real context but only works if the building exists or the site is accessible. The most common approach is CG building + drone context — best of both. If 360° views are also needed, the drone shoot covers both the context splat and the 360 photography.
360° equirectangular images — one per room/viewpoint. Source: drone photography (exterior/views) or CG rendering (interior). High-res (typically 8K+)
Scene map — which scenes connect to which, hotspot positions
Note
If view photography is needed (what you see from the unit), this requires a drone shoot at the actual site — same shoot can cover context splat + 360 views
Alternatively, views can come from CG renders if the building isn't built yet
Location
Must Have
POI list — name, category (dining, transport, parks, education, etc.), coordinates or relative positions
POI images — photo per point of interest for the popup/detail view
POI descriptions — short copy per POI (distance, what it is, why it matters)
Property coordinates (lat/lng)
Map style direction — what the map should look like (minimal, branded, satellite-ish)
If Custom Map
Aerial imagery — drone or CG aerial for custom tile generation
Mapbox style JSON — custom branded map style
Transport route data — if highlighting specific lines/routes
Floor Plans
Must Have
Marketing floor plans — per unit type. From client's sales pack. High-res images or PDFs
Floor level mapping — which plans go on which floor
Splash / Intro
Must Have
Client logo — SVG format, light + dark variants
Brand colours + typography — from client brand guidelines
Optional
Intro video — MP4, typically a flythrough/lifestyle reel, for background loop
Screensaver images — for kiosk idle rotation
About / Investor Info
Must Have
Project copy — description, vision statement, developer info
Hero images — lifestyle/architectural renders
Optional
Investor data — yields, pricing structure, financial projections
Timeline milestones
Always Required (Global)
Every Project
Brand guidelines — colours, typography, logo usage
Accommodation schedule — complete unit data (this drives everything)
Content sign-off contacts — who approves copy, images, data
06
Common Patterns & Conventions
Patterns that recur across Luna projects. Understanding these makes onboarding to any project faster.
Navigation
How users move through the app
Typically a burger icon (bottom-left or top-left) that opens a side drawer or modal overlay. Menu items stagger in with GSAP (0.08s each). Active page highlighted. Nav closes on outside click or item select.
Navigation has evolved through several patterns: vertical sidebar with icon buttons, full-height drawers, and more recently modal overlays with animated circular backgrounds. The burger → X animation is always GSAP-driven.
Long-press logo (3s+) resets the entire app and clears favourites — this is the "escape hatch" for sales staff if something goes wrong.
State Management
Single Zustand store
Every project uses a single Zustand store with one setLocalState(partial) update function. No Redux. Everything lives in one flat object: nav state, selected unit, filters, gallery state, map state.
What goes where:
Store: nav open, selected unit, filters, gallery popup
URL params: current page, view, floor
localStorage: favourites (persists between sessions)
Local useState: hover states, transient UI
useRef: animation state, timelines, GSAP targets
Animation
GSAP everywhere
GSAP is the animation engine across all projects. Key patterns:
Page transitions: navigating flag → loader fades in, page fades out → new page fades in, loader fades out. Duration controlled by navDurationSec in store (0.5–0.8s)
Menu stagger: items animate in with 0.08s stagger on open, no stagger on close. Uses autoAlpha (opacity + visibility)
Timeline reuse: single timeline instance, clear() + re-populate for reversible animations
Easing:power2.out is the default, power3.out for intro sequences
CSS animations only for spinners: keyframe rotate for simple loops, GSAP for everything else
Unit Data Flow
How accommodation data reaches the UI
Two approaches depending on project:
Static JSON (most common): unit data lives in a TypeScript data file. Each unit has an ID, label, floor, area, type, category, and a 3D position [x,y,z] for marker placement. Data is grouped by floor level.
CRM API (live projects): fetches from middleware on init with Bearer token auth. Returns units keyed by project name. Filtered version maintained separately in store. Filters update the filtered set, not the raw data.
In both cases: user taps unit on 3D → raycast gets mesh ID → looks up unit data by ID → setLocalState({ activeID, selectedUnit: true }) → info panel renders.
Typography & Fonts
Per-project branding
Fonts are always project-specific — loaded from /public/fonts/ via @font-face. Common approach: custom family names per weight (e.g. "Bold", "Light") rather than using font-weight property. Dual format: woff2 primary, woff fallback.
Arabic-supporting projects load IBM Plex Sans Arabic or FS Albert Arabic. Font sizing is often viewport-based (1vw on body) for consistent scaling across devices.
Styling Approach
CSS-in-JS or Modules
Two approaches in use:
Styled Components — used in older/mid projects. Theme context integration. Dynamic props for variants.
CSS/SCSS Modules — used in newer projects. Isolated scoping. BEM-like naming. Lighter weight.
Both approaches use CSS custom properties (variables) for theming. Colours, spacing, and typography tokens defined globally.
Loading & Error States
How assets load, what happens when they fail
Loading: A boolean flag in the store. Full-screen overlay with either a CSS spinner (simple) or a GSAP-animated progress bar with clipPath (fancier). Some show percentage, most just show a branded animation.
3D asset loading: Splats load with progress tracking. Dynamic DPR: drops to 1x while camera is moving, back to 2x when static — big perf win on iPad.
Errors: Minimal. API failures log to console and return empty objects. No error boundaries, no retry logic, no user-facing error states. This is an area that could improve.
Idle & Reset Behaviour
What happens when nobody touches it
Idle timer (where implemented): tracks mouse/touch events, resets to splash after configurable timeout (2–20 min). Not present in all projects.
Manual reset: Long-press the logo for 3+ seconds. Triggers a full page reload and clears favourites/localStorage. This is the universal "escape hatch" across all projects — sales staff can reset without technical knowledge.
Screensaver: Some projects rotate through images or show a looping video on the splash screen while idle.
Routing
How pages are structured
Most projects use React Router v6+ with hash-based routing (HashRouter) for easy static hosting — no server-side rewrites needed. Newer projects are moving to Vike (file-based routing).
Deep linking via URL query params is common: /masterplan?page=unitFinder&floor=2F&selected=unit-3b. This means views are shareable and bookmarkable.
Provider Stack
What wraps the app
Typical provider order from outside in:
Router (BrowserRouter or Vike)
SocketIOProvider (if multi-device sync)
ThemeProvider (styled-components theme)
Layout (nav, loading screen, page slot)
Routes / Pages
Known Gaps & Gotchas
Things that have caused issues or are inconsistently handled across projects.
Technical
No error boundaries — if a component crashes, the whole app goes white. No graceful fallback.
API failures are silent — if CRM data fails to load, it logs to console and returns empty. No user feedback, no retry.
No font-display: swap — custom fonts load without fallback, causing a flash of invisible text on slow connections.
No node engine pinned — different machines may use different Node versions, causing inconsistent builds.
Process
GLB mesh IDs must match unit IDs — if the 3D team names a mesh "Unit_01" but the schedule says "1F-01", selection breaks silently. Needs a handshake between 3D and dev.
Camera positions are set in code, not Figma — producers sometimes expect camera angles from design phase. They're actually set during dev using Tweakpane. Provide intent, not coordinates.
Splat generation takes time — the COLMAP → PostShot pipeline isn't instant. Factor it into the timeline, especially for per-floor splats (multiply by number of floors).
Per-floor splats = ~280MB total — first load on slow sales centre WiFi can be painful. Consider preloading strategy or offline caching.
07
Technology
General pattern. Evolves continuously.
@playcanvas/react is an internal library (monorepo with lib, UI blocks, and docs) that wraps PlayCanvas for React. It provides declarative components (Application, Entity, Camera, Light), hooks (useApp, useSplat, useTexture, useFrame), and built-in scripts (OrbitControls). All recent 3D work goes through this — it's the foundation that makes splats and WebGL work in React.
What physically goes into a sales centre. The approach has evolved significantly — the direction is always toward simplifying and eliminating things that can go wrong.
How It's Evolved
Legacy: PC + Wails App
Moving away from this
Dedicated PC (often Windows) running a Wails desktop app (Go + React). Connected to wall screen(s) via HDMI. Separate LightSwarm server running on the same PC or a Raspberry Pi for physical model lighting.
Problems:
PCs are expensive to procure and ship
More hardware to maintain (fan failures, OS updates, driver issues)
Windows environment inconsistencies between installs
Client staff can accidentally close apps, change settings
iPad running the Luna app (SwiftUI wrapper + WebView), AirPlaying or extending to a wall-mounted screen. If lighting control is needed, a small Linux box handles LightSwarm — that's it. The iPad is the single source of truth.
Why this is better:
iPad is cheap, reliable, familiar to clients
OTA updates via TestFlight — no site visit for software changes
Single device to manage, charge, and troubleshoot
Kiosk mode locks it down — clients can't break it
Linux box for lights is low-power, headless, set-and-forget
Remote support is straightforward
Physically small — iPad on a stand, Linux box hidden
Typical Physical Setup
Standard Sales Centre Installation
Control
iPad
→
Display
Wall Screen
AirPlay / HDMI via Apple TV / Extended Display
Control
iPad
→
Display
Wall Screen
+
Lighting
Linux Box
→
Hardware
LightSwarm
→
Physical
Model Lights
+ physical model lighting via serial connection
What Goes On-Site
Device
Role
Notes
iPad
Runs the Luna app. Sales agent holds/uses this.
Kiosk mode, idle timeout, TestFlight updates. Usually on a stand or counter mount.
Wall screen / TV
Displays the experience to the buyer.
Connected via AirPlay (wireless) or HDMI (wired via Apple TV or adapter). 16:9 preferred.
Apple TV(if needed)
Receives AirPlay or runs its own app.
Only if mirroring wirelessly to screen, or running independent ATV app for immersion rooms.
Linux box(if needed)
Runs LightSwarm server for physical model lighting.
Small, headless, low-power. Serial connection to LightSwarm controller. Set and forget.
LightSwarm controller(if needed)
Controls LEDs on physical architectural model.
Serial protocol. Syncs light states with unit selection in the app.
WiFi router(if needed)
Network for AirPlay, Socket.IO sync, OTA updates.
Dedicated network recommended. Sales centre WiFi is often unreliable.
Installation Complexity Tiers
Simple
iPad only
Just the iPad. No wall screen, no hardware. Sales agent walks around with it. Good for events, temporary showrooms, early-stage sales.
Devices: 1 · Setup time: minutes
Standard
iPad + screen
iPad mirroring or extending to wall screen. The most common setup. AirPlay or HDMI. No lighting hardware.
Devices: 2-3 · Setup time: hours
Full
iPad + screen + lighting
iPad, wall screen, Linux box, LightSwarm, physical model. The full experience. Requires site visit, cable routing, calibration.
Devices: 4-6 · Setup time: 1-2 days
10
Multi-Screen Modes
How the iPad connects to the wall screen. React app loads in a SwiftUI wrapper (WKWebView) that handles the multi-screen logic.
1. Mirroring
AirPlay — simplest
iPad AirPlays to display. Same content, 4:3.
Zero overhead, no extra dev
4:3 letterboxed, can't differ
2. Extended Display
Different content per screen
iPad (4:3) + screen (16:9), different content. Same app, shared ViewModel, two WebViews.
Bespoke layout, 16:9, single app
Dual render overhead
3. Apple TV App
Two apps, Multipeer Connectivity
Separate iPad + ATV apps. ATV advertises, iPad discovers. Most flexible.
Independent, multi-screen
Two apps, peer complexity
React side: Sender in Zustand store, Receiver hook on ScreenView. App doesn't care which mode. Philosophy: simplest config that meets requirements.
11
Deployment
Web
Vite SPA → Cloudflare Pages / AWS / Vercel. Bitbucket Pipelines CI/CD. Deploys in minutes.