MASSIF Built Floor by Floor AI website prompt
Scroll-driven site for a structural construction firm: a three.js building that goes up floor by floor with live stats, photo-backed capability cards, a pinned project reel and process section, on warm paper with construction-orange accents.
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
Showing 5 prompts
Frequently asked questions
What does the MASSIF Built Floor by Floor prompt build?
Scroll-driven site for a structural construction firm: a three.js building that goes up floor by floor with live stats, photo-backed capability cards, a pinned project reel and process section, on warm paper with construction-orange accents.
Which AI tools does the MASSIF Built Floor by Floor 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 MASSIF Built Floor by Floor 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
MASSIF Built Floor by Floor · Section prompt
# MASTER PROMPT — MASSIF — Structural Construction (fully self-contained)
> **Before you start, read the ⛔ STRICT RULES at the end of this prompt — they decide what you
> deliver and they override everything else.**
---
## ▶ WHAT THIS IS
The **original MASSIF — Structural Construction landing page**, byte for byte: one `index.html`
and the five photographs in `img/` it loads with relative paths. You write the files from the
Appendix, download the photographs, and deliver **one ZIP** with the untouched source. Nothing is
installed, published or previewed.
- **Stack:** vanilla HTML + CSS + JS; three.js 0.165 (ES module via importmap), GSAP 3.12.5 +
ScrollTrigger, Lenis 1.1.13 — all from CDNs. No build step.
- **Fonts:** Archivo 400–900 and JetBrains Mono 400/500 from Google Fonts.
- **Sections (5):** `#top` · `#caps` · `#work` · `#proc` · `#contact`.
- **Scene:** one fixed WebGL canvas: a structural maquette rises floor by floor with the scroll, a
HUD reads stage / floor / height off the model, and pinned ScrollTrigger acts drive the
capability plates, the project reel and the process.
## ZIP CONTENTS — one ZIP, `massif.zip`
```
massif/
├── index.html B1
├── README.md B0
├── img/
│ ├── rebar.jpg
│ ├── frame.jpg
│ ├── steel.jpg
│ ├── span.jpg
│ └── demolition.jpg
└── CHECKSUMS.txt "<sha256> <path>" for every file above, paths relative to massif/
```
**8 files, in ONE ZIP.** The original site only — nothing else goes in, and no second archive is
ever built.
Every text file is UTF-8 and ends with exactly one newline (`\n`), which the Integrity hashes include.
## STEPS
1. **Create the folder** `massif/` and its `img/` sub-folder.
2. **Write the text files** from the Appendix, verbatim: `README.md` = B0, `index.html` = B1.
3. **Download the five photographs** into `img/`. For each `<name>` in rebar, frame,
steel, span, demolition, download
`https://demo.uichemy.com/wp-content/uploads/2026/09/massif-<name>.jpg`
— this is the only source; do not fetch the photographs from anywhere else. Save as
`img/<name>.jpg` (no `massif-` prefix) and check it against the Integrity table.
If a photograph cannot be downloaded, send only this line and stop:
`The five MASSIF photographs could not be downloaded — a network connection is required.`
4. **Write `CHECKSUMS.txt`** — sha256 of every file, sorted by path.
5. **Verify:** every sha256 equals the Integrity table; create the ZIP from the folder
(`zip -r massif.zip massif`); `unzip -t` reports **No errors**; `unzip -l` lists exactly the
8 files above, nothing else.
6. **Deliver** that one ZIP with the one-line reply from STRICT RULE 7.
## ASSET MANIFEST (the five photographs)
| File | Bytes | sha256 |
|---|---|---|
| `img/rebar.jpg` | 200,433 | `5e97fe3fc871994b142a82ae244b676af9fa03efa43cd8842ea614ad3104a419` |
| `img/frame.jpg` | 262,490 | `e4a6b4ee74d6d83a072e1fd2f0b3a1b888e742754bd813b0003a06df51f9d4fe` |
| `img/steel.jpg` | 269,919 | `d57e13b54d725fe17aea69281b753bf98f4fb0bed76913e51a7c888d426eaa0d` |
| `img/span.jpg` | 634,148 | `52d5c017a7442d0c63231af274b0d736ff6e2e382332c1c675cceb7afbe7ef70` |
| `img/demolition.jpg` | 443,390 | `a1544a92e8d71eec2ad0f93bb7baca65b489dad95861142a1f6cc350c2ab8e1b` |
---
# APPENDIX · THE FILES (write each verbatim)
## B0 · `README.md`
```markdown
# MASSIF — Structural Construction
The original MASSIF landing page, exactly as it was built: one self-contained index.html and the
five photographs it uses. Nothing has been converted, minified or renamed.
## What is in this folder
- index.html : the whole page (HTML, CSS and the three.js / GSAP / Lenis script)
- img/ : rebar.jpg, frame.jpg, steel.jpg, span.jpg, demolition.jpg
- CHECKSUMS.txt : sha256 of every file, so you can confirm nothing was altered
## View it
The page loads ES modules and image textures, which browsers block when a file is opened
straight from disk. Serve the folder instead: from inside this folder run
python3 -m http.server
and open the address it prints in a browser. An internet connection is needed for the libraries
(three.js, GSAP, ScrollTrigger, Lenis) and the Google fonts.
## Turning it into a WordPress page later
Keep this folder unchanged — it is the source of truth for any conversion. The page is a single
file, so a converter (or a developer) can split it the usual way: the markup and CSS become the
page section, the script becomes a JavaScript file the page loads, and the five photographs go
into the media library with the image paths updated to match.
```
## B1 · `index.html`
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>MASSIF — Structural Construction</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
/* ═══════════════════════════════════════════════════════════════
MASSIF — a construction studio.
The hero is a construction-site maquette built in three.js — a
lattice tower crane, a frame that rises floor by floor, scaffold and
site props — lit with a studio environment and soft shadows, and
driven by scroll. The model and the grain are generated in this file;
the only assets are the five site photographs in ./img, which are
served from beside the page rather than hotlinked.
═══════════════════════════════════════════════════════════════ */
:root{
/* White build. --bg / --fg are roles, not colours: swap the two and
the whole site inverts, which is how the dark build became this one. */
--bg:#F5F3EF; /* warm paper, not clinical white */
--fg:#121110;
--orange:#FF4D14;
--concrete:#6E685C; /* dark enough to pass on paper at 10.5px */
--steel:#E4E0D8;
--line:rgba(18,17,16,.15);
--pad:clamp(20px,4vw,72px);
}
*,*::before,*::after{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{
margin:0; background:var(--bg); color:var(--fg);
font-family:'Archivo',system-ui,-apple-system,sans-serif;
font-size:16px; line-height:1.6; font-weight:400;
-webkit-font-smoothing:antialiased; overflow-x:hidden;
}
body.lock{overflow:hidden;height:100vh}
a{color:inherit;text-decoration:none}
.mono{font-family:'JetBrains Mono',ui-monospace,monospace}
/* ── display type. Tight, heavy, slightly negative tracking. ───── */
.d1,.d2,.d3{
font-weight:900; line-height:.86; letter-spacing:-.04em;
text-transform:uppercase; margin:0;
}
/* vh is in the clamp on purpose: the hero headline is two lines and has
to share a fixed-height screen with the nav and the standfirst, so on a
short window it must shrink on height, not just width. */
.d1{font-size:clamp(38px,min(10vw,15vh),168px)}
.d2{font-size:clamp(38px,7.2vw,112px)}
.d3{font-size:clamp(28px,4.2vw,62px); line-height:.94}
.label{
font-family:'JetBrains Mono',monospace; font-size:10.5px;
letter-spacing:.24em; text-transform:uppercase; color:var(--concrete);
}
.rule{height:1px;background:var(--line);width:100%}
/* ── overlays ──────────────────────────────────────────────────── */
#grain{position:fixed;inset:-1px;z-index:60;pointer-events:none;
opacity:.055;mix-blend-mode:multiply;background-repeat:repeat;background-size:170px 170px}
/* The model canvas sits behind the hero copy. GSAP fades it out once the
hero is scrolled past; the loader covers it until the scene is ready. */
#scene{position:fixed;inset:0;width:100%;height:100%;z-index:1;
display:block;pointer-events:none}
.cursor{position:fixed;top:0;left:0;width:9px;height:9px;margin:-4.5px 0 0 -4.5px;
border-radius:50%;background:var(--orange);z-index:70;pointer-events:none;
/* a paper halo, so the dot is legible on a dark truss as well as on paper */
box-shadow:0 0 0 1.6px var(--bg);will-change:transform;
transition:width .3s cubic-bezier(.4,0,.2,1),height .3s cubic-bezier(.4,0,.2,1),
margin .3s cubic-bezier(.4,0,.2,1),opacity .3s}
/* The setting-out target. Step 01 of the method is "Survey & ground",
the drawings are covered in these marks, so the pointer is one: a
reticle that trails the dot and settles onto it, ticked N/E/S/W and
turning about once every half minute. Every stroke is drawn twice —
a paper halo under a dark hairline — so it survives a black steel
photograph and a bright sky equally. */
.curs{position:fixed;top:0;left:0;width:64px;height:64px;margin:-32px 0 0 -32px;
z-index:69;pointer-events:none;will-change:transform;opacity:0;
transition:opacity .4s;display:grid;place-items:center}
.curs.ready{opacity:1}
.curs svg{grid-area:1/1;overflow:visible;
transition:transform .42s cubic-bezier(.22,1,.36,1),opacity .42s}
/* Paper, so it is invisible over paper and a clean stamped disc over a
photograph — which is the only way a hairline reticle stays readable
on rust-orange steel. The brand colour cannot do this job: orange on
an orange truss is orange. */
.curs__fill{fill:var(--bg);opacity:.32;transition:opacity .35s}
.curs .hal{fill:none;stroke:var(--bg);stroke-width:3.6;stroke-linecap:round;opacity:.8}
.curs .ln{fill:none;stroke:var(--fg);stroke-width:1.15;stroke-linecap:round;opacity:.52;
transition:stroke .35s,opacity .35s}
.curs__spin{transform-origin:32px 32px;animation:curspin 34s linear infinite}
@keyframes curspin{to{transform:rotate(360deg)}}
.curs__tag{grid-area:1/1;font-family:'JetBrains Mono',monospace;font-size:9px;
letter-spacing:.22em;text-transform:uppercase;color:var(--orange);white-space:nowrap;
font-weight:500;
opacity:0;transform:translateY(4px);transition:opacity .3s,transform .3s}
/* the dot stands out of the way when the reticle has something to say */
.cursor.hide{opacity:0}
/* over something you can act on, the reticle opens and takes the brand colour */
.curs.on svg{transform:scale(2.05)}
.curs.on .ln{stroke:var(--orange);opacity:.95}
.curs.on .curs__fill{opacity:.9}
.curs.tag svg{transform:scale(3.15)}
/* dark ring on a paper disc reads on anything; the ticks keep the orange */
.curs.tag .ring{stroke:var(--fg);opacity:.9}
.curs.tag .ticks{stroke:var(--orange);opacity:1}
.curs.tag .curs__fill{opacity:1}
.curs.tag .curs__tag{opacity:1;transform:translateY(0);color:var(--fg)}
.curs.down svg{transform:scale(.82)}
/* Standing still — which is most of what a pointer does while the page
scrolls under it — the instrument stands down instead of sitting
there as a loose dot. */
.curs.still:not(.on):not(.tag) svg{transform:scale(.72);opacity:.42}
.cursor.still:not(.hide){width:7px;height:7px;margin:-3.5px 0 0 -3.5px;opacity:.6}
@media (hover:hover) and (pointer:fine){
html{cursor:none}
a,button,.btn,.xray,.plate,.cap{cursor:none}
}
@media (hover:none),(pointer:coarse){.cursor,.curs{display:none}}
/* ── loader ────────────────────────────────────────────────────── */
#loader{position:fixed;inset:0;z-index:100;background:var(--bg);
display:flex;align-items:flex-end;padding:var(--pad);
padding-bottom:calc(var(--pad) + 62px); /* clear the tape below */
will-change:transform}
.ld__row{width:100%;display:flex;align-items:flex-end;justify-content:space-between;gap:24px}
/* The counter is an odometer, not a number being retyped: three digit
columns, each translated by its own continuous fraction, so units
spin, tens crawl and the hundred arrives once. The eleventh cell in
every column is a second 0 — that is what lets 9 roll forward into 0
instead of snapping back through eight digits. */
.ld__n{color:var(--orange);font-weight:900;font-size:clamp(56px,13vw,172px);
line-height:1;letter-spacing:-.05em;display:flex;margin-top:4px}
/* The cells are 1em but a digit does not fill one, so a 1em window
leaves slack for the neighbouring digit to show through during a
flick — which reads as two numbers rather than one rolling. Measured
off the face itself (Archivo 900, canvas TextMetrics): the ink runs
0.134em → 0.846em inside the cell, so the window is that 0.712em and
the column is seated by 0.13em to put the ink at the top of it. */
.ld__d{height:.72em;overflow:hidden}
.ld__d i{display:block;will-change:transform}
.ld__d b{display:block;height:1em;line-height:1;font-weight:inherit;font-style:normal}
/* A ruled tape rather than a bare line — minor tick every 2, major
every 10 — with the five stages of the programme marked where they
actually fall. Each one lights as the fill crosses it, so what you
watch is a programme being worked through, not a percentage. */
.ld__tape{position:absolute;left:var(--pad);right:var(--pad);bottom:var(--pad)}
.ld__stages{position:relative;height:15px;margin-bottom:9px}
.ld__stage{position:absolute;bottom:0;transform:translateX(-50%);
font-family:'JetBrains Mono',monospace;font-size:9px;letter-spacing:.18em;
color:var(--concrete);opacity:.55;transition:color .4s,opacity .4s}
.ld__stage.hit{color:var(--orange);opacity:1}
.ld__stage:first-child{transform:none}
.ld__ticks{position:relative;height:9px}
.ld__ticks i{position:absolute;bottom:0;width:1px;background:var(--line)}
.ld__bar{position:relative;height:1px;background:var(--line)}
.ld__bar i{display:block;height:100%;width:0;background:var(--orange)}
/* ── nav ───────────────────────────────────────────────────────── */
.nav{position:fixed;top:0;left:0;right:0;z-index:50;display:flex;
align-items:center;justify-content:space-between;padding:22px var(--pad);
color:var(--fg)}
/* Over the hero this is invisible — the hero is paper too. Everywhere
else it is the difference between a header and two lines of type
lying on top of a paragraph. */
.nav::before{content:'';position:absolute;inset:-2px 0 -20px;z-index:-1;pointer-events:none;
background:linear-gradient(180deg,var(--bg) 62%,rgba(245,243,239,0))}
.nav__logo{font-weight:900;font-size:17px;letter-spacing:-.02em;text-transform:uppercase}
.nav__links{display:flex;gap:34px}
.nav__links a{font-family:'JetBrains Mono',monospace;font-size:10.5px;
letter-spacing:.2em;text-transform:uppercase;position:relative;padding-bottom:3px}
.nav__links a::after{content:'';position:absolute;left:0;bottom:0;height:1px;
width:0;background:var(--fg);transition:width .4s cubic-bezier(.4,0,.2,1)}
.nav__links a:hover::after{width:100%}
/* The links used to be display:none below 800px, which left a phone
with no navigation at all. They stay — they just get tighter, and the
founding date goes first, because it is the only decorative thing in
the row. */
@media (max-width:800px){.nav__links{gap:18px}
.nav__links a{font-size:9.5px;letter-spacing:.14em}}
@media (max-width:620px){.nav{padding:16px var(--pad)}
.nav > .label{display:none}
.nav__links{gap:14px}}
@media (max-width:380px){.nav__links{gap:10px}
.nav__logo{font-size:15px}
.nav__links a{font-size:9px;letter-spacing:.08em}}
.prog{position:fixed;top:0;left:0;height:2px;width:0;background:var(--orange);z-index:55}
/* ── hero ──────────────────────────────────────────────────────── */
.hero{position:relative;height:100vh;z-index:2}
.hero__in{position:absolute;inset:0;padding:var(--pad);
padding-top:clamp(78px,11vh,124px); /* clear the fixed nav */
display:flex;flex-direction:column;justify-content:space-between}
.hero__top{max-width:520px}
.hero__bot{display:flex;align-items:flex-end;justify-content:space-between;gap:30px;flex-wrap:wrap}
/* The acts are absolutely stacked, so the rail has to reserve two lines
of the display size itself or the standfirst slides up underneath. */
.hero__acts{position:relative;height:calc(clamp(38px,min(10vw,15vh),168px) * 1.8)}
.act{position:absolute;left:0;top:0;white-space:nowrap;opacity:0}
.act em{font-style:normal;color:var(--orange)}
.hero__meta{max-width:300px;font-size:14.5px;color:#57534B}
/* The model is pushed up in portrait, but a tall thin screen can still
bring the site's shadow down into the type. This is the floor. */
@media (max-aspect-ratio:115/100){
.hero__in::before{content:'';position:absolute;left:0;right:0;bottom:0;
height:54%;pointer-events:none;
background:linear-gradient(0deg,var(--bg) 42%,rgba(245,243,239,0))}
}
/* ── generic section ───────────────────────────────────────────── */
.sec{position:relative;z-index:2;background:var(--bg);padding:clamp(90px,13vw,190px) var(--pad)}
.sec--edge{padding-left:0;padding-right:0}
.head{display:flex;align-items:baseline;gap:18px;margin-bottom:52px}
.head span{width:7px;height:7px;background:var(--orange);flex:none;border-radius:50%}
/* manifesto */
.man{max-width:1150px}
.man p{font-size:clamp(24px,3.6vw,54px);line-height:1.16;font-weight:600;
letter-spacing:-.025em;margin:0}
.man .w{display:inline-block;opacity:.13}
.man .hl{color:var(--orange)}
/* stats */
.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--line);
border-top:1px solid var(--line);border-bottom:1px solid var(--line)}
.stat{background:var(--bg);padding:44px 28px}
.stat__n{font-weight:900;font-size:clamp(40px,5.6vw,86px);line-height:.9;letter-spacing:-.04em}
.stat__n sup{font-size:.4em;vertical-align:super;color:var(--orange);font-weight:700}
.stat__l{margin-top:14px}
@media (max-width:860px){.stats{grid-template-columns:repeat(2,1fr)}}
/* capabilities — horizontal */
.caps{position:relative;z-index:2;background:var(--bg);overflow:hidden}
.caps__track{display:flex;gap:1px;background:var(--line);will-change:transform}
.cap{flex:none;width:clamp(300px,34vw,470px);background:var(--bg);position:relative;
padding:46px 36px 40px;min-height:66vh;display:flex;flex-direction:column;
/* was space-between, which pushed a 130px photograph to the floor and
left a third of every card empty. The art takes the slack instead. */
justify-content:flex-start}
/* The live card is marked by its number and its photograph coming up to
full grade — no rule. Two orange lines stacked above each other read
as chrome, not as signal. */
.cap.is-live .cap__n{color:var(--orange)}
/* 66vh of card with three lines in it is a hole, not a layout: on a
phone the card shrinks to its content and the art follows the copy
instead of being pushed to the floor. */
@media (max-width:760px){
.cap{width:min(80vw,330px);min-height:0;padding:34px 24px 30px}
}
.cap__n{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--orange);
letter-spacing:.2em}
.cap h3{margin:26px 0 0;font-size:clamp(24px,2.7vw,38px);font-weight:800;
letter-spacing:-.03em;line-height:1.02;text-transform:uppercase}
.cap p{margin:16px 0 0;font-size:15px;color:#5E594F;max-width:38ch}
.cap__art{flex:1;min-height:210px;margin-top:30px;background-size:cover;background-position:center;
/* .55 was tuned against a dark ground; light art at that opacity over
paper all but disappears. */
/* Colour, not a duotone — but a common grade, so five photographs
from five sources still read as one set. saturate lifts hi-vis and
painted steel (which is most of the colour on a site anyway) and
the sliver of sepia ties them to the paper without greying them. */
filter:saturate(1.12) contrast(1.05) sepia(.06);
opacity:.94;transition:opacity .5s,filter .5s}
/* after the base rule, not before it — the old mobile override sat above
and lost on source order, so it had never done anything */
@media (max-width:760px){.cap__art{flex:none;min-height:0;height:212px;margin-top:24px}}
.cap:hover .cap__art,.cap.is-live .cap__art{opacity:1;
filter:saturate(1.24) contrast(1.06) sepia(0)}
.caps__intro{padding:clamp(90px,13vw,170px) var(--pad) 32px}
/* Pinned, the section IS the viewport — so give it a definite height and
let the rail take whatever the intro leaves. Cards stretch to the rail,
the photograph takes the slack inside them, and nothing can run off the
bottom however tall the heading gets. */
@media (min-width:861px){
.caps{height:100vh;display:flex;flex-direction:column}
.caps__intro{flex:none;padding:clamp(62px,8vh,104px) var(--pad) 22px}
.caps__intro h2{font-size:clamp(34px,5vw,72px)}
.caps__track{flex:1;min-height:0;align-items:stretch}
.cap{min-height:0;padding:34px 36px 34px}
.cap__art{min-height:0}
}
.capbar{display:flex;gap:1px;margin-top:30px;background:var(--line)}
.capbar__seg{flex:1;position:relative;padding-top:13px;background:var(--bg)}
.capbar__seg::before{content:'';position:absolute;left:0;top:0;height:2px;width:100%;
background:var(--line)}
.capbar__seg b{font-family:'JetBrains Mono',monospace;font-size:9.5px;font-weight:400;
letter-spacing:.2em;color:var(--concrete);transition:color .4s}
.capbar__seg.done b{color:var(--fg)}
/* work plates */
.work__grid{display:grid;grid-template-columns:repeat(12,1fr);gap:clamp(14px,2vw,30px)}
.plate{position:relative;overflow:hidden;background:var(--steel)}
.plate__img{position:absolute;inset:0;background-size:cover;background-position:center;
transform:scale(1.16);will-change:transform;
filter:saturate(1.12) contrast(1.05) sepia(.06);transition:filter .6s}
.plate:hover .plate__img{filter:saturate(1.24) contrast(1.06) sepia(0)}
.plate__mask{position:absolute;inset:0;background:var(--bg);transform-origin:bottom}
.plate__cap{position:absolute;left:0;bottom:0;padding:24px;z-index:2;width:100%;
display:flex;justify-content:space-between;align-items:flex-end;gap:14px;
background:linear-gradient(0deg,rgba(245,243,239,.96),rgba(245,243,239,.62) 46%,transparent)}
.plate h4{margin:0;font-size:clamp(17px,1.9vw,26px);font-weight:800;
text-transform:uppercase;letter-spacing:-.02em;line-height:1}
/* The meta sits on a photograph, not on paper: --concrete was picked to
pass against the page and has nothing to hold on to here. Orange is
the one colour that reads over a bright sky and a dark truss alike. */
.plate__cap .label{color:var(--orange)}
.p1{grid-column:1/8;aspect-ratio:16/11}
.p2{grid-column:8/13;aspect-ratio:3/4;align-self:end}
.p3{grid-column:3/13;aspect-ratio:16/8}
@media (max-width:820px){
.p1,.p2,.p3{grid-column:1/13;aspect-ratio:4/3}
}
/* Side by side, the meta wraps into the second line of the title. Stack
it, and put it above — a caption line, which is what it is. */
@media (max-width:620px){
.plate__cap{flex-direction:column;align-items:flex-start;gap:7px;padding:18px;
background:linear-gradient(0deg,rgba(245,243,239,.97),rgba(245,243,239,.82) 62%,rgba(245,243,239,0))}
.plate__cap .label{order:-1}
.plate h4{font-size:clamp(19px,5.2vw,26px)}
}
/* process */
.proc{display:grid;grid-template-columns:repeat(12,1fr);gap:clamp(14px,2vw,30px)}
/* Act two — the method demonstrated on the model.
Two things this layout has to respect. A pinned section must stay
OPAQUE or the rest of the page scrolls visibly underneath it, so the
paper moves to the CANVAS for the duration and the section goes
transparent above it: canvas paper → model → method. And the layout
must be STATIC, not switched on by the .is-act2 class, because GSAP
bakes the element's height at pin time — anything the class changes
afterwards is measured against the old box and the copy ends up a
screen and a half tall. So only state lives on the class. */
@media (min-width:861px){
#proc{min-height:100vh;display:flex;align-items:center;background:transparent;
padding-top:clamp(70px,9vh,104px);padding-bottom:clamp(34px,5vh,62px)}
#proc .proc{position:relative;z-index:4;width:100%}
/* both columns stack left; they need explicit ROWS or grid drops them
in one cell and the steps hide under the heading */
#proc .proc__sticky,#proc .proc__list{grid-column:1/7}
#proc .proc__sticky{grid-row:1;position:static;margin-bottom:20px}
#proc .proc__list{grid-row:2}
#proc .head{margin-bottom:20px}
#proc .proc__sticky h2{font-size:clamp(24px,2.8vw,40px)}
/* A rail, not a list. One stage is open; the rest are a single line
each, waiting their turn — which is how a programme is actually
read, and it stops the column being a wall of five paragraphs. */
#proc .proc__list{position:relative;padding-left:40px}
#proc .proc__list::before{content:'';position:absolute;left:0;top:14px;bottom:14px;
width:1px;background:var(--line)}
#proc .prail{position:absolute;left:-1px;top:14px;width:3px;height:0;
background:var(--orange);transition:height .55s cubic-bezier(.4,0,.2,1)}
#proc .step{padding:9px 0;gap:20px;position:relative;border-top:none}
#proc .step + .step{border-top:none}
#proc .step:last-child{border-bottom:none}
#proc .step::after{content:'';position:absolute;left:-44px;top:20px;width:9px;height:9px;
border-radius:50%;background:var(--bg);border:1px solid var(--line);
transition:background .4s,border-color .4s,transform .4s}
#proc .step.on::after{background:var(--orange);border-color:var(--orange);transform:scale(1.35)}
#proc .step h4{font-size:clamp(17px,1.7vw,22px)}
/* the paragraph opens only for the stage being demonstrated */
#proc .step p{margin:0;font-size:13.5px;max-width:42ch;overflow:hidden;
max-height:0;opacity:0;
transition:max-height .55s cubic-bezier(.4,0,.2,1),opacity .4s,margin-top .55s}
#proc .step.on p{max-height:110px;opacity:1;margin-top:6px}
}
#proc.is-act2{z-index:4}
#proc.is-act2 .step{transition:opacity .45s}
#proc.is-act2 .step:not(.on){opacity:.28}
#proc.is-act2 .step.on .step__n{color:var(--orange)}
/* opacity via the class, NOT a tween: a gsap.to(canvas,{overwrite:true})
here kills the hero's own scrubbed fade permanently, and the model
never comes back when you scroll up to it again. */
#scene.act2{z-index:3;background:var(--bg);opacity:1 !important}
.lvl{position:fixed;inset:0;z-index:5;pointer-events:none;opacity:0;transition:opacity .6s}
#proc.is-act2 .lvl{opacity:1}
.lvl__t{position:absolute;top:0;left:0;width:26px;height:1px;background:var(--fg);opacity:.3}
.lvl__t.maj{width:44px;opacity:.5}
.lvl__t b{position:absolute;left:52px;top:-7px;font-family:'JetBrains Mono',monospace;
font-size:9.5px;letter-spacing:.14em;color:var(--concrete);white-space:nowrap}
.lvl__now{position:absolute;top:0;left:0}
.lvl__now i{position:absolute;top:0;left:0;width:72px;height:1px;background:var(--orange)}
.lvl__now b{position:absolute;left:52px;top:-17px;font-family:'JetBrains Mono',monospace;
font-size:11px;letter-spacing:.14em;color:var(--orange);white-space:nowrap}
#proc.is-act2 .step{transition:opacity .45s,color .45s}
#proc.is-act2 .step:not(.on){opacity:.3}
#proc.is-act2 .step.on .step__n{color:var(--orange)}
.proc__sticky{grid-column:1/5;position:sticky;top:22vh;height:max-content}
.proc__list{grid-column:6/13}
.step{padding:38px 0;border-top:1px solid var(--line);display:flex;gap:26px;align-items:flex-start}
.step:last-child{border-bottom:1px solid var(--line)}
.step__n{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--orange);
padding-top:7px;flex:none;letter-spacing:.18em}
.step h4{margin:0;font-size:clamp(21px,2.5vw,34px);font-weight:800;
text-transform:uppercase;letter-spacing:-.028em;line-height:1}
.step p{margin:11px 0 0;color:#5E594F;font-size:15px;max-width:46ch}
@media (max-width:860px){
.proc__sticky,.proc__list{grid-column:1/13}
.proc__sticky{position:static;margin-bottom:34px}
}
/* marquee */
.marq{overflow:hidden;border-top:1px solid var(--line);
border-bottom:1px solid var(--line);padding:20px 0;background:var(--bg);
position:relative;z-index:2}
.marq__in{display:flex;gap:46px;white-space:nowrap;will-change:transform}
.marq__in span{font-weight:900;font-size:clamp(26px,4.4vw,64px);
text-transform:uppercase;letter-spacing:-.035em;display:flex;align-items:center;gap:46px}
.marq__in i{width:12px;height:12px;background:var(--orange);border-radius:50%;
display:inline-block;font-style:normal;flex:none}
/* cta + footer */
.cta{position:relative;z-index:2;background:var(--bg);text-align:center;
padding:clamp(110px,17vw,230px) var(--pad)}
.btn{display:inline-flex;align-items:center;gap:14px;margin-top:44px;
padding:19px 34px;border:1px solid var(--fg);border-radius:999px;
font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:.2em;
text-transform:uppercase;position:relative;overflow:hidden;isolation:isolate}
.btn::before{content:'';position:absolute;inset:0;background:var(--fg);
transform:translateY(101%);transition:transform .5s cubic-bezier(.4,0,.2,1);z-index:-1}
.btn:hover{color:var(--bg)}
.btn:hover::before{transform:translateY(0)}
.foot{position:relative;z-index:2;background:var(--bg);
padding:clamp(150px,23vw,360px) var(--pad) 30px}
/* 190 = 804 / 4.23, from the measured width of MASSIF in Archivo 900 at
1em. It was 227 — the word filling the whole viewBox — but the crane
has to stand somewhere, and a mast outside the box is a mast clipped
at the window edge. The word gives up 156 units and gains a machine. */
/* Room above for the crane, which stands outside the viewBox — as the
FOOTER's padding, not the mark's margin. A top margin on the first
child collapses out through a parent with no top padding, so it sat
outside <footer>: the pin measured a box 360px shorter than the one on
screen, and the lift's end ran past the bottom of the page. */
.foot__mark{margin:0 0 clamp(26px,4vw,52px)}
.foot__mark svg{display:block;width:100%;height:auto;overflow:visible}
.mk{font-family:'Archivo',sans-serif;font-weight:900;font-size:190px}
.mk--built{fill:var(--fg)}
.mk--buried{fill:none;stroke:var(--orange);stroke-width:2.2;
stroke-dasharray:10 8;animation:mkdash 14s linear infinite}
@keyframes mkdash{to{stroke-dashoffset:-180}}
/* The crane is drawn, not painted — hairlines like the rest of the
footer's line work, with the orange kept for the parts that move. */
.mk__crane line,.mk__crane path{stroke:var(--fg);stroke-width:1.5;fill:none;opacity:.42}
.mk__crane .hot{stroke:var(--orange);opacity:1;stroke-width:2.6}
.mk__crane rect{fill:none;stroke:var(--fg);stroke-width:1.5;opacity:.42}
.mk__rig{opacity:1}
.mk__rope{stroke:var(--fg);stroke-width:1.4;opacity:.45}
.mk__hook path{fill:none;stroke:var(--orange);stroke-width:3.4;stroke-linecap:round}
.mk__ground line{stroke:var(--fg);stroke-width:1;opacity:.28}
.mk__ticks line{stroke:var(--fg);stroke-width:1;opacity:.2}
@media (prefers-reduced-motion:reduce){.mk--buried{animation:none}}
.foot__grid{display:flex;justify-content:space-between;gap:26px;flex-wrap:wrap;
padding-top:34px;border-top:1px solid var(--line)}
.foot__grid div{min-width:150px}
.foot__grid p{margin:9px 0 0;font-size:14px;color:#5E594F}
/* ── showcase layer ──────────────────────────────────────────────── */
html.lenis, html.lenis body{height:auto}
.lenis.lenis-smooth{scroll-behavior:auto !important}
.lenis.lenis-stopped{overflow:hidden}
/* live telemetry, read straight off the model every frame */
.hud{position:fixed;right:var(--pad);bottom:clamp(92px,13vh,128px);z-index:20;width:214px;
padding:13px 16px 12px;border:1px solid var(--line);background:rgba(245,243,239,.74);
backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);pointer-events:none;
font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.13em;
text-transform:uppercase;color:var(--concrete)}
.hud__head{display:flex;justify-content:space-between;align-items:center;
margin-bottom:8px;color:var(--fg)}
.hud__head i{width:7px;height:7px;border-radius:50%;background:var(--orange);
animation:hudblink 1.8s steps(1) infinite}
@keyframes hudblink{50%{opacity:.18}}
.hud__row{display:flex;justify-content:space-between;padding:4px 0;border-top:1px solid var(--line)}
.hud__row b{font-weight:500;color:var(--fg);font-variant-numeric:tabular-nums}
.hud__bar{height:2px;background:var(--line);margin-top:9px}
.hud__bar i{display:block;height:100%;width:0;background:var(--orange)}
@media (max-width:900px){.hud{display:none}}
/* annotations pinned to points on the model */
.notes{position:fixed;inset:0;z-index:15;pointer-events:none}
.note{position:absolute;left:0;top:0;opacity:0;transition:opacity .45s ease;will-change:transform}
.note.on{opacity:1}
.note i{position:absolute;left:-5px;top:-5px;width:10px;height:10px;border-radius:50%;
background:var(--bg);border:2px solid var(--orange)}
.note i::after{content:'';position:absolute;left:50%;top:50%;width:28px;height:28px;
margin:-14px 0 0 -14px;border-radius:50%;border:1px solid var(--orange);
animation:noteping 2.2s ease-out infinite}
@keyframes noteping{from{transform:scale(.3);opacity:.9}to{transform:scale(1.5);opacity:0}}
.note span{position:absolute;left:0;bottom:0;padding:0 0 38px 12px;white-space:nowrap;
border-left:1px solid var(--orange);font-family:'JetBrains Mono',monospace;font-size:10px;
letter-spacing:.14em;text-transform:uppercase;color:var(--concrete);line-height:1.55}
.note span b{display:block;color:var(--fg);font-weight:500}
@media (max-width:900px){.notes{display:none}} /* they land on the mast below this */
/* drawing / built toggle */
.xray{display:inline-flex;align-items:center;gap:10px;margin-top:18px;
padding:11px 17px 11px 12px;border:1px solid var(--fg);border-radius:999px;
background:transparent;color:var(--fg);cursor:pointer;font-family:'JetBrains Mono',monospace;
font-size:10px;letter-spacing:.18em;text-transform:uppercase;
transition:background .35s,color .35s}
.xray i{width:17px;height:17px;border-radius:50%;border:1.5px dashed var(--orange);
transition:transform .7s cubic-bezier(.4,0,.2,1)}
.xray:hover i{transform:rotate(180deg)}
.xray.is-drawing{background:var(--fg);color:var(--bg)}
/* headline letters, for the per-act reveal */
.act{perspective:900px}
.act .ch{display:inline-block;transform-origin:50% 100%;will-change:transform}
/* the first section rides up over the model like a sheet */
.sec--sheet{border-radius:30px 30px 0 0;box-shadow:0 -30px 80px rgba(18,17,16,.07)}
@media (prefers-reduced-motion:reduce){
.man .w{opacity:1 !important}
}
</style>
</head>
<body class="lock">
<div id="grain"></div>
<div class="cursor"></div>
<div class="curs" aria-hidden="true">
<svg viewBox="0 0 64 64" width="64" height="64">
<circle class="curs__fill" cx="32" cy="32" r="13"/>
<g class="curs__spin">
<circle class="hal" cx="32" cy="32" r="13"/>
<path class="hal" d="M32 14.5V9.5M32 49.5v5M14.5 32h-5M49.5 32h5"/>
<circle class="ln ring" cx="32" cy="32" r="13"/>
<path class="ln ticks" d="M32 14.5V9.5M32 49.5v5M14.5 32h-5M49.5 32h5"/>
</g>
</svg>
<b class="curs__tag"></b>
</div>
<div class="prog"></div>
<div id="loader">
<div class="ld__row">
<div>
<div class="label">Massif — Structural Construction</div>
<div class="ld__n" id="ldn" aria-hidden="true"></div>
</div>
<div class="label" id="ldtask">Surveying</div>
</div>
<div class="ld__tape">
<div class="ld__stages"></div>
<div class="ld__ticks"></div>
<div class="ld__bar"><i></i></div>
</div>
</div>
<canvas id="scene"></canvas>
<aside class="hud" aria-hidden="true">
<div class="hud__head"><span>Site telemetry</span><i></i></div>
<div class="hud__row"><span>Stage</span><b data-hud="stage">01 / 04</b></div>
<div class="hud__row"><span>Floor</span><b data-hud="floor">03 / 11</b></div>
<div class="hud__row"><span>Height</span><b data-hud="height">10.2 m</b></div>
<div class="hud__row"><span>Slew</span><b data-hud="slew">044°</b></div>
<div class="hud__row"><span>Hook</span><b data-hud="hook">39.3 m</b></div>
<div class="hud__row"><span>Load</span><b data-hud="load">—</b></div>
<div class="hud__bar"><i></i></div>
</aside>
<div class="notes" aria-hidden="true">
<div class="note" data-note="crane"><i></i><span>Tower crane<b>50 m · 46 m jib</b></span></div>
<div class="note" data-note="core"><i></i><span>Slipform core<b>a floor ahead</b></span></div>
<div class="note" data-note="frame"><i></i><span>In-situ frame<b>11 floors · 3.4 m</b></span></div>
<div class="note" data-note="ties"><i></i><span>Mast ties<b>15 m & 31 m</b></span></div>
<div class="note" data-note="beam"><i></i><span>Steel beam<b>12 m · 2.4 t</b></span></div>
<div class="note" data-note="roof"><i></i><span>Topped out<b>37.4 m</b></span></div>
</div>
<header class="nav">
<a class="nav__logo" href="#top">Massif<span style="color:var(--orange)">.</span></a>
<nav class="nav__links">
<a href="#work">Work</a><a href="#caps">Capability</a>
<a href="#proc">Process</a><a href="#contact">Contact</a>
</nav>
<div class="label">EST. 1994 — 03 OFFICES</div>
</header>
<main>
<!-- ══ hero: pinned, four acts, the site model builds under it ══ -->
<section class="hero" id="top">
<div class="hero__in">
<div class="hero__top">
<div class="label">01 — Structural & Civil</div>
</div>
<div>
<div class="hero__acts">
<h1 class="d1 act" data-act="0">We raise<br>the <em>frame</em>.</h1>
<h1 class="d1 act" data-act="1">Then the<br><em>floors</em>.</h1>
<h1 class="d1 act" data-act="2">Then the<br><em>span</em>.</h1>
<h1 class="d1 act" data-act="3">That is<br><em>Massif</em>.</h1>
</div>
<div class="hero__bot" style="margin-top:34px">
<div>
<p class="hero__meta">Heavy structural contractors. Thirty-one years of
concrete, steel and the awkward gap between a drawing and a site.</p>
<button class="xray" type="button"><i></i><span>View drawings</span></button>
</div>
<div class="label">Scroll — 04 stages</div>
</div>
</div>
</div>
</section>
<!-- ══ manifesto ══ -->
<section class="sec sec--sheet">
<div class="head"><span></span><div class="label">02 — Position</div></div>
<div class="man">
<p data-split>A building is an argument about load. Everything else —
the cladding, the glazing, the render in the brochure — is a conversation
that only happens <i class="hl">after the frame stands up</i>. We do the
part that has to be right the first time.</p>
</div>
</section>
<!-- ══ stats ══ -->
<section class="sec" style="padding-top:0">
<div class="stats">
<div class="stat"><div class="stat__n"><span data-count="148">0</span></div>
<div class="stat__l label">Structures topped out</div></div>
<div class="stat"><div class="stat__n"><span data-count="2.4" data-dec="1">0</span><sup>M</sup></div>
<div class="stat__l label">Sq ft delivered</div></div>
<div class="stat"><div class="stat__n"><span data-count="31">0</span></div>
<div class="stat__l label">Years trading</div></div>
<div class="stat"><div class="stat__n"><span data-count="0">0</span></div>
<div class="stat__l label">Reportable incidents 2025</div></div>
</div>
</section>
<!-- ══ capabilities — horizontal scroll ══ -->
<section class="caps" id="caps">
<div class="caps__intro">
<div class="head"><span></span><div class="label">03 — Capability</div></div>
<h2 class="d2">What we<br>actually do.</h2>
<!-- The rail is a programme, so it gets a programme bar: five
segments filling continuously as the cards pass. Same rule as
the loader tape and the chainage in the footer. -->
<div class="capbar" aria-hidden="true"></div>
</div>
<div class="caps__track">
<article class="cap"><div><div class="cap__n">/ 01</div>
<h3>Substructure<br>& piling</h3>
<p>Bored and CFA piling, pile caps, capping beams, and the deep basements
nobody photographs.</p></div>
<div class="cap__art" data-art="rebar"></div></article>
<article class="cap"><div><div class="cap__n">/ 02</div>
<h3>In-situ<br>concrete frame</h3>
<p>Slipform cores, post-tensioned slabs, and formwork cycles held to the day.</p></div>
<div class="cap__art" data-art="frame"></div></article>
<article class="cap"><div><div class="cap__n">/ 03</div>
<h3>Structural<br>steel</h3>
<p>Fabrication, erection and connection design for long-span and transfer
structures.</p></div>
<div class="cap__art" data-art="steel"></div></article>
<article class="cap"><div><div class="cap__n">/ 04</div>
<h3>Heavy civils<br>& bridges</h3>
<p>Viaducts, underpasses and the temporary works that make them buildable.</p></div>
<div class="cap__art" data-art="span"></div></article>
<article class="cap"><div><div class="cap__n">/ 05</div>
<h3>Demolition<br>& enabling</h3>
<p>Top-down deconstruction on live sites, with the neighbours still open
for business.</p></div>
<div class="cap__art" data-art="demolition"></div></article>
</div>
</section>
<!-- ══ work ══ -->
<section class="sec" id="work">
<div class="head"><span></span><div class="label">04 — Selected work</div></div>
<div class="work__grid">
<figure class="plate p1" style="margin:0">
<div class="plate__img" data-art="frame"></div><div class="plate__mask"></div>
<figcaption class="plate__cap"><h4>Northgate Exchange</h4>
<div class="label">42 floors — 2024</div></figcaption></figure>
<figure class="plate p2" style="margin:0">
<div class="plate__img" data-art="steel"></div><div class="plate__mask"></div>
<figcaption class="plate__cap"><h4>Dock Four</h4>
<div class="label">Steel — 2023</div></figcaption></figure>
<figure class="plate p3" style="margin:0">
<div class="plate__img" data-art="span"></div><div class="plate__mask"></div>
<figcaption class="plate__cap"><h4>Calder Viaduct</h4>
<div class="label">610 m span — 2025</div></figcaption></figure>
</div>
</section>
<div class="marq">
<div class="marq__in">
<span>Concrete <i></i> Steel <i></i> Piling <i></i> Civils <i></i></span>
<span>Concrete <i></i> Steel <i></i> Piling <i></i> Civils <i></i></span>
</div>
</div>
<!-- ══ process ══ -->
<section class="sec" id="proc">
<div class="proc">
<!-- A levelling staff, stood beside the tower in the model's own
coordinates. Every tick is a floor level projected through the
live camera, so the scale leans and foreshortens with the shot
instead of being drawn next to it. -->
<div class="lvl" aria-hidden="true"><div class="lvl__ticks"></div>
<div class="lvl__now"><i></i><b>0.0 m</b></div></div>
<div class="proc__sticky">
<div class="head"><span></span><div class="label">05 — Method</div></div>
<h2 class="d3">Five stages.<br>No surprises.</h2>
</div>
<div class="proc__list"><div class="prail" aria-hidden="true"></div>
<div class="step"><div class="step__n">01</div><div>
<h4>Survey & ground</h4>
<p>Boreholes before opinions. The ground decides the programme, so we
find out what it is first.</p></div></div>
<div class="step"><div class="step__n">02</div><div>
<h4>Temporary works</h4>
<p>Every permanent structure is held up by a temporary one. Ours are
designed, not improvised.</p></div></div>
<div class="step"><div class="step__n">03</div><div>
<h4>Frame</h4>
<p>Core, columns, slabs. A cycle you can set a calendar by, because the
trades behind us are setting theirs by it too.</p></div></div>
<div class="step"><div class="step__n">04</div><div>
<h4>Envelope handover</h4>
<p>We leave a frame that is plumb, level and dimensionally honest, so the
cladding contractor has nothing to argue about.</p></div></div>
<div class="step"><div class="step__n">05</div><div>
<h4>Close out</h4>
<p>As-builts, test records and a defects list that is already empty when
we hand you the file.</p></div></div>
</div>
</div>
</section>
<!-- ══ cta ══ -->
<section class="cta" id="contact">
<div class="label">06 — Start something heavy</div>
<h2 class="d2" style="margin-top:24px">Send us<br>the drawings.</h2>
<a class="btn" href="#top">Begin a project <span>→</span></a>
</section>
</main>
<footer class="foot">
<!-- The wordmark as a foundation. Capability 01 calls the basements
"the part nobody photographs" — so here the brand is drawn the way
the building is: solid where it is built, and below the ground
line, dashed orange, the way the hero draws a thing before it
exists. The ground is ticked like the loader tape. -->
<div class="foot__mark" aria-label="Massif">
<svg viewBox="0 0 1000 200" preserveAspectRatio="xMidYMax meet" role="img">
<defs>
<clipPath id="mkUp"><rect x="-40" y="-60" width="1080" height="175"/></clipPath>
<clipPath id="mkDn"><rect x="-40" y="115" width="1080" height="150"/></clipPath>
</defs>
<g class="mk__ground">
<line x1="0" y1="115" x2="1000" y2="115"/>
<g class="mk__ticks"></g>
</g>
<!-- ASSIF is set; the M is still on the hook, out by the mast. The
crane traverses it along the whole jib and sets it as the first
letter. ASSIF's x is measured at runtime off the width of M in
the real face, so the word closes up on the letter's own kerning. -->
<text class="mk mk--buried mk__word" clip-path="url(#mkDn)" x="180" y="175">ASSIF</text>
<text class="mk mk--built mk__word" clip-path="url(#mkUp)" x="180" y="175">ASSIF</text>
<g class="mk__crane"></g>
<g class="mk__rig">
<line class="mk__rope" x1="0" y1="-300" x2="0" y2="16"/>
<g class="mk__hook"><path d="M-13 0h26M0 0v13M-7 13h14v9h-14z"/></g>
</g>
<g class="mk__set">
<text class="mk mk--buried" clip-path="url(#mkDn)" x="20" y="175">M</text>
<text class="mk mk--built" clip-path="url(#mkUp)" x="20" y="175">M</text>
</g>
</svg>
</div>
<div class="foot__grid">
<div><div class="label">Studio</div><p>Unit 7, Calder Works<br>Leeds LS10</p></div>
<div><div class="label">Enquiries</div><p>[email protected]<br>+44 113 000 0000</p></div>
<div><div class="label">Accreditation</div><p>CHAS · ISO 9001<br>ISO 45001</p></div>
<div><div class="label">Index</div><p>© 2026 Massif Ltd<br>All rights reserved</p></div>
</div>
</footer>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/ScrollTrigger.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/lenis.min.js"></script>
<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>
<script type="module">
import * as THREE from 'three'
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'
import { GPUComputationRenderer } from 'three/addons/misc/GPUComputationRenderer.js'
gsap.registerPlugin(ScrollTrigger)
/* ══════════════════════════════════════════════════════════════════
ASSETS — everything below is drawn here, in code. No files.
══════════════════════════════════════════════════════════════════ */
/* ── 1. grain ──────────────────────────────────────────────────── */
;(function grain(){
const c = document.createElement('canvas'); c.width = c.height = 170
const x = c.getContext('2d'); const img = x.createImageData(170, 170)
for (let i = 0; i < img.data.length; i += 4){
const v = 110 + Math.random() * 145
img.data[i] = img.data[i+1] = img.data[i+2] = v; img.data[i+3] = 255
}
x.putImageData(img, 0, 0)
document.getElementById('grain').style.backgroundImage = `url(${c.toDataURL()})`
})()
/* ── 2. the plates ─────────────────────────────────────────────────
Photographs, served from ./img next to this file — not hotlinked, so
the page keeps working if a CDN does not. Five, one per capability,
and the three named projects reuse the frame / steel / span shots.
Chosen for colour that is already the brand's: hi-vis and painted
steel are safety orange and plant is yellow, so the palette comes out
of the work rather than being imposed on it. Graded in CSS, not in the
files — a light shared saturate/contrast so five photographs from five
sources read as one set, and so hover can lift the grade instead of
swapping to a second image.
Sources: unsplash.com, free licence —
rebar photo-1768926968986-a88590ce5025
frame photo-1576577610667-c9ea0ac983fd
steel photo-1651150538399-3bd977cb30c0
span photo-1601036452788-deedf9b7893f
demolition photo-1677588508537-5106322c2d40 */
const PLATES = {
rebar: 'img/rebar.jpg', // substructure: hi-vis and rebar, before the pour
frame: 'img/frame.jpg', // the frame going up under two tower cranes
steel: 'img/steel.jpg', // erected steel in daylight, painted the crane's orange
span: 'img/span.jpg', // a viaduct threading a valley, seen from above
demolition: 'img/demolition.jpg', // plant taking a structure down on a live site
}
document.querySelectorAll('[data-art]').forEach(el => {
el.style.backgroundImage = `url(${PLATES[el.dataset.art]})`
})
/* ══════════════════════════════════════════════════════════════════
THE 3D — a construction site, modelled and lit as a maquette.
An architect's model rather than a photoreal render: white concrete,
a safety-orange tower crane, glass on the lower floors, soft studio
light from a room environment and one sun casting soft shadows. The
maquette register is deliberate — it suits the paper palette, and a
procedural model reads as considered in that register, where the
same geometry pushed toward photoreal reads as a game asset.
Scroll drives one value, p (0 → 3), across the pinned hero:
act 0 the site, core started
act 1 the frame rises floor by floor
act 2 the crane picks a steel beam off the laydown and sets it
on the roof
act 3 topped out, a low wide hero shot
══════════════════════════════════════════════════════════════════ */
const canvas = document.getElementById('scene')
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true })
renderer.setPixelRatio(Math.min(devicePixelRatio, 2))
renderer.setClearColor(0x000000, 0) // the page itself is the backdrop
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.toneMapping = THREE.NeutralToneMapping // keeps the whites white
renderer.toneMappingExposure = 1.0
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap
renderer.localClippingEnabled = true
const scene = new THREE.Scene()
const pmrem = new THREE.PMREMGenerator(renderer)
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture
scene.environmentIntensity = 0.42
const camera = new THREE.PerspectiveCamera(32, innerWidth / innerHeight, 1, 1400)
/* ── materials ─────────────────────────────────────────────────── */
const M = {
white: new THREE.MeshStandardMaterial({ color: 0xF8F5EF, roughness: 0.8 }),
pad: new THREE.MeshStandardMaterial({ color: 0xE9E4DA, roughness: 0.92 }),
concrete: new THREE.MeshStandardMaterial({ color: 0xEAE5DB, roughness: 0.88 }),
core: new THREE.MeshStandardMaterial({ color: 0xD9D3C7, roughness: 0.94 }),
paint: new THREE.MeshStandardMaterial({ color: 0xF2541B, roughness: 0.44, metalness: 0.2 }),
steel: new THREE.MeshStandardMaterial({ color: 0x3A3936, roughness: 0.46, metalness: 0.72 }),
galv: new THREE.MeshStandardMaterial({ color: 0xB4B3AE, roughness: 0.4, metalness: 0.8 }),
glass: new THREE.MeshStandardMaterial({ color: 0x8C9EA8, roughness: 0.1, metalness: 0.55 }),
ballast: new THREE.MeshStandardMaterial({ color: 0xC3BDB1, roughness: 0.96 }),
timber: new THREE.MeshStandardMaterial({ color: 0xCDAB7C, roughness: 0.86 }),
cable: new THREE.MeshStandardMaterial({ color: 0x23221F, roughness: 0.36, metalness: 0.84 }),
hook: new THREE.MeshStandardMaterial({ color: 0xF2B51C, roughness: 0.42, metalness: 0.24 }),
cabin: new THREE.MeshStandardMaterial({ color: 0xEEEBE5, roughness: 0.66 }),
tree: new THREE.MeshStandardMaterial({ color: 0xE2DED5, roughness: 0.9, flatShading: true }),
figure: new THREE.MeshStandardMaterial({ color: 0xD4CFC4, roughness: 0.85 }),
}
/* ── geometry helpers ──────────────────────────────────────────────
Every member is placed between two points and then merged per
material, so a lattice of hundreds of members is one draw call. */
const V = (x, y, z) => new THREE.Vector3(x, y, z)
const UP = V(0, 1, 0)
function orient(g, a, b){
const d = b.clone().sub(a)
g.applyMatrix4(new THREE.Matrix4().compose(
a.clone().add(b).multiplyScalar(0.5),
new THREE.Quaternion().setFromUnitVectors(UP, d.normalize()),
V(1, 1, 1)))
return g
}
const strut = (a, b, t) => orient(new THREE.BoxGeometry(t, a.distanceTo(b), t), a, b)
const rod = (a, b, r) => orient(new THREE.CylinderGeometry(r, r, a.distanceTo(b), 8, 1), a, b)
const box = (w, h, d, x, y, z) => new THREE.BoxGeometry(w, h, d).translate(x, y, z)
function merged(list, mat, cast = true){
const m = new THREE.Mesh(mergeGeometries(list, false), mat)
m.castShadow = cast; m.receiveShadow = true
return m
}
const clamp01 = x => Math.min(1, Math.max(0, x))
const ss = (a, b, x) => { const t = clamp01((x - a) / (b - a)); return t * t * (3 - 2 * t) }
const lerp = (a, b, t) => a + (b - a) * t
const lerpAng = (a, b, t) => { const d = Math.atan2(Math.sin(b - a), Math.cos(b - a)); return a + d * t }
const site = new THREE.Group()
scene.add(site)
/* ── the plinth ────────────────────────────────────────────────────
A white disc, like the base of a presentation model. Below it, a
ShadowMaterial plane that is invisible except where a shadow falls,
so the model's shadow lands on the page itself rather than on a
rectangle that has to be colour-matched to it. */
{
const catcher = new THREE.Mesh(new THREE.PlaneGeometry(700, 700),
new THREE.ShadowMaterial({ opacity: 0.2 }))
catcher.rotation.x = -Math.PI / 2; catcher.position.y = -1.62; catcher.receiveShadow = true
site.add(catcher)
const plinth = new THREE.Mesh(new THREE.CylinderGeometry(40, 40.9, 1.6, 144), M.white)
plinth.position.y = -0.8; plinth.castShadow = true; plinth.receiveShadow = true
site.add(plinth)
/* hardstanding, with the setting-out grid drawn on it the way a site
plan draws it: a faint line every metre, an orange axis every five,
lettered one way and numbered the other */
const gc = document.createElement('canvas'); gc.width = 1880; gc.height = 1480
const gx = gc.getContext('2d'), PPM = 40
gx.fillStyle = '#E9E4DA'; gx.fillRect(0, 0, gc.width, gc.height)
const grid = (count, vertical) => {
for (let m = 0; m <= count; m++){
const major = m % 5 === 0
gx.strokeStyle = major ? 'rgba(255,77,20,.34)' : 'rgba(18,17,16,.055)'
gx.lineWidth = major ? 2.2 : 1
gx.beginPath()
if (vertical){ gx.moveTo(m * PPM, 0); gx.lineTo(m * PPM, gc.height) }
else { gx.moveTo(0, m * PPM); gx.lineTo(gc.width, m * PPM) }
gx.stroke()
}
}
grid(47, true); grid(37, false)
gx.fillStyle = 'rgba(255,77,20,.85)'; gx.font = '600 22px "JetBrains Mono", monospace'
for (let m = 5, i = 0; m < 47; m += 5, i++) gx.fillText('ABCDEFGHI'[i], m * PPM + 6, 26)
for (let m = 5, i = 1; m < 37; m += 5, i++) gx.fillText(String(i), 8, m * PPM - 6)
const gt = new THREE.CanvasTexture(gc)
gt.colorSpace = THREE.SRGBColorSpace
gt.anisotropy = renderer.capabilities.getMaxAnisotropy()
M.padGrid = new THREE.MeshStandardMaterial({ map: gt, roughness: 0.92 })
const padMesh = new THREE.Mesh(new THREE.PlaneGeometry(47, 37), M.padGrid)
padMesh.rotation.x = -Math.PI / 2; padMesh.position.set(-1, 0.04, 3); padMesh.receiveShadow = true
site.add(padMesh)
}
/* ══ the building ══════════════════════════════════════════════════ */
const B = { x: -8, z: 2, W: 17, D: 13, FH: 3.4, N: 11 }
const ROOF_Y = B.N * B.FH
const building = new THREE.Group()
building.position.set(B.x, 0, B.z)
site.add(building)
const COL_X = [-B.W / 2, -B.W / 4, 0, B.W / 4, B.W / 2]
const COL_Z = [-B.D / 2, -B.D / 6, B.D / 6, B.D / 2]
const onEdge = (x, z) => Math.abs(x) === B.W / 2 || Math.abs(z) === B.D / 2
/* One group per floor, geometry built from the floor's own base — but
split into the three things that actually happen at different times:
the columns climb out of the deck below, the deck is then poured
across them, and the cladding goes on behind that. Scaling a floor as
one mesh is what made the old frame look like it was being extruded
rather than built. */
const floors = []
for (let i = 0; i < B.N; i++){
const g = new THREE.Group()
g.position.y = i * B.FH
const h = B.FH - 0.34
const conc = [], glass = [], mull = []
for (const x of COL_X) for (const z of COL_Z)
if (onEdge(x, z)) conc.push(box(0.56, h, 0.56, x, h / 2, z))
conc.push(box(B.W + 0.9, 0.34, B.D + 0.9, 0, h + 0.17, 0)) // slab, proud of the frame
if (true){ // every floor CAN be clad; CLAD says which are
const gh = h - 0.18, gy = gh / 2 + 0.09
glass.push(box(B.W - 0.3, gh, 0.07, 0, gy, B.D / 2 - 0.12))
glass.push(box(B.W - 0.3, gh, 0.07, 0, gy, -B.D / 2 + 0.12))
glass.push(box(0.07, gh, B.D - 0.3, B.W / 2 - 0.12, gy, 0))
glass.push(box(0.07, gh, B.D - 0.3, -B.W / 2 + 0.12, gy, 0))
for (let x = -B.W / 2 + 1.42; x < B.W / 2 - 0.4; x += 1.42){
mull.push(box(0.07, gh, 0.14, x, gy, B.D / 2 - 0.05))
mull.push(box(0.07, gh, 0.14, x, gy, -B.D / 2 + 0.05))
}
for (let z = -B.D / 2 + 1.44; z < B.D / 2 - 0.4; z += 1.44){
mull.push(box(0.14, gh, 0.07, B.W / 2 - 0.05, gy, z))
mull.push(box(0.14, gh, 0.07, -B.W / 2 + 0.05, gy, z))
}
}
const cols = merged(conc, M.concrete)
g.add(cols)
/* The deck hangs off a pivot at the west edge, so scaling the pivot in
x sweeps the slab across the bay the way a pour actually runs —
full thickness the whole time, never stretched. */
const deck = new THREE.Group()
deck.position.x = -(B.W + 0.9) / 2
deck.add(merged([box(B.W + 0.9, 0.34, B.D + 0.9, (B.W + 0.9) / 2, h + 0.17, 0)], M.concrete))
g.add(deck)
let clad = null
if (glass.length){
clad = new THREE.Group()
clad.add(merged(glass, M.glass), merged(mull, M.steel))
g.add(clad)
}
/* the dashed drawing needs the finished floor, columns and deck both */
g.userData = { cols, deck, clad,
ghostGeo: mergeGeometries([...conc, box(B.W + 0.9, 0.34, B.D + 0.9, 0, h + 0.17, 0)], false) }
building.add(g)
floors.push(g)
}
/* the slipform core runs a floor or so ahead of the frame */
const core = merged([box(4.6, 1, 4.2, 1.6, 0.5, -1.2)], M.core)
building.add(core)
/* edge protection, starter bars and formwork: the kit that lives on
whichever floor is currently being built, and climbs with it */
const topKit = new THREE.Group()
building.add(topKit)
{
const rails = [], rebar = [], form = []
const hw = B.W / 2 + 0.36, hd = B.D / 2 + 0.36
const corners = [V(-hw, 0, -hd), V(hw, 0, -hd), V(hw, 0, hd), V(-hw, 0, hd)]
for (let k = 0; k < 4; k++){
const a = corners[k], b = corners[(k + 1) % 4]
for (const yy of [0.55, 1.08]) rails.push(strut(V(a.x, yy, a.z), V(b.x, yy, b.z), 0.07))
const n = Math.round(a.distanceTo(b) / 2.2)
for (let s = 0; s <= n; s++){
const q = a.clone().lerp(b, s / n)
rails.push(box(0.08, 1.12, 0.08, q.x, 0.56, q.z))
}
}
for (const x of COL_X) for (const z of COL_Z){
if (!onEdge(x, z)) continue
for (const dx of [-0.17, 0.17]) for (const dz of [-0.17, 0.17])
rebar.push(rod(V(x + dx, 0, z + dz), V(x + dx, 1.4, z + dz), 0.03))
}
form.push(box(2.4, 0.18, 1.2, -4, 0.09, 3), box(2.4, 0.18, 1.2, -4, 0.27, 3),
box(1.3, 1.5, 0.12, 5, 0.75, -3.4))
topKit.add(merged(rails, M.paint), merged(rebar, M.steel), merged(form, M.timber))
}
/* Crew. Five, and no more: a figure is the one thing in a maquette that
states the scale outright, and a crowd would turn a model into a
diorama. Three ride the deck being worked, two the deck below, and
both climb with the frame. They are placed in the building's own
coordinates, so a deck top is exactly where a pair of boots goes. */
const crew = new THREE.Group(), crewBelow = new THREE.Group()
building.add(crew, crewBelow)
{
const put = (g, spots) => g.add(
merged(spots.map(([x, z]) => new THREE.CapsuleGeometry(0.24, 1.1, 4, 10).translate(x, 0.8, z)), M.figure),
merged(spots.map(([x, z]) => box(0.5, 0.46, 0.5, x, 1.07, z)), M.paint)) // hi-vis, the one colour on site
put(crew, [[-5.2, -4.4], [2.6, 3.8], [6.4, -2.0]])
put(crewBelow, [[-6.0, 2.4], [4.2, -5.2]])
}
/* scaffold on the west face, cut to the current height by a clipping
plane rather than scaled — scaling would flatten the ledgers */
const scaffoldClip = new THREE.Plane(V(0, -1, 0), 0)
const scaffold = new THREE.Group()
building.add(scaffold)
{
const tubeM = M.galv.clone(), boardM = M.timber.clone()
for (const m of [tubeM, boardM]){ m.clippingPlanes = [scaffoldClip]; m.clipShadows = true }
const tubes = [], boards = []
const H = ROOF_Y + 1.4
const x0 = -B.W / 2 - 1.0, x1 = -B.W / 2 - 2.15
const zs = []
for (let z = -B.D / 2; z <= B.D / 2 + 0.01; z += B.D / 6) zs.push(z)
for (const z of zs) for (const x of [x0, x1]) tubes.push(rod(V(x, 0, z), V(x, H, z), 0.045))
for (let y = 2; y <= H; y += 2){
for (const x of [x0, x1]) tubes.push(rod(V(x, y, zs[0]), V(x, y, zs[zs.length - 1]), 0.04))
for (const z of zs) tubes.push(rod(V(x0, y, z), V(x1, y, z), 0.035))
boards.push(box(1.06, 0.06, B.D, (x0 + x1) / 2, y + 0.06, 0))
}
for (let i = 0; i < zs.length - 1; i++)
for (let y = 0; y < H - 4; y += 4) tubes.push(rod(V(x1, y, zs[i]), V(x1, y + 4, zs[i + 1]), 0.035))
scaffold.add(merged(tubes, tubeM), merged(boards, boardM))
scaffold.userData.H = H
}
/* ══ the tower crane ═══════════════════════════════════════════════ */
const C = { x: 13, z: -4, MH: 50, S: 1.9, JL: 46, CJ: 15 }
const TROLLEY_Y = 1.3
const crane = new THREE.Group()
crane.position.set(C.x, 0, C.z)
site.add(crane)
/* footing and ballast */
crane.add(merged([box(7, 1.1, 7, 0, 0.55, 0)], M.concrete))
crane.add(merged([
box(2.6, 1.2, 1.4, -1.8, 1.7, -2.4), box(2.6, 1.2, 1.4, 1.8, 1.7, -2.4),
box(2.6, 1.2, 1.4, -1.8, 1.7, 2.4), box(2.6, 1.2, 1.4, 1.8, 1.7, 2.4)], M.ballast))
/* the mast: four chords, and a braced bay every couple of metres */
{
const mast = [], h = C.S / 2, y0 = 1.1
const n = 20, bay = (C.MH - y0) / n
const ring = y => [V(-h, y, -h), V(h, y, -h), V(h, y, h), V(-h, y, h)]
for (const c of ring(0)) mast.push(strut(V(c.x, y0, c.z), V(c.x, C.MH, c.z), 0.2))
for (let i = 0; i < n; i++){
const A = ring(y0 + i * bay), Bc = ring(y0 + (i + 1) * bay)
for (let k = 0; k < 4; k++){
const k2 = (k + 1) % 4
mast.push(strut(Bc[k], Bc[k2], 0.1))
const f = (i + k) % 2
mast.push(strut(f ? A[k] : A[k2], f ? Bc[k2] : Bc[k], 0.085))
}
}
crane.add(merged(mast, M.paint))
}
/* mast ties back to the building — only once the frame reaches them */
const ties = []
for (const ty of [15, 31]){
const h = C.S / 2, fx = B.x + B.W / 2 + 0.5 - C.x
const m = merged([
strut(V(-h, ty, -h), V(fx, ty, -2.5 - C.z), 0.17),
strut(V(-h, ty, h), V(fx, ty, -0.5 - C.z), 0.17),
strut(V(-h, ty, -h), V(fx, ty, -0.5 - C.z), 0.12)], M.paint)
m.userData.y = ty
crane.add(m); ties.push(m)
}
/* everything above the slewing ring turns together */
const slew = new THREE.Group()
slew.position.y = C.MH
crane.add(slew)
slew.add(merged([new THREE.CylinderGeometry(1.5, 1.5, 0.9, 28).translate(0, 0.45, 0)], M.steel))
slew.add(merged([box(3.6, 0.5, 3.6, 0, 1.15, 0)], M.paint))
slew.add(merged([box(2.1, 2.3, 2.0, 0.4, 1.9, 2.6)], M.cabin)) // cab
slew.add(merged([box(0.06, 1.3, 1.7, 1.47, 2.2, 2.6), box(1.9, 1.3, 0.06, 0.4, 2.2, 3.62)], M.glass))
/* tower head */
const HEAD_H = 11
{
const head = []
const base = [V(-1.5, 1.4, -1.5), V(1.5, 1.4, -1.5), V(1.5, 1.4, 1.5), V(-1.5, 1.4, 1.5)]
const apex = V(0, 1.4 + HEAD_H, 0)
for (const b of base) head.push(strut(b, apex, 0.2))
for (let s = 1; s < 4; s++){
const ring = base.map(b => b.clone().lerp(apex, s / 4))
const lower = base.map(b => b.clone().lerp(apex, (s - 1) / 4))
for (let k = 0; k < 4; k++){
head.push(strut(ring[k], ring[(k + 1) % 4], 0.09))
head.push(strut(lower[k], ring[(k + 1) % 4], 0.07))
}
}
slew.add(merged(head, M.paint))
}
/* jib: a triangular lattice that tapers toward the tip */
const JIB_Y = 1.4, JIB_H = 1.9, JIB_W = 0.95
{
const jib = []
const n = 20, bay = C.JL / n
const taper = x => 1 - 0.35 * Math.max(0, (x - C.JL * 0.55) / (C.JL * 0.45))
const tri = x => { const t = taper(x); return [V(x, JIB_Y, -JIB_W * t), V(x, JIB_Y, JIB_W * t), V(x, JIB_Y + JIB_H * t, 0)] }
for (let i = 0; i < n; i++){
const xa = 1.8 + i * bay, xb = xa + bay
const A = tri(xa), Bt = tri(xb)
for (let k = 0; k < 3; k++) jib.push(strut(A[k], Bt[k], k === 2 ? 0.2 : 0.18))
jib.push(strut(Bt[0], Bt[1], 0.08), strut(Bt[1], Bt[2], 0.08), strut(Bt[2], Bt[0], 0.08))
const f = i % 2
jib.push(strut(f ? A[0] : Bt[0], f ? Bt[2] : A[2], 0.07))
jib.push(strut(f ? A[1] : Bt[1], f ? Bt[2] : A[2], 0.07))
jib.push(strut(f ? A[0] : Bt[0], f ? Bt[1] : A[1], 0.06))
}
slew.add(merged(jib, M.paint))
}
/* counter-jib, machinery and counterweights */
{
const cj = []
const n = 6, bay = C.CJ / n
for (let i = 0; i < n; i++){
const xa = -1.8 - i * bay, xb = xa - bay
for (const z of [-1.1, 1.1]) cj.push(strut(V(xa, 1.4, z), V(xb, 1.4, z), 0.24))
cj.push(strut(V(xb, 1.4, -1.1), V(xb, 1.4, 1.1), 0.12))
cj.push(strut(V(xa, 1.4, -1.1), V(xb, 1.4, 1.1), 0.08))
}
cj.push(box(C.CJ, 0.08, 2.0, -1.8 - C.CJ / 2, 1.56, 0))
slew.add(merged(cj, M.paint))
slew.add(merged([box(2.6, 1.4, 1.8, -6, 2.3, 0)], M.steel))
const cw = []
for (let i = 0; i < 4; i++) cw.push(box(0.9, 3.0, 2.6, -C.CJ + 0.2 + i * 1.0, 0.1, 0))
slew.add(merged(cw, M.ballast))
}
/* pendant ties from the head to the jib and counter-jib */
{
const ap = V(0, 1.4 + HEAD_H, 0)
slew.add(merged([
rod(ap, V(C.JL * 0.52, JIB_Y + JIB_H, 0), 0.06),
rod(ap, V(C.JL * 0.86, JIB_Y + JIB_H * 0.76, 0), 0.06),
rod(ap, V(-C.CJ + 0.6, 1.6, -0.9), 0.06),
rod(ap, V(-C.CJ + 0.6, 1.6, 0.9), 0.06)], M.cable))
}
/* the name board on the counter-jib — every real crane carries one */
const boardTex = (() => {
const c = document.createElement('canvas'); c.width = 1024; c.height = 150
const t = new THREE.CanvasTexture(c)
t.colorSpace = THREE.SRGBColorSpace; t.anisotropy = 8
const draw = () => {
const x = c.getContext('2d')
x.fillStyle = '#FAF8F4'; x.fillRect(0, 0, 1024, 150)
x.fillStyle = '#FF4D14'; x.fillRect(0, 0, 1024, 12); x.fillRect(0, 138, 1024, 12)
x.fillStyle = '#121110'; x.font = '900 104px Archivo, system-ui, sans-serif'
x.textAlign = 'center'; x.textBaseline = 'middle'
x.fillText('MASSIF', 512, 80)
t.needsUpdate = true
}
draw()
document.fonts?.ready.then(draw) // redraw once Archivo has actually loaded
return t
})()
{
const bm = new THREE.MeshStandardMaterial({ map: boardTex, roughness: 0.6 })
const front = new THREE.Mesh(new THREE.PlaneGeometry(10, 1.5), bm)
front.position.set(-8.6, 2.55, 1.22)
const back = front.clone(); back.position.z = -1.22; back.rotation.y = Math.PI
front.castShadow = back.castShadow = true
slew.add(front, back)
}
/* trolley, hoist ropes, hook block and the load */
const trolley = new THREE.Group()
trolley.position.set(30, TROLLEY_Y, 0)
slew.add(trolley)
trolley.add(merged([box(2.0, 0.55, 2.1, 0, -0.27, 0)], M.steel))
const hoist = new THREE.Group()
trolley.add(hoist)
const ropes = merged([rod(V(-0.35, 0, 0), V(-0.35, -1, 0), 0.03),
rod(V( 0.35, 0, 0), V( 0.35, -1, 0), 0.03)], M.cable)
hoist.add(ropes)
const block = new THREE.Group()
hoist.add(block)
block.add(merged([box(1.0, 1.1, 0.7, 0, -0.55, 0)], M.hook))
block.add(merged([new THREE.TorusGeometry(0.34, 0.09, 10, 24, Math.PI * 1.35)
.rotateZ(Math.PI * 0.9).translate(0, -1.45, 0)], M.steel))
/* An I-beam. The underside of its bottom flange sits BEAM_DROP below
the hook block, which is what the pick and place heights are
computed from. */
const BEAM_DROP = 4.53
const beamParts = () => [box(0.42, 0.06, 12, 0, -3.9, 0), box(0.42, 0.06, 12, 0, -4.5, 0),
box(0.05, 0.6, 12, 0, -4.2, 0)]
const load = new THREE.Group()
block.add(load)
const loadBeam = merged(beamParts(), M.steel)
load.add(loadBeam)
load.add(merged([rod(V(0, -1.72, 0), V(0, -3.87, -4), 0.025),
rod(V(0, -1.72, 0), V(0, -3.87, 4), 0.025)], M.cable))
/* ══ the choreography ══════════════════════════════════════════════ */
const HOIST_Y = C.MH + TROLLEY_Y
const PAD_H = 0.6
const LAYDOWN = { x: 26, z: 10 }
const ROOF_AT = { x: B.x + 1.5, z: B.z - 0.5 }
const thetaFor = (wx, wz) => Math.atan2(-(wz - C.z), wx - C.x)
const radiusFor = (wx, wz) => Math.hypot(wx - C.x, wz - C.z)
const TH = { idle: thetaFor(40, -30), pick: thetaFor(LAYDOWN.x, LAYDOWN.z),
place: thetaFor(ROOF_AT.x, ROOF_AT.z), rest: thetaFor(30, 25) }
const RAD = { pick: radiusFor(LAYDOWN.x, LAYDOWN.z), place: radiusFor(ROOF_AT.x, ROOF_AT.z) }
const L_PICK = HOIST_Y - BEAM_DROP - PAD_H
const L_PLACE = HOIST_Y - BEAM_DROP - ROOF_Y + 0.01
const P_PICK = 1.64, P_PLACE = 2.40
/* Rope length sets the load's height outright — the trolley head never
moves in y — so CARRY is simply "3.4 m of daylight over the roof".
The load is hoisted to it before the slew starts and held there all
the way across, which is both how it is actually done and the only
way the beam does not track through the top floors. */
const CARRY = L_PLACE - 3.4
/* what the model is doing right now, for the telemetry and the notes */
/* How far the glazing has climbed, in floors. The hero sits at 4, which
is exactly what it looked like when only the bottom four floors had
glass built at all; the method section drives it up the tower. */
const CLAD = { v: 4 }
const SW_MAX = 0.17 // ~10° — a load you can see swinging
const clampF = v => v < -20 ? -20 : v > 20 ? 20 : v // the forcing a real trolley can deliver
/* 1 everywhere except a dip to 0 across a pick or a set */
const steady = (i0, i1, o0, o1, p) => clamp01(1 - ss(i0, i1, p) + ss(o0, o1, p))
const LIVE = { f: 3, th: 0, L: 12, r: 30 }
/* The load is not drawn where the hook is — it is drawn where a load on
a 40 m rope would actually be. SWING is a damped pendulum hanging off
the trolley, forced by the trolley's own acceleration: slew away and
the block lags behind, stop and it keeps going, then rings down. Two
axes — a is tangential (across the slew), b is radial (along the jib).
The scroll-driven accelerations are spiky, so they are low-passed
before they reach the pendulum. */
const SWING = { th: 0, r: 0, vt: 0, vr: 0, at: 0, ar: 0, a: 0, av: 0, b: 0, bv: 0 }
let ghosts = [] // filled once the model exists
function applyState(p, t, dt){
/* The frame. A floor is a cycle, not a fade: columns up, deck poured
across, edge protection struck and climbed — and then the deck sits
while it gains strength. The pause is the eased fractional part of
f, which is what turns a smooth extrusion into a floor-by-floor
rhythm. The slipform core is deliberately exempt: a slipform really
does run continuously, day and night, ahead of the frame. */
const fr = 3 + (B.N - 3) * ss(0.30, 1.50, p)
const fi = Math.floor(fr), ft = fr - fi
const f = fi + ss(0.05, 0.74, ft) // work, then a beat of curing
floors.forEach((g, k) => {
const u = clamp01(f - k), ud = g.userData
g.visible = u > 0.002
const cu = ss(0.00, 0.55, u) // columns climb
const du = ss(0.52, 0.95, u) // then the pour sweeps across
const cl = Math.min(ss(0.78, 1.00, u), clamp01(CLAD.v - k)) // deck first, then glazing
ud.cols.visible = cu > 0.004; ud.cols.scale.y = Math.max(cu, 0.004)
ud.deck.visible = du > 0.004; ud.deck.scale.x = Math.max(du, 0.004)
if (ud.clad){ ud.clad.visible = cl > 0.004; ud.clad.scale.y = Math.max(cl, 0.004) }
if (ghosts[k]) ghosts[k].visible = u < 0.999 // the drawing, until concrete replaces it
})
core.scale.y = Math.min((fr + 1.4) * B.FH, ROOF_Y + 3.8)
topKit.position.y = (fi + ss(0.78, 0.99, ft)) * B.FH // struck and climbed once the deck is cast
crew.position.y = topKit.position.y // the crew is on the deck the kit is on
crewBelow.position.y = topKit.position.y - B.FH
crewBelow.visible = topKit.position.y > B.FH * 1.5 // nothing below the deck they started from
scaffoldClip.constant = Math.min(f * B.FH + 1.4, scaffold.userData.H)
ties.forEach(m => { m.visible = f * B.FH > m.userData.y + 2 })
/* the crane: slew to the laydown, pick, slew over the roof, set it
down, swing clear. Each move is its own eased window so the load
is exactly at the pick and place points at P_PICK and P_PLACE. */
let th = TH.idle, r = 30, L = 12
const a1 = ss(1.10, 1.45, p); th = lerpAng(th, TH.pick, a1); r = lerp(r, RAD.pick, a1)
/* A hoist never drops a load onto its bearing. It runs down fast to
about a metre and a half clear, then creeps the last stretch, and
the smoothstep leaves the rope moving at nearly zero at contact —
which is what makes the set-down look like a set-down. */
L = lerp(L, L_PICK - 1.5, ss(1.34, 1.54, p)) // run down to just clear
L = lerp(L, L_PICK, ss(1.54, 1.64, p)) // creep onto the timbers
L = lerp(L, CARRY, ss(1.70, 1.94, p)) // a beat, then hoist to carrying height
const a2 = ss(1.78, 2.18, p); th = lerpAng(th, TH.place, a2); r = lerp(r, RAD.place, a2)
L = lerp(L, L_PLACE - 1.6, ss(2.16, 2.32, p)) // over the roof, run down to just clear
L = lerp(L, L_PLACE, ss(2.32, 2.40, p)) // creep onto the steel; contact at P_PLACE
L = lerp(L, CARRY, ss(2.46, 2.74, p)) // unhook, then hoist the block clear
const a3 = ss(2.52, 2.95, p); th = lerpAng(th, TH.rest, a3); r = lerp(r, 34, a3)
slew.rotation.y = th
trolley.position.x = r
ropes.scale.y = L
block.position.y = -L
/* The hook swings freely except on an approach: a driver steadies the
load well before it lands, holds it dead through the contact, and
lets it hang free again once the rope is off. The window is
asymmetric for that reason — long coming in, short going out — and
it is also what makes the handover to the static copy invisible,
since both are plumb at the swap. */
const calm = Math.min(steady(1.34, 1.54, 1.72, 1.90, p),
steady(2.04, 2.22, 2.44, 2.64, p))
if (dt > 0){
const h = Math.min(dt, 1 / 60)
/* one model unit is one metre; g is shortened a little so a 40 m
rope rings at ~8 s instead of its true ~13 — long enough to read
as mass, short enough to see a whole swing while scrolling */
const Lh = Math.max(6, L), w2 = 9.81 / Lh * 2.4, drag = 0.26
const vt = (th - SWING.th) / h * r // tangential speed of the trolley head
const vr = (r - SWING.r) / h // radial speed along the jib
SWING.th = th; SWING.r = r
const at = clampF((vt - SWING.vt) / h), ar = clampF((vr - SWING.vr) / h)
SWING.vt = vt; SWING.vr = vr
SWING.at += (at - SWING.at) * 0.18 // low-pass: scrubbed deltas are spiky
SWING.ar += (ar - SWING.ar) * 0.18
for (let i = 0; i < 2; i++){ // half-steps, the scrub can move fast
const hh = h / 2
SWING.av += (-w2 * Math.sin(SWING.a) - SWING.at / Lh - drag * SWING.av) * hh
SWING.bv += (-w2 * Math.sin(SWING.b) - SWING.ar / Lh - drag * SWING.bv) * hh
SWING.a += SWING.av * hh; SWING.b += SWING.bv * hh
}
/* the rope cannot lay over — bounce off the stop instead of pinning */
if (Math.abs(SWING.a) > SW_MAX){ SWING.a = Math.sign(SWING.a) * SW_MAX; SWING.av *= -0.25 }
if (Math.abs(SWING.b) > SW_MAX){ SWING.b = Math.sign(SWING.b) * SW_MAX; SWING.bv *= -0.25 }
}
const breeze = Math.sin(t * 0.47) * 0.012 + Math.sin(t * 0.81 + 1.3) * 0.006
hoist.rotation.x = (SWING.a + breeze) * calm
hoist.rotation.z = (SWING.b + breeze * 0.6) * calm
/* the mast is 60 m of lattice: it breathes, and it leans a hair
toward wherever the load is swinging */
crane.rotation.z = Math.sin(t * 0.31) * 0.0022 + SWING.b * 0.035 * calm
crane.rotation.x = Math.sin(t * 0.24 + 0.9) * 0.0018 - SWING.a * 0.035 * calm
load.visible = p >= P_PICK && p < P_PLACE
groundBeam.visible = p < P_PICK
roofBeam.visible = p >= P_PLACE
LIVE.f = f; LIVE.th = th; LIVE.L = L; LIVE.r = r
}
/* The beam on the laydown and the beam on the roof are static copies
of the hoisted one, taken from the rig itself at the exact pick and
place moments. Swapping between them is therefore seamless by
construction — there is nothing to line up by hand. */
function beamCopyAt(p){
applyState(p, 0)
site.updateMatrixWorld(true)
const copy = new THREE.Mesh(loadBeam.geometry, M.steel)
copy.castShadow = copy.receiveShadow = true
loadBeam.matrixWorld.decompose(copy.position, copy.quaternion, copy.scale)
site.add(copy)
return copy
}
let groundBeam = { visible: true }, roofBeam = { visible: false }
groundBeam = beamCopyAt(P_PICK)
roofBeam = beamCopyAt(P_PLACE)
/* sleepers and spares under the laydown beam, aligned to it */
{
const dir = V(0, 0, 1).applyQuaternion(groundBeam.quaternion)
const lay = new THREE.Group()
lay.position.set(groundBeam.position.x, 0, groundBeam.position.z)
lay.rotation.y = Math.atan2(dir.x, dir.z)
lay.add(merged([box(2.6, PAD_H, 0.45, 0, PAD_H / 2, -4), box(2.6, PAD_H, 0.45, 0, PAD_H / 2, 4)], M.timber))
lay.add(merged([...beamParts().map(g => g.translate(-0.8, BEAM_DROP + PAD_H, 0)),
...beamParts().map(g => g.translate( 0.8, BEAM_DROP + PAD_H, 0))], M.steel))
site.add(lay)
}
/* ── the site around it ─────────────────────────────────────────── */
{
const cab = new THREE.Group()
cab.position.set(-25, 0, -17); cab.rotation.y = 0.5
cab.add(merged([box(6.2, 2.6, 2.5, 0, 1.3, 0), box(6.2, 2.6, 2.5, 0.7, 3.95, 0.25)], M.cabin))
cab.add(merged([box(4.6, 0.7, 0.05, 0, 1.6, 1.27), box(4.6, 0.7, 0.05, 0.7, 4.25, 1.52)], M.glass))
cab.add(merged([box(6.24, 0.16, 2.54, 0, 0.35, 0), box(6.24, 0.16, 2.54, 0.7, 3.0, 0.25)], M.paint))
site.add(cab)
const blocks = [], pallets = []
for (let i = 0; i < 3; i++) for (let j = 0; j < 2; j++){
const px = -21 + i * 1.6, pz = 16 + j * 1.6, hh = 0.8 + ((i * 7 + j * 3) % 3) * 0.2
pallets.push(box(1.35, 0.15, 1.35, px, 0.075, pz))
blocks.push(box(1.2, hh, 1.2, px, 0.15 + hh / 2, pz))
}
site.add(merged(blocks, M.ballast), merged(pallets, M.timber))
const bars = []
for (let i = 0; i < 7; i++) bars.push(rod(V(-0.5, 0.35 + (i % 3) * 0.14, 20 + i * 0.16),
V(9.5, 0.35 + (i % 3) * 0.14, 20 + i * 0.16), 0.06))
site.add(merged(bars, M.steel),
merged([box(0.3, 0.28, 1.6, 1, 0.14, 20.5), box(0.3, 0.28, 1.6, 8, 0.14, 20.5)], M.timber))
/* Maquette trees and figures. Nothing gives a model scale like a
person, and nothing makes a white model read as a model like
faceted trees. */
const crowns = [], trunks = []
const TREES = [[18, 35, 2.4], [62, 36, 2.0], [108, 35, 2.7], [148, 34, 2.1],
[192, 36, 2.6], [242, 35, 1.9], [292, 36, 2.5], [328, 34, 2.2]]
for (const [deg, rr, s] of TREES){
const a = deg * Math.PI / 180, x = Math.cos(a) * rr, z = Math.sin(a) * rr
crowns.push(new THREE.IcosahedronGeometry(s, 1).translate(x, 2.2 + s, z))
trunks.push(new THREE.CylinderGeometry(0.12, 0.16, 2.4, 6).translate(x, 1.2, z))
}
site.add(merged(crowns, M.tree), merged(trunks, M.core))
const people = []
for (const [x, z] of [[-10, 11.2], [-8.6, 11.8], [23, 13.4], [24.4, 14.2], [-20.5, -13.5], [16.5, 1.2]])
people.push(new THREE.CapsuleGeometry(0.24, 1.1, 4, 10).translate(x, 0.8, z))
site.add(merged(people, M.figure))
}
/* ══════════════════════════════════════════════════════════════════
SHOWCASE LAYER
══════════════════════════════════════════════════════════════════ */
let lenis = null
/* ── drawing ↔ building ────────────────────────────────────────────
The model exists two ways at once: as the orange line drawing it was
set out from, and as the built thing. A horizontal cut divides them.
Below it every material renders solid; above it the same geometry
renders as its own edges. On load the cut rises through the site, so
it is literally drawn first and built second, and the button in the
hero sends it back to drawings and home again. */
const SCAN = { h: 0 }
const SCAN_TOP = 80
const keepBelow = new THREE.Plane(V(0, -1, 0), 0) // solid: y <= h
const keepAbove = new THREE.Plane(V(0, 1, 0), 0) // drawing: y >= h
const lineM = new THREE.LineBasicMaterial({ color: 0xFF4D14, transparent: true, opacity: 0.82,
toneMapped: false, clippingPlanes: [keepAbove] })
const lineScafM = lineM.clone()
lineScafM.clippingPlanes = [keepAbove, scaffoldClip] // the drawn scaffold still stops at the build
const edges = []
{
const solidOnly = new Set([M.white, M.pad, M.padGrid]) // the plinth is the table, never a drawing
const meshes = []
site.traverse(o => { if (o.isMesh && !solidOnly.has(o.material) && !o.material.isShadowMaterial) meshes.push(o) })
const clipped = new Set()
for (const o of meshes){
const mat = o.material
if (!clipped.has(mat)){
mat.clippingPlanes = [...(mat.clippingPlanes || []), keepBelow]
mat.clipShadows = true
mat.needsUpdate = true
clipped.add(mat)
}
const l = new THREE.LineSegments(new THREE.EdgesGeometry(o.geometry, 24),
mat.clippingPlanes.includes(scaffoldClip) ? lineScafM : lineM)
o.add(l) // a child, so it inherits every scale, slew and visibility of its mesh
edges.push(l)
}
}
const scanGroup = new THREE.Group()
{
const disc = new THREE.Mesh(new THREE.CircleGeometry(47, 96),
new THREE.MeshBasicMaterial({ color: 0xFF4D14, transparent: true, opacity: 0.06,
depthWrite: false, side: THREE.DoubleSide, toneMapped: false }))
const ring = new THREE.Mesh(new THREE.RingGeometry(46.3, 47, 160),
new THREE.MeshBasicMaterial({ color: 0xFF4D14, transparent: true, opacity: 0.62,
depthWrite: false, side: THREE.DoubleSide, toneMapped: false }))
disc.rotation.x = ring.rotation.x = -Math.PI / 2
scanGroup.add(disc, ring)
scene.add(scanGroup)
}
function updateScan(){
keepBelow.constant = SCAN.h
keepAbove.constant = -SCAN.h
scanGroup.position.y = SCAN.h
scanGroup.visible = SCAN.h > 0.3 && SCAN.h < SCAN_TOP - 6
const drawing = SCAN.h < SCAN_TOP - 1 // skip ~50k line segments once fully built
if (edges[0].visible !== drawing) edges.forEach(l => { l.visible = drawing })
}
/* ── the finished tower, drawn ahead of the concrete ───────────────
Every floor that is not built yet is still there as a dashed orange
outline, so the whole tower is visible from the first frame and the
scroll fills it in. */
const ghostM = new THREE.LineDashedMaterial({ color: 0xFF4D14, dashSize: 0.5, gapSize: 0.42,
transparent: true, opacity: 0.42, toneMapped: false })
ghosts = floors.map((g, k) => {
const l = new THREE.LineSegments(new THREE.EdgesGeometry(g.userData.ghostGeo, 24), ghostM)
l.computeLineDistances()
l.position.set(B.x, k * B.FH, B.z)
site.add(l)
return l
})
/* ── welding sparks on the floor being built ─────────────────────── */
const SPARKS = 110
const spPos = new Float32Array(SPARKS * 3), spVel = new Float32Array(SPARKS * 3), spLife = new Float32Array(SPARKS)
const sparkGeo = new THREE.BufferGeometry()
sparkGeo.setAttribute('position', new THREE.BufferAttribute(spPos, 3))
sparkGeo.setAttribute('aLife', new THREE.BufferAttribute(spLife, 1))
const sparkMat = new THREE.ShaderMaterial({
transparent: true, depthWrite: false,
uniforms: { uPix: { value: Math.min(devicePixelRatio, 2) }, uH: { value: innerHeight } },
vertexShader: `
attribute float aLife; uniform float uPix, uH; varying float vL;
void main(){
vL = aLife;
vec4 mv = modelViewMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * mv;
gl_PointSize = (1.6 + aLife * 3.0) * uPix * (uH * 0.0011) * (140.0 / max(-mv.z, 1.0));
}`,
fragmentShader: `
varying float vL;
void main(){
vec2 d = gl_PointCoord - 0.5; float r = dot(d, d);
if (r > 0.25 || vL <= 0.0) discard;
vec3 c = mix(vec3(1.0, 0.30, 0.08), vec3(1.0, 0.86, 0.42), clamp(vL * 1.3, 0.0, 1.0));
gl_FragColor = vec4(c, smoothstep(0.25, 0.0, r) * min(1.0, vL * 2.0));
}`,
})
const sparks = new THREE.Points(sparkGeo, sparkMat)
sparks.frustumCulled = false
scene.add(sparks)
let burstAt = 0
function updateSparks(t, dt, active){
if (active && t > burstAt){
burstAt = t + 0.6 + Math.random() * 1.4
const cx = B.x + COL_X[(Math.random() * COL_X.length) | 0]
const cz = B.z + (Math.random() < 0.5 ? -B.D / 2 : B.D / 2)
const cy = LIVE.f * B.FH + 0.9
for (let i = 0, n = 0; i < SPARKS && n < 38; i++){
if (spLife[i] > 0) continue
n++
spPos[i*3] = cx; spPos[i*3+1] = cy; spPos[i*3+2] = cz
const a = Math.random() * 6.283, sp = 1.8 + Math.random() * 5.5
spVel[i*3] = Math.cos(a) * sp; spVel[i*3+1] = 1.2 + Math.random() * 4.5; spVel[i*3+2] = Math.sin(a) * sp
spLife[i] = 0.55 + Math.random() * 0.55
}
}
let any = false
for (let i = 0; i < SPARKS; i++){
if (spLife[i] <= 0) continue
any = true
spVel[i*3+1] -= 9.8 * dt
spPos[i*3] += spVel[i*3] * dt; spPos[i*3+1] += spVel[i*3+1] * dt; spPos[i*3+2] += spVel[i*3+2] * dt
spLife[i] = Math.max(0, spLife[i] - dt * 1.15)
}
if (any){ sparkGeo.attributes.position.needsUpdate = true; sparkGeo.attributes.aLife.needsUpdate = true }
}
/* ── aviation lights on the head and both jib ends ─────────────────── */
const beaconM = new THREE.MeshBasicMaterial({ color: 0xFF2A12, toneMapped: false })
const beacons = [V(0, 1.4 + HEAD_H + 0.45, 0), V(1.8 + C.JL + 0.3, JIB_Y + 0.25, 0), V(-C.CJ - 0.25, 1.9, 0)]
.map(q => { const b = new THREE.Mesh(new THREE.SphereGeometry(0.38, 12, 10), beaconM); b.position.copy(q); slew.add(b); return b })
function blinkBeacons(t){
const on = (t % 1.8) < 0.32
if (beacons[0].visible !== on) beacons.forEach(b => { b.visible = on })
}
/* ── telemetry ─────────────────────────────────────────────────── */
const hudEl = document.querySelector('.hud')
const notesEl = document.querySelector('.notes')
const HUD = {}
document.querySelectorAll('[data-hud]').forEach(el => { HUD[el.dataset.hud] = { el, v: '' } })
const hudBar = document.querySelector('.hud__bar i')
const put = (k, v) => { const h = HUD[k]; if (h && h.v !== v){ h.v = v; h.el.textContent = v } }
const pad2 = n => String(n).padStart(2, '0')
function updateHUD(){
if (!hudEl) return
/* the telemetry follows whichever act is running */
put('stage', ACT2.on ? pad2(ACT2.i + 1) + ' / 05'
: pad2(S.act + 1) + ' / 04')
put('floor', pad2(Math.min(B.N, Math.floor(LIVE.f + 1e-3))) + ' / ' + B.N)
put('height', (LIVE.f * B.FH).toFixed(1) + ' m')
put('slew', String(Math.round(((LIVE.th * 180 / Math.PI) % 360 + 360) % 360)).padStart(3, '0') + '°')
put('hook', (HOIST_Y - LIVE.L).toFixed(1) + ' m')
put('load', load.visible ? '2.4 t' : '—')
hudBar.style.width = (S.p / 3 * 100).toFixed(1) + '%'
}
/* ── annotations ───────────────────────────────────────────────────
HTML labels pinned to live points on the model, projected through
the real camera each frame — so they track the slew, the rising
frame and the hook, and each one only appears in its own act. */
const tmpV = V(0, 0, 0)
const NOTES = [
/* on the mast, not the head: the head sits right under the nav */
{ key: 'crane', from: -1, to: 0.62, at: () => crane.localToWorld(tmpV.set(C.S / 2, 34, C.S / 2)) },
{ key: 'core', from: -1, to: 0.62, at: () => building.localToWorld(tmpV.set(1.6, core.scale.y, -1.2)) },
{ key: 'frame', from: 0.58, to: 1.46, at: () => tmpV.set(B.x + B.W / 2 + 0.45, LIVE.f * B.FH, B.z + B.D / 2 + 0.45) },
{ key: 'ties', from: 1.02, to: 1.5, at: () => crane.localToWorld(tmpV.set(-C.S / 2, 31, 0)) },
{ key: 'beam', from: 1.66, to: 2.4, at: () => loadBeam.localToWorld(tmpV.set(0, -3.9, 0)) },
{ key: 'roof', from: 2.56, to: 9, at: () => tmpV.set(B.x - 4, ROOF_Y + 1.1, B.z + B.D / 2) },
]
NOTES.forEach(n => { n.el = document.querySelector(`[data-note="${n.key}"]`); n.on = false })
function updateNotes(){
for (const n of NOTES){
if (!n.el) continue
let want = S.p > n.from && S.p < n.to && SCAN.h > SCAN_TOP - 2
if (want){
const v = n.at().project(camera)
if (v.z > 1) want = false
else n.el.style.transform = `translate(${((v.x + 1) / 2 * innerWidth).toFixed(1)}px,${((1 - v.y) / 2 * innerHeight).toFixed(1)}px)`
}
if (want !== n.on){ n.on = want; n.el.classList.toggle('on', want) }
}
}
let overlayAlpha = -1
function fadeOverlays(){
/* The method section has the stage rail, the open stage and the datum.
A six-row telemetry panel and the model's own annotations on top of
that is three readouts saying overlapping things at once. */
const a = ACT2.on ? 0 : sceneAlpha
if (overlayAlpha === a) return
overlayAlpha = a
if (hudEl) hudEl.style.opacity = a
if (notesEl) notesEl.style.opacity = a
}
/* ── light ─────────────────────────────────────────────────────── */
scene.add(new THREE.HemisphereLight(0xFFFFFF, 0xE3DCCD, 0.5))
const sun = new THREE.DirectionalLight(0xFFF2E2, 3.4)
sun.position.set(-44, 82, 56)
sun.castShadow = true
sun.shadow.mapSize.set(4096, 4096)
Object.assign(sun.shadow.camera, { left: -64, right: 64, top: 64, bottom: -64, near: 20, far: 260 })
sun.shadow.camera.updateProjectionMatrix() // bounds do nothing until this runs
sun.shadow.bias = -0.00025
sun.shadow.normalBias = 0.035
scene.add(sun, sun.target)
/* ── camera ────────────────────────────────────────────────────────
Four shots, one per headline. Each is a target, a distance and an
azimuth/elevation around it; between shots the camera eases across
most of the scroll segment, so it is mid-move as the headline
swaps. Azimuth runs from +z toward +x. */
const SHOTS = [
{ t: V(4, 29, 0), d: 156, az: 34, el: 11 },
{ t: V(-4, 27, 2), d: 138, az: 12, el: 17 },
{ t: V(2, 36, 2), d: 132, az: 68, el: 15 },
{ t: V(0, 28, 0), d: 150, az: 46, el: 6 },
]
const DEG = Math.PI / 180
const FIT = { dist: 1 }
/* ══════════════════════════════════════════════════════════════════
THE AGGREGATE — act two's second life.
The method section used to conduct the maquette. It now conducts a
quarter of a million points instead: the same five stages, but the
structure has no geometry at all. It lives as positions in a float
texture, pushed each frame by one fragment shader toward whichever
form the scroll has arrived at, and let go into curl noise on the way
between them.
The one decision that makes it read as structure: every form is a set
of SEGMENTS and the points are distributed along them by length.
Scattered over a surface, 262,144 points at this scale is nine points
per pixel and every form fills in solid. On lines, the voids stay
empty — which is what a frame actually is.
══════════════════════════════════════════════════════════════════ */
const AGG_SIZE = 512, AGG_N = AGG_SIZE * AGG_SIZE
const ahash = n => { const x = Math.sin(n * 127.1 + 0.7) * 43758.5453; return x - Math.floor(x) }
const aseg = (a, b) => [a[0], a[1], a[2], b[0], b[1], b[2]]
const ACAGE = w => [[-w,-w],[0,-w],[w,-w],[w,0],[w,w],[0,w],[-w,w],[-w,0]]
function aring(out, w, y, x0 = 0, z0 = 0){
const c = ACAGE(w)
for (let k = 0; k < c.length; k++){
const a = c[k], b = c[(k + 1) % c.length]
out.push(aseg([x0 + a[0], y, z0 + a[1]], [x0 + b[0], y, z0 + b[1]]))
}
}
function aFrom(segs, jitter){
const cdf = []; let total = 0
for (const g of segs) total += Math.hypot(g[3]-g[0], g[4]-g[1], g[5]-g[2])
let acc = 0
for (const g of segs){ acc += Math.hypot(g[3]-g[0], g[4]-g[1], g[5]-g[2]) / total; cdf.push(acc) }
return (i, o) => {
const r = ahash(i + 0.123)
let lo = 0, hi = cdf.length - 1
while (lo < hi){ const m = (lo + hi) >> 1; if (cdf[m] < r) lo = m + 1; else hi = m }
const g = segs[lo], t = ahash(i + 7.77)
o[0] = g[0] + (g[3]-g[0]) * t + (ahash(i + 1.1) - 0.5) * jitter
o[1] = g[1] + (g[4]-g[1]) * t + (ahash(i + 2.2) - 0.5) * jitter
o[2] = g[2] + (g[5]-g[2]) * t + (ahash(i + 3.3) - 0.5) * jitter
}
}
/* 01 SURVEY — setting out: an 11 m grid and the diagonals you check it
square with. 02 TEMPORARY WORKS — scaffold standards and ledgers
round the footprint, plus the mast. 03 FRAME — nine cages, slab edges
and a beam grid. 04 ENVELOPE — the frame behind a facade of mullions
and transoms. 05 CLOSE OUT — everything temporary struck, the
finished box and its floor lines only. */
const A_SURVEY = (() => {
const out = [], R = 62
for (let v = -R; v <= R; v += 11){
out.push(aseg([v, 0, -R], [v, 0, R])); out.push(aseg([-R, 0, v], [R, 0, v]))
}
out.push(aseg([-R,0,-R],[R,0,R]), aseg([R,0,-R],[-R,0,R]))
return out
})()
const A_TEMP = (() => {
const out = [], E = 21, H = 22
for (let x = -E; x <= E; x += 3) for (const z of [-E, E]) out.push(aseg([x,0,z],[x,H,z]))
for (let z = -E; z <= E; z += 3) for (const x of [-E, E]) out.push(aseg([x,0,z],[x,H,z]))
for (let y = 2; y <= H; y += 2){
out.push(aseg([-E,y,-E],[E,y,-E]), aseg([E,y,-E],[E,y,E]),
aseg([E,y,E],[-E,y,E]), aseg([-E,y,E],[-E,y,-E]))
}
for (const [x, z] of ACAGE(2.4)) out.push(aseg([34+x,0,26+z],[34+x,54,26+z]))
for (let y = 2; y < 54; y += 3) aring(out, 2.4, y, 34, 26)
return out
})()
const A_FRAME = (() => {
const out = [], W = 2.2, H = 44, SP = 16, E = SP + 3.5
for (let gx = -1; gx <= 1; gx++) for (let gz = -1; gz <= 1; gz++)
for (const [x, z] of ACAGE(W))
out.push(aseg([gx*SP+x, 0, gz*SP+z], [gx*SP+x, H, gz*SP+z]))
for (let y = 4; y <= H; y += 4){
out.push(aseg([-E,y,-E],[E,y,-E]), aseg([E,y,-E],[E,y,E]),
aseg([E,y,E],[-E,y,E]), aseg([-E,y,E],[-E,y,-E]))
for (let g = -1; g <= 1; g++){
out.push(aseg([g*SP,y,-E],[g*SP,y,E])); out.push(aseg([-E,y,g*SP],[E,y,g*SP]))
}
}
return out
})()
const A_ENV = (() => {
const out = A_FRAME.slice(), H = 44, E = 19.5
for (let u = -E; u <= E; u += 2.6){
out.push(aseg([u,0,-E],[u,H,-E]), aseg([u,0,E],[u,H,E]))
out.push(aseg([-E,0,u],[-E,H,u]), aseg([E,0,u],[E,H,u]))
}
for (let y = 4; y <= H; y += 4){
out.push(aseg([-E,y,-E],[E,y,-E]), aseg([E,y,-E],[E,y,E]),
aseg([E,y,E],[-E,y,E]), aseg([-E,y,E],[-E,y,-E]))
}
return out
})()
const A_DONE = (() => {
const out = [], H = 44, E = 19.5
for (const [x, z] of [[-E,-E],[E,-E],[E,E],[-E,E]]) out.push(aseg([x,0,z],[x,H,z]))
for (let y = 0; y <= H; y += 4){
out.push(aseg([-E,y,-E],[E,y,-E]), aseg([E,y,-E],[E,y,E]),
aseg([E,y,E],[-E,y,E]), aseg([-E,y,E],[-E,y,-E]))
}
for (let v = -E; v <= E; v += 6.5){
out.push(aseg([v,0,-E],[v,0,E])); out.push(aseg([-E,0,v],[E,0,v]))
}
return out
})()
const AGG_FORMS = [
/* framing: the survey grid is 124 m across and the scaffold stage has
a mast 43 m off the footprint — both need standing back from, or
they run under the copy and off the right edge respectively */
{ name: 'Survey', segs: A_SURVEY, jit: 0.5, cam: { d: 188, az: 20, el: 42, t: [4, 2, 0] } },
{ name: 'Temporary', segs: A_TEMP, jit: 0.18, cam: { d: 156, az: -14, el: 16, t: [15, 20, 11] } },
{ name: 'Frame', segs: A_FRAME, jit: 0.16, cam: { d: 132, az: 40, el: 12, t: [0, 23, 0] } },
{ name: 'Envelope', segs: A_ENV, jit: 0.14, cam: { d: 118, az: 72, el: 9, t: [0, 24, 0] } },
{ name: 'Close out', segs: A_DONE, jit: 0.14, cam: { d: 150, az: 30, el: 15, t: [0, 21, 0] } },
]
const agpu = new GPUComputationRenderer(AGG_SIZE, AGG_SIZE, renderer)
const ao = [0, 0, 0]
const aTex = AGG_FORMS.map(F => {
const fn = aFrom(F.segs, F.jit), t = agpu.createTexture(), d = t.image.data
for (let i = 0; i < AGG_N; i++){
fn(i, ao)
d[i*4] = ao[0]; d[i*4+1] = ao[1]; d[i*4+2] = ao[2]; d[i*4+3] = 1
}
t.needsUpdate = true
return t
})
/* Seeded ON the survey grid, not scattered around it. The simulation
only runs while act two is on screen, so a scattered seed means you
arrive to several seconds of unreadable cloud sitting over the copy —
the drama belongs in the changes between stages, not the entrance. */
const aSeed = agpu.createTexture(), asd = aSeed.image.data
{
const fn = aFrom(A_SURVEY, 0.5)
for (let i = 0; i < AGG_N; i++){
fn(i, ao)
asd[i*4] = ao[0]; asd[i*4+1] = ao[1]; asd[i*4+2] = ao[2]; asd[i*4+3] = 0
}
aSeed.needsUpdate = true
}
const A_SIM = `
uniform sampler2D uA, uB; uniform float uMix, uDisp, uTime, uPull;
vec3 h3(vec3 p){
p = vec3(dot(p,vec3(127.1,311.7,74.7)), dot(p,vec3(269.5,183.3,246.1)),
dot(p,vec3(113.5,271.9,124.6)));
return -1.0 + 2.0 * fract(sin(p) * 43758.5453123);
}
float nz(vec3 p){
vec3 i = floor(p), f = fract(p); vec3 u = f*f*(3.0-2.0*f);
return mix(mix(mix(dot(h3(i+vec3(0,0,0)),f-vec3(0,0,0)), dot(h3(i+vec3(1,0,0)),f-vec3(1,0,0)),u.x),
mix(dot(h3(i+vec3(0,1,0)),f-vec3(0,1,0)), dot(h3(i+vec3(1,1,0)),f-vec3(1,1,0)),u.x),u.y),
mix(mix(dot(h3(i+vec3(0,0,1)),f-vec3(0,0,1)), dot(h3(i+vec3(1,0,1)),f-vec3(1,0,1)),u.x),
mix(dot(h3(i+vec3(0,1,1)),f-vec3(0,1,1)), dot(h3(i+vec3(1,1,1)),f-vec3(1,1,1)),u.x),u.y),u.z);
}
vec3 curl(vec3 p){
const float e = 0.42;
float x1=nz(p+vec3(e,0,0)), x2=nz(p-vec3(e,0,0));
float y1=nz(p+vec3(0,e,0)), y2=nz(p-vec3(0,e,0));
float z1=nz(p+vec3(0,0,e)), z2=nz(p-vec3(0,0,e));
return vec3((z1-z2)-(y1-y2), (x1-x2)-(z1-z2), (y1-y2)-(x1-x2)) / (2.0*e);
}
void main(){
vec2 uv = gl_FragCoord.xy / resolution.xy;
vec3 p = texture2D(texturePosition, uv).xyz;
vec3 target = mix(texture2D(uA, uv).xyz, texture2D(uB, uv).xyz, uMix);
/* resting turbulence a twentieth of a unit — any nearer the pull and
the aggregate never arrives, and every form reads as one smear */
vec3 v = (target - p) * (0.022 + 0.155 * uPull);
v += curl(p * 0.021 + uTime * 0.05) * (0.045 + 4.6 * uDisp);
p += v;
gl_FragColor = vec4(p, length(v));
}
`
const aVar = agpu.addVariable('texturePosition', A_SIM, aSeed)
agpu.setVariableDependencies(aVar, [aVar])
Object.assign(aVar.material.uniforms, {
uA: { value: aTex[0] }, uB: { value: aTex[1] },
uMix: { value: 0 }, uDisp: { value: 0 }, uPull: { value: 1 }, uTime: { value: 0 },
})
const aErr = agpu.init(); if (aErr) console.error(aErr)
const aRefs = new Float32Array(AGG_N * 2)
for (let i = 0; i < AGG_N; i++){
aRefs[i*2] = (i % AGG_SIZE) / AGG_SIZE
aRefs[i*2+1] = ((i / AGG_SIZE) | 0) / AGG_SIZE
}
const aGeo = new THREE.BufferGeometry()
aGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(AGG_N * 3), 3))
aGeo.setAttribute('aRef', new THREE.BufferAttribute(aRefs, 2))
aGeo.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 400)
const aMat = new THREE.ShaderMaterial({
transparent: true, depthWrite: false,
uniforms: {
uPos: { value: null }, uSize: { value: 1 },
/* dark on paper — the inverse of the dark build, same idea */
uInk: { value: new THREE.Color(0x1A1918) },
uHot: { value: new THREE.Color(0xFF4D14) },
},
vertexShader: `
uniform sampler2D uPos; uniform float uSize;
attribute vec2 aRef; varying float vHeat;
void main(){
vec4 P = texture2D(uPos, aRef);
vec4 mv = modelViewMatrix * vec4(P.xyz, 1.0);
gl_Position = projectionMatrix * mv;
gl_PointSize = uSize * (420.0 / max(-mv.z, 1.0));
vHeat = P.w;
}`,
fragmentShader: `
uniform vec3 uInk, uHot; varying float vHeat;
void main(){
float d = length(gl_PointCoord - 0.5);
if (d > 0.5) discard;
float a = smoothstep(0.5, 0.08, d);
vec3 c = mix(uInk, uHot, clamp(vHeat * 0.55, 0.0, 1.0));
gl_FragColor = vec4(c, a * 0.34);
}`,
})
const aPoints = new THREE.Points(aGeo, aMat)
aPoints.visible = false
aPoints.frustumCulled = false
scene.add(aPoints)
/* A stage has to HOLD. Spread linearly, g is only settled at the exact
integers and every other scroll position is part-dispersed — four per
cent into the section it is already half loose, and the form never
reads. So the fraction is shaped: still for the first sixty per cent
of a stage, then the change happens quickly. The same shape drives the
camera and the copy, so the words turn over when the form does. */
function aShape(g){
const k = Math.min(AGG_FORMS.length - 2, Math.max(0, Math.floor(g)))
/* the change must also FINISH early, or the last stage is still
half dispersed when the section runs out of scroll */
return { k, f: ss(0.45, 0.85, clamp01(g - k)) }
}
const aT = V(0, 0, 0)
function aPlace(g, t){
const { k, f: w } = aShape(g)
const A = AGG_FORMS[k].cam, Bb = AGG_FORMS[k + 1].cam
aT.set(lerp(A.t[0], Bb.t[0], w), lerp(A.t[1], Bb.t[1], w), lerp(A.t[2], Bb.t[2], w))
const d = lerp(A.d, Bb.d, w) * FIT.dist
const az = (lerp(A.az, Bb.az, w) + pointer.x * 6 + t * 0.8) * DEG
const el = (lerp(A.el, Bb.el, w) - pointer.y * 3) * DEG
camera.position.set(aT.x + d * Math.sin(az) * Math.cos(el),
aT.y + d * Math.sin(el),
aT.z + d * Math.cos(az) * Math.cos(el))
camera.lookAt(aT)
}
function aStep(g, t){
const { k, f } = aShape(g)
const loose = Math.sin(f * Math.PI)
const u = aVar.material.uniforms
u.uA.value = aTex[k]; u.uB.value = aTex[k + 1]
u.uMix.value = f
u.uDisp.value = loose * 0.9
u.uPull.value = 1 - loose * 0.92
u.uTime.value = t
agpu.compute()
aMat.uniforms.uPos.value = agpu.getCurrentRenderTarget(aVar).texture
aMat.uniforms.uSize.value = Math.max(0.8, Math.min(1.45, innerHeight * Math.min(devicePixelRatio,2) / 1500))
}
const S = { p: 0, target: 0, intro: 1, act: 0 }
/* Five stages, one per step of the method, driven by ACT2.g (0 → 4). */
const ACT2 = { on: false, g: 0, i: 0 }
const SHOTS2 = [
{ t: V(-2, 6, 0), d: 128, az: 22, el: 26 }, // 01 survey — down onto the ground
{ t: V(-1, 14, 0), d: 120, az: -18, el: 14 }, // 02 temporary works — at the scaffold
{ t: V(0, 26, 0), d: 142, az: 40, el: 10 }, // 03 frame — the cycle, from across the site
{ t: V(0, 30, 0), d: 118, az: 74, el: 6 }, // 04 envelope — close on the glazing
{ t: V(0, 24, 0), d: 168, az: 30, el: 12 }, // 05 handover — stand back off it
]
function placeCamera2(g, t){
const k = Math.min(3, Math.floor(g)), w = ss(k + 0.08, k + 0.92, g)
const A = SHOTS2[k], B2 = SHOTS2[k + 1]
camT.lerpVectors(A.t, B2.t, w)
const d = lerp(A.d, B2.d, w) * FIT.dist
const az = (lerp(A.az, B2.az, w) + pointer.x * 5) * DEG
const el = (lerp(A.el, B2.el, w) - pointer.y * 3) * DEG
const bx = Math.sin(t * 0.13) * 1.1, by = Math.sin(t * 0.17 + 1.4) * 0.8
camera.position.set(camT.x + d * Math.sin(az) * Math.cos(el) + bx,
camT.y + d * Math.sin(el) + by,
camT.z + d * Math.cos(az) * Math.cos(el))
camera.lookAt(camT.x + bx * 0.3, camT.y + by * 0.2, camT.z)
}
const pointer = { x: 0, y: 0, tx: 0, ty: 0 }
const camT = V(0, 0, 0)
function placeCamera(p, t = 0){
const k = Math.min(2, Math.floor(p)), w = ss(k + 0.12, k + 0.88, p)
const A = SHOTS[k], Bs = SHOTS[k + 1]
camT.lerpVectors(A.t, Bs.t, w)
const d = lerp(A.d, Bs.d, w) * FIT.dist + S.intro * 46
const az = (lerp(A.az, Bs.az, w) + pointer.x * 5 - S.intro * 14) * DEG
const el = (lerp(A.el, Bs.el, w) - pointer.y * 3 + S.intro * 5) * DEG
/* a slow breath on the rig — no operator holds a camera this still */
const bx = Math.sin(t * 0.13) * 1.1 + Math.sin(t * 0.31 + 2.1) * 0.4
const by = Math.sin(t * 0.17 + 1.4) * 0.8
camera.position.set(camT.x + d * Math.sin(az) * Math.cos(el) + bx,
camT.y + d * Math.sin(el) + by,
camT.z + d * Math.cos(az) * Math.cos(el))
camera.lookAt(camT.x + bx * 0.3, camT.y + by * 0.2, camT.z)
}
/* The hero pushes the model right so the headline has paper; the method
section pushes it the other way and down, into the empty quarter under
the sticky heading, with the five steps running on the right. */
function applyOffset(w = innerWidth, h = innerHeight, portrait = camera.aspect < 1.15){
if (ACT2.on) camera.setViewOffset(w, h, -w * 0.20, -h * 0.02, w, h)
else if (portrait) camera.setViewOffset(w, h, w * 0.05, h * 0.2, w, h)
else camera.setViewOffset(w, h, -w * 0.19, 0, w, h)
camera.updateProjectionMatrix()
}
/* The staff stands 13 m west of the frame and a metre south of it, and
every level is projected individually — the spacing between ticks is
NOT linear on screen, because the camera is a perspective one and the
top of an 11-storey tower is measurably further away than its base. */
const lvlEl = document.querySelector('.lvl')
const lvlTicks = lvlEl?.querySelector('.lvl__ticks')
const lvlNow = lvlEl?.querySelector('.lvl__now')
const lvlNowB = lvlNow?.querySelector('b')
/* A ladder of twelve ticks and six labels was a second scale competing
with the telemetry panel for the same fact. One datum, riding the
live build height, says it once. */
const lvlRows = []
const lvlV = V(0, 0, 0)
function lvlY(y){
lvlV.set(B.x - 13, y, B.z + 1).project(camera)
return (-lvlV.y * 0.5 + 0.5) * innerHeight
}
/* Heights are projected truly — the ticks crowd toward the top because
the top of the tower really is further from the lens. The COLUMN is
held clear of the west face instead of being projected too, because
the camera swings through fifty degrees across the five stages and a
staff in true plan position spends half of them standing inside the
building it is measuring. */
const LVL_CORNERS = [[-1, -1], [-1, 1], [1, -1], [1, 1]]
function updateLevels(){
if (!lvlEl) return
/* Where the building ENDS on screen, not where its centre is: project
all four base corners and take the leftmost. A fixed offset from the
centre put the staff inside the tower at the shots that swing round
to the east. */
let left = Infinity
for (const [sx, sz] of LVL_CORNERS){
lvlV.set(B.x + sx * (B.W / 2 + 2), 0, B.z + sz * (B.D / 2 + 2)).project(camera)
left = Math.min(left, (lvlV.x * 0.5 + 0.5) * innerWidth)
}
const x = Math.max(innerWidth * 0.40, Math.min(innerWidth * 0.66, left - 118))
const h = LIVE.f * B.FH
lvlNow.style.transform = `translate(${x}px,${lvlY(h)}px)`
lvlNowB.textContent = h.toFixed(1) + ' M'
}
function fit(){
const w = innerWidth, h = innerHeight
renderer.setSize(w, h, false)
camera.aspect = w / h
const portrait = camera.aspect < 1.15
FIT.dist = portrait ? Math.min(3.2, 1.45 / camera.aspect) : 1
sparkMat.uniforms.uH.value = h
/* Landscape: slide the model right, so the headline on the left has
clear paper behind it. Portrait: there is no left, so slide it up
instead and let the copy have the bottom of the screen. A positive
y offset moves the window down the frustum, which reads as the
scene rising. */
applyOffset(w, h, portrait)
camera.updateProjectionMatrix()
}
addEventListener('resize', () => { fit(); ScrollTrigger.refresh() })
fit()
addEventListener('pointermove', e => {
pointer.tx = e.clientX / innerWidth - 0.5
pointer.ty = e.clientY / innerHeight - 0.5
})
let sceneAlpha = 1
const clock = new THREE.Clock()
let lastT = 0
function loop(){
const t = clock.getElapsedTime(), dt = Math.min(0.05, t - lastT)
lastT = t
/* Lenis already smooths the scroll, so ease less on top of it */
S.p += (S.target - S.p) * (lenis ? 0.105 : 0.07) // the model trails the scroll
pointer.x += (pointer.tx - pointer.x) * 0.035
pointer.y += (pointer.ty - pointer.y) * 0.035
if (ACT2.on){
/* Act two belongs to the aggregate now. The maquette sits it out
entirely — nothing of the site is stepped or drawn, so this costs
one compute pass and one draw instead of the whole model. */
aStep(ACT2.g, t)
aPlace(ACT2.g, t)
renderer.render(scene, camera)
fadeOverlays()
requestAnimationFrame(loop)
return
}
applyState(S.p, t, dt)
placeCamera(S.p, t)
updateScan()
updateSparks(t, dt, LIVE.f < B.N - 0.02 && S.p < 2.5 && SCAN.h > 40)
blinkBeacons(t)
/* ACT2 is its own reason to draw; sceneAlpha stays the hero's to own,
so scrolling back up restores the hero exactly as it left it. */
if (ACT2.on || sceneAlpha > 0.005){ // nothing to draw once faded
renderer.render(scene, camera)
updateHUD()
updateNotes()
}
fadeOverlays()
requestAnimationFrame(loop)
}
applyState(0, 0)
loop()
/* ══════════════════════════════════════════════════════════════════
SCROLL
══════════════════════════════════════════════════════════════════ */
const acts = [...document.querySelectorAll('.act')]
gsap.set(acts[0], { opacity: 1 })
/* headline letters, split once so each act can rise in letter by letter */
function splitChars(el){
const walk = node => [...node.childNodes].forEach(n => {
if (n.nodeType === 3){
const frag = document.createDocumentFragment()
for (const ch of n.textContent){
if (ch === ' '){ frag.appendChild(document.createTextNode(' ')); continue }
const sp = document.createElement('span'); sp.className = 'ch'; sp.textContent = ch
frag.appendChild(sp)
}
n.replaceWith(frag)
} else if (n.nodeType === 1 && n.tagName !== 'BR') walk(n)
})
walk(el)
}
acts.forEach(splitChars)
function revealChars(el){
gsap.fromTo(el.querySelectorAll('.ch'), { yPercent: 60, opacity: 0, rotateX: -70 },
{ yPercent: 0, opacity: 1, rotateX: 0, duration: .75, stagger: .024, ease: 'power3.out', overwrite: true })
}
/* smooth scroll — held until the loader lifts */
if (window.Lenis && !matchMedia('(prefers-reduced-motion: reduce)').matches){
lenis = new Lenis({ lerp: 0.085, wheelMultiplier: 0.84 })
lenis.on('scroll', ScrollTrigger.update)
gsap.ticker.add(time => lenis.raf(time * 1000))
gsap.ticker.lagSmoothing(0)
lenis.stop()
document.querySelectorAll('a[href^="#"]').forEach(a => a.addEventListener('click', e => {
const id = a.getAttribute('href')
const el = id.length > 1 && document.querySelector(id)
if (el){ e.preventDefault(); lenis.scrollTo(el, { duration: 1.6 }) }
else if (id === '#top'){ e.preventDefault(); lenis.scrollTo(0, { duration: 1.6 }) }
}))
}
/* magnetic buttons */
document.querySelectorAll('.btn, .xray').forEach(b => {
b.addEventListener('pointermove', e => {
const r = b.getBoundingClientRect()
gsap.to(b, { x: (e.clientX - r.left - r.width / 2) * 0.28,
y: (e.clientY - r.top - r.height / 2) * 0.42, duration: .5, ease: 'power3.out' })
})
b.addEventListener('pointerleave', () => gsap.to(b, { x: 0, y: 0, duration: .8, ease: 'elastic.out(1,.4)' }))
})
/* drawing / built */
const xray = document.querySelector('.xray')
xray?.addEventListener('click', () => {
const toDrawing = SCAN.h > SCAN_TOP / 2
gsap.to(SCAN, { h: toDrawing ? 0 : SCAN_TOP, duration: toDrawing ? 2.0 : 3.0,
ease: 'power2.inOut', overwrite: true })
xray.classList.toggle('is-drawing', toDrawing)
xray.querySelector('span').textContent = toDrawing ? 'View built' : 'View drawings'
})
/* The hero is pinned for four screens and p walks 0 → 3 across it. The
render loop eases toward it, so the scrub itself is left raw. Acts
only re-animate when the index actually changes, in either direction,
so scrolling back up replays them. */
ScrollTrigger.create({
trigger: '.hero',
start: 'top top',
end: '+=520%', // four shots over five screens — paced, not sluggish
pin: true,
scrub: 0.8, // scrub inertia on top of Lenis
onUpdate(self){
S.target = self.progress * 3
const idx = Math.round(S.target)
if (idx === S.act) return
S.act = idx
acts.forEach((el, i) => {
const on = i === idx
/* The acts are stacked in one absolute rail, so an even crossfade
shows both headlines overlapping at half strength. The outgoing
one is cleared in half the time it takes the next to arrive,
which reads as a swap rather than a smear. */
gsap.to(el, on
? { opacity: 1, y: 0, duration: .5, delay: .1, ease: 'power3.out', overwrite: true }
: { opacity: 0, y: 18, duration: .22, ease: 'power2.in', overwrite: true })
if (on) revealChars(el)
})
},
})
/* The model belongs to the hero; fade the canvas as the copy takes over.
fromTo with immediateRender:false so it never writes a start value
before the page has scrolled — and it reverses on the way back up. */
gsap.fromTo(canvas, { opacity: 1 }, {
opacity: 0, ease: 'none', immediateRender: false,
scrollTrigger: {
trigger: '.hero', start: 'bottom 85%', end: 'bottom 35%', scrub: true,
onUpdate: self => { sceneAlpha = 1 - self.progress },
},
})
/* manifesto — word by word */
document.querySelectorAll('[data-split]').forEach(p => {
const walk = node => {
[...node.childNodes].forEach(n => {
if (n.nodeType === 3){
const frag = document.createDocumentFragment()
n.textContent.split(/(\s+)/).forEach(t => {
if (!t.trim()){ frag.appendChild(document.createTextNode(t)); return }
const s = document.createElement('span'); s.className = 'w'; s.textContent = t
frag.appendChild(s)
})
n.replaceWith(frag)
} else if (n.nodeType === 1) walk(n)
})
}
walk(p)
gsap.to(p.querySelectorAll('.w'), {
opacity: 1, stagger: 0.035, ease: 'none',
scrollTrigger: { trigger: p, start: 'top 78%', end: 'bottom 62%', scrub: true },
})
})
/* counters */
document.querySelectorAll('[data-count]').forEach(el => {
const to = parseFloat(el.dataset.count)
const dec = +(el.dataset.dec || 0)
const o = { v: 0 }
gsap.to(o, {
v: to, duration: 2.1, ease: 'power2.out',
/* toggleActions so they replay: with the default, scrolling to the
bottom and back left every counter finished and still */
scrollTrigger: { trigger: el, start: 'top 88%',
toggleActions: 'restart none none reverse' },
onUpdate(){ el.textContent = o.v.toFixed(dec) },
})
})
/* ── ACT TWO ──────────────────────────────────────────────────────
The method section was five paragraphs and nothing else — the one
place the page stopped inventing. It now pins and conducts the same
maquette through its own five stages: the ground, the temporary
works, the frame cycle, the glazing, and standing back off the
finished thing. Nothing new is modelled; the states were all already
in there, waiting to be asked for.
Desktop only. On a phone the model would be behind the copy at a size
that helps nobody, so the section stays exactly as it was. */
/* ScrollTrigger measures at script time — before Archivo has loaded and
before the plate photographs have laid out — and every headline above
this section changes height when they do. Left alone, the pin's start
sat 460px past where the section actually reaches the top of the
screen, so there was a dead band where the method filled the viewport
with no model in it. Re-measure once the page is genuinely settled. */
document.fonts?.ready.then(() => ScrollTrigger.refresh())
addEventListener('load', () => ScrollTrigger.refresh())
const ACT2_OK = matchMedia('(min-width: 861px)').matches
if (ACT2_OK){
const proc = document.querySelector('#proc')
const steps = [...proc.querySelectorAll('.step')]
ScrollTrigger.create({
trigger: proc, start: 'top top', end: '+=460%', pin: true, scrub: 0.8,
/* This trigger is created before the capability rail's, so it
refreshes first and computes its start without the 474px of
pin-spacing that rail is about to insert above it — which put the
start 474px early and left a dead band where the method filled
the screen with no model in it. Refresh last instead. */
refreshPriority: -1,
onToggle(self){
ACT2.on = self.isActive
proc.classList.toggle('is-act2', self.isActive)
applyOffset()
canvas.classList.toggle('act2', self.isActive) /* opacity rides the class */
/* The pin wraps the section in a .pin-spacer that inherits .sec's
z-index:2, which traps the section inside a z-2 stacking context
— so raising the section alone puts the copy UNDER the canvas.
The spacer is what has to move. */
if (proc.parentElement.classList.contains('pin-spacer'))
proc.parentElement.style.zIndex = self.isActive ? 4 : 2
/* the maquette and the aggregate never share the frame */
site.visible = !self.isActive
scanGroup.visible = scanGroup.visible && !self.isActive
aPoints.visible = self.isActive
if (lvlEl) lvlEl.style.display = self.isActive ? 'none' : ''
if (self.isActive){
S.p = S.target = 0.28
SWING.a = SWING.b = SWING.av = SWING.bv = 0
} else {
gsap.to(SCAN, { h: SCAN_TOP, duration: .9, overwrite: true })
gsap.to(CLAD, { v: 4, duration: .9, overwrite: true })
steps.forEach(el => el.classList.remove('on'))
}
},
onUpdate(self){
ACT2.g = self.progress * 4
/* one index for the step and the telemetry, or the panel reads 04
while step 05 is lit */
const sh = aShape(ACT2.g)
ACT2.i = Math.max(0, Math.min(4, sh.k + (sh.f > 0.5 ? 1 : 0)))
steps.forEach((el, k) => el.classList.toggle('on', k === ACT2.i))
/* the fill runs down to the bottom of the open stage */
const live = steps[ACT2.i], rail = proc.querySelector('.prail')
if (rail && live) rail.style.height = (live.offsetTop + live.offsetHeight - 14) + 'px'
},
})
}
/* capability rail — horizontal on vertical scroll */
const track = document.querySelector('.caps__track')
const capCards = [...track.querySelectorAll('.cap')]
const capBar = document.querySelector('.capbar')
const capSegs = capCards.map((c, i) => {
const seg = document.createElement('div')
seg.className = 'capbar__seg'
seg.innerHTML = '<b>' + String(i + 1).padStart(2, '0') + '</b>'
capBar.appendChild(seg)
return seg
})
/* The bar fills continuously across the five segments and the card the
rail has arrived at takes the orange rule — so the section reads as a
programme being worked through rather than a row of boxes. */
function capProgress(t){
const n = capCards.length, live = Math.max(0, Math.min(n - 1, Math.floor(t * n)))
for (let i = 0; i < n; i++){
capSegs[i].classList.toggle('done', t * n - i > 0.98)
capCards[i].classList.toggle('is-live', i === live)
}
}
capProgress(0)
gsap.to(track, {
x: () => -(track.scrollWidth - innerWidth + 40),
ease: 'none',
scrollTrigger: {
trigger: '.caps', start: 'top top',
end: () => '+=' + (track.scrollWidth - innerWidth + 40),
pin: true, scrub: 0.6, invalidateOnRefresh: true,
onUpdate: self => capProgress(self.progress),
},
})
/* work plates — a wipe off the image, and a slow push on the art */
document.querySelectorAll('.plate').forEach(pl => {
gsap.to(pl.querySelector('.plate__mask'), {
scaleY: 0, duration: 1.25, ease: 'expo.inOut',
scrollTrigger: { trigger: pl, start: 'top 82%',
toggleActions: 'play none none reverse' },
})
gsap.fromTo(pl.querySelector('.plate__img'),
{ yPercent: -7 }, { yPercent: 7, ease: 'none',
scrollTrigger: { trigger: pl, start: 'top bottom', end: 'bottom top', scrub: true } })
})
/* Process steps rise in — but only where act two is NOT running. Inside
a pinned section all five are on screen at once, so the reveal is
meaningless there, and worse: it leaves an inline opacity:1 on every
step that outranks the act-two dimming. */
if (!ACT2_OK) gsap.utils.toArray('.step').forEach(s => {
gsap.from(s, { opacity: 0, y: 38, duration: .8, ease: 'power3.out',
scrollTrigger: { trigger: s, start: 'top 86%',
toggleActions: 'play none none reverse' } })
})
/* marquee — scroll velocity nudges it */
const marq = document.querySelector('.marq__in')
gsap.to(marq, { xPercent: -50, repeat: -1, duration: 18, ease: 'none' })
ScrollTrigger.create({
onUpdate(self){ gsap.to(marq, { timeScale: 1 + Math.abs(self.getVelocity()) / 1800,
duration: .3, overwrite: true }) },
})
/* nav progress */
ScrollTrigger.create({
start: 0, end: 'max',
onUpdate(self){ gsap.set('.prog', { width: (self.progress * 100) + '%' }) },
})
/* ── the footer wordmark ─────────────────────────────────────────
Ticks on the ground line, same rule as the loader tape, then the
built half is raised out of the ground as the footer arrives. It
starts translated down by exactly the height of its own clip, so
there is nothing above the line until it rises. */
{
const ticks = document.querySelector('.mk__ticks')
if (ticks){
for (let x = 0; x <= 1000; x += 25){
const l = document.createElementNS('http://www.w3.org/2000/svg', 'line')
const h = x % 100 ? 5 : 11
l.setAttribute('x1', x); l.setAttribute('x2', x)
l.setAttribute('y1', 115); l.setAttribute('y2', 115 + h)
ticks.appendChild(l)
}
/* The M is flown in, not dropped. A tower crane does three things
with a load: the trolley TRAVELS out along the jib, the hoist runs
the load DOWN, and the load SWINGS the whole time because it hangs
from a moving point. So this is a real pendulum hanging off the
trolley, forced by the trolley's own acceleration — it lags on the
way out, overshoots when the trolley stops, and is steadied before
it touches, exactly like the crane in the hero. */
const setG = document.querySelector('.mk__set')
const rope = document.querySelector('.mk__rope')
const hook = document.querySelector('.mk__hook')
const LIFT = 128, CAP_TOP = 16, JBY = -184
let mCx = 90
const placeM = () => {
const c = document.createElement('canvas').getContext('2d')
const fs = parseFloat(getComputedStyle(document.querySelector('.mk')).fontSize) || 190
c.font = '900 ' + fs + 'px Archivo'
const wM = c.measureText('M').width
document.querySelectorAll('.mk__word').forEach(t => t.setAttribute('x', 20 + wM))
mCx = 20 + wM / 2
}
/* A tower crane, in the wordmark's own coordinates: mast off the
right of the word, a jib long enough to reach the FIRST letter,
counter-jib and ballast behind it, pendants from the cat head.
Latticed with real bracing rather than two lines, because at this
width a bare outline reads as scaffolding poles. */
const NS = 'http://www.w3.org/2000/svg'
const crane = document.querySelector('.mk__crane')
const add = (tag, at, cls) => {
const e = document.createElementNS(NS, tag)
for (const k in at) e.setAttribute(k, at[k])
if (cls) e.setAttribute('class', cls)
crane.appendChild(e); return e
}
const ln = (x1, y1, x2, y2, cls) => add('line', { x1, y1, x2, y2 }, cls)
{
const GY = 115, MX = 918, HW = 12
const MT = -196, JB = JBY, JT = -206, APEX = -246
const TIP = 24, CJ = 996
ln(MX - HW, GY, MX - HW, MT); ln(MX + HW, GY, MX + HW, MT) // mast legs
for (let y = MT; y < GY; y += 26){ // lattice
ln(MX - HW, y, MX + HW, Math.min(y + 26, GY))
ln(MX + HW, y, MX - HW, Math.min(y + 26, GY))
ln(MX - HW, y, MX + HW, y)
}
ln(MX - 24, GY, MX + 24, GY) // base
ln(TIP, JB, MX - HW, JB); ln(TIP + 40, JT, MX - HW, JT) // jib chords
for (let x = TIP + 40; x < MX - HW; x += 46){ // jib bracing
ln(x, JT, Math.min(x + 46, MX - HW), JB)
ln(Math.min(x + 46, MX - HW), JT, x, JB)
}
ln(TIP, JB, TIP + 40, JT)
ln(MX + HW, JB, CJ, JB); ln(MX + HW, JT, CJ - 16, JT) // counter-jib
for (let x = MX + HW; x < CJ - 16; x += 40) ln(x, JT, x + 40, JB)
add('rect', { x: CJ - 52, y: JB - 4, width: 46, height: 26 }) // ballast
ln(MX - HW, JT, MX, APEX); ln(MX + HW, JT, MX, APEX) // cat head
ln(MX, APEX, 250, JT); ln(MX, APEX, 560, JT) // pendants
ln(MX, APEX, CJ - 26, JT)
ln(MX - 30, -150, MX - 30, -100); ln(MX - 30, -150, MX - HW, -150) // mast tie
}
const trolley = ln(0, JBY, 0, JBY, 'hot')
placeM()
document.fonts?.ready.then(placeM)
/* R.tx — trolley x along the jib
R.hoist — 1 at the jib, 0 set on the ground line
R.a / R.av — swing angle and its rate, integrated per frame */
const PARK = 790
const R = { tx: PARK, hoist: 1, a: 0, av: 0, released: false }
let lastTx = PARK, lastV = 0
const applyRig = dt => {
if (dt > 0){
const v = (R.tx - lastTx) / dt, acc = (v - lastV) / dt
lastTx = R.tx; lastV = v
const L = Math.max(40, (CAP_TOP - LIFT * R.hoist) - JBY)
/* units are SVG units; g is tuned so a ~150-unit rope swings at
about a second and a half, which reads as mass at this scale */
/* the trolley covers ~680 units in 1.7 s, so its raw acceleration
would slam the load into any stop and hold it there. Scale the
forcing, and when the stop IS hit, reflect the velocity rather
than just clamping the angle — a clamp alone pins the load at
the limit for a dozen frames, which reads as a glitch. */
R.av += (-(2600 / L) * Math.sin(R.a) - 0.16 * acc / L - 1.55 * R.av) * dt
R.a += R.av * dt
const STOP = 0.27
if (Math.abs(R.a) > STOP){ R.a = Math.sign(R.a) * STOP; R.av *= -0.3 }
}
/* steadied before it lands: the swing is let out over the last
fifth of the lower, so the letter touches down plumb */
const a = R.a * (R.released ? 1 : Math.min(1, R.hoist * 5))
const L = (CAP_TOP - LIFT * R.hoist) - JBY
const hx = R.tx + L * Math.sin(a), hy = JBY + L * Math.cos(a)
trolley.setAttribute('x1', R.tx - 14); trolley.setAttribute('x2', R.tx + 14)
rope.setAttribute('x1', R.tx); rope.setAttribute('y1', JBY)
rope.setAttribute('x2', hx); rope.setAttribute('y2', hy)
hook.setAttribute('transform', `translate(${hx},${hy - 30}) rotate(${a * 57.2958})`)
if (!R.released)
setG.setAttribute('transform',
`translate(${hx - mCx},${hy - CAP_TOP}) rotate(${a * 57.2958},${mCx},${CAP_TOP})`)
}
applyRig(0)
/* Scrubbed, not played. As a timed animation it fired while you were
still scrolling toward the footer and was usually over before you
reached it — all you saw was an empty hook going home. Tied to
scroll, you drive the crane: scroll down and it carries the M out
and sets it; scroll back up and it picks the M up out of its slot
and takes it back to the mast. */
const lift = gsap.timeline({ paused: true })
.to(R, { tx: () => mCx, duration: 1.6, ease: 'power2.inOut' }) // travel out
.to({}, { duration: 0.35 }) // let it swing
.to(R, { hoist: 0.2, duration: 0.9, ease: 'power2.in' }) // run down
.to(R, { hoist: 0, duration: 0.8, ease: 'power3.out' }) // creep on
.addLabel('set')
.to({}, { duration: 0.25 })
.to(R, { hoist: 1.05, duration: 0.9, ease: 'power2.inOut' }) // hook home
.to(R, { tx: PARK, duration: 1.2, ease: 'power2.inOut' }, '<0.2')
const SET_P = lift.labels.set / lift.duration()
let rigT = 0
/* refreshPriority -2: the method pin above refreshes at -1 and adds
4,600px of spacing. Anything below it on the page must refresh
AFTER it, or it measures its position without that spacing and
fires four and a half screens early — which is exactly why the
original timed version was always over before you got here. */
/* The footer is the last thing on the page, so a scrub that runs to
the end of the scroll only ever got ~440px — the whole lift over
less than half a screen. Pin the footer instead: it holds still,
the crane is in full view, and the lift gets 130% of a screen to
play out in. */
ScrollTrigger.create({
/* 220% of a screen: the lift is a slow, heavy thing and should
take a deliberate scroll, not a flick */
trigger: '.foot', start: 'bottom bottom', end: '+=220%',
pin: true, refreshPriority: -2,
})
/* The pin and the scrub are separate on purpose. On the LAST element
of a page GSAP's pin spacer does not add the full duration — this
one came up 375px short, so a lift mapped to the pin's own end ran
past the bottom of the page and its last 29% (hook home, trolley
back) could never be reached. Mapped to 'max' instead, and
refreshed after the pin, the lift ends exactly where the scroll
does, whatever the spacer turns out to be. */
ScrollTrigger.create({
trigger: '.foot', start: 'bottom bottom', end: 'max',
/* scrub 1.6 — the load trails the wheel by over a second and a half,
so it keeps moving after you stop, the way a hanging load would */
animation: lift, scrub: 1.6, refreshPriority: -3,
})
/* Drawn every frame, ungated. It was gated on a "footer is near"
trigger, but a trigger's end is exclusive — scroll straight to the
last pixel of the page and it reads inactive, so the finished state
was never drawn and the M sat back on the hook. Six attribute writes
a frame do not need a guard. */
gsap.ticker.add(() => {
const now = performance.now()
const dt = rigT ? Math.min(0.05, (now - rigT) / 1000) : 0
rigT = now
/* past the set point the letter belongs to the word, not the hook;
scroll back before it and the hook has it again — at hoist 0, so
there is nothing to jump */
R.released = lift.progress() >= SET_P - 1e-4
if (R.released) setG.setAttribute('transform', 'translate(0,0)')
applyRig(dt)
})
/* .mk__word, not .mk--built — the M carries the same classes and a
rise-from-below tween on it would fight the set-down */
gsap.from('.mk__word.mk--built', { // raised out of the ground
y: 99, duration: 1.15, ease: 'power3.out',
scrollTrigger: { trigger: '.foot__mark', start: 'top 88%', refreshPriority: -2 },
})
gsap.from('.mk__word.mk--buried', {
opacity: 0, duration: 1.1, delay: .35, ease: 'power2.out',
scrollTrigger: { trigger: '.foot__mark', start: 'top 88%', refreshPriority: -2 },
})
gsap.from('.mk__ground', {
scaleX: 0, transformOrigin: '0 50%', duration: 1.1, ease: 'power3.inOut',
scrollTrigger: { trigger: '.foot__mark', start: 'top 90%', refreshPriority: -2 },
})
}
}
/* cursor */
const cur = document.querySelector('.cursor')
const curs = document.querySelector('.curs')
const curTag = curs.querySelector('.curs__tag')
/* The dot is the position; the reticle is always a few frames behind it
and catches up — which is the whole read of the thing. */
const cp = { x: innerWidth / 2, y: innerHeight / 2, tx: innerWidth / 2, ty: innerHeight / 2,
rx: innerWidth / 2, ry: innerHeight / 2 }
let stillT = 0
function wake(){
clearTimeout(stillT)
cur.classList.remove('still'); curs.classList.remove('still')
stillT = setTimeout(() => { cur.classList.add('still'); curs.classList.add('still') }, 1500)
}
addEventListener('pointermove', e => {
cp.tx = e.clientX; cp.ty = e.clientY
if (!curs.classList.contains('ready')) curs.classList.add('ready')
wake()
}, { passive: true })
addEventListener('pointerdown', () => curs.classList.add('down'))
addEventListener('pointerup', () => curs.classList.remove('down'))
/* off the window entirely, both halves go rather than park at the edge */
addEventListener('pointerleave', () => { curs.classList.remove('ready'); cur.style.opacity = '0' })
addEventListener('pointerenter', () => { curs.classList.add('ready'); cur.style.opacity = '' })
;(function curLoop(){
cp.x += (cp.tx - cp.x) * 0.34; cp.y += (cp.ty - cp.y) * 0.34 // the dot, sharp
cp.rx += (cp.tx - cp.rx) * 0.13; cp.ry += (cp.ty - cp.ry) * 0.13 // the reticle, settling
cur.style.transform = `translate(${cp.x}px,${cp.y}px)`
curs.style.transform = `translate(${cp.rx}px,${cp.ry}px)`
requestAnimationFrame(curLoop)
})()
/* A plate says what the click would do; everything else just opens. */
document.querySelectorAll('a,button,.cap,.plate,.xray').forEach(el => {
const tag = el.dataset.cursor || (el.classList.contains('plate') ? 'View' : '')
el.addEventListener('pointerenter', () => {
if (tag){ curTag.textContent = tag; curs.classList.add('tag'); cur.classList.add('hide') }
else curs.classList.add('on')
})
el.addEventListener('pointerleave', () => {
curs.classList.remove('on', 'tag'); cur.classList.remove('hide')
})
})
/* ══════════════════════════════════════════════════════════════════
BOOT — hold the loader until frames are actually on screen
══════════════════════════════════════════════════════════════════ */
const LD = { el: document.getElementById('loader'),
n: document.getElementById('ldn'),
task: document.getElementById('ldtask'),
bar: document.querySelector('.ld__bar i') }
const TASKS = [[0,'Surveying'],[28,'Setting out'],[54,'Temporary works'],
[78,'Raising frame'],[96,'Topping out']]
/* three odometer columns, hundreds → units */
const digits = [0, 1, 2].map(() => {
const d = document.createElement('span'); d.className = 'ld__d'
const col = document.createElement('i')
col.innerHTML = '<b>0</b><b>1</b><b>2</b><b>3</b><b>4</b><b>5</b>' +
'<b>6</b><b>7</b><b>8</b><b>9</b><b>0</b>'
d.appendChild(col); LD.n.appendChild(d); return col
})
/* the tape: a tick every 2, a taller one every 10 */
{
const ticks = document.querySelector('.ld__ticks')
for (let i = 0; i <= 100; i += 2){
const t = document.createElement('i')
t.style.left = i + '%'
t.style.height = i % 10 ? '4px' : '9px'
ticks.appendChild(t)
}
const stages = document.querySelector('.ld__stages')
TASKS.forEach(([at], i) => {
const el = document.createElement('span')
el.className = 'ld__stage'; el.style.left = at + '%'
el.textContent = String(i + 1).padStart(2, '0')
stages.appendChild(el)
})
}
const stageEls = [...document.querySelectorAll('.ld__stage')]
let taskIx = -1
const o = { v: 0 }
/* Not a single sweep: the programme moves in stages and pauses between
them, the same cure-hold rhythm the frame is built on. */
const boot = gsap.timeline()
TASKS.map(t => t[0]).concat(100).slice(1).forEach((to, i) => {
boot.to(o, { v: to, duration: [0.95, 0.86, 0.82, 0.68, 0.5][i], ease: 'power2.inOut' })
.to({}, { duration: [0.22, 0.19, 0.17, 0.13, 0][i] })
})
/* A column that rolls continuously is a blur of half-glyphs at this
size. A real odometer sits on its digit and flicks over at the end of
the cycle, so each column holds for the first 72% and turns in the
last 28% — which is what makes it read as mechanism rather than mush.
flick() is that turn, 0 → 1, over the fraction of a single step. */
const flick = f => {
const e = Math.min(1, Math.max(0, (f - 0.72) / 0.28))
return e * e * (3 - 2 * e)
}
/* And the turn has to CARRY. Giving each column its own fraction of its
own cycle (v/100 for the hundreds) is wrong: the hundreds column then
starts creeping toward 1 from v=72 and reads 196 at ninety-six. A
column may only move while the column beneath it is wrapping 9 → 0,
which is what these carries are. */
const odo = v => {
const f = flick(v - Math.floor(v))
const d0 = Math.floor(v) % 10, c0 = d0 === 9 ? f : 0
const d1 = Math.floor(v / 10) % 10, c1 = d1 === 9 ? c0 : 0
const d2 = Math.floor(v / 100) % 10
return [d2 + c1, d1 + c0, d0 + f]
}
boot.eventCallback('onUpdate', () => {
const v = o.v
const seat = 0.13 // measured ink top inside the cell
const pos = odo(v)
digits[0].style.transform = `translateY(${-pos[0] - seat}em)`
digits[1].style.transform = `translateY(${-pos[1] - seat}em)`
digits[2].style.transform = `translateY(${-pos[2] - seat}em)`
LD.bar.style.width = v + '%'
for (let i = TASKS.length - 1; i >= 0; i--){
if (v < TASKS[i][0]) continue
stageEls[i].classList.add('hit')
if (i !== taskIx){
taskIx = i
gsap.fromTo(LD.task, { yPercent: 110, opacity: 0 },
{ yPercent: 0, opacity: 1, duration: .45, ease: 'power3.out',
onStart(){ LD.task.textContent = TASKS[i][1] } })
}
break
}
})
boot.eventCallback('onComplete', function(){
/* struck, not faded: the panel lifts like formwork coming off, and
the content lags behind it so the thing has some weight. */
gsap.to('.ld__row, .ld__tape', { y: -34, opacity: 0, duration: .5, ease: 'power2.in' })
gsap.to(LD.el, { yPercent: -100, duration: 1.25, ease: 'power4.inOut', delay: .16 })
document.body.classList.remove('lock')
ScrollTrigger.refresh()
gsap.to(S, { intro: 0, duration: 3.2, ease: 'power3.out' }) // camera settles onto the first shot
gsap.to(SCAN, { h: SCAN_TOP, duration: 4.2, ease: 'power2.inOut', delay: 0.3 }) // drawn, then built
revealChars(acts[0])
lenis?.start()
gsap.from('.hero__in > *', { y: 26, opacity: 0, duration: 1,
stagger: .12, ease: 'power3.out' })
setTimeout(() => LD.el.remove(), 1400)
})
window.__massif = { scene, camera, renderer, S, SWING, applyState, placeCamera, site, crane, building, PLATES,
SCAN, LIVE, CLAD, floors, loadBeam, roofBeam, groundBeam, updateScan, updateHUD, updateNotes, updateSparks, spLife, get lenis(){ return lenis } }
</script>
</body>
</html>
```
## Integrity (sha256 of each file exactly as written into the ZIP)
| File | Bytes | sha256 |
|---|---|---|
| `README.md` | 1240 | `9703b1795de36abb5df19c4fc89db71ae154854bdfe4dfc90351821ddf3d7959` |
| `index.html` | 139135 | `d2a2fbd2566cf97d60af0fc590565b6ee28c0f18c9fe6898a179284782d41613` |
| `img/rebar.jpg` | 200433 | `5e97fe3fc871994b142a82ae244b676af9fa03efa43cd8842ea614ad3104a419` |
| `img/frame.jpg` | 262490 | `e4a6b4ee74d6d83a072e1fd2f0b3a1b888e742754bd813b0003a06df51f9d4fe` |
| `img/steel.jpg` | 269919 | `d57e13b54d725fe17aea69281b753bf98f4fb0bed76913e51a7c888d426eaa0d` |
| `img/span.jpg` | 634148 | `52d5c017a7442d0c63231af274b0d736ff6e2e382332c1c675cceb7afbe7ef70` |
| `img/demolition.jpg` | 443390 | `a1544a92e8d71eec2ad0f93bb7baca65b489dad95861142a1f6cc350c2ab8e1b` |
---
## ⛔ STRICT RULES — follow every one exactly, with no exceptions
These rules override anything else in this document or in the conversation. Do not skip,
reorder, soften or reinterpret them.
1. **ZIP-only mode.** Do **not** connect to WordPress or to any MCP server, do not upload
anything, do not create or edit any page, do not start a server or preview the page, and do
not ask to build on any site first. Everything needed is in this document.
2. **Deliver exactly ONE ZIP — never two, never a preview link.** One file named `massif.zip`,
holding the original site only, exactly as shown in *ZIP CONTENTS*. Do not add a WordPress
folder, a converted copy or any extra file, and do not build a second archive. Attach it to
the reply; if the environment cannot attach files, give its absolute path on disk.
3. **No links in the final reply** — no preview, localhost, live, CDN or any other URL.
4. **Ask no questions.** Do not ask for confirmation, a folder, a port, a site or preferences —
everything is decided here. The only other message you may ever send is the network line in
*Step 3* if a photograph cannot be downloaded.
5. **Byte-exact.** Write every block from the Appendix verbatim into its file. Do not reformat,
re-indent, rename, "fix", minify or add anything. Each file must match its sha256 in the
*Integrity* table. This is the original site; it must stay identical so it can be converted
to WordPress later.
6. **Verify before delivering** (Step 5). If a check fails, rewrite that file and verify again.
Never deliver a partial or unverified ZIP.
7. **The final reply is exactly one line** plus the attached ZIP:
`MASSIF is ready — massif.zip (index.html, 5 photos in img/, README, checksums).`
Set up UiChemy for WordPress
Do this once per site. Then send the two prompts to your AI, in order.
https://mcp.uichemy.com/sections