NOX Autonomous LiDAR Inspection Drone AI website prompt
A dark, cinematic one-page site for a fictional autonomous LiDAR-inspection drone brand. A real 3D drone flies in on load and is flown through its own feature tour by scroll, alongside a live point-cloud globe, a fault-detection HUD and an exploding anatomy view.
Now put it in WordPress
You have a site. Now make it yours.
An AI build hands you a front end and stops there. No CMS, no client login, no real ownership: you cannot hand it over, and you cannot change it without going back and re-prompting. UiChemy turns that build into native, editable WordPress you own, in the builder you already use.
Bolt to WordPressClaude to WordPressCodex to WordPressCursor to WordPressLovable to WordPressv0 to WordPressAI to WordPress
Get started free
More prompts
This is the only prompt in this category so far. More are on the way.
Showing 0 prompts
Frequently asked questions
What does the NOX Autonomous LiDAR Inspection Drone prompt build?
A dark, cinematic one-page site for a fictional autonomous LiDAR-inspection drone brand. A real 3D drone flies in on load and is flown through its own feature tour by scroll, alongside a live point-cloud globe, a fault-detection HUD and an exploding anatomy view.
Which AI tools does the NOX Autonomous LiDAR Inspection Drone prompt work with?
It's written to work with . Paste it into your AI tool and it builds the site, then move the result into WordPress with UiChemy.
How do I turn the result into a WordPress site?
After the AI builds your site, UiChemy Composer rebuilds it as native, editable WordPress in Elementor or Gutenberg (Bricks coming soon) — you own it and edit it in your builder. See AI to WordPress.
Is the NOX Autonomous LiDAR Inspection Drone prompt free?
Yes — it's free to copy, no account needed.
Can I edit the website after it's built?
Yes. Once it's in WordPress with UiChemy, every section is native and editable — no re-prompting, no lock-in.
Copy prompt
Copy code
Coming soon
Copy SKILL.md
Coming soon
NOX Autonomous LiDAR Inspection Drone · Section prompt
# MASTER PROMPT — NOX · Full Autonomous LiDAR-Drone Website (dark, Three.js — fully self-contained)
> **v3.** Assets now resolve to the **WordPress media library** instead of a CDN:
> the eight images are inline in `index.html` as
> `demo.uichemy.com/wp-content/uploads/2026/09/…`, and Clash Display + Satoshi are
> self-hosted as eight `.woff2` files through an `@font-face` block at the top of
> `css/style.css` — the Fontshare `<link>` is gone from the head entirely. The two
> `.glb` drone models are the sole remaining external dependency, because WordPress
> refuses the `model/gltf-binary` MIME type on both its own REST endpoint and
> UiChemy's; see the ASSET MANIFEST for the one-line filter that lifts it.
>
> Carried over from **v2**: `js/main.js` derives walk progress from
> `getBoundingClientRect().top + window.scrollY` rather than `offsetTop` (which
> returns 0 inside Elementor's positioned wrappers and pins the drone in one pose),
> plus the **BUILDING THIS ON WORDPRESS (UiChemy / Elementor)** section before the
> appendix.
>
> Rebuilding on a different site? Re-upload the images and fonts there and swap the
> uploads base URL — the layout, CSS and engine are unchanged.
## ▶ HOW TO USE THIS PROMPT (do this first)
**Paste this entire document into Claude — nothing to attach, no files to download.** It is fully self-contained: every asset (two 3D models, eight images, the fonts) loads from a public URL baked into the code below.
This document has **two halves:**
- **PART A — the specification** (everything up to the appendix): describes the site — its structure, the 15 sections, the design system, the four WebGL/canvas scenes and the scroll engine — as documentation.
- **PART B — the appendix** (`APPENDIX · VERBATIM SOURCE` at the very end): the **complete source of all 3 files**. **This is the half that guarantees the identical website.**
**If you want the exact same result: go to the appendix and write those three files out verbatim** (`index.html`, `css/style.css`, `js/main.js`), then serve over HTTP. Read Part A as documentation for what you wrote, not as instructions to re-invent it — generating from Part A alone gets you close, not identical.
If the target is **WordPress** rather than a static folder, read **BUILDING THIS ON WORDPRESS (UiChemy / Elementor)** further down before you start — it carries the section split, the media limits, and the one scroll-measurement rule that silently breaks the whole walkthrough.
**INSTRUCTION TO CLAUDE:** Transcribe the three files in the appendix exactly as written (do not "improve", reformat, rename, shorten, or omit anything — the CSS and JS are long on purpose), create them in a folder, and tell the user to run it with a static server (`python3 -m http.server 8777`, then open `http://localhost:8777`). Do not reproduce from Part A when the appendix is present. An internet connection is required (Three.js, the fonts, and both drone models load from the web).
---
## CRITICAL — RUNTIME, ASSETS & FONTS (read first)
1. **Serve over HTTP — not `file://`.** The page uses native ES modules + an `importmap` and fetches two `.glb` models at runtime; browsers restrict this on `file://`. Run `python3 -m http.server 8777` and open `http://localhost:8777`.
2. **Three.js r0.160.0 is the only library**, loaded as an ES module via the `importmap` from jsDelivr (see the appendix `<head>`), with addons `GLTFLoader` and `RoomEnvironment`. Do not swap versions or use a bundler — the lighting/exposure/rig numbers are tuned to r160.
3. **All media is hosted; reference the exact URLs verbatim** (see ASSET MANIFEST). The eight images and both font families are served from the WordPress media library at `demo.uichemy.com`; only the two `.glb` drone models still come from the shared asset repo, because WordPress refuses the `model/gltf-binary` MIME type. Do not re-host, rename, or substitute them.
4. **Fonts are self-hosted** — **Clash Display** (display headings) and **Satoshi** (body + technical labels), as eight `.woff2` files in the media library. The `@font-face` rules sit at the TOP of `css/style.css`; there is **no** Fontshare `<link>` in the head any more. Keep both the rules and the weights exactly.
5. **Match every number verbatim.** This is a reproduction of a finished site, not a redesign.
---
You are an expert creative director, interaction designer and WebGL/frontend engineer. Build a **single-page, dark, premium marketing website** for a fictional autonomous LiDAR-inspection drone brand, **NOX**, in **vanilla HTML + CSS + ONE ES-module JavaScript file** plus **Three.js r160** (the only library). No framework, no build step, no bundler. Deliver `index.html`, `css/style.css`, `js/main.js` and a short `README.md` (serve over HTTP).
The site is a **preloader + fixed header + mobile-nav overlay + 15 `<section>`s + footer**, with a pinned 3D drone that is choreographed by scroll, plus three more canvas scenes. Full spec below; **the appendix is authoritative**.
---
## DESIGN SYSTEM — `:root` tokens (verbatim)
```css
:root {
/* --- palette (deep, cool, near-black) --- */
--bg: #070809;
--bg-2: #0c0e11;
--ink: #eef1f3; /* primary text */
--ink-72: rgba(238,241,243,.72);
--ink-52: rgba(238,241,243,.52);
--ink-34: rgba(238,241,243,.34);
--ink-18: rgba(238,241,243,.18);
--line: rgba(238,241,243,.12);
--line-soft: rgba(238,241,243,.07);
--accent: #b6ff3a; /* neon lime accent, used sparingly */
--accent-deep: #4c7a12;
--ember: #ff4d4d; /* echoes the drone's nav lights */
/* --- type --- */
--sans: "Satoshi", "Space Grotesk", "Helvetica Neue", Arial, sans-serif;
--display: "Clash Display", "Satoshi", "Helvetica Neue", sans-serif;
--mono: "Satoshi", "Helvetica Neue", Arial, sans-serif;
/* --- rhythm --- */
--pad: clamp(16px, 3.2vw, 44px);
--ease: cubic-bezier(.22,.61,.36,1);
--ease-out: cubic-bezier(.16,1,.3,1);
--head-h: 72px; /* 60px ≤767px */
}
```
**Fonts — self-hosted, no `<link>`:** the eight `@font-face` rules are the first thing in `css/style.css` and point at the media library. Clash Display 300/400/500/600, Satoshi 300/400/500/700, all `font-display: swap`:
```css
@font-face {
font-family: 'Clash Display';
src: url('https://demo.uichemy.com/wp-content/uploads/2026/09/clash-display-400.woff2') format('woff2');
font-weight: 400; font-style: normal; font-display: swap;
}
/* …and the other seven, see the appendix */
```
**Style rules (strict):** dark editorial-technical; the neon-lime accent used sparingly (brackets `[ ]` in `.brk`, hover states, HUD ticks); a pulsing red `--ember` nav-light dot on the logo. Light sections carry the class `sec--light` (white surfaces: showcase, imaging, pipeline, specs) and the fixed header flips to a light theme while over them. No decorative drop shadows; depth comes from 3D, gradients and opacity steps of `--ink`. The **NOX logo is an inline SVG `<symbol id="nox-logo">`** reused in the header, the giant faint hero brandmark, and the footer — never typed text.
---
## RUNTIME — Three.js module wiring (in `<head>`)
```html
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/"
}
}
</script>
```
`js/main.js` is `<script type="module">` and imports `three`, `GLTFLoader`, `RoomEnvironment`.
---
## STRUCTURE — chrome + 15 sections (in order)
**Chrome:** a counting **preloader** (driven by the real `model.glb` download progress); a **fixed header** (`.masthead`) with the SVG logo + ember dot, primary nav (Capability · Solutions · Anatomy · Specs · Field), a live **GMT clock**, a "Request demo" CTA and a hamburger; a full-screen **mobile-nav overlay**; and a **footer** (brand + System/Solutions/Contact columns + legal + tech-stack chips).
1. **`#hero`** — pinned 3D drone (`model.glb`) flies in over the giant faint NOX brandmark; headline *"Find the fault before it fails."* bottom-left, scroll cue bottom-right.
2. **`#reveal` (Walkthrough)** — the pinned drone is **rotated/travelled by scroll** through 4 keyframed poses while feature callouts fade in: F.01 Payload, F.02 Airframe, F.03 Link, then a final CTA panel.
3. **`#statement`** — drone fades out; per-character scrub headline *"A crack you'd miss from the ground…"* + meta + stat list (5 returns, ±2 cm, 450 m, 240K pts/s).
4. **`#gear` (Payload)** — big "Payload" title + scrubbed lead about the Halo-L sensor core.
5. **`#showcase` (light)** — "Fits a case. Maps a bridge." + big product image (`product.jpg`).
6. **`#scan` (Capability)** — a live **3D LiDAR point-cloud globe** (`#cloud`, Three.js, uses `scan-drone.glb`) + CSS-radio tabs (Scan/Link/Frame).
7. **`#solutions`** — CSS-radio tabbed verticals: Energy · Infrastructure · Industry · Geospatial.
8. **`#imaging` (light, split)** — sensor image (`sensor.jpg`) + copy + 3 stats.
9. **`#fault`** — a **point-cloud lattice tower with a scan sweep** (`#faultCloud`, canvas 2D) + a HUD ("Surface deformation detected…", confidence, risk).
10. **`#pipeline` (light)** — 4-step "Ingest → Register → Classify → Report".
11. **`#airframe`** — 4 flight-performance cells (42 min, 12 m·s⁻¹, 20 km, 360°).
12. **`#inside` (Anatomy)** — a scroll-driven **exploded/disassembling drone** (`#insideCanvas`) with a 4-item legend (shell, power core, motors, Halo-L sensor).
13. **`#field`** — 6-tile inspection gallery from the 6 `field-*.jpg` images (point cloud / RGB / thermal / DSM captions).
14. **`#specs` (light)** — four spec groups (Payload, Airframe, Transmission, Software).
15. **`#deploy`** — closing CTA "Put NOX over your assets." + buttons.
---
## THE JS ENGINE — modules in `js/main.js` (11 systems)
Utilities (`clamp`, `lerp`) + a `prefers-reduced-motion` flag, then:
- **Rotor discs** (`propTexture`, `addRotors`) — procedural translucent spinning rotor-blur discs added to the drone (the supplied `model.glb` is one fused mesh, so the blades are faked as spinning discs).
- **`heroReveal`** — word-level blur-in of the hero headline via `IntersectionObserver`.
- **`charScrub`** — per-character colour scrub on `[data-scrub]` headlines (statement + gear) tied to scroll.
- **`stage`** — the main **Three.js drone stage** (canvas `#scene`): renderer (ACES tone-map, exposure ~1.14), `RoomEnvironment` reflections, a cool-key / lime-and-cyan rim / red-ember lighting rig, loads `model.glb`, auto-centres + scales, spirals in on load, then is **posed and moved by scroll** through the walkthrough; fades out before the statement.
- **`heroClock`** — live **GMT clock** in the header, updated each minute.
- **`mobileMenu`** — open/close the full-screen mobile nav overlay.
- **`headerTheme`** — flips the fixed header to a light theme while it overlaps any `.sec--light` section.
- **`walkFeatures`** — fades each walkthrough feature callout in/out on scroll.
- **`scanGlobe`** — §04 **3D LiDAR point-cloud globe** (canvas `#cloud`, Three.js): a rotating full-hemisphere cloud of points, with `scan-drone.glb` at the centre.
- **`faultCloud`** — §06 **point-cloud lattice tower + scan sweep** (canvas `#faultCloud`, Canvas 2D).
- **`insideStage`** — the **exploded/disassembling drone** in §Anatomy (canvas `#insideCanvas`), driven by scroll through 4 layers.
All heavy motion respects `prefers-reduced-motion`. Everything scroll-driven reads the scrollbar directly (no smooth-scroll hijack).
---
## ASSET MANIFEST (reference these EXACT public URLs — verbatim)
Images and fonts are served from the **WordPress media library**; only the two 3D models remain on the shared asset repo, because WordPress rejects `model/gltf-binary` on both its own REST endpoint and UiChemy's (a MIME restriction, not a size one).
```
Base (WP media): https://demo.uichemy.com/wp-content/uploads/2026/09/
Base (CDN): https://cdn.jsdelivr.net/gh/truptiPosimyth/landing-assets@main/nox/
Base (raw): https://raw.githubusercontent.com/truptiPosimyth/landing-assets/main/nox/
```
| Asset | URL |
|---|---|
| Product shot (§Showcase) | `https://demo.uichemy.com/wp-content/uploads/2026/09/product.jpg` |
| Sensor (§Imaging) | `https://demo.uichemy.com/wp-content/uploads/2026/09/sensor.jpg` |
| Field gallery ×6 | `https://demo.uichemy.com/wp-content/uploads/2026/09/field-wind.jpg`, `field-bridge.jpg`, `field-refinery.jpg`, `field-terrain.jpg`, `field-substation.jpg`, `field-solar.jpg` |
| Clash Display ×4 | `https://demo.uichemy.com/wp-content/uploads/2026/09/clash-display-300.woff2`, `-400`, `-500`, `-600` |
| Satoshi ×4 | `https://demo.uichemy.com/wp-content/uploads/2026/09/satoshi-300.woff2`, `-400`, `-500`, `-700` |
| Scan drone (light, §Scan) | `…@main/nox/scan-drone.glb` — CDN |
| Main drone (28 MB, hero) | `…/raw…/nox/model.glb` — raw GitHub |
*(The logo is an inline SVG — no logo file.)* Every URL above is already wired into the appendix: images inline in `index.html`, fonts in the `@font-face` block at the top of `css/style.css`, models in `js/main.js`. To rebuild on a **different** site, re-upload the images and fonts there and swap the `https://demo.uichemy.com/wp-content/uploads/2026/09/` base for that site's uploads path — nothing else changes.
---
## RESPONSIVE & MOTION
Genuinely responsive at 991 / 767px: header collapses to logo + hamburger (mobile-nav overlay), walkthrough callouts centre and sit low over the drone, pipeline/specs/solutions grids stack, the exploded-view section lengthens. `prefers-reduced-motion` neutralises the scrubs, spins, floats and canvas animations and shows content at rest.
## QUALITY BAR
Premium, dark, technical, cinematic: a real 3D drone that flies in and is flown through its own feature tour by scroll, a live point-cloud globe, a fault-detection HUD, and an exploding anatomy view — over crisp editorial type with a single neon-lime accent. No template look, no fake 3D, no shadow-as-decoration. Served over HTTP, it must load both models + all images from their URLs and reproduce the appendix exactly.
---
---
## BUILDING THIS ON WORDPRESS (UiChemy / Elementor)
Everything above describes the standalone site. If the target is a real
WordPress page built from UiChemy Composer widgets, these rules are what make
it actually work — each one was learned by hitting the failure first.
**Pipeline.** Read the `code-to-wordpress` skill, then call
`uichemy-composer/describe-site` before building. Stop if
`platform.checks.elementor_active` is false.
**Header & footer — page only.** Build them as the FIRST and LAST sections of
this page, not site-wide Theme Builder templates. Keep the source's literal
SVG logo and nav links verbatim; do NOT swap in `<uichemy-site-logo>` or
`<uichemy-nav-menu>`. This is a one-off landing page, not a site chrome change.
**Section split — 17 widgets.** Header (which also carries the SVG logo sprite,
the preloader and the mobile-nav overlay), then the 15 sections in order, then
Footer.
**Do NOT split or rename the CSS/JS.** The stylesheet and the ES-module engine
are too interdependent to divide per section, and the engine queries the
source's own class names and ids. Re-namespacing them BEM-style — the usual
Composer convention — breaks every selector and guarantees the page will not
match. Upload `style.css` and `main.js` WHOLE as `uichemy-composer/code-file`
entries and keep the original class names. The trade-off is real and worth
stating to the user: those sections lose per-section editability in the
Composer. Fidelity wins here because the whole site is one motion system.
**Page shell.** The importmap MUST sit in `page_before_head`, before the module
script, together with the stylesheet `<link>`. No font `<link>` belongs there any
more — the `@font-face` rules ride inside the stylesheet itself. The
engine goes in `page_before_body` as
`<script type="module" src="…/nox-main.js?v=N"></script>`.
Always carry a `?v=` version string on both files: the filenames never change,
the files are served with `cache-control: max-age=2592000`, and without a
version bump browsers keep the stale copy for a month. Bump N on every edit.
**Media.** The images and fonts are ALREADY on the media library in the appendix URLs; re-upload them only when building on a different site. Upload the 8 images and the 8 `.woff2` font files to the WordPress media library
(`uichemy-composer/media`, `action: "request-upload"` — one slot, all files)
and point the inline `background-image` URLs at the returned URLs. Note the
limits: `per_file_mb: 25`, and the allowlist is
`png, jpeg, webp, gif, svg+xml, mp4, webm, ogg, quicktime, woff2, woff, ttf, otf`.
**The two `.glb` models cannot go into the media library.** `model/gltf-binary`
is rejected with `415 uich_upload_type_blocked` by UiChemy, and with
`rest_upload_sideload_error` by WordPress core's own endpoint — this is a MIME
restriction, not a size one (an 850 KB model is refused just the same), and
renaming does not help because the type is sniffed from the bytes. Either keep
both models on a public CDN, or add:
```php
add_filter('upload_mimes', function ($m) { $m['glb'] = 'model/gltf-binary'; return $m; });
```
The hero model is ~28 MB, over the 25 MB cap, so it also needs shrinking:
`gltf-transform quantize` gives 23.1 MB with no decoder dependency
(`KHR_mesh_quantization` is native to three.js), and `gltf-transform draco`
gives 3.33 MB but requires wiring `DRACOLoader` into the engine.
### CRITICAL — never derive scroll progress from `offsetTop`
`offsetTop` is measured from the element's `offsetParent`, not the document.
In the flat standalone site `#statement` sits in a static `<main>`, so its
offsetParent is `<body>` and `offsetTop` is the true document position. Inside
Elementor every section is nested in `position: relative` wrappers, so the
offsetParent becomes the widget wrapper and `offsetTop` returns **0**. Measured
on a real page:
```
stmtOffsetParent: "elementor-element … elementor-widget-uichemy-composer"
stmtOffsetTop: 0
stmtDocTop: 4500
```
With `end = 0`, `clamp(scrollY / Math.max(1, end - vh), 0, 1)` saturates to 1
after a single pixel of scroll: the drone jumps straight to its final pose and
never travels through the walkthrough. Nothing errors, and the page looks
built. Always write:
```js
const end = statementEl.getBoundingClientRect().top + window.scrollY;
```
Both expressions are identical in the flat layout, so this is safe either way.
Every other scroll system in `main.js` already uses `getBoundingClientRect()`
and is immune. (The appendix below already contains this fix.)
**What does NOT break:** Elementor's containers set no `transform`, `filter`,
`perspective` or `contain`, so the five `position: fixed` elements — preloader,
masthead, mobile nav, `.hero__stage`, `.hero__brand` — pin correctly. Verified
on a live page: no transform-bearing ancestors.
**Where the drone stops.** The pinned drone runs through the hero and the
walkthrough and ends on the final panel, `[ 01 — Autonomous Flight ]` /
"Ready when you are.", then fades out as the Statement section takes over.
Do not extend it into the later dark sections: they are opaque slabs
(`z-index: 2` + a background, commented "above the fixed drone stage") with
full-width content, so there is nothing for the drone to sit beside without
restyling them.
**Verify before declaring done.** Read the page code back after the first
upload and confirm every `<link>` and `<script src>` actually landed — a page
whose stylesheet never arrived looks completely unstyled and every later
section uploads cleanly on top of it. Then run `uichemy-composer/audit`.
---
# APPENDIX · VERBATIM SOURCE
The complete source of all three files. **Transcribe them exactly** — do not change a value, a comment, a class name, or the order of a declaration. Create the layout below, then serve over HTTP (`python3 -m http.server 8777` → open `http://localhost:8777`). An internet connection is required (Three.js, the fonts, and both drone models load from the web). No other files are needed.
```
nox/
├── index.html
├── css/style.css
└── js/main.js
```
## `index.html`
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NOX — Autonomous LiDAR Inspection Drone</title>
<meta name="description" content="NOX Aerial — autonomous LiDAR inspection systems. Millimeter precision for the infrastructure that can't fail." />
<!-- Clash Display + Satoshi are self-hosted: the @font-face rules live at the
top of css/style.css and load .woff2 from the WordPress media library.
No Fontshare <link> is needed. -->
<link rel="stylesheet" href="css/style.css?v=28" />
<!-- Three.js (module) — needs to run from a local/remote server, not file:// -->
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/"
}
}
</script>
</head>
<body>
<!-- reusable NOX logo (fill inherits currentColor so it adapts to light/dark) -->
<svg width="0" height="0" style="position:absolute" aria-hidden="true"><symbol id="nox-logo" viewBox="0 0 837 302"><path fill="currentColor" d="M332.483 95.5819C389.227 94.6468 436.047 139.731 437.239 196.447C438.439 253.163 393.553 300.178 336.82 301.631C279.725 303.091 232.324 257.866 231.122 200.784C229.921 143.701 275.377 96.5231 332.483 95.5819ZM437.599 108.176C457.511 107.485 481.489 107.909 501.572 108.137L553.667 164.796C570.779 145.751 588.618 127.271 605.688 108.141C629.405 107.959 653.13 108.01 676.854 108.295L594.111 199.093C616.252 222.121 637.258 246.854 659.244 270.09C664.845 276.003 671.083 282.676 676.218 288.93C674.577 290.066 670.601 289.702 668.209 289.736L605.729 289.765L553.667 232.864C536.049 251.547 518.733 270.508 501.711 289.737L430.006 289.714C438.186 279.588 452.253 265.181 461.43 255.183C478.509 236.371 495.751 217.712 513.165 199.211C486.354 169.395 456.776 138.785 430.732 109.074C432.063 108.008 435.476 108.275 437.599 108.176ZM116.41 99.8886C138.423 96.3932 165.677 105.136 181.3 121.19C209.901 150.582 207.458 190.681 207.442 228.574L207.375 289.691L156.448 289.708C155.691 268.656 156.748 246.151 156.359 225.02C156.168 214.587 156.974 202.383 155.379 192.141C154.38 185.478 152.156 179.058 148.82 173.205C133.81 147.269 102.86 146.228 79.1113 160.532C48.871 182.258 50.6001 211.793 50.6973 244.727L50.748 289.649L0.00292969 289.691L0 108.116C17.0172 107.918 34.0354 107.938 51.0518 108.176C51.0232 115.862 51.0698 123.55 51.1914 131.234C71.4431 112.506 88.7708 103.311 116.41 99.8886ZM388.148 189.064C382.833 159.464 354.565 139.743 324.939 144.968C295.187 150.215 275.356 178.622 280.694 208.348C286.032 238.073 314.509 257.812 344.229 252.387C373.823 246.985 393.463 218.664 388.148 189.064ZM740.371 116.084C758.65 114.363 758.413 123.58 757.344 139.116C756.617 149.74 758.511 165.344 750.421 172.863C731.325 178.515 734.746 151.128 733.872 140.193C733.439 134.771 733.709 128.695 734.175 123.291C734.477 119.835 737.791 117.891 740.371 116.084ZM740.91 72.7167C748.503 70.0994 756.789 74.0883 759.483 81.6571C762.177 89.2267 758.275 97.5516 750.724 100.318C743.074 103.124 734.608 99.15 731.873 91.4755C729.146 83.7998 733.203 75.3708 740.91 72.7167ZM783.478 75.328C795.438 74.6531 806.966 76.0559 818.648 76.1669C833.646 76.3107 845.361 88.441 827.204 96.1347C820.624 96.8072 813.358 97.3393 806.77 97.6103C796.181 98.0461 773.583 102.148 776.366 84.4589C777.158 79.4364 779.559 77.8001 783.478 75.328ZM689.215 75.4491C696.856 75.0239 705.739 73.544 711.993 78.8954C716.932 82.9892 717.275 90.1245 712.344 94.4042C705.478 100.36 691.403 97.8965 682.7 97.3603C676.863 97.1807 646.566 97.9745 658.265 80.7069C661.751 75.5629 681.81 75.8033 689.215 75.4491ZM742.894 0.00771655C758.813 -0.379946 756.103 13.9154 757.034 25.6269C757.687 33.8322 757.834 43.3874 756.675 51.4394C756.25 54.3806 750.846 56.8745 748.462 58.0204C727.913 58.8006 734.419 38.1363 734.256 24.6073C734.15 15.4488 734.624 5.07183 742.894 0.00771655Z"/></symbol></svg>
<!-- ================= PRELOADER ================= -->
<div class="preloader" id="preloader" aria-hidden="true">
<div class="preloader__inner">
<div class="preloader__brand">NOX</div>
<div class="preloader__bar"><span id="preloadFill"></span></div>
<div class="preloader__pct"><span id="preloadPct">0</span><em>%</em></div>
</div>
</div>
<!-- ================= HEADER ================= -->
<header class="masthead" id="masthead">
<a class="masthead__logo" href="#hero" aria-label="NOX home">
<span class="dot"></span><svg class="logo-svg" aria-hidden="true"><use href="#nox-logo"/></svg>
</a>
<nav class="masthead__nav" aria-label="Primary">
<a href="#scan">Capability</a>
<a href="#solutions">Solutions</a>
<a href="#inside">Anatomy</a>
<a href="#specs">Specs</a>
<a href="#field">Field</a>
</nav>
<div class="masthead__right">
<span class="masthead__clock" id="heroClock">GMT · --:--</span>
<a class="masthead__cta" href="#deploy">Request demo</a>
<button class="masthead__menu" id="menuBtn" type="button" aria-label="Open menu" aria-expanded="false">
<span class="masthead__bars"><i></i><i></i></span>
</button>
</div>
</header>
<!-- ================= MOBILE NAV OVERLAY ================= -->
<div class="navmenu" id="navMenu" aria-hidden="true">
<button class="navmenu__close" id="menuClose" type="button" aria-label="Close menu">Close ✕</button>
<nav class="navmenu__links" aria-label="Mobile">
<a href="#scan">Capability</a>
<a href="#solutions">Solutions</a>
<a href="#inside">Anatomy</a>
<a href="#specs">Specifications</a>
<a href="#field">Field</a>
<a href="#deploy" class="navmenu__cta">Request a demo →</a>
</nav>
<div class="navmenu__foot">
<span>NOX — LiDAR Inspection Systems</span>
<span>N 47.3769 · E 08.5417</span>
</div>
</div>
<main>
<!-- ================= 01 · HERO ================= -->
<section class="hero" id="hero">
<!-- soft contact shadow under the drone -->
<div class="hero__glow" aria-hidden="true"></div>
<!-- giant brand mark (real logo, faint) behind the drone -->
<div class="hero__brand" id="heroBrand" aria-hidden="true"><svg class="hero__brandmark" viewBox="0 0 837 302"><use href="#nox-logo"/></svg></div>
<!-- 3D stage (pinned overlay) -->
<div class="hero__stage" id="stage" aria-hidden="true">
<canvas id="scene"></canvas>
</div>
<!-- bottom: headline left · scroll-down right -->
<div class="hero__base">
<div class="hero__lead">
<h1 class="hero__headline" data-reveal>
<span class="bright">Find the fault before it fails.</span>
<span class="muted">Autonomous LiDAR inspection for bridges, turbines and power grids — millimetre-accurate, from a single flight.</span>
</h1>
</div>
<a class="scrolldown" href="#reveal">
Scroll Down
<span class="scrolldown__line"><span></span></span>
</a>
</div>
</section>
<!-- ============ 01 · WALKTHROUGH — drone is rotated BY SCROLL through features ==
The pinned 3D stage floats over these panels. As you scroll, main.js drives the
drone through keyframed poses (front → payload → airframe → rear → rest) while
each feature callout fades in. No hover-rotate — motion is scroll-driven. ===== -->
<section class="walk" id="reveal" data-walk>
<!-- F.01 — drone tilts to present the underside payload (drone travels right) -->
<div class="walk__panel walk__panel--left" data-panel="0">
<div class="feat">
<p class="feat__idx"><span class="brk">[</span> F.01 — Payload <span class="brk">]</span></p>
<h3 class="feat__title">A survey-grade LiDAR core, slung low.</h3>
<p class="feat__desc">The Halo-L sensor sits on a 3-axis gimbal beneath the airframe — 240,000 points per second, five returns per pulse.</p>
<div class="feat__leader" aria-hidden="true"></div>
</div>
</div>
<!-- F.02 — drone yaws to show the arms (drone travels left) -->
<div class="walk__panel walk__panel--right" data-panel="1">
<div class="feat">
<p class="feat__idx"><span class="brk">[</span> F.02 — Airframe <span class="brk">]</span></p>
<h3 class="feat__title">Folding carbon arms, ready in seconds.</h3>
<p class="feat__desc">Four brushless rotors on carbon-fibre arms fold to a 480 mm frame — survey-grade capture that ships as carry-on.</p>
<div class="feat__leader" aria-hidden="true"></div>
</div>
</div>
<!-- F.03 — drone shows rear three-quarter (drone travels right) -->
<div class="walk__panel walk__panel--left" data-panel="2">
<div class="feat">
<p class="feat__idx"><span class="brk">[</span> F.03 — Link <span class="brk">]</span></p>
<h3 class="feat__title">Streams home from 20 km out.</h3>
<p class="feat__desc">An encrypted O4-class link holds a 1080p feed to the ground station and georeferences the point cloud mid-flight.</p>
<div class="feat__leader" aria-hidden="true"></div>
</div>
</div>
<!-- FINAL — the drone parks; a compact CTA closes the walkthrough -->
<div class="walk__panel walk__panel--left walk__panel--final" data-panel="3">
<div class="feat">
<p class="feat__idx"><span class="brk">[</span> 01 — Autonomous Flight <span class="brk">]</span></p>
<h3 class="feat__title">Ready when you are.</h3>
<div class="hero__cta">
<a class="btn btn--primary" href="#deploy">Request a demo
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true"><path d="M3 7.5h9M8 3.5l4 4-4 4" stroke="currentColor" stroke-width="1.2"/></svg>
</a>
<a class="btn btn--ghost" href="#scan">Watch it scan</a>
</div>
</div>
</div>
</section>
<!-- ================= 02 · STATEMENT — drone fades out, text takes over ===== -->
<section class="statement" id="statement">
<div class="statement__wrap">
<div class="statement__main">
<h2 class="statement__line" data-scrub>A crack you'd miss from the ground is unmissable at 240,000 points a second.</h2>
</div>
<div class="statement__facts">
<p class="statement__meta">It flies the line itself and lands with a georeferenced point cloud of every surface it passed — the exact geometry your engineers need. No crew at height. No scaffolding.</p>
<ul class="statement__stats">
<li><b>5</b><span>Returns / Pulse</span></li>
<li><b>±2 cm</b><span>Detection Accuracy</span></li>
<li><b>450 m</b><span>Max Range</span></li>
<li><b>240K</b><span>Points / Second</span></li>
</ul>
</div>
</div>
</section>
<!-- ================= 03 · PAYLOAD / SENSOR CORE ============================= -->
<section class="gear" id="gear">
<div class="gear__inner">
<div class="gear__head">
<h2 class="gear__title"><span>Payload</span></h2>
<span class="gear__num"><span class="brk">[</span> 03 <span class="brk">]</span></span>
</div>
<div class="gear__body">
<p class="gear__cat">Sensor Core</p>
<p class="gear__lead" data-scrub>Built around the Halo-L survey-grade LiDAR core — frame-based point-cloud acquisition with five simultaneous returns per pulse, so dense canopy, lattice steel and hairline cracks resolve in a single pass.</p>
</div>
</div>
</section>
<!-- ============ WHITE · SHOWCASE — big product image on light (DJI-style) === -->
<section class="showcase sec--light" id="showcase">
<div class="showcase__inner">
<p class="eyebrow showcase__eyebrow"><span class="brk">[</span> The Aircraft <span class="brk">]</span></p>
<h2 class="showcase__title">Fits a case. Maps a bridge.</h2>
<p class="showcase__sub">Survey LiDAR core, folding carbon airframe, encrypted downlink — the whole system packs into a single hard case one operator carries to site.</p>
<div class="showcase__media imgslot imgslot--filled" style="background-image:url(https://demo.uichemy.com/wp-content/uploads/2026/09/product.jpg)"></div>
</div>
</section>
<!-- ================= 04 · SCAN — 360° full-hemisphere ====================== -->
<section class="scan" id="scan">
<div class="scan__ghost" aria-hidden="true"><span>360°</span></div>
<div class="scan__inner">
<div class="scan__stage">
<canvas id="cloud" aria-hidden="true"></canvas>
<div class="scan__ring">[ POINT CLOUD · LIVE ]</div>
</div>
<div class="scan__copy">
<p class="eyebrow"><span class="brk">[</span> 04 — Capability <span class="brk">]</span></p>
<h2 class="scan__title">One pass.<br>Every angle.</h2>
<p class="scan__body">Terrain, steel and vegetation captured as one full-hemisphere point cloud — the whole asset, from a single flight line, at 240,000 points a second.</p>
<div class="scan__tabs">
<input type="radio" name="scan" id="scan-a" checked>
<input type="radio" name="scan" id="scan-b">
<input type="radio" name="scan" id="scan-c">
<div class="scan__labels">
<label class="scan__tab" for="scan-a">Scan</label>
<label class="scan__tab" for="scan-b">Link</label>
<label class="scan__tab" for="scan-c">Frame</label>
</div>
<div class="scan__panels">
<p class="scan__spec" data-k="a">Full-hemisphere LiDAR at 240,000 pts·s⁻¹ with five returns per pulse — foliage, steel and terrain in one dome of geometry.</p>
<p class="scan__spec" data-k="b">Georeferenced in flight over an encrypted O4-class link, so the point cloud lands ready to process the moment NOX touches down.</p>
<p class="scan__spec" data-k="c">Folds to a 480 mm frame that fits a single hard case — survey-grade capture that ships as carry-on.</p>
</div>
</div>
</div>
</div>
</section>
<!-- ================= 05 · SOLUTIONS — tabbed verticals ===================== -->
<section class="sol" id="solutions">
<div class="sol__inner">
<div class="sol__head">
<h2 class="sol__title">Solutions</h2>
<span class="sol__num"><span class="brk">[</span> 05 <span class="brk">]</span></span>
</div>
<input type="radio" name="sol" id="sol-1" checked>
<input type="radio" name="sol" id="sol-2">
<input type="radio" name="sol" id="sol-3">
<input type="radio" name="sol" id="sol-4">
<div class="sol__grid">
<div class="sol__tabs">
<label class="sol__tab" for="sol-1"><span class="i">01</span> Energy</label>
<label class="sol__tab" for="sol-2"><span class="i">02</span> Infrastructure</label>
<label class="sol__tab" for="sol-3"><span class="i">03</span> Industry</label>
<label class="sol__tab" for="sol-4"><span class="i">04</span> Geospatial</label>
</div>
<div class="sol__view">
<div class="sol__panel" data-k="1">
<p class="i">Energy</p>
<p class="sol__desc">Grids and wind farms sprawl across terrain no ground crew can cover. NOX flies it in hours, not weeks — and never sends a climber up a turbine.</p>
<div class="sol__tags"><span>Wind turbines</span><span>Solar farms</span><span>Power lines</span><span>Substations</span></div>
</div>
<div class="sol__panel" data-k="2">
<p class="i">Infrastructure</p>
<p class="sol__desc">Bridges, dams and tunnels hide movement where crews can't safely reach. NOX resolves millimetre drift across a full span in one pass.</p>
<div class="sol__tags"><span>Bridges</span><span>Dams</span><span>Rail</span><span>Tunnels</span></div>
</div>
<div class="sol__panel" data-k="3">
<p class="i">Industry</p>
<p class="sol__desc">Inspect live plant from the air — refineries, flare stacks, tank farms — with no shutdown, no scaffolding and no confined-space entry.</p>
<div class="sol__tags"><span>Refineries</span><span>Flare stacks</span><span>Tank farms</span><span>Pipelines</span></div>
</div>
<div class="sol__panel" data-k="4">
<p class="i">Geospatial</p>
<p class="sol__desc">Quarries, coastlines and worksites change by the day. Every overflight becomes a georeferenced surface model — volumes, contours and change, measured.</p>
<div class="sol__tags"><span>Topography</span><span>Volumetrics</span><span>Coastal</span><span>Earthworks</span></div>
</div>
</div>
</div>
</div>
</section>
<!-- ============ WHITE · IMAGING — image + copy feature block =============== -->
<section class="split sec--light" id="imaging">
<div class="split__inner">
<div class="split__media imgslot imgslot--filled" style="background-image:url(https://demo.uichemy.com/wp-content/uploads/2026/09/sensor.jpg)"></div>
<div class="split__copy">
<p class="eyebrow split__eyebrow"><span class="brk">[</span> Imaging <span class="brk">]</span></p>
<h2 class="split__title">A survey sensor, on a stabilised gimbal.</h2>
<p class="split__desc">Five returns per pulse, stabilised on a 3-axis gimbal — hairline cracks and dense canopy resolve sharp, in a single pass.</p>
<div class="split__stats">
<div class="split__stat"><b>±2 cm</b><span>Accuracy</span></div>
<div class="split__stat"><b>5</b><span>Returns / pulse</span></div>
<div class="split__stat"><b>450 m</b><span>Range</span></div>
</div>
</div>
</div>
</section>
<!-- ================= 06 · FAULT ZONE — detection =========================== -->
<section class="fault" id="fault">
<canvas class="fault__canvas" id="faultCloud" aria-hidden="true"></canvas>
<div class="fault__vignette" aria-hidden="true"></div>
<div class="fault__corner" aria-hidden="true">
<span>[ NOX ]</span>
<span>[ D:02 · M:CIF ]</span>
</div>
<div class="fault__hud">
<p class="fault__index">01 — Structural Fault Zone</p>
<h2 class="fault__caption" data-scrub>Surface deformation detected across a 42 m² sector.</h2>
<div class="fault__meta">
<span>CONFIDENCE 87%</span>
<span class="fault__risk"><i class="d"></i> RISK LEVEL: <b>MODERATE</b></span>
</div>
</div>
</section>
<!-- ================= 07 · PIPELINE — white "how it works" ================== -->
<section class="pipe sec--light" id="pipeline">
<div class="pipe__inner">
<div class="pipe__head">
<h2 class="pipe__title">Raw flight data to georeferenced deliverable in under 20 minutes.</h2>
<span class="pipe__num"><span class="brk">[</span> 07 — Platform <span class="brk">]</span></span>
</div>
<div class="pipe__steps">
<div class="pipe__step">
<span class="pipe__k">01</span>
<div class="pipe__st">Ingest</div>
<p>Flight logs and raw returns import automatically the moment NOX lands.</p>
</div>
<div class="pipe__step">
<span class="pipe__k">02</span>
<div class="pipe__st">Register</div>
<p>RTK/PPK positions fuse into one georeferenced point cloud, ±2 cm.</p>
</div>
<div class="pipe__step">
<span class="pipe__k">03</span>
<div class="pipe__st">Classify</div>
<p>Ground, structure and vegetation separate; anomalies get flagged.</p>
</div>
<div class="pipe__step">
<span class="pipe__k">04</span>
<div class="pipe__st">Report</div>
<p>A quality report and LAS/LAZ/E57 deliverable export, ready to hand off.</p>
</div>
</div>
</div>
</section>
<!-- ================= 08 · AIRFRAME — flight performance ==================== -->
<section class="airframe" id="airframe">
<div class="airframe__inner">
<div class="airframe__head">
<h2 class="airframe__title">Long in the air. Steady in the wind.</h2>
<span class="airframe__num"><span class="brk">[</span> 08 <span class="brk">]</span></span>
</div>
<div class="airframe__specs">
<div class="airframe__cell">
<div class="airframe__val">42<em>min</em></div>
<p>A single-charge inspection window over the asset.</p>
<div class="airframe__k">Endurance</div>
</div>
<div class="airframe__cell">
<div class="airframe__val">12<em>m·s⁻¹</em></div>
<p>Holds a survey line steady through gusting wind.</p>
<div class="airframe__k">Wind Resistance</div>
</div>
<div class="airframe__cell">
<div class="airframe__val">20<em>km</em></div>
<p>Encrypted HD downlink to the ground station.</p>
<div class="airframe__k">O4 Link Range</div>
</div>
<div class="airframe__cell">
<div class="airframe__val">360<em>°</em></div>
<p>Omnidirectional obstacle sensing, day or night.</p>
<div class="airframe__k">Awareness</div>
</div>
</div>
</div>
</section>
<!-- ============ INSIDE — scroll-driven exploded drone (reveals internals) === -->
<section class="inside" id="inside">
<div class="inside__sticky">
<div class="inside__glow" aria-hidden="true"></div>
<canvas class="inside__canvas" id="insideCanvas" aria-hidden="true"></canvas>
<div class="inside__copy">
<p class="eyebrow"><span class="brk">[</span> Anatomy <span class="brk">]</span></p>
<h2 class="inside__title">Built to be opened up.</h2>
<p class="inside__desc">Shell, power core, motors, sensor — NOX comes apart in layers, so a field swap takes minutes, not a trip back to base.</p>
</div>
<div class="inside__legend">
<div class="inside__item" data-step="0"><span class="k">01</span><span class="n">Composite shell</span></div>
<div class="inside__item" data-step="1"><span class="k">02</span><span class="n">Power core</span></div>
<div class="inside__item" data-step="2"><span class="k">03</span><span class="n">Brushless motors</span></div>
<div class="inside__item" data-step="3"><span class="k">04</span><span class="n">Halo-L sensor</span></div>
</div>
<p class="inside__hint">↓ scroll to disassemble</p>
</div>
</section>
<!-- ================= 09 · FIELD — inspection gallery ======================= -->
<!-- Procedural placeholders. To use real imagery, add
style="background-image:url(assets/field/<name>.jpg)" to a .field__tile and
delete its <span class="field__ph"> watermark. Suggested sources: IMG-7a…e. -->
<section class="field" id="field">
<div class="field__inner">
<div class="field__head">
<div>
<h2 class="field__title">From the field</h2>
<p class="field__lead">Captures pulled straight from NOX inspection flights — point cloud, RGB and thermal.</p>
</div>
<span class="field__num"><span class="brk">[</span> 09 <span class="brk">]</span></span>
</div>
<div class="field__grid">
<div class="field__tile field__tile--filled" style="background-image:url(https://demo.uichemy.com/wp-content/uploads/2026/09/field-wind.jpg)"><span class="field__i">01</span><span class="field__chip">Point Cloud</span><span class="field__cap">N 47.37 · WIND ARRAY</span></div>
<div class="field__tile field__tile--filled" style="background-image:url(https://demo.uichemy.com/wp-content/uploads/2026/09/field-bridge.jpg)"><span class="field__i">02</span><span class="field__chip">RGB</span><span class="field__cap">BRIDGE DECK · SPAN 04</span></div>
<div class="field__tile field__tile--filled" style="background-image:url(https://demo.uichemy.com/wp-content/uploads/2026/09/field-refinery.jpg)"><span class="field__i">03</span><span class="field__chip">Thermal</span><span class="field__cap">REFINERY · STACK 02</span></div>
<div class="field__tile field__tile--filled" style="background-image:url(https://demo.uichemy.com/wp-content/uploads/2026/09/field-terrain.jpg)"><span class="field__i">04</span><span class="field__chip">DSM</span><span class="field__cap">TERRAIN · Q-14</span></div>
<div class="field__tile field__tile--filled" style="background-image:url(https://demo.uichemy.com/wp-content/uploads/2026/09/field-substation.jpg)"><span class="field__i">05</span><span class="field__chip">Point Cloud</span><span class="field__cap">SUBSTATION · BAY 3</span></div>
<div class="field__tile field__tile--filled" style="background-image:url(https://demo.uichemy.com/wp-content/uploads/2026/09/field-solar.jpg)"><span class="field__i">06</span><span class="field__chip">RGB</span><span class="field__cap">SOLAR FARM · ROW 22</span></div>
</div>
</div>
</section>
<!-- ================= 10 · SPECIFICATIONS (white) ========================== -->
<section class="specs sec--light" id="specs">
<div class="specs__inner">
<div class="specs__head">
<h2 class="specs__title">Specifications</h2>
<span class="specs__num"><span class="brk">[</span> 10 <span class="brk">]</span></span>
</div>
<div class="specs__grid">
<div class="specs__group">
<h3>Payload — Halo-L</h3>
<dl class="specs__row"><dt>Acquisition rate</dt><dd>240,000 pts·s⁻¹</dd></dl>
<dl class="specs__row"><dt>Returns per pulse</dt><dd>5</dd></dl>
<dl class="specs__row"><dt>Detection accuracy</dt><dd>±2 cm</dd></dl>
<dl class="specs__row"><dt>Max range</dt><dd>450 m</dd></dl>
<dl class="specs__row"><dt>Field of view</dt><dd>360° × 90°</dd></dl>
</div>
<div class="specs__group">
<h3>Airframe</h3>
<dl class="specs__row"><dt>Endurance</dt><dd>42 min</dd></dl>
<dl class="specs__row"><dt>Wind resistance</dt><dd>12 m·s⁻¹</dd></dl>
<dl class="specs__row"><dt>Folded frame</dt><dd>480 mm</dd></dl>
<dl class="specs__row"><dt>Takeoff weight</dt><dd>3.4 kg</dd></dl>
<dl class="specs__row"><dt>Obstacle sensing</dt><dd>Omnidirectional</dd></dl>
</div>
<div class="specs__group">
<h3>Transmission</h3>
<dl class="specs__row"><dt>System</dt><dd>O4-class</dd></dl>
<dl class="specs__row"><dt>Range</dt><dd>20 km</dd></dl>
<dl class="specs__row"><dt>Live feed</dt><dd>1080p / 60fps</dd></dl>
<dl class="specs__row"><dt>Link</dt><dd>AES-256 encrypted</dd></dl>
</div>
<div class="specs__group">
<h3>Software</h3>
<dl class="specs__row"><dt>Pipeline</dt><dd>Ingest → Report</dd></dl>
<dl class="specs__row"><dt>Deliverable</dt><dd>< 20 min</dd></dl>
<dl class="specs__row"><dt>Output</dt><dd>LAS / LAZ / E57</dd></dl>
<dl class="specs__row"><dt>Georeferencing</dt><dd>RTK / PPK</dd></dl>
</div>
</div>
</div>
</section>
<!-- ================= 11 · DEPLOY — closing CTA ============================= -->
<section class="deploy" id="deploy">
<div class="deploy__glow" aria-hidden="true"></div>
<div class="deploy__inner">
<p class="eyebrow deploy__eyebrow"><span class="brk">[</span> 11 — Deploy <span class="brk">]</span></p>
<h2 class="deploy__title">Put NOX over your assets.</h2>
<p class="deploy__sub">Book a pilot flight over one of your sites. We fly it, process it, and hand back the deliverable — end to end.</p>
<div class="hero__cta deploy__cta">
<a class="btn btn--primary" href="#">Request a deployment
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true"><path d="M3 7.5h9M8 3.5l4 4-4 4" stroke="currentColor" stroke-width="1.2"/></svg>
</a>
<a class="btn btn--ghost" href="#">Download spec sheet</a>
</div>
</div>
</section>
</main>
<!-- ================= FOOTER ================================================= -->
<footer class="foot" id="footer">
<div class="foot__inner">
<div class="foot__top">
<div class="foot__brand">
<span class="foot__logo"><span class="dot"></span><svg class="logo-svg logo-svg--foot" aria-hidden="true"><use href="#nox-logo"/></svg></span>
<p class="foot__note">Autonomous LiDAR inspection — millimetre-accurate maps of the infrastructure that can't fail.</p>
</div>
<div class="foot__col">
<h4>System</h4>
<ul>
<li><a href="#scan">Capability</a></li>
<li><a href="#gear">Payload</a></li>
<li><a href="#specs">Specifications</a></li>
<li><a href="#airframe">Airframe</a></li>
</ul>
</div>
<div class="foot__col">
<h4>Solutions</h4>
<ul>
<li><a href="#solutions">Energy</a></li>
<li><a href="#solutions">Infrastructure</a></li>
<li><a href="#solutions">Industry</a></li>
<li><a href="#solutions">Geospatial</a></li>
</ul>
</div>
<div class="foot__col">
<h4>Contact</h4>
<ul>
<li><a href="#deploy">Request a demo</a></li>
<li><a href="#">Field reports</a></li>
<li><a href="#">Careers</a></li>
<li><a href="#">Press</a></li>
</ul>
</div>
</div>
<div class="foot__bottom">
<p class="foot__legal">NOX is a non-commercial concept demo. All branding, copy, palette and interactions are original; imagery of the aircraft is illustrative.</p>
<div class="foot__stack">
<span>Three.js</span><span>WebGL</span><span>Canvas 2D</span><span>Space Grotesk</span>
</div>
<span>© NOX 2026</span>
</div>
</div>
</footer>
<script type="module" src="js/main.js?v=28"></script>
</body>
</html>
```
## `css/style.css`
```css
/* =====================================================================
FONTS — self-hosted from the WordPress media library.
Originally loaded from api.fontshare.com via a <link> in the page head;
moved here so every asset the page needs is served from this site.
Same families, same weights, same font-display.
===================================================================== */
@font-face {
font-family: 'Clash Display';
src: url('https://demo.uichemy.com/wp-content/uploads/2026/09/clash-display-300.woff2') format('woff2');
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Clash Display';
src: url('https://demo.uichemy.com/wp-content/uploads/2026/09/clash-display-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Clash Display';
src: url('https://demo.uichemy.com/wp-content/uploads/2026/09/clash-display-500.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Clash Display';
src: url('https://demo.uichemy.com/wp-content/uploads/2026/09/clash-display-600.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Satoshi';
src: url('https://demo.uichemy.com/wp-content/uploads/2026/09/satoshi-300.woff2') format('woff2');
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Satoshi';
src: url('https://demo.uichemy.com/wp-content/uploads/2026/09/satoshi-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Satoshi';
src: url('https://demo.uichemy.com/wp-content/uploads/2026/09/satoshi-500.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Satoshi';
src: url('https://demo.uichemy.com/wp-content/uploads/2026/09/satoshi-700.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* =====================================================================
NOX — dark, premium LiDAR-drone hero + statement
Art direction inverted from a light reference into a deep dark theme.
Tokens first; sections follow in source order.
===================================================================== */
:root {
/* --- palette (deep, cool, near-black) --- */
--bg: #070809;
--bg-2: #0c0e11;
--ink: #eef1f3; /* primary text */
--ink-72: rgba(238,241,243,.72);
--ink-52: rgba(238,241,243,.52);
--ink-34: rgba(238,241,243,.34);
--ink-18: rgba(238,241,243,.18);
--line: rgba(238,241,243,.12);
--line-soft: rgba(238,241,243,.07);
--accent: #b6ff3a; /* neon lime accent, used sparingly */
--accent-deep: #4c7a12;
--ember: #ff4d4d; /* echoes the drone's nav lights */
/* --- type --- */
--sans: "Satoshi", "Space Grotesk", "Helvetica Neue", Arial, sans-serif;
--display: "Clash Display", "Satoshi", "Helvetica Neue", sans-serif;
/* "mono" role kept for small technical labels, but rendered in the normal sans */
--mono: "Satoshi", "Helvetica Neue", Arial, sans-serif;
/* --- rhythm --- */
--pad: clamp(16px, 3.2vw, 44px);
--ease: cubic-bezier(.22,.61,.36,1);
--ease-out: cubic-bezier(.16,1,.3,1);
--head-h: 72px;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
html, body { margin: 0; }
body {
/* dark stage behind the drone (shows through the transparent hero + walk) */
background: radial-gradient(130% 92% at 50% 0%, #0b0f13 0%, #070809 55%, #050609 100%) fixed;
color: var(--ink);
font-family: var(--sans);
font-weight: 300;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow-x: hidden;
}
a { color: inherit; text-decoration: none; }
::selection { background: var(--accent); color: #0f1a04; }
.brk { color: var(--accent); font-weight: 400; }
/* big display headlines use Clash Display; keeps their per-section weights/sizes */
.hero__brand span, .walk__headline, .feat__title, .reveal__headline,
.statement__line, .gear__title, .scan__title, .sol__title, .field__title,
.specs__title, .pipe__title, .airframe__title, .deploy__title,
.showcase__title, .split__title {
font-family: var(--display);
letter-spacing: -0.03em;
}
.eyebrow {
font-family: var(--mono);
font-size: 11px;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--ink-52);
margin: 0 0 clamp(20px, 2.4vw, 34px);
}
/* =====================================================================
PRELOADER
===================================================================== */
.preloader {
position: fixed; inset: 0; z-index: 200;
background: #070809;
display: flex; align-items: center; justify-content: center;
transition: opacity .8s var(--ease), visibility .8s;
}
.preloader.is-done { opacity: 0; visibility: hidden; }
.preloader__inner { width: min(78vw, 360px); text-align: center; }
.preloader__brand {
font-size: 20px; letter-spacing: .5em; font-weight: 500;
padding-left: .5em; margin-bottom: 22px; color: var(--ink);
}
.preloader__bar {
height: 1px; width: 100%; background: var(--ink-18); overflow: hidden;
}
.preloader__bar span {
display: block; height: 100%; width: 0%;
background: linear-gradient(90deg, var(--accent-deep), var(--accent));
transition: width .3s var(--ease-out);
}
.preloader__pct {
margin-top: 16px; font-family: var(--mono); font-size: 12px;
color: var(--ink-52); letter-spacing: .1em;
}
.preloader__pct em { font-style: normal; color: var(--ink-34); }
/* =====================================================================
HEADER
===================================================================== */
.masthead {
position: fixed; top: 0; left: 0; right: 0; z-index: 90;
height: var(--head-h);
display: grid; grid-template-columns: 1fr auto 1fr; align-items: center;
padding: 0 var(--pad);
border-bottom: 1px solid var(--line-soft);
background: linear-gradient(180deg, rgba(7,8,9,.86), rgba(7,8,9,0));
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.masthead__logo {
display: inline-flex; align-items: center; gap: 9px;
font-weight: 600; font-size: 15px; letter-spacing: .34em; padding-left: .34em;
justify-self: start;
}
.masthead__logo .dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--ember);
box-shadow: 0 0 10px 1px rgba(255,77,77,.7);
animation: pulse 2.6s ease-in-out infinite;
}
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.35} }
.masthead__tag {
justify-self: center; margin: 0;
font-family: var(--mono); font-size: 11px; letter-spacing: .08em;
color: var(--ink-52); white-space: nowrap;
}
/* primary nav (desktop) */
.masthead__nav { justify-self: center; display: flex; gap: clamp(16px, 2.1vw, 34px); }
.masthead__nav a {
position: relative; font-family: var(--sans); font-weight: 500; font-size: 14px;
color: var(--ink-72); transition: color .3s var(--ease);
}
.masthead__nav a::after {
content: ""; position: absolute; left: 0; right: 100%; bottom: -5px; height: 1.5px;
background: var(--accent); transition: right .35s var(--ease);
}
.masthead__nav a:hover { color: var(--ink); }
.masthead__nav a:hover::after { right: 0; }
.masthead__cta {
font-family: var(--sans); font-weight: 600; font-size: 13px;
padding: 9px 17px; border-radius: 4px; background: var(--accent); color: #0f1a04;
transition: transform .3s var(--ease-out), filter .3s var(--ease);
}
.masthead__cta:hover { transform: translateY(-1px); filter: brightness(1.08); }
/* hamburger (mobile only) */
.masthead__menu { display: none; background: none; border: none; cursor: pointer; padding: 8px; margin-right: -8px; color: var(--ink); }
.masthead__bars { display: block; width: 22px; }
.masthead__bars i { display: block; height: 1.5px; background: currentColor; margin: 5px 0; }
/* header flips to a light treatment while it sits over a white section */
.masthead, .masthead__logo, .masthead__tag, .masthead__menu {
transition: background .4s var(--ease), border-color .4s var(--ease), color .4s var(--ease);
}
.masthead--light {
background: linear-gradient(180deg, rgba(243,245,246,.92), rgba(243,245,246,0));
border-bottom-color: rgba(10,12,14,.08);
}
.masthead--light .masthead__logo { color: #0a0c0e; }
.masthead--light .masthead__clock { color: rgba(10,12,14,.55); }
.masthead--light .masthead__nav a { color: rgba(10,12,14,.6); }
.masthead--light .masthead__nav a:hover { color: #0a0c0e; }
.masthead--light .masthead__menu { color: #0a0c0e; }
/* ================= MOBILE NAV OVERLAY ================= */
.navmenu {
position: fixed; inset: 0; z-index: 150;
background: rgba(6,8,10,.98); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
display: flex; flex-direction: column; justify-content: center; padding: var(--pad);
opacity: 0; visibility: hidden; transform: translateY(-14px);
transition: opacity .4s var(--ease), transform .5s var(--ease-out), visibility .4s;
}
.navmenu.is-open { opacity: 1; visibility: visible; transform: none; }
.navmenu__close {
position: absolute; top: 20px; right: var(--pad); background: none; border: none; cursor: pointer;
font-family: var(--mono); font-size: 13px; letter-spacing: .08em; color: var(--ink-72);
}
.navmenu__links { display: flex; flex-direction: column; gap: 4px; }
.navmenu__links a {
font-family: var(--display); font-weight: 400; font-size: clamp(32px, 8vw, 58px);
letter-spacing: -0.02em; color: var(--ink); padding: 6px 0;
transition: color .3s var(--ease), padding-left .35s var(--ease-out);
}
.navmenu__links a:hover { color: var(--accent); padding-left: 12px; }
.navmenu__cta { color: var(--accent) !important; margin-top: 16px; font-size: clamp(20px, 5vw, 30px) !important; }
.navmenu__foot {
position: absolute; bottom: 24px; left: var(--pad); right: var(--pad);
display: flex; justify-content: space-between; gap: 16px;
font-family: var(--mono); font-size: 11px; letter-spacing: .05em; color: var(--ink-34);
}
@media (max-width: 991px) {
.masthead__nav, .masthead__cta, .masthead__clock { display: none; }
.masthead__menu { display: block; }
}
/* =====================================================================
01 · HERO
===================================================================== */
.hero {
position: relative;
min-height: 100svh;
display: flex; flex-direction: column;
padding: 0 var(--pad);
overflow: hidden;
background: transparent; /* light body shows through */
}
/* cinematic dark stage — cyan bloom behind the drone + soft floor shadow */
.hero__glow {
position: absolute; inset: 0; z-index: 0; pointer-events: none;
background:
radial-gradient(56% 44% at 50% 42%, rgba(182,255,58,.13), transparent 64%), /* cyan bloom */
radial-gradient(60% 44% at 50% 4%, rgba(150,180,195,.06), transparent 60%), /* faint top light */
radial-gradient(26% 8% at 50% 78%, rgba(0,0,0,.5), transparent 72%); /* floor shadow */
}
/* faint technical grid */
.hero__grid-lines {
position: absolute; inset: 0; z-index: 0; pointer-events: none;
background-image:
linear-gradient(var(--line-soft) 1px, transparent 1px),
linear-gradient(90deg, var(--line-soft) 1px, transparent 1px);
background-size: 100% 25%, 25% 100%;
mask-image: radial-gradient(circle at 50% 45%, #000 0%, transparent 78%);
-webkit-mask-image: radial-gradient(circle at 50% 45%, #000 0%, transparent 78%);
opacity: .5;
}
/* 3D stage is pinned BEHIND the solid content sections (z-index 1 < sections' 2) so
the drone shows through the transparent hero + walk panels, but the moment a solid
section (Statement onward) scrolls up it slides OVER the drone and hides it cleanly.
Click-through; the drone is driven from window pointer events. */
.hero__stage {
position: fixed; inset: 0; z-index: 1;
pointer-events: none; will-change: opacity;
}
.hero__stage canvas { display: block; width: 100%; height: 100%; }
/* giant brand wordmark sits behind the drone */
.hero__brand {
position: fixed; inset: 0; z-index: 0;
display: flex; align-items: center; justify-content: center;
pointer-events: none; opacity: 0;
will-change: opacity, transform;
}
.hero__brandmark {
width: min(96vw, 1600px); height: auto; display: block;
color: rgba(238,241,243,.055); /* faint ghost on the dark stage */
user-select: none;
}
/* hero bottom row — headline left, scroll-down right (reference layout) */
.hero__base {
position: relative; z-index: 5; margin-top: auto;
display: flex; align-items: flex-end; justify-content: space-between; gap: 24px;
padding-bottom: clamp(24px, 4vh, 48px); pointer-events: none;
}
.hero__lead { max-width: 44ch; pointer-events: auto; }
.hero__headline {
margin: 0; font-family: var(--display); font-weight: 400;
font-size: clamp(24px, 3vw, 44px); line-height: 1.14; letter-spacing: -0.02em;
}
.hero__headline .bright { color: var(--ink); display: block; }
.hero__headline .muted {
color: var(--ink-52); display: block; font-family: var(--sans);
font-size: 0.42em; font-weight: 400; line-height: 1.45; letter-spacing: 0;
margin-top: 0.7em; max-width: 40ch;
}
/* logo (masthead + footer) */
.logo-svg { height: 18px; width: auto; display: block; color: inherit; }
.logo-svg--foot { height: 24px; }
.masthead__right { justify-self: end; display: inline-flex; align-items: center; gap: clamp(12px, 1.6vw, 22px); }
.masthead__clock { font-family: var(--mono); font-size: 11px; letter-spacing: .06em; color: var(--ink-52); white-space: nowrap; }
@media (max-width: 767px) { .masthead__clock { display: none; } }
/* radar / target reticle behind the drone — signals a LiDAR inspection HUD */
.hero__reticle {
position: absolute; top: 46%; left: 50%; transform: translate(-50%, -50%);
width: min(66vw, 80vh); height: min(66vw, 80vh); z-index: 0; pointer-events: none;
color: rgba(182,255,58,.16); opacity: .8;
-webkit-mask: radial-gradient(circle, #000 34%, transparent 72%);
mask: radial-gradient(circle, #000 34%, transparent 72%);
}
/* thin scan line sweeping down the hero */
.hero__scan {
position: absolute; left: 0; right: 0; top: 0; height: 1px; z-index: 0; pointer-events: none;
background: linear-gradient(90deg, transparent, rgba(182,255,58,.35) 20%, rgba(182,255,58,.35) 80%, transparent);
filter: blur(.4px); opacity: .0;
animation: heroScan 7s cubic-bezier(.5,0,.5,1) infinite;
}
@keyframes heroScan { 0%{top:12%;opacity:0} 10%{opacity:.7} 88%{top:86%;opacity:0} 100%{opacity:0} }
/* corner HUD brackets */
.hero__corners { position: absolute; inset: calc(var(--head-h) + 10px) var(--pad) 20px; z-index: 5; pointer-events: none; }
.hero__corners i { position: absolute; width: 26px; height: 26px; opacity: .5; }
.hero__corners i:nth-child(1) { top: 0; left: 0; border-top: 1px solid var(--accent); border-left: 1px solid var(--accent); }
.hero__corners i:nth-child(2) { top: 0; right: 0; border-top: 1px solid var(--accent); border-right: 1px solid var(--accent); }
.hero__corners i:nth-child(3) { bottom: 0; left: 0; border-bottom: 1px solid var(--accent); border-left: 1px solid var(--accent); }
.hero__corners i:nth-child(4) { bottom: 0; right: 0; border-bottom: 1px solid var(--accent); border-right: 1px solid var(--accent); }
/* flanking telemetry read-outs */
.hero__readout {
position: absolute; top: 50%; transform: translateY(-50%); z-index: 5; pointer-events: none;
display: grid; grid-template-columns: auto; gap: 3px 0;
font-family: var(--mono); font-size: 10px; letter-spacing: .1em; line-height: 1.5;
}
.hero__readout--l { left: var(--pad); text-align: left; }
.hero__readout--r { right: var(--pad); text-align: right; }
.hero__readout span { color: var(--ink-34); text-transform: uppercase; }
.hero__readout b { color: var(--ink-72); font-weight: 400; margin-bottom: 8px; }
/* fine film grain — kills the flat digital gradient look */
.hero__noise {
position: absolute; inset: 0; z-index: 6; pointer-events: none; opacity: .05; mix-blend-mode: overlay;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
background-size: 140px 140px;
}
/* hero holds only minimal chrome at the bottom now (copy lives in §01 Reveal) */
.hero__hint {
position: relative; z-index: 5; margin-top: auto;
display: flex; align-items: flex-end; justify-content: space-between;
padding-bottom: clamp(20px, 3.2vh, 34px);
pointer-events: none;
}
/* shared word-level blur reveal (hero copy in §01) */
[data-reveal] .bright { color: var(--ink); }
[data-reveal] .muted { color: var(--ink-52); }
[data-reveal] .word {
display: inline-block;
filter: blur(14px);
opacity: 0;
transform: translateY(6px);
transition: filter .9s var(--ease-out), opacity .9s var(--ease-out), transform .9s var(--ease-out);
}
[data-reveal].is-in .word { filter: blur(0); opacity: 1; transform: none; }
.hero__cta {
display: flex; flex-wrap: wrap; gap: 14px;
margin-top: clamp(28px, 3vw, 44px);
}
.btn {
display: inline-flex; align-items: center; gap: 10px;
font-family: var(--sans); font-weight: 500; font-size: 15px; letter-spacing: 0;
padding: 14px 24px; border-radius: 4px;
border: 1px solid transparent;
transition: transform .4s var(--ease-out), background .4s var(--ease), border-color .4s, color .4s;
will-change: transform;
}
.btn svg { transition: transform .4s var(--ease-out); }
.btn--primary { background: var(--ink); color: #06110f; }
.btn--primary:hover { transform: translateY(-2px); background: var(--accent); }
.btn--primary:hover svg { transform: translateX(4px); }
.btn--ghost { color: var(--ink); border-color: var(--line); }
.btn--ghost:hover { border-color: var(--ink-34); background: rgba(238,241,243,.05); transform: translateY(-2px); }
/* hero bottom chrome */
.scrolldown {
pointer-events: auto;
display: inline-flex; align-items: center; gap: 13px;
font-family: var(--mono); font-size: 11px; letter-spacing: .12em;
text-transform: uppercase; color: var(--ink-52);
transition: color .3s var(--ease);
}
.scrolldown:hover { color: var(--ink); }
.scrolldown__line {
position: relative; width: 46px; height: 1px; background: var(--ink-18); overflow: hidden;
}
.scrolldown__line span {
position: absolute; inset: 0; width: 40%;
background: var(--accent);
animation: sweep 2.4s var(--ease) infinite;
}
@keyframes sweep { 0%{transform:translateX(-110%)} 100%{transform:translateX(260%)} }
.hero__coords {
margin: 0; font-family: var(--mono); font-size: 11px; letter-spacing: .06em;
color: var(--ink-34);
}
/* =====================================================================
01 · REVEAL — hero copy on the left, drone parked on the right
===================================================================== */
.reveal {
position: relative; z-index: 2;
min-height: 100vh; /* a full screen so the drone has room on the right */
display: flex; align-items: center;
background: var(--bg);
border-top: 1px solid var(--line);
padding: clamp(80px, 14vh, 170px) var(--pad);
}
.reveal__wrap {
width: 100%;
display: grid; grid-template-columns: 1fr 1fr;
max-width: 1500px; margin: 0 auto;
}
.reveal__main {
min-width: 0;
padding-right: clamp(24px, 4vw, 90px);
}
.reveal__void { min-width: 0; } /* right half — the drone parks here */
.reveal__headline {
margin: 0; font-weight: 300;
font-size: clamp(30px, 3.6vw, 56px);
line-height: 1.14; letter-spacing: -0.02em;
}
/* =====================================================================
WALKTHROUGH — the drone is driven by SCROLL through feature callouts
(pinned 3D stage floats over these panels; rotation is scroll-keyframed)
===================================================================== */
.walk { position: relative; z-index: 2; }
.walk__panel {
position: relative; z-index: 2;
min-height: 100svh; display: flex; align-items: center;
padding: 0 var(--pad); pointer-events: none;
}
.walk__panel--left { justify-content: flex-start; }
.walk__panel--right { justify-content: flex-end; }
.feat {
max-width: 32ch; pointer-events: auto;
opacity: 0; transform: translateY(24px); filter: blur(6px);
transition: opacity .9s var(--ease-out), transform .9s var(--ease-out), filter .9s var(--ease-out);
}
.walk__panel.is-in .feat { opacity: 1; transform: none; filter: blur(0); }
.walk__panel--right .feat { text-align: right; }
.feat__idx {
margin: 0 0 18px; font-family: var(--mono); font-size: 12px;
letter-spacing: .14em; text-transform: uppercase; color: var(--accent);
}
.feat__title {
margin: 0 0 16px; font-weight: 300; letter-spacing: -0.02em; line-height: 1.06;
font-size: clamp(26px, 3.4vw, 50px);
}
.feat__desc { margin: 0; font-size: clamp(14px, 1.15vw, 17px); line-height: 1.62; color: var(--ink-72); }
.feat__leader {
margin-top: 24px; width: 72px; height: 1px;
background: linear-gradient(90deg, var(--accent), transparent);
}
.walk__panel--right .feat__leader { margin-left: auto; background: linear-gradient(270deg, var(--accent), transparent); }
/* the final panel carries the hero copy + CTA (drone parks beside it) */
.walk__panel--final .feat { max-width: 40ch; }
.walk__headline {
margin: 0; font-weight: 300; letter-spacing: -0.02em; line-height: 1.12;
font-size: clamp(30px, 3.6vw, 56px);
}
.walk__headline .bright { color: var(--ink); }
.walk__headline .muted { color: var(--ink-52); }
/* =====================================================================
LIGHT THEME — invert the tokens for white sections (ref is light)
===================================================================== */
.sec--light {
--bg: #f3f5f6;
--bg-2: #ffffff;
--ink: #0a0c0e;
--ink-72: rgba(10,12,14,.72);
--ink-52: rgba(10,12,14,.56);
--ink-34: rgba(10,12,14,.40);
--ink-18: rgba(10,12,14,.16);
--line: rgba(10,12,14,.14);
--line-soft: rgba(10,12,14,.08);
--accent: #5a8a0f; /* deepened cyan for AA contrast on white */
background: var(--bg); color: var(--ink);
}
.sec--light .brk { color: var(--accent); }
.sec--light ::selection { background: var(--accent); color: #fff; }
/* light TOKENS without a background — used on hero + walk so the light body
shows through (the drone needs a transparent stage to render over) */
.zone--light {
--bg: #f3f5f6;
--bg-2: #ffffff;
--ink: #0a0c0e;
--ink-72: rgba(10,12,14,.72);
--ink-52: rgba(10,12,14,.5);
--ink-34: rgba(10,12,14,.38);
--ink-18: rgba(10,12,14,.14);
--line: rgba(10,12,14,.14);
--line-soft: rgba(10,12,14,.07);
--accent: #5a8a0f;
color: var(--ink);
}
.zone--light .brk { color: var(--accent); }
/* =====================================================================
PIPELINE — white "how it works" section
===================================================================== */
.pipe {
position: relative; z-index: 2;
border-top: 1px solid var(--line);
padding: clamp(80px, 13vh, 190px) var(--pad);
}
.pipe__inner { max-width: 1500px; margin-inline: auto; }
.pipe__head {
display: flex; align-items: flex-end; justify-content: space-between; gap: 24px;
margin-bottom: clamp(44px, 7vh, 96px);
}
.pipe__title { margin: 0; max-width: 20ch; font-weight: 300; font-size: clamp(30px, 3.6vw, 54px); line-height: 1.08; letter-spacing: -0.02em; }
.pipe__num { flex: none; font-family: var(--mono); font-size: 14px; color: var(--ink-52); }
.pipe__steps {
display: grid; grid-template-columns: repeat(4, 1fr);
gap: 1px; background: var(--line); border: 1px solid var(--line);
}
.pipe__step {
background: var(--bg-2); padding: clamp(24px, 2.6vw, 40px);
display: flex; flex-direction: column; gap: 16px; min-height: clamp(200px, 26vh, 280px);
position: relative; transition: background .4s var(--ease);
}
.pipe__step:hover { background: #fff; }
.pipe__k { font-family: var(--mono); font-size: 13px; letter-spacing: .1em; color: var(--accent); }
.pipe__st { margin: auto 0 0; font-weight: 400; font-size: clamp(20px, 2vw, 28px); letter-spacing: -0.02em; }
.pipe__step p { margin: 0; font-size: 14px; line-height: 1.55; color: var(--ink-72); }
/* =====================================================================
02 · STATEMENT
===================================================================== */
.statement {
position: relative;
z-index: 2;
background: var(--bg);
border-top: 1px solid var(--line);
padding: 0 var(--pad);
}
.statement__wrap {
display: grid; grid-template-columns: 7fr 5fr;
max-width: 1500px; margin: 0 auto;
}
.statement__main {
min-width: 0; /* allow the heading to wrap inside the grid track */
border-right: 1px solid var(--line);
padding: clamp(90px, 20vh, 280px) clamp(30px, 5vw, 110px) clamp(90px, 20vh, 280px) 0;
}
.statement__line {
margin: 0;
font-weight: 300;
font-size: clamp(30px, 4vw, 60px);
line-height: 1.12;
letter-spacing: -0.03em;
overflow-wrap: break-word;
}
/* per-character color scrub (set by JS) */
.statement__line .ch { color: var(--ink-18); transition: color .18s linear; }
.statement__facts {
min-width: 0;
padding: clamp(90px, 20vh, 280px) 0 clamp(90px, 20vh, 280px) clamp(30px, 4vw, 80px);
display: flex; flex-direction: column; justify-content: center; gap: 40px;
}
.statement__meta {
margin: 0; max-width: 40ch;
font-size: clamp(15px, 1.15vw, 18px); line-height: 1.62; color: var(--ink-72);
}
.statement__stats {
list-style: none; margin: 0; padding: 24px 0 0; border-top: 1px solid var(--line);
display: grid; gap: 20px;
}
.statement__stats li { display: flex; align-items: baseline; gap: 16px; }
.statement__stats b {
font-family: var(--mono); font-weight: 400; font-size: clamp(22px, 2vw, 30px);
color: var(--ink); min-width: 3.4em; letter-spacing: -0.01em;
}
.statement__stats span {
font-family: var(--mono); font-size: 11px; letter-spacing: .1em;
text-transform: uppercase; color: var(--ink-52);
}
/* =====================================================================
03 · PAYLOAD / GEAR
===================================================================== */
.gear {
position: relative;
z-index: 2; /* above the fixed drone stage */
background: var(--bg);
border-top: 1px solid var(--line);
padding: clamp(80px, 13vh, 190px) var(--pad);
}
.gear__inner, .gear__head, .gear__body { max-width: 1500px; margin-inline: auto; }
.gear__head {
display: flex; align-items: flex-start; justify-content: space-between; gap: 24px;
}
.gear__title {
margin: 0; min-width: 0;
font-weight: 300;
font-size: clamp(64px, 12.5vw, 200px);
line-height: 0.9;
letter-spacing: -0.05em;
color: var(--ink);
}
.gear__title span { display: inline-block; }
.gear__num {
flex: none;
font-family: var(--mono); font-size: 14px; color: var(--ink-52);
padding-top: 10px;
}
.gear__body {
display: grid; grid-template-columns: 1fr 2fr;
gap: clamp(24px, 6vw, 120px);
margin-top: clamp(48px, 10vh, 150px);
align-items: start;
}
.gear__cat {
min-width: 0;
font-family: var(--mono); font-size: 12px; letter-spacing: .14em;
text-transform: uppercase; color: var(--ink-52);
padding-top: 14px;
}
.gear__lead {
min-width: 0; margin: 0; max-width: 34ch;
font-weight: 300;
font-size: clamp(22px, 2.7vw, 34px);
line-height: 1.28; letter-spacing: -0.02em;
overflow-wrap: break-word;
}
/* shared per-character reveal target — starts dim, JS lights it on scroll */
.ch { color: var(--ink-18); transition: color .18s linear; }
/* =====================================================================
04 · SCAN — 360° full-hemisphere (procedural point-cloud canvas)
===================================================================== */
.scan {
position: relative; z-index: 2; overflow: hidden;
background: var(--bg);
border-top: 1px solid var(--line);
padding: clamp(90px, 16vh, 220px) var(--pad);
}
.scan__ghost {
position: absolute; inset: 0; z-index: 0; pointer-events: none;
display: flex; align-items: center; justify-content: center;
}
.scan__ghost span {
font-weight: 500; line-height: 1; letter-spacing: -0.05em;
font-size: min(34vw, 52vh);
color: rgba(238,241,243,.035);
-webkit-text-stroke: 1px rgba(238,241,243,.05);
user-select: none;
}
.scan__inner {
position: relative; z-index: 2;
max-width: 1500px; margin-inline: auto;
display: grid; grid-template-columns: 1.05fr 1fr;
gap: clamp(24px, 5vw, 96px); align-items: center;
}
.scan__stage {
position: relative; min-width: 0;
aspect-ratio: 1 / 1;
border: 1px solid var(--line); border-radius: 2px;
background:
radial-gradient(60% 60% at 50% 52%, rgba(182,255,58,.10), rgba(7,8,9,0) 70%),
var(--bg-2);
overflow: hidden;
}
.scan__stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.scan__ring {
position: absolute; left: 12px; bottom: 12px;
font-family: var(--mono); font-size: 10px; letter-spacing: .12em;
color: var(--ink-34);
}
.scan__copy { min-width: 0; }
.scan__title {
margin: .1em 0 .55em; font-weight: 300;
font-size: clamp(34px, 4.6vw, 72px);
line-height: 1.02; letter-spacing: -0.03em;
}
.scan__body {
margin: 0 0 clamp(30px, 3.4vw, 46px); max-width: 42ch;
font-size: clamp(15px, 1.15vw, 18px); line-height: 1.62; color: var(--ink-72);
}
/* CSS-only sub-tabs */
.scan__tabs { border-top: 1px solid var(--line); }
.scan__tabs input { position: absolute; width: 0; height: 0; opacity: 0; pointer-events: none; }
.scan__labels { display: flex; }
.scan__tab {
flex: 1; text-align: left; padding: 16px 0; margin-right: 26px;
border-top: 1px solid transparent; margin-top: -1px;
font-family: var(--mono); font-size: 12px; letter-spacing: .12em; text-transform: uppercase;
color: var(--ink-34); cursor: pointer;
transition: color .3s var(--ease), border-color .3s var(--ease);
}
.scan__tab:last-child { margin-right: 0; }
.scan__panels { padding-top: 8px; position: relative; min-height: 4.4em; }
.scan__spec {
margin: 0; max-width: 38ch;
font-size: clamp(14px, 1.05vw, 16px); line-height: 1.6; color: var(--ink-72);
opacity: 0; visibility: hidden; position: absolute; top: 8px; left: 0;
transform: translateY(6px); transition: opacity .4s var(--ease), transform .4s var(--ease);
}
#scan-a:checked ~ .scan__labels label[for="scan-a"],
#scan-b:checked ~ .scan__labels label[for="scan-b"],
#scan-c:checked ~ .scan__labels label[for="scan-c"] { color: var(--ink); border-top-color: var(--accent); }
#scan-a:checked ~ .scan__panels .scan__spec[data-k="a"],
#scan-b:checked ~ .scan__panels .scan__spec[data-k="b"],
#scan-c:checked ~ .scan__panels .scan__spec[data-k="c"] { opacity: 1; visibility: visible; transform: none; }
/* =====================================================================
05 · SOLUTIONS — tabbed verticals (CSS-only, crossfading viewport)
===================================================================== */
.sol {
position: relative; z-index: 2;
background: var(--bg); border-top: 1px solid var(--line);
padding: clamp(80px, 13vh, 190px) var(--pad);
}
.sol__inner { max-width: 1500px; margin-inline: auto; }
.sol__head {
display: flex; align-items: baseline; justify-content: space-between; gap: 24px;
margin-bottom: clamp(34px, 6vh, 70px);
}
.sol__title { margin: 0; font-weight: 300; font-size: clamp(34px, 4.6vw, 72px); letter-spacing: -0.03em; }
.sol__num { flex: none; font-family: var(--mono); font-size: 14px; color: var(--ink-52); }
.sol__grid {
display: grid; grid-template-columns: 1fr 1.1fr;
gap: clamp(24px, 5vw, 90px); align-items: stretch;
}
.sol input { position: absolute; width: 0; height: 0; opacity: 0; pointer-events: none; }
.sol__tabs { display: flex; flex-direction: column; border-top: 1px solid var(--line); min-width: 0; }
.sol__tab {
display: flex; align-items: baseline; gap: 18px;
padding: clamp(18px, 2.3vw, 30px) 0; border-bottom: 1px solid var(--line);
cursor: pointer; color: var(--ink-34);
font-weight: 300; font-size: clamp(22px, 2.6vw, 34px); letter-spacing: -0.02em;
transition: color .35s var(--ease), padding-left .45s var(--ease-out);
}
.sol__tab .i { font-family: var(--mono); font-size: 12px; letter-spacing: .06em; color: var(--ink-34); transition: color .35s; }
.sol__view {
position: relative; min-height: clamp(330px, 46vh, 540px);
border: 1px solid var(--line); border-radius: 2px; overflow: hidden; background: var(--bg-2);
}
.sol__panel {
position: absolute; inset: 0; opacity: 0; visibility: hidden;
transition: opacity .55s var(--ease);
display: flex; flex-direction: column; justify-content: flex-end;
padding: clamp(22px, 2.6vw, 40px);
}
.sol__panel::before { /* per-vertical procedural scan texture */
content: ""; position: absolute; inset: 0; z-index: 0; opacity: .9;
background-image:
radial-gradient(70% 90% at 78% 12%, rgba(182,255,58,.14), rgba(7,8,9,0) 60%),
linear-gradient(var(--line-soft) 1px, transparent 1px),
linear-gradient(90deg, var(--line-soft) 1px, transparent 1px);
background-size: 100% 100%, 34px 34px, 34px 34px;
mask-image: radial-gradient(120% 100% at 70% 20%, #000 30%, transparent 92%);
-webkit-mask-image: radial-gradient(120% 100% at 70% 20%, #000 30%, transparent 92%);
}
.sol__panel::after { /* sweeping scan line */
content: ""; position: absolute; left: 0; right: 0; top: 0; height: 1px; z-index: 1;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
opacity: .5; animation: solsweep 5.5s var(--ease) infinite;
}
@keyframes solsweep { 0%{transform:translateY(6%)} 50%{transform:translateY(94%)} 100%{transform:translateY(6%)} }
.sol__panel .i {
position: relative; z-index: 2; font-family: var(--mono); font-size: 11px;
letter-spacing: .14em; text-transform: uppercase; color: var(--accent); margin-bottom: auto;
}
.sol__desc {
position: relative; z-index: 2; margin: 0 0 22px; max-width: 40ch;
font-size: clamp(15px, 1.15vw, 18px); line-height: 1.6; color: var(--ink-72);
}
.sol__tags { position: relative; z-index: 2; display: flex; flex-wrap: wrap; gap: 8px; }
.sol__tags span {
font-family: var(--mono); font-size: 10px; letter-spacing: .1em; text-transform: uppercase;
color: var(--ink-72); border: 1px solid var(--line); border-radius: 2px; padding: 7px 11px;
}
#sol-1:checked ~ .sol__grid .sol__tabs label[for="sol-1"],
#sol-2:checked ~ .sol__grid .sol__tabs label[for="sol-2"],
#sol-3:checked ~ .sol__grid .sol__tabs label[for="sol-3"],
#sol-4:checked ~ .sol__grid .sol__tabs label[for="sol-4"] { color: var(--ink); padding-left: 14px; }
#sol-1:checked ~ .sol__grid .sol__tabs label[for="sol-1"] .i,
#sol-2:checked ~ .sol__grid .sol__tabs label[for="sol-2"] .i,
#sol-3:checked ~ .sol__grid .sol__tabs label[for="sol-3"] .i,
#sol-4:checked ~ .sol__grid .sol__tabs label[for="sol-4"] .i { color: var(--accent); }
#sol-1:checked ~ .sol__grid .sol__view .sol__panel[data-k="1"],
#sol-2:checked ~ .sol__grid .sol__view .sol__panel[data-k="2"],
#sol-3:checked ~ .sol__grid .sol__view .sol__panel[data-k="3"],
#sol-4:checked ~ .sol__grid .sol__view .sol__panel[data-k="4"] { opacity: 1; visibility: visible; }
/* =====================================================================
06 · FAULT ZONE — detection (point-cloud structure + HUD overlay)
===================================================================== */
.fault {
position: relative; z-index: 2; overflow: hidden;
min-height: 100vh; display: flex; align-items: center;
background: #04060a; border-top: 1px solid var(--line);
}
.fault__canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; z-index: 0; }
.fault__vignette {
position: absolute; inset: 0; z-index: 1; pointer-events: none;
background: radial-gradient(120% 90% at 50% 50%, rgba(4,6,10,0) 40%, rgba(4,6,10,.85) 100%);
}
.fault__hud {
position: relative; z-index: 3; width: 100%;
max-width: 1500px; margin-inline: auto; padding: 0 var(--pad);
}
.fault__corner {
position: absolute; top: calc(var(--head-h) + 8px); left: var(--pad); right: var(--pad);
display: flex; justify-content: space-between;
font-family: var(--mono); font-size: 11px; letter-spacing: .1em; color: var(--ink-52);
}
.fault__index {
font-family: var(--mono); font-size: 12px; letter-spacing: .14em; text-transform: uppercase;
color: var(--accent); margin: 0 0 24px;
}
.fault__caption {
margin: 0; max-width: 20ch; font-weight: 300;
font-size: clamp(30px, 4vw, 62px); line-height: 1.1; letter-spacing: -0.03em;
}
.fault__meta {
margin: clamp(26px, 3vw, 40px) 0 0; display: flex; align-items: center; gap: 18px;
font-family: var(--mono); font-size: 12px; letter-spacing: .06em; color: var(--ink-52);
}
.fault__risk {
display: inline-flex; align-items: center; gap: 9px;
border: 1px solid rgba(255,77,77,.4); border-radius: 2px; padding: 8px 13px; color: var(--ink);
}
.fault__risk .d {
width: 7px; height: 7px; border-radius: 50%; background: var(--ember);
box-shadow: 0 0 10px 1px rgba(255,77,77,.7); animation: pulse 2.2s ease-in-out infinite;
}
.fault__risk b { font-weight: 400; color: var(--ember); }
/* =====================================================================
07 · AIRFRAME — flight-performance spec highlights
===================================================================== */
.airframe {
position: relative; z-index: 2;
background: var(--bg-2); border-top: 1px solid var(--line);
padding: clamp(80px, 13vh, 190px) var(--pad);
}
.airframe__inner { max-width: 1500px; margin-inline: auto; }
.airframe__head {
display: flex; align-items: flex-end; justify-content: space-between; gap: 24px;
margin-bottom: clamp(40px, 7vh, 90px);
}
.airframe__title {
margin: 0; max-width: 20ch; font-weight: 300;
font-size: clamp(30px, 3.6vw, 54px); line-height: 1.08; letter-spacing: -0.02em;
}
.airframe__num { flex: none; font-family: var(--mono); font-size: 14px; color: var(--ink-52); }
.airframe__specs {
display: grid; grid-template-columns: repeat(4, 1fr);
gap: 1px; background: var(--line); border: 1px solid var(--line);
}
.airframe__cell {
background: var(--bg); padding: clamp(24px, 2.6vw, 42px);
display: flex; flex-direction: column; gap: 14px; min-height: clamp(190px, 24vh, 260px);
}
.airframe__val {
font-weight: 300; font-size: clamp(38px, 4.6vw, 72px); line-height: 1; letter-spacing: -0.03em;
}
.airframe__val em { font-style: normal; font-size: .38em; color: var(--ink-52); margin-left: .18em; }
.airframe__cell p { margin: 0; font-size: 14px; line-height: 1.5; color: var(--ink-72); }
.airframe__k {
margin-top: auto; font-family: var(--mono); font-size: 11px; letter-spacing: .12em;
text-transform: uppercase; color: var(--accent);
}
/* =====================================================================
08 · FIELD — asymmetric gallery (procedural placeholders, image-ready)
===================================================================== */
.field {
position: relative; z-index: 2;
background: var(--bg); border-top: 1px solid var(--line);
padding: clamp(80px, 13vh, 190px) var(--pad);
}
.field__inner { max-width: 1500px; margin-inline: auto; }
.field__head {
display: flex; align-items: baseline; justify-content: space-between; gap: 24px;
margin-bottom: clamp(34px, 6vh, 60px);
}
.field__title { margin: 0; font-weight: 300; font-size: clamp(34px, 4.6vw, 72px); letter-spacing: -0.03em; }
.field__lead { margin: 12px 0 0; max-width: 34ch; font-size: clamp(14px,1.05vw,16px); line-height: 1.6; color: var(--ink-52); }
.field__num { flex: none; font-family: var(--mono); font-size: 14px; color: var(--ink-52); }
.field__grid {
display: grid; grid-template-columns: repeat(6, 1fr);
grid-auto-rows: clamp(150px, 19vh, 224px); gap: 12px;
}
.field__tile {
position: relative; overflow: hidden; min-width: 0;
border: 1px solid var(--line); border-radius: 2px; background: var(--bg-2);
display: flex; flex-direction: column; justify-content: flex-end;
padding: clamp(14px, 1.4vw, 20px);
/* real imagery drops straight in here later: background-image: url(assets/field/xx.jpg) */
background-size: cover; background-position: center;
transition: transform .5s var(--ease-out), border-color .4s var(--ease);
}
.field__tile::before { /* procedural LiDAR scatter + grid (the placeholder look) */
content: ""; position: absolute; inset: 0; z-index: 0;
background-image:
radial-gradient(60% 70% at 30% 18%, rgba(182,255,58,.16), rgba(7,8,9,0) 62%),
radial-gradient(rgba(238,241,243,.5) .6px, transparent .7px),
linear-gradient(var(--line-soft) 1px, transparent 1px),
linear-gradient(90deg, var(--line-soft) 1px, transparent 1px);
background-size: 100% 100%, 9px 9px, 30px 30px, 30px 30px;
opacity: .5;
mask-image: radial-gradient(130% 120% at 30% 15%, #000 20%, transparent 92%);
-webkit-mask-image: radial-gradient(130% 120% at 30% 15%, #000 20%, transparent 92%);
transition: transform .6s var(--ease-out), opacity .5s;
}
.field__tile::after { /* slow scan sweep */
content: ""; position: absolute; left: 0; right: 0; top: 0; height: 1px; z-index: 1;
background: linear-gradient(90deg, transparent, rgba(182,255,58,.55), transparent);
opacity: .0; animation: fieldsweep 6s var(--ease) infinite;
}
@keyframes fieldsweep { 0%,100%{transform:translateY(8%);opacity:0} 45%{opacity:.6} 90%{transform:translateY(92%);opacity:0} }
.field__tile:hover { border-color: var(--ink-34); transform: translateY(-3px); }
.field__tile:hover::before { transform: scale(1.05); opacity: .68; }
.field__ph { /* [ IMG ] watermark — remove when real photo lands */
position: absolute; inset: 0; z-index: 1; display: flex; align-items: center; justify-content: center;
font-family: var(--mono); font-size: 10px; letter-spacing: .3em; color: var(--ink-18);
}
.field__i {
position: absolute; top: clamp(12px,1.2vw,16px); left: clamp(12px,1.2vw,16px); z-index: 2;
font-family: var(--mono); font-size: 10px; letter-spacing: .1em; color: var(--ink-34);
}
.field__chip {
position: absolute; top: clamp(10px,1vw,14px); right: clamp(10px,1vw,14px); z-index: 2;
font-family: var(--mono); font-size: 9px; letter-spacing: .12em; text-transform: uppercase;
color: var(--accent); border: 1px solid rgba(182,255,58,.3); border-radius: 2px; padding: 4px 7px;
background: rgba(4,6,10,.4);
}
.field__cap {
position: relative; z-index: 2;
font-family: var(--mono); font-size: 11px; letter-spacing: .06em; color: var(--ink-72);
}
.field__tile:nth-child(1) { grid-column: span 3; grid-row: span 2; }
.field__tile:nth-child(2) { grid-column: span 3; }
.field__tile:nth-child(3) { grid-column: span 2; }
.field__tile:nth-child(4) { grid-column: span 1; }
.field__tile:nth-child(5) { grid-column: span 2; }
.field__tile:nth-child(6) { grid-column: span 4; }
/* vary a couple tiles toward the ember/alert read */
.field__tile:nth-child(3)::before { background-image:
radial-gradient(60% 70% at 70% 20%, rgba(255,77,77,.14), rgba(7,8,9,0) 60%),
radial-gradient(rgba(238,241,243,.5) .6px, transparent .7px),
linear-gradient(var(--line-soft) 1px, transparent 1px),
linear-gradient(90deg, var(--line-soft) 1px, transparent 1px);
background-size: 100% 100%, 9px 9px, 30px 30px, 30px 30px; }
.field__tile:nth-child(2)::after,
.field__tile:nth-child(5)::after { animation-delay: -3s; }
/* =====================================================================
08 · SPECIFICATIONS — full spec sheet
===================================================================== */
.specs {
position: relative; z-index: 2;
background: var(--bg); border-top: 1px solid var(--line);
padding: clamp(80px, 13vh, 190px) var(--pad);
}
.specs__inner { max-width: 1500px; margin-inline: auto; }
.specs__head {
display: flex; align-items: baseline; justify-content: space-between; gap: 24px;
margin-bottom: clamp(24px, 4vh, 44px);
}
.specs__title { margin: 0; font-weight: 300; font-size: clamp(34px, 4.6vw, 72px); letter-spacing: -0.03em; }
.specs__num { flex: none; font-family: var(--mono); font-size: 14px; color: var(--ink-52); }
.specs__grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 clamp(40px, 8vw, 120px); }
.specs__group { border-top: 1px solid var(--line); padding: clamp(22px, 3vw, 40px) 0; }
.specs__group h3 {
margin: 0 0 14px; font-family: var(--mono); font-size: 12px; letter-spacing: .14em;
text-transform: uppercase; color: var(--accent);
}
.specs__row {
display: flex; align-items: baseline; justify-content: space-between; gap: 20px;
padding: 12px 0; border-bottom: 1px solid var(--line-soft);
}
.specs__row:last-child { border-bottom: none; }
.specs__row dt { margin: 0; font-size: 15px; color: var(--ink-52); }
.specs__row dd { margin: 0; font-family: var(--mono); font-size: 13px; color: var(--ink); text-align: right; }
/* =====================================================================
09 · DEPLOY — closing call to action
===================================================================== */
.deploy {
position: relative; z-index: 2; overflow: hidden; text-align: center;
background: var(--bg-2); border-top: 1px solid var(--line);
padding: clamp(100px, 20vh, 260px) var(--pad);
}
.deploy__glow {
position: absolute; inset: 0; z-index: 0; pointer-events: none;
background:
radial-gradient(46% 60% at 50% 46%, rgba(182,255,58,.14), rgba(7,8,9,0) 66%),
radial-gradient(40% 46% at 50% 70%, rgba(255,77,77,.07), rgba(7,8,9,0) 60%);
}
.deploy__inner { position: relative; z-index: 2; max-width: 1100px; margin-inline: auto; }
.deploy__eyebrow { margin-bottom: 22px; }
.deploy__title {
margin: 0 auto; max-width: 16ch; font-weight: 300;
font-size: clamp(40px, 7vw, 110px); line-height: .98; letter-spacing: -0.04em;
}
.deploy__sub {
margin: clamp(22px, 2.6vw, 34px) auto 0; max-width: 46ch;
font-size: clamp(15px, 1.2vw, 18px); line-height: 1.6; color: var(--ink-72);
}
.deploy__cta {
display: flex; gap: 14px; justify-content: center; flex-wrap: wrap;
margin-top: clamp(34px, 4vw, 54px);
}
/* =====================================================================
FOOTER
===================================================================== */
.foot {
position: relative; z-index: 2;
background: var(--bg); border-top: 1px solid var(--line);
padding: clamp(50px, 7vh, 90px) var(--pad) clamp(28px, 4vh, 48px);
}
.foot__inner { max-width: 1500px; margin-inline: auto; }
.foot__top {
display: grid; grid-template-columns: 2.2fr 1fr 1fr 1fr; gap: 40px;
padding-bottom: clamp(38px, 6vh, 66px); border-bottom: 1px solid var(--line);
}
.foot__logo {
display: inline-flex; align-items: center; gap: 9px;
font-weight: 600; font-size: 16px; letter-spacing: .34em; padding-left: .34em; margin-bottom: 18px;
}
.foot__logo .dot {
width: 7px; height: 7px; border-radius: 50%; background: var(--ember);
box-shadow: 0 0 10px 1px rgba(255,77,77,.7);
}
.foot__note { margin: 0; max-width: 32ch; font-size: 14px; line-height: 1.6; color: var(--ink-52); }
.foot__col h4 {
margin: 0 0 16px; font-family: var(--mono); font-size: 11px; letter-spacing: .12em;
text-transform: uppercase; color: var(--ink-34); font-weight: 400;
}
.foot__col ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 11px; }
.foot__col a { font-size: 14px; color: var(--ink-72); transition: color .3s var(--ease); }
.foot__col a:hover { color: var(--ink); }
.foot__bottom {
display: flex; align-items: center; justify-content: space-between; gap: 16px 30px;
flex-wrap: wrap; padding-top: 24px;
font-family: var(--mono); font-size: 11px; letter-spacing: .05em; color: var(--ink-34);
}
.foot__stack { display: flex; flex-wrap: wrap; gap: 8px; }
.foot__stack span { border: 1px solid var(--line); border-radius: 2px; padding: 5px 9px; color: var(--ink-52); }
.foot__legal { max-width: 62ch; line-height: 1.6; }
/* =====================================================================
IMAGE SLOTS — drop-in ready (light + dark variants). Add a real image with
style="background-image:url(assets/xx.jpg)" and remove the [ IMG ] label.
===================================================================== */
.imgslot {
position: relative; overflow: hidden; border-radius: 4px;
background-size: cover; background-position: center;
background-color: var(--bg-2);
}
.imgslot::before { /* light product-plate placeholder texture */
content: ""; position: absolute; inset: 0; z-index: 0;
background-image:
radial-gradient(72% 90% at 50% 24%, rgba(13,138,148,.12), transparent 60%),
radial-gradient(40% 60% at 78% 88%, rgba(10,12,14,.06), transparent 60%),
linear-gradient(rgba(10,12,14,.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(10,12,14,.05) 1px, transparent 1px);
background-size: 100% 100%, 100% 100%, 32px 32px, 32px 32px;
mask-image: radial-gradient(120% 110% at 50% 30%, #000 30%, transparent 92%);
-webkit-mask-image: radial-gradient(120% 110% at 50% 30%, #000 30%, transparent 92%);
}
.imgslot::after { /* [ label ] watermark — delete when a photo lands */
content: attr(data-label); position: absolute; inset: auto 0 16px; z-index: 1;
text-align: center; font-family: var(--mono); font-size: 10px; letter-spacing: .28em;
color: var(--ink-34);
}
/* once a real photo is set, drop the placeholder texture + label */
.imgslot--filled::before, .imgslot--filled::after { content: none; display: none; }
/* field gallery tiles with a real photo: swap the scatter texture for a legibility
scrim, hide the [ IMG ] watermark, keep index/chip/caption + scan sweep on top */
.field__tile--filled::before {
display: block; opacity: 1; mask: none; -webkit-mask: none;
background: linear-gradient(0deg, rgba(4,6,10,.80), rgba(4,6,10,0) 48%);
background-size: auto;
}
.field__tile--filled .field__ph { display: none; }
.field__tile--filled .field__cap { text-shadow: 0 1px 8px rgba(4,6,10,.9); }
.field__tile--filled .field__i { text-shadow: 0 1px 8px rgba(4,6,10,.9); color: var(--ink-52); }
/* dark-context slot (used inside dark sections) */
.imgslot--dark::before {
background-image:
radial-gradient(72% 90% at 50% 24%, rgba(182,255,58,.14), transparent 60%),
radial-gradient(rgba(238,241,243,.5) .6px, transparent .7px),
linear-gradient(var(--line-soft) 1px, transparent 1px),
linear-gradient(90deg, var(--line-soft) 1px, transparent 1px);
background-size: 100% 100%, 9px 9px, 30px 30px, 30px 30px; opacity: .55;
}
/* =====================================================================
SHOWCASE — white, big centred product image (DJI-style hero moment)
===================================================================== */
.showcase {
position: relative; z-index: 2; text-align: center;
border-top: 1px solid var(--line);
padding: clamp(90px, 15vh, 220px) var(--pad);
}
.showcase__inner { max-width: 1200px; margin-inline: auto; }
.showcase__eyebrow { justify-content: center; margin-bottom: 22px; }
.showcase__title {
margin: 0 auto .34em; max-width: 15ch; font-weight: 400;
font-size: clamp(38px, 6.4vw, 96px); line-height: 0.98; letter-spacing: -0.035em;
}
.showcase__sub {
margin: 0 auto clamp(40px, 5vw, 72px); max-width: 52ch;
font-size: clamp(15px, 1.25vw, 19px); line-height: 1.6; color: var(--ink-72);
}
.showcase__media {
aspect-ratio: 16 / 9; width: 100%;
box-shadow: 0 40px 90px -50px rgba(10,12,14,.45);
}
/* =====================================================================
SPLIT — white, image + copy feature block (DJI alternating media)
===================================================================== */
.split {
position: relative; z-index: 2;
border-top: 1px solid var(--line);
padding: clamp(80px, 13vh, 190px) var(--pad);
}
.split__inner {
max-width: 1500px; margin-inline: auto;
display: grid; grid-template-columns: 1.05fr 1fr; gap: clamp(30px, 5vw, 96px);
align-items: center;
}
.split--reverse .split__media { order: 2; }
.split__media { aspect-ratio: 4 / 3; width: 100%; box-shadow: 0 30px 70px -48px rgba(10,12,14,.4); }
.split__copy { min-width: 0; }
.split__eyebrow { margin-bottom: 20px; }
.split__title {
margin: 0 0 18px; max-width: 16ch; font-weight: 400;
font-size: clamp(30px, 3.8vw, 58px); line-height: 1.04; letter-spacing: -0.03em;
}
.split__desc { margin: 0 0 26px; max-width: 44ch; font-size: clamp(15px, 1.2vw, 18px); line-height: 1.62; color: var(--ink-72); }
.split__stats { display: flex; gap: clamp(28px, 4vw, 56px); flex-wrap: wrap; }
.split__stat b { display: block; font-family: var(--display); font-weight: 500; font-size: clamp(26px, 2.6vw, 40px); letter-spacing: -0.02em; }
.split__stat span { font-family: var(--mono); font-size: 11px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-52); }
/* =====================================================================
INSIDE — scroll-driven exploded 3D drone (disassembles to reveal internals)
===================================================================== */
.inside {
position: relative; z-index: 2;
background: var(--bg); border-top: 1px solid var(--line);
height: 300vh; /* tall: gives the teardown room to play out */
}
.inside__sticky {
position: sticky; top: 0; height: 100svh; overflow: hidden;
}
.inside__canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; z-index: 1; }
.inside__glow {
position: absolute; inset: 0; z-index: 0; pointer-events: none;
background: radial-gradient(50% 55% at 50% 48%, rgba(182,255,58,.10), rgba(7,8,9,0) 68%);
}
.inside__copy {
position: absolute; left: var(--pad); top: 50%; transform: translateY(-50%);
max-width: 30ch; z-index: 3;
}
.inside__title { margin: 0 0 16px; font-weight: 400; font-size: clamp(30px, 3.8vw, 56px); line-height: 1.04; letter-spacing: -0.03em; }
.inside__desc { margin: 0; font-size: clamp(14px, 1.15vw, 17px); line-height: 1.62; color: var(--ink-72); }
.inside__legend {
position: absolute; right: var(--pad); top: 50%; transform: translateY(-50%);
z-index: 3; display: flex; flex-direction: column; gap: 16px;
}
.inside__item {
display: flex; align-items: baseline; gap: 14px;
opacity: .25; transform: translateX(10px);
transition: opacity .5s var(--ease), transform .5s var(--ease);
}
.inside__item.is-on { opacity: 1; transform: none; }
.inside__item .k { font-family: var(--mono); font-size: 12px; letter-spacing: .1em; color: var(--accent); min-width: 2em; }
.inside__item .n { font-size: clamp(15px, 1.2vw, 18px); }
.inside__hint {
position: absolute; bottom: clamp(20px, 3vh, 34px); left: 50%; transform: translateX(-50%);
z-index: 3; font-family: var(--mono); font-size: 11px; letter-spacing: .1em; color: var(--ink-34);
}
/* =====================================================================
RESPONSIVE
===================================================================== */
@media (max-width: 991px) {
.pipe__steps { grid-template-columns: repeat(2, 1fr); }
.reveal { min-height: auto; }
.reveal__wrap { grid-template-columns: 1fr; }
.reveal__main { border-right: none; padding-right: 0; }
.reveal__void { display: none; } /* on narrow screens the drone fades out instead */
.statement__wrap { grid-template-columns: 1fr; }
.statement__main {
border-right: none; border-bottom: 1px solid var(--line);
padding: clamp(60px,10vh,110px) 0;
}
.statement__facts { padding: clamp(50px,8vh,90px) 0; }
.scan__inner { grid-template-columns: 1fr; gap: clamp(28px,5vh,54px); }
.scan__stage { aspect-ratio: 4 / 3; max-height: 60vh; }
.sol__grid { grid-template-columns: 1fr; }
.sol__view { min-height: clamp(280px, 40vh, 420px); order: -1; }
.airframe__specs { grid-template-columns: repeat(2, 1fr); }
.foot__top { grid-template-columns: 1fr 1fr; }
.split__inner { grid-template-columns: 1fr; gap: clamp(24px,5vh,44px); }
.split--reverse .split__media { order: -1; }
.field__grid { grid-template-columns: repeat(2, 1fr); }
.field__tile { grid-column: auto !important; grid-row: auto !important; }
.field__tile:nth-child(1) { grid-column: span 2 !important; }
.field__tile:nth-child(6) { grid-column: span 2 !important; }
}
@media (max-width: 767px) {
.airframe__specs { grid-template-columns: 1fr; }
.field__grid { grid-template-columns: 1fr; grid-auto-rows: clamp(150px, 30vh, 200px); }
.field__tile:nth-child(1) { grid-column: auto !important; grid-row: auto !important; }
.field__tile:nth-child(6) { grid-column: auto !important; }
.specs__grid { grid-template-columns: 1fr; gap: 0; }
.foot__top { grid-template-columns: 1fr; gap: 30px; }
.foot__bottom { flex-direction: column; align-items: flex-start; }
}
@media (max-width: 767px) {
:root { --head-h: 60px; }
.masthead { grid-template-columns: 1fr auto; }
.masthead__tag { display: none; }
/* on phones the drone stays centred; callouts sit low so they don't cover it */
.walk__panel, .walk__panel--left, .walk__panel--right { justify-content: center; align-items: flex-end; }
.walk__panel .feat { text-align: center; max-width: 40ch; padding-bottom: clamp(40px, 8vh, 90px); }
.walk__panel--right .feat { text-align: center; }
.walk__panel--right .feat__leader, .feat__leader { margin-inline: auto; }
.pipe__steps { grid-template-columns: 1fr; }
.pipe__head { flex-direction: column; align-items: flex-start; gap: 14px; }
.pipe__title { font-size: 28px; }
.reveal__headline { font-size: 27px; line-height: 1.22; }
.hero__coords { display: none; }
.statement__line { font-size: 29px; }
.scan__labels { flex-wrap: wrap; gap: 4px 20px; }
.fault { min-height: 88vh; }
.fault__caption { font-size: 30px; }
.fault__corner { font-size: 10px; }
/* inside: stack copy top, legend bottom, drone centred */
.inside { height: 260vh; }
.inside__copy { top: auto; bottom: auto; left: var(--pad); right: var(--pad); top: 84px; transform: none; max-width: none; text-align: center; }
.inside__legend { top: auto; bottom: 60px; right: 0; left: 0; transform: none; flex-direction: row; justify-content: center; flex-wrap: wrap; gap: 10px 18px; }
.inside__item .n { font-size: 13px; }
.inside__hint { display: none; }
}
/* =====================================================================
REDUCED MOTION
===================================================================== */
@media (prefers-reduced-motion: reduce) {
[data-reveal] .word { filter: none; opacity: 1; transform: none; transition: none; }
.masthead__logo .dot, .scrolldown__line span { animation: none; }
html { scroll-behavior: auto; }
}
```
## `js/main.js`
```js
// =====================================================================
// NOX — hero interactions
// 1. utils + reduced-motion
// 2. hero headline: word-level blur reveal
// 3. statement: per-character colour scrub on scroll
// 4. Three.js drone: cursor parallax, inertia, atmospheric lighting
// =====================================================================
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
// ---------- 1. utils --------------------------------------------------
const REDUCED = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const lerp = (a, b, t) => a + (b - a) * t;
// ---------- 1b. spinning rotor-blur discs -----------------------------
// The GLBs are single fused Tripo meshes (props can't be separated), so we fake
// spinning propellers with 4 translucent motion-blur discs at the rotor corners.
let _PROP_TEX = null;
function propTexture() {
if (_PROP_TEX) return _PROP_TEX;
const s = 128, c = document.createElement('canvas'); c.width = c.height = s;
const g = c.getContext('2d'); g.translate(s/2, s/2);
const halo = g.createRadialGradient(0,0,3, 0,0,s/2);
halo.addColorStop(0, 'rgba(200,232,238,0)');
halo.addColorStop(0.5, 'rgba(160,208,222,0.12)');
halo.addColorStop(0.9, 'rgba(120,180,200,0.05)');
halo.addColorStop(1, 'rgba(120,180,200,0)');
g.fillStyle = halo; g.beginPath(); g.arc(0,0,s/2,0,7); g.fill();
for (let b = 0; b < 2; b++) { // two swept blades → motion blur
g.rotate(Math.PI);
for (let i = 0; i < 24; i++) {
const a0 = i * 0.075;
g.beginPath(); g.arc(0, 0, 12 + i*2.0, a0, a0 + 0.7);
g.strokeStyle = 'rgba(220,244,249,' + (0.22 - i*0.008).toFixed(3) + ')';
g.lineWidth = 6; g.stroke();
}
// brighter leading edge so the spin reads
g.beginPath(); g.arc(0, 0, s/2 - 6, 0, 0.5);
g.strokeStyle = 'rgba(235,250,252,0.30)'; g.lineWidth = 3; g.stroke();
}
g.beginPath(); g.arc(0,0,6,0,7); g.fillStyle = 'rgba(18,22,26,0.9)'; g.fill();
_PROP_TEX = new THREE.CanvasTexture(c); _PROP_TEX.needsUpdate = true;
return _PROP_TEX;
}
// attaches 4 spinning discs to `parent`; dims = post-scale {x,y,z} of the drone.
function addRotors(parent, dims, tune = {}) {
const rx = dims.x * (tune.spreadX ?? 0.34);
const rz = dims.z * (tune.spreadZ ?? 0.34);
const ry = dims.y * (tune.lift ?? 0.20);
const rad = Math.min(dims.x, dims.z) * (tune.radius ?? 0.26);
const discs = [];
for (const sx of [-1, 1]) for (const sz of [-1, 1]) {
const holder = new THREE.Group();
holder.rotation.x = -Math.PI / 2; // lay flat (horizontal)
holder.position.set(sx * rx, ry, sz * rz);
const disc = new THREE.Mesh(
new THREE.CircleGeometry(rad, 40),
new THREE.MeshBasicMaterial({ map: propTexture(), transparent: true, depthWrite: false,
blending: THREE.AdditiveBlending, opacity: 0.9, side: THREE.DoubleSide })
);
holder.add(disc); parent.add(holder);
discs.push(disc); // spin disc.rotation.z each frame
}
return discs;
}
// ---------- 2. hero headline: word blur reveal ------------------------
(function heroReveal() {
const h = document.querySelector('[data-reveal]');
if (!h) return;
h.querySelectorAll('span').forEach(part => {
const words = part.textContent.split(/(\s+)/);
part.textContent = '';
words.forEach(w => {
if (w.trim() === '') { part.appendChild(document.createTextNode(w)); return; }
const s = document.createElement('span');
s.className = 'word';
s.textContent = w;
part.appendChild(s);
});
});
const words = [...h.querySelectorAll('.word')];
const start = () => {
h.classList.add('is-in');
words.forEach((w, i) => { w.style.transitionDelay = (i * 0.05) + 's'; });
};
// the copy lives in §01 (below the fold) — reveal it when it scrolls into view
if (REDUCED) { start(); }
else {
const io = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) { start(); io.disconnect(); }
}, { threshold: 0.4 });
io.observe(h);
}
})();
// ---------- 3. per-character colour scrub (statement + gear) -----------
(function charScrub() {
const nodes = [...document.querySelectorAll('[data-scrub]')];
if (!nodes.length) return;
const bright = getComputedStyle(document.documentElement).getPropertyValue('--ink').trim() || '#eef1f3';
const dim = 'rgba(238,241,243,0.16)';
// split each target into characters, remember its char list
const items = nodes.map(node => {
const text = node.textContent.trim();
node.textContent = '';
const chars = [];
for (const c of text) {
const s = document.createElement('span');
s.className = 'ch';
if (c === ' ') s.innerHTML = ' '; else s.textContent = c;
node.appendChild(s);
chars.push(s);
}
return { node, chars };
});
function update() {
const vh = window.innerHeight;
const startY = vh * 0.82, endY = vh * 0.34;
for (const { node, chars } of items) {
const r = node.getBoundingClientRect();
const p = clamp((startY - r.top) / (startY - endY + r.height), 0, 1);
const lit = Math.round(p * chars.length);
for (let i = 0; i < chars.length; i++) {
chars[i].style.color = i < lit ? bright : dim;
}
}
}
let ticking = false;
const onScroll = () => {
if (ticking) return; ticking = true;
requestAnimationFrame(() => { update(); ticking = false; });
};
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
update();
})();
// ---------- 4. Three.js drone stage -----------------------------------
(function stage() {
const canvas = document.getElementById('scene');
const stageEl = document.getElementById('stage');
const hero = document.getElementById('hero');
const brandEl = document.getElementById('heroBrand');
const statementEl = document.getElementById('statement'); // drone fades out before this
if (!canvas) return;
const renderer = new THREE.WebGLRenderer({
canvas, antialias: true, alpha: true, powerPreference: 'high-performance'
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.14;
renderer.outputColorSpace = THREE.SRGBColorSpace;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(38, 1, 0.1, 100);
camera.position.set(0, 0, 7.4);
// environment reflections (soft studio) for realistic PBR on a dark set
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
// ---- lighting: cool key, back rim, ember accents (dark stage) ----
scene.add(new THREE.HemisphereLight(0x9fb6c2, 0x05070a, 0.7));
const key = new THREE.DirectionalLight(0xeaf2f7, 2.7);
key.position.set(-4, 6, 5);
scene.add(key);
const front = new THREE.DirectionalLight(0xbcd2dd, 1.15); // gentle face fill from camera
front.position.set(1, 1, 6);
scene.add(front);
const rim = new THREE.DirectionalLight(0xb6ff3a, 2.2); // cyan rim from behind
rim.position.set(5, 2, -6);
scene.add(rim);
const ember1 = new THREE.PointLight(0xff4d4d, 8, 12, 2); // echoes nav lights
ember1.position.set(2.6, -1.6, 2.2);
scene.add(ember1);
const ember2 = new THREE.PointLight(0x8ecf3a, 5, 12, 2);
ember2.position.set(-2.8, -1.0, 1.6);
scene.add(ember2);
const fill = new THREE.SpotLight(0xffffff, 1.4, 20, Math.PI / 5, 0.6);
fill.position.set(0, 8, 2);
scene.add(fill);
// ---- model group ----
const pivot = new THREE.Group(); // holds the model, centred
const rig = new THREE.Group(); // rotates for interaction
rig.add(pivot);
scene.add(rig);
let model = null;
let rotors = []; // spinning propeller-blur discs
const state = {
px: 0, py: 0, // pointer normalised (-1..1)
tx: 0, ty: 0, // eased pointer
baseYaw: 0, // idle + drag accumulated yaw
yawVel: 0, // inertia after drag
dragging: false,
lastX: 0,
hover: 0, hoverT: 0, // hover lift 0..1
scroll: 0, // walkthrough scroll progress 0..1
scrollEase: 0, // eased progress for smooth, inertial motion
};
// ---- scroll keyframes: the drone is posed BY SCROLL through the feature walk ----
// p = progress 0..1 across the walk region; each pose = position + rotation + zoom.
// front (hero) -> payload underside -> airframe/arms -> rear 3/4 -> parked front-right
const POSES = [
{ p: 0.00, x: 0.00, y: 0.15, yaw: 0.00, pitch: -0.05, z: 7.4 }, // hero — front
{ p: 0.25, x: 2.30, y: -0.10, yaw: -0.55, pitch: 0.62, z: 6.2 }, // F.01 payload underside (right)
{ p: 0.50, x: -2.30, y: 0.10, yaw: 1.55, pitch: -0.12, z: 6.6 }, // F.02 airframe/arms (left)
{ p: 0.75, x: 2.00, y: 0.05, yaw: 2.75, pitch: 0.18, z: 6.3 }, // F.03 rear three-quarter (right)
{ p: 1.00, x: 2.30, y: 0.15, yaw: Math.PI * 2, pitch: -0.05, z: 7.1 }, // final — parked front, right
];
const easeInOut = (t) => t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t+2, 3)/2;
function samplePose(p) {
let i = 0;
while (i < POSES.length - 2 && p > POSES[i + 1].p) i++;
const a = POSES[i], b = POSES[i + 1];
const t = easeInOut(clamp((p - a.p) / (b.p - a.p), 0, 1));
return {
x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t),
yaw: lerp(a.yaw, b.yaw, t), pitch: lerp(a.pitch, b.pitch, t),
z: lerp(a.z, b.z, t),
};
}
// ---- loader ----
const loader = new GLTFLoader();
const fill$ = document.getElementById('preloadFill');
const pct$ = document.getElementById('preloadPct');
const pre = document.getElementById('preloader');
function setProgress(p) {
const v = Math.round(clamp(p, 0, 1) * 100);
if (fill$) fill$.style.width = v + '%';
if (pct$) pct$.textContent = v;
}
loader.load('https://raw.githubusercontent.com/truptiPosimyth/landing-assets/main/nox/model.glb', (gltf) => {
model = gltf.scene;
// frame: centre + scale to a consistent size
const box = new THREE.Box3().setFromObject(model);
const size = new THREE.Vector3(); box.getSize(size);
const center = new THREE.Vector3(); box.getCenter(center);
model.position.sub(center); // centre at origin
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 3.95 / maxDim; // target world size
model.scale.setScalar(scale);
pivot.add(model);
// spinning propeller-blur discs at the four rotor corners
rotors = addRotors(pivot, { x: size.x*scale, y: size.y*scale, z: size.z*scale },
{ spreadX: 0.40, spreadZ: 0.40, lift: 0.24, radius: 0.30 });
setProgress(1);
// reveal
requestAnimationFrame(() => {
pre && pre.classList.add('is-done');
stageEl.classList.add('is-grab');
window.dispatchEvent(new Event('nox:loaded'));
});
},
(ev) => { if (ev.total) setProgress(ev.loaded / ev.total); },
(err) => {
console.error('Model load failed:', err);
if (pct$) pct$.textContent = '!';
// still reveal the copy so the page is usable
window.dispatchEvent(new Event('nox:loaded'));
pre && pre.classList.add('is-done');
});
// ---- layout: hero-centre at rest; slides to `rightX` for section 02 ----
let baseRigY = 0.15, isMobile = false, rightX = 2.4;
function layout() {
const w = hero.clientWidth;
isMobile = w <= 767;
rightX = isMobile ? 0 : 2.6; // mobile keeps it centred (then fades out)
baseRigY = isMobile ? 0.45 : 0.15;
camera.fov = isMobile ? 48 : 38;
camera.updateProjectionMatrix();
}
function resize() {
const w = hero.clientWidth, h = hero.clientHeight;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
layout();
}
window.addEventListener('resize', resize);
// ---- pointer: SUBTLE camera parallax only. The drone does NOT rotate on hover —
// all rotation + travel is driven by scroll (see samplePose in the render loop). ----
if (!REDUCED) {
window.addEventListener('pointermove', (e) => {
state.px = clamp((e.clientX / window.innerWidth) * 2 - 1, -1, 1);
state.py = clamp((e.clientY / window.innerHeight) * 2 - 1, -1, 1);
});
}
// scroll progress across the whole walk region (hero + feature panels), 0..1
function updateProgress() {
const vh = window.innerHeight;
// NOTE: offsetTop is offsetParent-relative; inside Elementor the section is nested in
// position:relative wrappers, which made this 0 and pinned the walk progress at 1.
// Document-absolute measurement is identical in the flat layout and correct when nested.
const end = statementEl ? (statementEl.getBoundingClientRect().top + window.scrollY) : vh * 4; // walk ends where §02 begins
state.scroll = clamp(window.scrollY / Math.max(1, end - vh), 0, 1);
}
window.addEventListener('scroll', updateProgress, { passive: true });
window.addEventListener('resize', updateProgress);
// ---- intro: drone flies in from the left once the preloader clears ----
let introStart = -1;
window.addEventListener('nox:loaded',
() => { if (introStart < 0) introStart = performance.now(); }, { once: true });
// ---- render loop ----
const clock = new THREE.Clock();
function tick() {
const t = clock.getElapsedTime();
const vh = window.innerHeight;
// intro progress (time-based): 0 -> 1 over 1.5s, eased
let intro = 1;
if (!REDUCED) intro = introStart < 0 ? 0 : clamp((performance.now() - introStart) / 1900, 0, 1);
const introE = 1 - Math.pow(1 - intro, 3); // easeOutCubic
if (!REDUCED) {
// eased pointer (inertia) — used ONLY for a whisper of camera parallax
state.tx = lerp(state.tx, state.px, 0.06);
state.ty = lerp(state.ty, state.py, 0.06);
// eased scroll progress -> smooth, inertial travel between feature poses
state.scrollEase = lerp(state.scrollEase, state.scroll, 0.09);
const pose = samplePose(state.scrollEase);
const xf = isMobile ? 0 : 1; // on phones the drone stays centred, only rotates
if (model) {
// intro fly-in: a shrinking spiral that decays into the first pose
const introR = (1 - introE) * 6.2;
const introA = Math.PI * 0.9 + introE * Math.PI * 1.7;
const introX = Math.cos(introA) * introR;
const introY = Math.sin(introA) * introR;
const introYaw = (1 - introE) * 0.9;
// POSITION — scroll pose + intro offset + gentle float
// (on phones the drone lifts up so it clears the low, centred feature callout)
const yLift = isMobile ? 1.05 : 0;
rig.position.x = pose.x * xf + introX;
rig.position.y = pose.y + yLift + introY + Math.sin(t * 0.8) * 0.05;
// ROTATION — driven purely by scroll pose (+ tiny idle float, + intro settle)
rig.rotation.y = pose.yaw + introYaw + Math.sin(t * 0.4) * 0.015;
rig.rotation.x = pose.pitch + Math.sin(t * 0.6) * 0.01;
for (let r = 0; r < rotors.length; r++) rotors[r].rotation.z += 0.55; // spinning props
// camera frames the drone as it travels; scroll drives zoom, pointer adds parallax
camera.position.x = lerp(camera.position.x, state.tx * 0.28, 0.05);
camera.position.y = lerp(camera.position.y, -state.ty * 0.18, 0.05);
camera.position.z = pose.z;
camera.lookAt(rig.position.x * 0.42, 0, 0);
}
// BRAND wordmark — fades in behind the landed drone, fades out as the walk begins
const brandIn = clamp((intro - 0.3) / 0.6, 0, 1);
const brandOut = 1 - clamp(state.scroll / 0.12, 0, 1);
brandEl.style.opacity = (brandIn * brandOut).toFixed(3);
brandEl.style.transform =
`translateY(${(-state.scroll * 60).toFixed(1)}px) scale(${(1 + state.scroll * 0.06).toFixed(3)})`;
// STAGE opacity — fade in on intro; stay visible through the whole walk; fade out
// just before the text-only statement (§02) scrolls up to take over
const fadeIn = clamp(intro * 2, 0, 1);
let fadeOut = 1;
if (statementEl) {
const rt = statementEl.getBoundingClientRect().top;
fadeOut = clamp(rt / (vh * 0.4), 0, 1);
}
stageEl.style.opacity = Math.min(fadeIn, fadeOut).toFixed(3);
ember1.intensity = 8 + Math.sin(t * 2.1) * 0.6;
} else if (model) {
// reduced motion: drone rests, three-quarter, no travel
rig.position.set(0, baseRigY, 0);
rig.rotation.set(-0.05, 0.5, 0);
camera.position.set(0, 0, 7.4); camera.lookAt(0, 0, 0);
brandEl.style.opacity = '0.5';
stageEl.style.opacity = '1';
}
renderer.render(scene, camera);
requestAnimationFrame(tick);
}
resize();
updateProgress();
tick();
})();
// ---------- 4z. HEADER clock (GMT) ------------------------------------
(function heroClock() {
const el = document.getElementById('heroClock');
if (!el) return;
const tick = () => {
const d = new Date();
const hh = String(d.getUTCHours()).padStart(2, '0');
const mm = String(d.getUTCMinutes()).padStart(2, '0');
el.textContent = `GMT · ${hh}:${mm}`;
};
tick(); setInterval(tick, 15000);
})();
// ---------- 4y. MOBILE NAV overlay ------------------------------------
(function mobileMenu() {
const btn = document.getElementById('menuBtn');
const close = document.getElementById('menuClose');
const menu = document.getElementById('navMenu');
if (!btn || !menu) return;
const set = (open) => {
menu.classList.toggle('is-open', open);
menu.setAttribute('aria-hidden', open ? 'false' : 'true');
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
document.body.style.overflow = open ? 'hidden' : '';
};
btn.addEventListener('click', () => set(true));
close && close.addEventListener('click', () => set(false));
menu.querySelectorAll('a').forEach(a => a.addEventListener('click', () => set(false)));
window.addEventListener('keydown', (e) => { if (e.key === 'Escape') set(false); });
})();
// ---------- 4a. HEADER — flip to light while over a white section -----
(function headerTheme() {
const head = document.getElementById('masthead');
const lights = [...document.querySelectorAll('.sec--light, .zone--light')];
if (!head || !lights.length) return;
let ticking = false;
function check() {
ticking = false;
const y = head.offsetHeight * 0.5; // sample the header's mid-line
let over = false;
for (const s of lights) {
const r = s.getBoundingClientRect();
if (r.top <= y && r.bottom >= y) { over = true; break; }
}
head.classList.toggle('masthead--light', over);
}
const onScroll = () => { if (ticking) return; ticking = true; requestAnimationFrame(check); };
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
check();
})();
// ---------- 4b. WALKTHROUGH — fade each feature callout in on scroll ---
(function walkFeatures() {
const panels = [...document.querySelectorAll('.walk__panel')];
if (!panels.length) return;
if (REDUCED) { panels.forEach(p => p.classList.add('is-in')); return; }
const io = new IntersectionObserver((entries) => {
entries.forEach(e => {
if (e.isIntersecting) e.target.classList.add('is-in');
else e.target.classList.remove('is-in'); // re-arm so it re-reveals on the way back
});
}, { threshold: 0.45 });
panels.forEach(p => io.observe(p));
})();
// ---------- 5. §04 SCAN — 3D LiDAR point-cloud globe (Three.js) -------
// A real rotating sphere of scan points with a vertical scan sweep, and the NOX
// drone (Magnific image-to-3D) hovering at the centre — "One pass. Every angle."
(function scanGlobe() {
const canvas = document.getElementById('cloud');
if (!canvas) return;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: 'high-performance' });
const DPR = Math.min(window.devicePixelRatio || 1, 2);
renderer.setPixelRatio(DPR);
renderer.outputColorSpace = THREE.SRGBColorSpace;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
camera.position.set(0, 0.15, 4.15);
camera.lookAt(0, 0, 0);
const group = new THREE.Group();
group.rotation.z = 0.12;
scene.add(group);
// ---- LiDAR point globe (sphere shell + ground disc) ----
const R = 1.36, N = 4400, DISC = 760;
const CYAN = [0.714, 1.0, 0.227], INDIGO = [0.13, 0.42, 0.16], EMBER = [1.0, 0.30, 0.30];
const mix = (a, b, t) => [a[0]+(b[0]-a[0])*t, a[1]+(b[1]-a[1])*t, a[2]+(b[2]-a[2])*t];
const total = N + DISC;
const pos = new Float32Array(total * 3), col = new Float32Array(total * 3), rnd = new Float32Array(total);
let p = 0;
for (let i = 0; i < N; i++) {
const u = Math.random(), v = Math.random();
const theta = 2 * Math.PI * u, phi = Math.acos(2 * v - 1); // full sphere
const r = R * (1 + (Math.random() - 0.5) * 0.05);
const x = r*Math.sin(phi)*Math.cos(theta), y = r*Math.cos(phi), z = r*Math.sin(phi)*Math.sin(theta);
const h = (y / R) * 0.5 + 0.5;
const c = Math.random() < 0.015 ? EMBER : mix(INDIGO, CYAN, h);
pos[p*3]=x; pos[p*3+1]=y; pos[p*3+2]=z; col[p*3]=c[0]; col[p*3+1]=c[1]; col[p*3+2]=c[2]; rnd[p]=Math.random(); p++;
}
for (let i = 0; i < DISC; i++) { // ground plane ring
const a = Math.random()*2*Math.PI, r = Math.sqrt(Math.random())*1.72;
const c = mix(INDIGO, CYAN, 0.2);
pos[p*3]=Math.cos(a)*r; pos[p*3+1]=-0.01+(Math.random()-0.5)*0.03; pos[p*3+2]=Math.sin(a)*r;
col[p*3]=c[0]; col[p*3+1]=c[1]; col[p*3+2]=c[2]; rnd[p]=Math.random(); p++;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
geo.setAttribute('aColor', new THREE.BufferAttribute(col, 3));
geo.setAttribute('aRand', new THREE.BufferAttribute(rnd, 1));
const mat = new THREE.ShaderMaterial({
uniforms: { uScanY: { value: 0 }, uSize: { value: 4.6 }, uDpr: { value: DPR } },
transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
vertexShader: `
attribute vec3 aColor; attribute float aRand;
uniform float uScanY; uniform float uSize; uniform float uDpr;
varying vec3 vCol; varying float vGlow;
void main(){
vCol = aColor;
float d = abs(position.y - uScanY);
vGlow = smoothstep(0.22, 0.0, d);
vec4 mv = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = uSize * uDpr * (0.55 + aRand*0.7) * (3.2 / -mv.z);
gl_Position = projectionMatrix * mv;
}`,
fragmentShader: `
varying vec3 vCol; varying float vGlow;
void main(){
vec2 c = gl_PointCoord - 0.5; float dist = length(c);
if (dist > 0.5) discard;
float a = smoothstep(0.5, 0.08, dist);
vec3 col = mix(vCol, vec3(0.92, 1.0, 0.62), vGlow);
gl_FragColor = vec4(col, a * (0.42 + vGlow*0.9));
}`
});
group.add(new THREE.Points(geo, mat));
// ---- centre object: the NOX drone (Magnific image-to-3D), lit to theme ----
let model = null, scanRotors = [];
scene.add(new THREE.HemisphereLight(0x9fb6c2, 0x05070a, 0.9));
const key = new THREE.DirectionalLight(0xeaf2f7, 2.2); key.position.set(-3, 4, 4); scene.add(key);
const rim = new THREE.DirectionalLight(0xb6ff3a, 2.4); rim.position.set(4, 1, -4); scene.add(rim);
const ember = new THREE.PointLight(0xff4d4d, 4, 10, 2); ember.position.set(1.6, -0.6, 1.4); scene.add(ember);
try {
new GLTFLoader().load('https://cdn.jsdelivr.net/gh/truptiPosimyth/landing-assets@main/nox/scan-drone.glb', (g) => {
model = g.scene;
const box = new THREE.Box3().setFromObject(model);
const sz = new THREE.Vector3(); box.getSize(sz);
const ctr = new THREE.Vector3(); box.getCenter(ctr);
model.position.sub(ctr);
const mscale = 1.9 / Math.max(sz.x, sz.y, sz.z);
model.scale.setScalar(mscale);
const holder = new THREE.Group(); holder.add(model); holder.rotation.x = 0.1; scene.add(holder);
// spinning propeller-blur discs
scanRotors = addRotors(holder, { x: sz.x*mscale, y: sz.y*mscale, z: sz.z*mscale },
{ spreadX: 0.40, spreadZ: 0.40, lift: 0.24, radius: 0.30 });
model = holder;
}, undefined, () => {}); // no model yet? the globe stands on its own
} catch (e) {}
function resize() {
const w = canvas.clientWidth, h = canvas.clientHeight;
renderer.setSize(w, h, false);
camera.aspect = w / h; camera.updateProjectionMatrix();
}
resize();
window.addEventListener('resize', resize);
const clock = new THREE.Clock();
function render() {
const t = clock.getElapsedTime();
if (!REDUCED) {
group.rotation.y = t * 0.12;
mat.uniforms.uScanY.value = Math.sin(t * 0.35) * R;
if (model) model.rotation.y = -t * 0.35;
for (let r = 0; r < scanRotors.length; r++) scanRotors[r].rotation.z += 0.6; // spinning props
} else {
group.rotation.y = 0.6;
}
renderer.render(scene, camera);
}
if (REDUCED) { render(); return; }
let raf = 0, running = false;
function loop() { render(); raf = requestAnimationFrame(loop); }
const io = new IntersectionObserver((e) => {
if (e[0].isIntersecting && !running) { running = true; loop(); }
else if (!e[0].isIntersecting && running) { running = false; cancelAnimationFrame(raf); }
}, { threshold: 0.05 });
io.observe(canvas);
})();
// ---------- 6. §06 FAULT — point-cloud lattice tower + scan sweep -----
(function faultCloud() {
const canvas = document.getElementById('faultCloud');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const DPR = Math.min(window.devicePixelRatio || 1, 2);
const CYAN = [182, 255, 58], STEEL = [120, 150, 168], EMBER = [255, 77, 77], HOT = [255, 176, 92];
const mix = (a, b, t) => [a[0]+(b[0]-a[0])*t, a[1]+(b[1]-a[1])*t, a[2]+(b[2]-a[2])*t];
// build a lattice transmission tower from points
const pts = [];
const w = 0.42, yTop = 1.45, yBot = -1.45, rings = 22;
const corners = [[-w,-w],[w,-w],[w,w],[-w,w]];
const add = (x, y, z, hot) => {
const base = mix(STEEL, CYAN, 0.5 + (Math.random()-0.5)*0.4);
pts.push({ x, y, z, hot, col: hot ? EMBER.slice() : base, s: hot ? 1.7 : 1.1 });
};
// taper the tower slightly toward the top
const taper = (y) => 0.6 + 0.4 * (yBot - y) / (yBot - yTop) * -1 + 1; // ~1 at bottom .. ~0.6 top
const hotBand = (y, cIdx) => cIdx === 1 && y > 0.15 && y < 0.72; // one face, mid-height
for (let k = 0; k <= rings; k++) {
const y = yTop + (yBot - yTop) * (k / rings);
const tp = 0.62 + 0.38 * (k / rings); // width factor, narrower up top
// corner rails
corners.forEach((c, ci) => {
for (let j = 0; j < 3; j++) {
const jy = y + (Math.random()-0.5) * 0.04;
add(c[0]*tp + (Math.random()-0.5)*0.03, jy, c[1]*tp + (Math.random()-0.5)*0.03, hotBand(y, ci));
}
});
// horizontal + diagonal braces between adjacent corners
for (let e = 0; e < 4; e++) {
const a = corners[e], b = corners[(e+1)%4];
for (let s = 0; s <= 6; s++) {
const tt = s / 6;
const bx = (a[0] + (b[0]-a[0])*tt) * tp;
const bz = (a[1] + (b[1]-a[1])*tt) * tp;
add(bx, y + (Math.random()-0.5)*0.05, bz, hotBand(y, e));
if (k < rings) { // diagonal to next ring
const y2 = yTop + (yBot - yTop) * ((k+1) / rings);
add(bx, (y + y2)/2 + (Math.random()-0.5)*0.05, bz, hotBand((y+y2)/2, e));
}
}
}
}
let W = 0, H = 0;
function size() {
W = canvas.clientWidth; H = canvas.clientHeight;
canvas.width = Math.round(W * DPR); canvas.height = Math.round(H * DPR);
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
}
size();
window.addEventListener('resize', size);
function frame(t) {
ctx.clearRect(0, 0, W, H);
const mobile = W < 768;
const cx = mobile ? W * 0.5 : W * 0.68, cy = H * 0.5;
const scale = Math.min(W * (mobile ? 0.42 : 0.3), H * 0.34), persp = 5.0;
const yaw = REDUCED ? 0.5 : 0.5 + Math.sin(t * 0.00022) * 0.5;
const cos = Math.cos(yaw), sin = Math.sin(yaw);
const scanY = REDUCED ? 0.4 : ((t * 0.00018) % 1) * 2.9 - 1.45; // sweeps -1.45..1.45
const pulse = 0.6 + (REDUCED ? 0.2 : Math.abs(Math.sin(t * 0.0016)) * 0.4);
for (const p of pts) { p._z = p.x * sin + p.z * cos; }
pts.sort((a, b) => a._z - b._z);
for (const p of pts) {
const x = p.x * cos - p.z * sin, z = p._z, y = p.y;
const f = persp / (persp - z);
const sx = cx + x * scale * f, sy = cy - y * scale * f;
const depth = (z + 1) / 2;
let [r, g, b] = p.hot ? mix(HOT, EMBER, pulse) : p.col;
let alpha = 0.12 + depth * 0.7;
const near = 1 - Math.min(Math.abs(y - scanY) / 0.16, 1); // scan-line proximity glow
if (near > 0) { alpha = Math.min(1, alpha + near * 0.6); r = Math.min(255, r + near*120); g = Math.min(255, g + near*120); b = Math.min(255, b + near*120); }
if (p.hot) alpha = Math.min(1, alpha + pulse * 0.25);
ctx.fillStyle = `rgba(${r|0},${g|0},${b|0},${alpha.toFixed(3)})`;
const sz = p.s * f * DPR * 0.95;
ctx.fillRect(sx - sz/2, sy - sz/2, sz, sz);
}
}
if (REDUCED) { frame(0); return; }
let raf = 0, running = false;
function loop() { frame(performance.now()); raf = requestAnimationFrame(loop); }
const io = new IntersectionObserver((e) => {
if (e[0].isIntersecting && !running) { running = true; loop(); }
else if (!e[0].isIntersecting && running) { running = false; cancelAnimationFrame(raf); }
}, { threshold: 0.02 });
io.observe(canvas);
})();
// ---------- 7. INSIDE — scroll-driven exploded drone (procedural) -----
// The GLB is a fused mesh, so this "teardown" is a clean stylized drone built from
// geometry: on scroll the shell lifts off and the core, motors and sensor separate.
(function insideStage() {
const canvas = document.getElementById('insideCanvas');
const section = document.getElementById('inside');
if (!canvas || !section) return;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 100);
camera.position.set(0, 0.35, 4.5);
camera.lookAt(0, -0.05, 0);
// lighting — cool key, front fill, cyan rim, ember accent (bright enough to read detail)
scene.add(new THREE.HemisphereLight(0xb4c8d2, 0x0a0e12, 1.15));
const key = new THREE.DirectionalLight(0xeaf2f7, 3.4); key.position.set(-4, 5, 5); scene.add(key);
const fill = new THREE.DirectionalLight(0xcfe0e8, 1.7); fill.position.set(2, 2, 6); scene.add(fill);
const rim = new THREE.DirectionalLight(0xb6ff3a, 3.0); rim.position.set(5, 1, -5); scene.add(rim);
const under = new THREE.DirectionalLight(0x8fa6b2, 0.8); under.position.set(0, -4, 2); scene.add(under);
const ember = new THREE.PointLight(0xff4d4d, 6, 14, 2); ember.position.set(2, -1, 2); scene.add(ember);
const assembly = new THREE.Group();
assembly.rotation.x = -0.32; // tilt so we look slightly down at the layers
scene.add(assembly);
// ---- materials ----
const graphite = new THREE.MeshStandardMaterial({ color: 0x3a424b, metalness: 0.55, roughness: 0.48 });
const carbon = new THREE.MeshStandardMaterial({ color: 0x1b1f24, metalness: 0.5, roughness: 0.45 });
const shellMat = new THREE.MeshStandardMaterial({ color: 0x4a525b, metalness: 0.5, roughness: 0.38 });
const pcbMat = new THREE.MeshStandardMaterial({ color: 0x105246, metalness: 0.3, roughness: 0.5 });
const chipMat = new THREE.MeshStandardMaterial({ color: 0x0a0d10, metalness: 0.45, roughness: 0.5 });
const capMat = new THREE.MeshStandardMaterial({ color: 0x27333b, metalness: 0.6, roughness: 0.4 });
const goldMat = new THREE.MeshStandardMaterial({ color: 0xc9a24a, metalness: 0.95, roughness: 0.3 });
const copperMat= new THREE.MeshStandardMaterial({ color: 0x9a5a2c, metalness: 0.9, roughness: 0.35, emissive: 0x2a1200, emissiveIntensity: 0.25 });
const motorMat = new THREE.MeshStandardMaterial({ color: 0x15191d, metalness: 0.75, roughness: 0.38 });
const screwMat = new THREE.MeshStandardMaterial({ color: 0x6b7278, metalness: 0.9, roughness: 0.3 });
const cellMat = new THREE.MeshStandardMaterial({ color: 0x1b2228, metalness: 0.35, roughness: 0.6 });
const wireRed = new THREE.MeshStandardMaterial({ color: 0x7a2020, metalness: 0.2, roughness: 0.6, emissive: 0x360606, emissiveIntensity: 0.3 });
const wireBlk = new THREE.MeshStandardMaterial({ color: 0x0e1114, metalness: 0.2, roughness: 0.6 });
const cyanEmit = new THREE.MeshStandardMaterial({ color: 0x0a2a2e, metalness: 0.2, roughness: 0.3, emissive: 0x9bff2a, emissiveIntensity: 1.2 });
const redEmit = new THREE.MeshStandardMaterial({ color: 0x2a0808, metalness: 0.2, roughness: 0.3, emissive: 0xff4d4d, emissiveIntensity: 1.4 });
const glassMat = new THREE.MeshStandardMaterial({ color: 0x04222a, metalness: 0.2, roughness: 0.06, emissive: 0x2f5a14, emissiveIntensity: 0.7 });
const parts = []; // { obj, from, to, rotTo? } — layer groups explode
function add(obj, from, to, extra) { obj.position.set(from.x, from.y, from.z); assembly.add(obj); parts.push(Object.assign({ obj, from, to }, extra || {})); return obj; }
// small geometry helpers
const box = (w,h,d,m) => new THREE.Mesh(new THREE.BoxGeometry(w,h,d), m);
const cyl = (rt,rb,h,m,s=18) => new THREE.Mesh(new THREE.CylinderGeometry(rt,rb,h,s), m);
function screwAt(g,x,z,y=0){ const s=cyl(0.017,0.017,0.03,screwMat,8); s.rotation.x=Math.PI/2; s.position.set(x,y,z); g.add(s); }
function tube(g,pts,m,r=0.014){ const c=new THREE.CatmullRomCurve3(pts.map(p=>new THREE.Vector3(...p))); g.add(new THREE.Mesh(new THREE.TubeGeometry(c,20,r,8,false), m)); }
// ============ LAYER 1 — composite shell (top cover) ============
const layerShell = new THREE.Group();
const canopy = box(1.28, 0.12, 0.92, shellMat); canopy.position.y = 0.0; layerShell.add(canopy);
const hump = box(0.66, 0.09, 0.5, shellMat); hump.position.y = 0.09; layerShell.add(hump);
const lip = box(1.3, 0.03, 0.94, graphite); lip.position.y = -0.06; layerShell.add(lip);
for (let i = 0; i < 6; i++) { // two vent grilles on the hump
const s1 = box(0.02, 0.006, 0.16, graphite); s1.position.set(-0.16 + i*0.03, 0.14, 0.1); layerShell.add(s1);
const s2 = box(0.02, 0.006, 0.16, graphite); s2.position.set(-0.16 + i*0.03, 0.14, -0.14); layerShell.add(s2);
}
[[-0.58,-0.4],[0.58,-0.4],[-0.58,0.4],[0.58,0.4]].forEach(([x,z]) => screwAt(layerShell, x, z, 0.06));
for (const sx of [-1,1]) { const nav = box(0.05,0.03,0.06, redEmit); nav.position.set(sx*0.6, 0.0, 0.42); layerShell.add(nav); }
const led = box(0.05,0.02,0.03, cyanEmit); led.position.set(0, 0.14, 0.24); layerShell.add(led);
const antenna = cyl(0.012,0.012,0.18, graphite, 8); antenna.position.set(-0.5, 0.12, -0.4); layerShell.add(antenna);
add(layerShell, { x: 0, y: 0.17, z: 0 }, { x: 0, y: 1.05, z: 0 }, { rotTo: { x: 0.16, y: 0, z: 0.04 } });
// ============ LAYER 2 — internals / inner mechanism ============
const layerCore = new THREE.Group();
const tray = box(1.02, 0.02, 0.66, graphite); tray.position.y = -0.02; layerCore.add(tray);
// flight-controller PCB (this is `core` — its glow pulses as it's revealed)
const core = box(0.84, 0.028, 0.5, pcbMat); core.position.set(0.02, 0.05, 0.02); layerCore.add(core);
// cyan circuit traces on the board
for (let i = 0; i < 5; i++) { const tr = box(0.5, 0.004, 0.008, cyanEmit); tr.position.set(-0.05, 0.066, -0.16 + i*0.075); layerCore.add(tr); }
for (let i = 0; i < 4; i++) { const tr = box(0.008, 0.004, 0.34, cyanEmit); tr.position.set(-0.22 + i*0.12, 0.066, 0.02); layerCore.add(tr); }
// chips of varying size
[[0.12,0.11,0.09],[-0.18,0.06,0.13],[0.24,-0.1,0.07],[-0.05,0.14,0.06],[0.0,-0.13,0.1],[-0.28,-0.05,0.05]].forEach(([x,z,w]) => {
const ch = box(w, 0.03, w*0.8, chipMat); ch.position.set(x, 0.08, z); layerCore.add(ch);
});
// standing capacitors
[[0.3,0.14],[0.34,0.05],[0.3,-0.05],[-0.32,0.16]].forEach(([x,z]) => { const c = cyl(0.03,0.03,0.1, capMat, 12); c.position.set(x, 0.11, z); layerCore.add(c); const cap = cyl(0.03,0.03,0.008, goldMat, 12); cap.position.set(x, 0.16, z); layerCore.add(cap); });
// header pin row (gold)
for (let i = 0; i < 8; i++) { const pin = box(0.012, 0.03, 0.012, goldMat); pin.position.set(-0.3 + i*0.028, 0.08, -0.2); layerCore.add(pin); }
// IMU module with dome
const imu = box(0.12, 0.04, 0.1, chipMat); imu.position.set(-0.25, 0.09, 0.14); layerCore.add(imu);
const dome = new THREE.Mesh(new THREE.SphereGeometry(0.03, 12, 8, 0, Math.PI*2, 0, Math.PI/2), capMat); dome.position.set(-0.25, 0.11, 0.14); layerCore.add(dome);
// battery — 3 cylindrical cells at the rear + gold terminals
for (let i = 0; i < 3; i++) {
const cell = cyl(0.085, 0.085, 0.44, cellMat, 20); cell.rotation.z = Math.PI/2; cell.position.set(0, 0.02, -0.34 + i*0.0); cell.position.x = -0.15 + i*0.15; cell.position.z = -0.22; layerCore.add(cell);
const tp = cyl(0.05,0.05,0.01, goldMat, 12); tp.rotation.z = Math.PI/2; tp.position.set(-0.15 + i*0.15 + 0.22, 0.02, -0.22); layerCore.add(tp);
}
// wiring harness: battery -> PCB
tube(layerCore, [[-0.15,0.06,-0.22],[-0.1,0.12,-0.05],[-0.02,0.08,0.02]], wireRed, 0.016);
tube(layerCore, [[0.0,0.06,-0.22],[0.06,0.11,-0.08],[0.1,0.08,0.0]], wireBlk, 0.016);
tube(layerCore, [[0.15,0.06,-0.22],[0.2,0.1,-0.1],[0.24,0.08,-0.02]], wireRed, 0.013);
// 4 ESC boards near arm roots
for (const sx of [-1,1]) for (const sz of [-1,1]) { const esc = box(0.14,0.02,0.09, pcbMat); esc.position.set(sx*0.38, 0.02, sz*0.24); layerCore.add(esc); }
// wire cage around the internals (glows as revealed) — kept as `cage`
const cage = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(1.06, 0.3, 0.72)),
new THREE.LineBasicMaterial({ color: 0xb6ff3a, transparent: true, opacity: 0.5 }));
cage.position.y = 0.05; layerCore.add(cage);
add(layerCore, { x: 0, y: 0.0, z: 0 }, { x: 0, y: 0.2, z: 0 });
// ============ LAYER 3 — frame + landing gear (bottom) ============
const layerFrame = new THREE.Group();
const plate = box(1.16, 0.028, 0.84, graphite); layerFrame.add(plate);
const plate2 = box(0.9, 0.02, 0.6, carbon); plate2.position.y = -0.02; layerFrame.add(plate2);
for (const sx of [-1,1]) for (const sz of [-1,1]) {
const leg = cyl(0.02, 0.025, 0.34, graphite, 8); leg.position.set(sx*0.42, -0.19, sz*0.34); leg.rotation.z = sx*0.28; leg.rotation.x = -sz*0.2; layerFrame.add(leg);
const foot = box(0.16, 0.02, 0.05, carbon); foot.position.set(sx*0.5, -0.35, sz*0.4); layerFrame.add(foot);
}
add(layerFrame, { x: 0, y: -0.12, z: 0 }, { x: 0, y: -0.66, z: 0 });
// ============ LAYER 4 — arms + detailed motors + spinning props ============
const insideRotors = [];
for (const sx of [-1, 1]) for (const sz of [-1, 1]) {
const armG = new THREE.Group();
const ang = Math.atan2(sz, sx);
const bar = cyl(0.04, 0.045, 0.6, carbon, 12); bar.rotation.z = Math.PI/2; // carbon arm tube
bar.position.set(-Math.cos(ang) * 0.3, -0.01, -Math.sin(ang) * 0.3); bar.rotation.y = -ang; armG.add(bar);
const base = cyl(0.09, 0.1, 0.05, motorMat, 20); base.position.y = -0.02; armG.add(base); // stator base
const wind = new THREE.Mesh(new THREE.TorusGeometry(0.075, 0.028, 8, 20), copperMat); wind.rotation.x = Math.PI/2; wind.position.y = 0.02; armG.add(wind); // copper windings
const bell = cyl(0.095, 0.095, 0.06, motorMat, 20); bell.position.y = 0.06; armG.add(bell); // rotor bell
for (let k = 0; k < 6; k++) { const v = box(0.008, 0.05, 0.012, graphite); const a = k/6*Math.PI*2; v.position.set(Math.cos(a)*0.085, 0.06, Math.sin(a)*0.085); armG.add(v); } // bell vents
screwAt(armG, 0.05, 0.05, 0.09); screwAt(armG, -0.05, -0.05, 0.09);
const hub = cyl(0.03, 0.03, 0.04, screwMat, 12); hub.position.y = 0.11; armG.add(hub);
const esc = box(0.1, 0.02, 0.06, pcbMat); esc.position.set(-Math.cos(ang)*0.22, -0.01, -Math.sin(ang)*0.22); esc.rotation.y = -ang; armG.add(esc);
const disc = new THREE.Mesh(new THREE.CircleGeometry(0.4, 40),
new THREE.MeshBasicMaterial({ map: propTexture(), transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, opacity: 0.9, side: THREE.DoubleSide }));
disc.rotation.x = -Math.PI / 2; disc.position.y = 0.14; armG.add(disc); insideRotors.push(disc);
add(armG, { x: sx * 0.58, y: 0.06, z: sz * 0.44 }, { x: sx * 1.28, y: 0.44, z: sz * 0.95 });
}
// ============ Halo-L sensor — detailed 3-axis gimbal ============
const sensorG = new THREE.Group();
const yawM = cyl(0.07, 0.07, 0.06, motorMat, 18); yawM.position.y = 0.2; sensorG.add(yawM);
const rollArmL = box(0.03, 0.24, 0.03, graphite); rollArmL.position.set(-0.16, 0.06, 0); sensorG.add(rollArmL);
const rollArmR = box(0.03, 0.24, 0.03, graphite); rollArmR.position.set(0.16, 0.06, 0); sensorG.add(rollArmR);
const yoke = box(0.34, 0.03, 0.16, graphite); yoke.position.y = 0.16; sensorG.add(yoke);
const pitchM = cyl(0.05, 0.05, 0.05, motorMat, 16); pitchM.rotation.z = Math.PI/2; pitchM.position.set(-0.17, 0.0, 0); sensorG.add(pitchM);
const camBody = box(0.24, 0.18, 0.2, chipMat); camBody.position.set(0, 0.0, 0.02); sensorG.add(camBody);
const barrel = cyl(0.1, 0.11, 0.14, motorMat, 24); barrel.rotation.x = Math.PI/2; barrel.position.set(0, 0.0, 0.16); sensorG.add(barrel);
const glass = cyl(0.088, 0.088, 0.02, glassMat, 24); glass.rotation.x = Math.PI/2; glass.position.set(0, 0.0, 0.24); sensorG.add(glass);
tube(sensorG, [[0.1,0.05,-0.02],[0.14,0.14,-0.02],[0.05,0.2,0]], wireBlk, 0.012); // ribbon/cable up to body
add(sensorG, { x: 0, y: -0.26, z: 0.16 }, { x: 0, y: -1.15, z: 0.05 });
const ease = (t) => t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t+2, 3)/2;
const legendItems = [...section.querySelectorAll('.inside__item')];
const STEP_AT = [0.10, 0.34, 0.56, 0.78];
function resize() {
const w = canvas.clientWidth, h = canvas.clientHeight;
renderer.setSize(w, h, false);
camera.aspect = w / h; camera.updateProjectionMatrix();
}
resize();
window.addEventListener('resize', resize);
let prog = 0;
function updateProg() {
const total = section.offsetHeight - window.innerHeight;
prog = clamp(-section.getBoundingClientRect().top / Math.max(1, total), 0, 1);
}
window.addEventListener('scroll', updateProg, { passive: true });
window.addEventListener('resize', updateProg);
updateProg();
let eased = 0;
const clock = new THREE.Clock();
function render() {
const t = clock.getElapsedTime();
eased = lerp(eased, prog, 0.12);
const e = ease(eased);
for (const pt of parts) {
pt.obj.position.set(lerp(pt.from.x, pt.to.x, e), lerp(pt.from.y, pt.to.y, e), lerp(pt.from.z, pt.to.z, e));
if (pt.rotTo) { pt.obj.rotation.set(pt.rotTo.x * e, pt.rotTo.y * e, pt.rotTo.z * e); }
}
core.material.emissiveIntensity = 0.5 + e * 0.9;
cage.material.opacity = 0.15 + e * 0.5;
if (!REDUCED) { assembly.rotation.y += 0.0026; for (let i = 0; i < insideRotors.length; i++) insideRotors[i].rotation.z += 0.5; }
legendItems.forEach((el, i) => el.classList.toggle('is-on', eased >= STEP_AT[i] - 0.02));
renderer.render(scene, camera);
}
if (REDUCED) { eased = prog = 0.6; render(); return; }
let raf = 0, running = false;
function loop() { render(); raf = requestAnimationFrame(loop); }
const io = new IntersectionObserver((ev) => {
if (ev[0].isIntersecting && !running) { running = true; loop(); }
else if (!ev[0].isIntersecting && running) { running = false; cancelAnimationFrame(raf); }
}, { threshold: 0 });
io.observe(section);
})();
```