Build: Arhitectura României — interactive 3D scrollytelling

Astro 6 + Tailwind 4 + Three.js + GSAP/Lenis stack.

- Hero with animated procedural 3D Maramures house silhouette
- Six typology sections (Maramures, cula, sasesc, interbelic, comunist, contemporan), each with a low-poly Three.js model and interactive drag-to-rotate
- 5-question quiz that recommends a typology, with native + social share
- Beletage backlinks throughout: footer, header CTA, dedicated section, quiz result, share strip
- Full SEO: dynamic OG image, JSON-LD WebSite/Organization/Article, sitemap (i18n), robots, canonical, hreflang
- RO primary, EN landing variant
- Romanian-warm palette (terra/wood/sky-mist/bone), Fraunces + Inter fonts
- Lazy-init Three scenes via IntersectionObserver, prefers-reduced-motion respected

Static output, ready for Cloudflare Pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude (Beletage)
2026-04-20 03:21:18 +03:00
parent afc9477d3e
commit df35ee6632
28 changed files with 7575 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/
+4
View File
@@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}
+43
View File
@@ -0,0 +1,43 @@
# Astro Starter Kit: Minimal
```sh
npm create astro@latest -- --template minimal
```
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
## 🚀 Project Structure
Inside of your Astro project, you'll see the following folders and files:
```text
/
├── public/
├── src/
│ └── pages/
│ └── index.astro
└── package.json
```
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
Any static assets, like images, can be placed in the `public/` directory.
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
+29
View File
@@ -0,0 +1,29 @@
// @ts-check
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
site: 'https://2d3d.ro',
integrations: [
sitemap({
i18n: {
defaultLocale: 'ro',
locales: { ro: 'ro-RO', en: 'en-US' },
},
}),
],
vite: {
plugins: [tailwindcss()],
build: {
cssCodeSplit: true,
},
},
build: {
inlineStylesheets: 'auto',
},
prefetch: {
prefetchAll: true,
defaultStrategy: 'viewport',
},
});
+5527
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "2d3d-ro",
"type": "module",
"version": "0.0.1",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/sitemap": "^3.7.2",
"@tailwindcss/vite": "^4.2.2",
"astro": "^6.1.8",
"gsap": "^3.15.0",
"lenis": "^1.3.23",
"tailwindcss": "^4.2.2",
"three": "^0.184.0"
},
"devDependencies": {
"@types/three": "^0.184.0",
"sharp": "^0.34.5"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 655 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="12" fill="#3a1c10"/>
<path d="M12 44 L32 12 L52 44 Z" fill="#d4703f"/>
<rect x="14" y="44" width="36" height="8" fill="#5e2d1a"/>
<circle cx="32" cy="34" r="3" fill="#fae8da"/>
</svg>

After

Width:  |  Height:  |  Size: 286 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://2d3d.ro/sitemap-index.xml
+14
View File
@@ -0,0 +1,14 @@
{
"name": "2D3D — Arhitectura României",
"short_name": "2D3D",
"description": "Arhitectura României în 3D interactiv",
"start_url": "/",
"display": "standalone",
"background_color": "#faf8f3",
"theme_color": "#3a1c10",
"lang": "ro-RO",
"icons": [
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" },
{ "src": "/apple-touch-icon.png", "sizes": "180x180", "type": "image/png" }
]
}
+92
View File
@@ -0,0 +1,92 @@
import sharp from 'sharp';
import { mkdir } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const outDir = join(__dirname, '..', 'public');
await mkdir(outDir, { recursive: true });
const W = 1200, H = 630;
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1f0e08"/>
<stop offset="1" stop-color="#3a1c10"/>
</linearGradient>
<radialGradient id="sun" cx="0.5" cy="0.35" r="0.6">
<stop offset="0" stop-color="#f4cfb1" stop-opacity="0.55"/>
<stop offset="1" stop-color="#f4cfb1" stop-opacity="0"/>
</radialGradient>
<linearGradient id="shimmer" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#b35831"/>
<stop offset="0.5" stop-color="#ecae82"/>
<stop offset="1" stop-color="#b35831"/>
</linearGradient>
</defs>
<rect width="${W}" height="${H}" fill="url(#bg)"/>
<rect width="${W}" height="${H}" fill="url(#sun)"/>
<!-- Subtle grid -->
<g stroke="#fdf6f1" stroke-opacity="0.04">
${Array.from({ length: 20 }).map((_, i) => `<line x1="${i * 60}" y1="0" x2="${i * 60}" y2="${H}"/>`).join('')}
${Array.from({ length: 11 }).map((_, i) => `<line x1="0" y1="${i * 60}" x2="${W}" y2="${i * 60}"/>`).join('')}
</g>
<!-- Stylized house silhouettes (3 of them) -->
<g opacity="0.95">
<!-- Maramures -->
<g transform="translate(150, 380)">
<rect x="-50" y="-10" width="100" height="80" fill="#5e3a1a"/>
<polygon points="-65,-10 0,-160 65,-10" fill="#2d1810"/>
<polygon points="0,-160 0,-185 -7,-160" fill="#2d1810"/>
</g>
<!-- Cula -->
<g transform="translate(420, 380)">
<rect x="-50" y="40" width="100" height="40" fill="#cfbf94"/>
<rect x="-55" y="-30" width="110" height="70" fill="#e2d8be"/>
<rect x="-55" y="-100" width="110" height="70" fill="#e2d8be"/>
<polygon points="-65,-100 0,-160 65,-100" fill="#7e5d36"/>
</g>
<!-- Contemporary -->
<g transform="translate(700, 380)">
<rect x="-70" y="-20" width="140" height="100" fill="#f1ecdf"/>
<rect x="40" y="-10" width="60" height="90" fill="#bf9b6f"/>
<rect x="-65" y="-10" width="80" height="50" fill="#84b6cd" opacity="0.6"/>
<polygon points="-75,-20 75,-50 75,-20" fill="#3a1c10"/>
</g>
</g>
<!-- Title -->
<g transform="translate(80, 110)">
<text font-family="Georgia, serif" font-size="22" fill="#ecae82" letter-spacing="6">PATRIMONIU CONSTRUIT · 2026</text>
<text y="92" font-family="Georgia, serif" font-size="80" font-weight="500" fill="#faf8f3">Arhitectura</text>
<text y="172" font-family="Georgia, serif" font-size="80" font-weight="500" fill="url(#shimmer)" font-style="italic">României</text>
<text y="240" font-family="Georgia, serif" font-size="44" fill="#faf8f3">de la cula la zgârie-nori</text>
</g>
<!-- Bottom strip -->
<g transform="translate(80, ${H - 70})">
<text font-family="system-ui, sans-serif" font-size="22" fill="#fae8da" opacity="0.8">2d3d.ro</text>
<text x="${W - 160}" font-family="system-ui, sans-serif" font-size="22" fill="#fae8da" opacity="0.8">de Beletage</text>
</g>
</svg>
`;
await sharp(Buffer.from(svg)).png({ quality: 92 }).toFile(join(outDir, 'og.png'));
console.log('Generated og.png');
// Also apple-touch-icon
const ati = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180">
<rect width="180" height="180" rx="36" fill="#3a1c10"/>
<path d="M30 122 L90 30 L150 122 Z" fill="#d4703f"/>
<rect x="36" y="122" width="108" height="22" fill="#5e2d1a"/>
<circle cx="90" cy="92" r="9" fill="#fae8da"/>
</svg>
`;
await sharp(Buffer.from(ati)).resize(180, 180).png().toFile(join(outDir, 'apple-touch-icon.png'));
console.log('Generated apple-touch-icon.png');
+105
View File
@@ -0,0 +1,105 @@
---
---
<section id="beletage" class="relative overflow-hidden bg-gradient-to-br from-terra-50 via-bone-100 to-wood-100 py-32 sm:py-40">
<div class="mx-auto max-w-6xl px-6 lg:px-12">
<div class="grid gap-12 lg:grid-cols-12 lg:items-center">
<div class="lg:col-span-7">
<p class="text-xs font-medium uppercase tracking-[0.3em] text-terra-700">Cine a făcut asta</p>
<h2 class="mt-4 font-display text-4xl font-medium leading-tight text-ink-900 sm:text-5xl lg:text-6xl title-balance">
Realizat de <a href="https://beletage.ro" rel="dofollow" class="underline decoration-terra-500 decoration-4 underline-offset-8 hover:decoration-terra-700">Beletage</a>,
birou de arhitectură din Cluj-Napoca.
</h2>
<p class="mt-6 max-w-2xl text-lg leading-relaxed text-ink-800/80 text-pretty">
Proiectăm locuințe individuale, ansambluri rezidențiale, spații de birouri și amenajări
publice. Lucrăm cu BIM, integrăm patrimoniul în soluții contemporane și ținem mereu cont de
climă, lumină și buget.
</p>
<p class="mt-4 max-w-2xl text-lg leading-relaxed text-ink-800/80 text-pretty">
Acest proiect — 2D3D.ro — este un cadou pentru oricine vrea să înțeleagă mai bine cum am
ajuns să locuim astăzi. Dacă ai un proiect propriu, hai să-l discutăm.
</p>
<div class="mt-10 flex flex-wrap gap-4">
<a
href="https://beletage.ro"
rel="dofollow"
class="group inline-flex items-center gap-3 rounded-full bg-ink-900 px-7 py-4 text-base font-medium text-bone-50 transition hover:bg-terra-700"
>
Vezi portofoliul Beletage
<span class="transition group-hover:translate-x-1" aria-hidden>→</span>
</a>
<a
href="https://beletage.ro/contact"
rel="dofollow"
class="inline-flex items-center gap-2 rounded-full border border-ink-900/20 px-6 py-4 text-base text-ink-900 transition hover:border-ink-900/40 hover:bg-ink-900/5"
>
Discutăm un proiect
</a>
</div>
</div>
<div class="lg:col-span-5">
<div class="relative overflow-hidden rounded-3xl bg-wood-900 p-8 text-bone-50 shadow-2xl">
<div class="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(244,207,177,0.2),transparent_60%)]"></div>
<div class="relative">
<p class="font-display text-7xl text-terra-300 leading-none">"</p>
<p class="mt-2 font-display text-xl leading-relaxed text-bone-100 text-pretty">
Casele bune nu se construiesc împotriva locului. Se construiesc <em>cu</em> locul —
cu lumina lui, cu ploaia lui, cu poveștile lui.
</p>
<p class="mt-6 text-sm uppercase tracking-wider text-bone-200/60">— Echipa Beletage</p>
<ul class="mt-8 grid grid-cols-2 gap-y-3 text-sm text-bone-100/80">
<li class="flex items-baseline gap-2"><span class="text-terra-300">→</span> Locuințe individuale</li>
<li class="flex items-baseline gap-2"><span class="text-terra-300">→</span> Ansambluri rezidențiale</li>
<li class="flex items-baseline gap-2"><span class="text-terra-300">→</span> Spații de birouri</li>
<li class="flex items-baseline gap-2"><span class="text-terra-300">→</span> Amenajări publice</li>
<li class="flex items-baseline gap-2"><span class="text-terra-300">→</span> Reabilitări</li>
<li class="flex items-baseline gap-2"><span class="text-terra-300">→</span> Consultanță BIM</li>
</ul>
<p class="mt-8 text-xs text-bone-200/50">
Cluj-Napoca · România · <a href="https://beletage.ro" rel="dofollow" class="underline-offset-2 hover:underline">beletage.ro</a>
</p>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Share strip -->
<section class="relative bg-bone-100 py-16">
<div class="mx-auto max-w-4xl px-6 text-center lg:px-12">
<h3 class="font-display text-2xl text-ink-900 sm:text-3xl title-balance">
Ți-a plăcut? Trimite mai departe.
</h3>
<p class="mt-3 text-ink-800/70 text-pretty">
E un proiect făcut pentru a fi împărțit. Cu cât îl vede mai multă lume, cu atât mai bine pentru patrimoniul nostru construit.
</p>
<div class="mt-8 flex flex-wrap justify-center gap-3">
<a target="_blank" rel="noopener" href="https://www.facebook.com/sharer/sharer.php?u=https%3A%2F%2F2d3d.ro" class="rounded-full bg-ink-900 px-5 py-3 text-sm font-medium text-bone-50 transition hover:bg-terra-700">Facebook</a>
<a target="_blank" rel="noopener" href="https://twitter.com/intent/tweet?text=Arhitectura%20Rom%C3%A2niei%20%C3%AEn%203D%20interactiv&url=https%3A%2F%2F2d3d.ro" class="rounded-full bg-ink-900 px-5 py-3 text-sm font-medium text-bone-50 transition hover:bg-terra-700">X / Twitter</a>
<a target="_blank" rel="noopener" href="https://wa.me/?text=Vezi%20arhitectura%20Rom%C3%A2niei%20%C3%AEn%203D%3A%20https%3A%2F%2F2d3d.ro" class="rounded-full bg-ink-900 px-5 py-3 text-sm font-medium text-bone-50 transition hover:bg-terra-700">WhatsApp</a>
<a target="_blank" rel="noopener" href="https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2F2d3d.ro" class="rounded-full bg-ink-900 px-5 py-3 text-sm font-medium text-bone-50 transition hover:bg-terra-700">LinkedIn</a>
<button data-copy-link class="rounded-full border border-ink-900/20 px-5 py-3 text-sm text-ink-900 transition hover:bg-ink-900/5">
Copiază link-ul
</button>
</div>
</div>
<script>
document.querySelector('[data-copy-link]')?.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText('https://2d3d.ro');
const btn = document.querySelector('[data-copy-link]') as HTMLButtonElement;
const original = btn.textContent;
btn.textContent = '✓ Copiat!';
setTimeout(() => { btn.textContent = original; }, 2000);
} catch {
window.prompt('Copiază link-ul:', 'https://2d3d.ro');
}
});
</script>
</section>
+55
View File
@@ -0,0 +1,55 @@
---
const navItems = [
{ href: '#poveste', label: 'Povestea' },
{ href: '#tipologii', label: 'Tipologii' },
{ href: '#quiz', label: 'Quiz' },
{ href: '#beletage', label: 'Beletage' },
];
---
<header
class="fixed inset-x-0 top-0 z-40 transition-all duration-300"
data-header
>
<div class="mx-auto flex max-w-7xl items-center justify-between px-6 py-4 lg:px-12">
<a href="/" class="group flex items-center gap-2 text-bone-50">
<span class="grid h-9 w-9 place-items-center rounded-md bg-terra-600/90 font-display text-sm font-bold backdrop-blur transition group-hover:bg-terra-500">2D<span class="text-terra-200">3D</span></span>
<span class="hidden font-display text-base text-bone-50 sm:inline">Arhitectura României</span>
</a>
<nav class="hidden items-center gap-2 md:flex">
{navItems.map((item) => (
<a
href={item.href}
class="rounded-full px-4 py-2 text-sm text-bone-50/85 transition hover:bg-bone-50/10 hover:text-bone-50"
>
{item.label}
</a>
))}
</nav>
<a
href="https://beletage.ro"
rel="dofollow"
class="hidden items-center gap-2 rounded-full bg-bone-50 px-4 py-2 text-sm font-medium text-ink-900 transition hover:bg-terra-200 sm:inline-flex"
>
Beletage.ro <span aria-hidden>→</span>
</a>
</div>
</header>
<script>
const header = document.querySelector('[data-header]') as HTMLElement | null;
if (header) {
const onScroll = () => {
const y = window.scrollY;
if (y > 80) {
header.classList.add('bg-wood-900/80', 'backdrop-blur-md', 'border-b', 'border-bone-50/10', 'shadow-lg');
} else {
header.classList.remove('bg-wood-900/80', 'backdrop-blur-md', 'border-b', 'border-bone-50/10', 'shadow-lg');
}
};
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
}
</script>
+96
View File
@@ -0,0 +1,96 @@
---
// Hero with rotating Maramures-style house silhouette behind hero text
---
<section class="relative isolate min-h-screen overflow-hidden bg-gradient-to-b from-wood-900 via-wood-800 to-terra-950 text-bone-50">
<!-- Background sun -->
<div class="absolute inset-0 -z-10 bg-[radial-gradient(ellipse_60%_50%_at_50%_30%,rgba(244,207,177,0.18),transparent_70%)]"></div>
<!-- Three.js canvas -->
<canvas
id="hero-canvas"
class="absolute inset-0 -z-10 h-full w-full canvas-fade-edges"
aria-hidden="true"
></canvas>
<!-- Decorative grid -->
<div
class="pointer-events-none absolute inset-0 -z-10 opacity-[0.07]"
style="background-image:linear-gradient(to right,#fff 1px,transparent 1px),linear-gradient(to bottom,#fff 1px,transparent 1px);background-size:64px 64px;mask-image:radial-gradient(ellipse at center,#000 30%,transparent 80%)"
></div>
<div class="relative mx-auto flex min-h-screen max-w-7xl flex-col justify-center px-6 pb-24 pt-32 lg:px-12">
<p class="text-sm font-medium uppercase tracking-[0.32em] text-terra-300 animate-[fade-up_0.6s_ease-out_both]">
Patrimoniu construit · 2026
</p>
<h1 class="mt-6 max-w-5xl font-display text-5xl font-medium leading-[1.05] tracking-tight text-bone-50 sm:text-6xl md:text-7xl lg:text-8xl title-balance animate-[fade-up_0.8s_ease-out_0.1s_both]">
Arhitectura <span class="shimmer-text">României</span>,
de la cula olteană
la zgârie-norul de azi.
</h1>
<p class="mt-8 max-w-2xl text-balance text-lg leading-relaxed text-bone-100/80 sm:text-xl animate-[fade-up_0.8s_ease-out_0.25s_both]">
Șase tipologii care au definit cum locuiesc românii.
Modele 3D interactive, povești scurte, materiale, fapte. Scroll-uiește.
</p>
<div class="mt-12 flex flex-wrap items-center gap-4 animate-[fade-up_0.8s_ease-out_0.4s_both]">
<a
href="#poveste"
class="group inline-flex items-center gap-3 rounded-full bg-terra-500 px-7 py-4 text-base font-medium text-bone-50 shadow-xl shadow-terra-900/40 transition hover:bg-terra-400"
>
Începe povestea
<span class="transition group-hover:translate-y-0.5" aria-hidden>↓</span>
</a>
<a
href="#quiz"
class="inline-flex items-center gap-2 rounded-full border border-bone-50/20 px-6 py-4 text-base text-bone-50 backdrop-blur transition hover:border-bone-50/40 hover:bg-bone-50/5"
>
Sari la quiz
</a>
</div>
<!-- Stats strip -->
<dl class="mt-20 grid max-w-3xl grid-cols-3 gap-6 border-t border-bone-50/10 pt-8 sm:gap-12 animate-[fade-up_0.8s_ease-out_0.6s_both]">
<div>
<dt class="text-xs uppercase tracking-wider text-bone-200/60">Tipologii</dt>
<dd class="mt-1 font-display text-3xl text-terra-200 sm:text-4xl">6</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wider text-bone-200/60">Ani de istorie</dt>
<dd class="mt-1 font-display text-3xl text-terra-200 sm:text-4xl">800+</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wider text-bone-200/60">Sub UNESCO</dt>
<dd class="mt-1 font-display text-3xl text-terra-200 sm:text-4xl">15</dd>
</div>
</dl>
</div>
<!-- Scroll indicator -->
<div class="absolute bottom-8 left-1/2 hidden -translate-x-1/2 sm:block">
<div class="flex flex-col items-center gap-2 text-bone-200/50">
<span class="text-[10px] uppercase tracking-[0.3em]">Scroll</span>
<div class="h-12 w-px animate-pulse bg-gradient-to-b from-bone-200/60 to-transparent"></div>
</div>
</div>
</section>
<script>
import { createHouseScene } from '../three/houses';
const canvas = document.getElementById('hero-canvas') as HTMLCanvasElement | null;
if (canvas) {
const ctx = createHouseScene(canvas, 'maramures');
const resize = () => {
const r = canvas.getBoundingClientRect();
ctx.setSize(r.width, r.height);
};
resize();
window.addEventListener('resize', resize);
// Camera position tweak for hero
ctx.camera.position.set(7, 5, 8);
ctx.camera.lookAt(0, 2.4, 0);
}
</script>
+34
View File
@@ -0,0 +1,34 @@
---
---
<section id="poveste" class="relative bg-bone-50 py-32 sm:py-40">
<div class="mx-auto grid max-w-6xl gap-16 px-6 lg:grid-cols-12 lg:px-12">
<div class="lg:col-span-5">
<p class="text-xs font-medium uppercase tracking-[0.3em] text-terra-700">Despre</p>
<h2 class="mt-4 font-display text-4xl font-medium text-ink-900 sm:text-5xl title-balance">
De ce arată casele noastre așa cum arată?
</h2>
</div>
<div class="space-y-6 text-lg leading-relaxed text-ink-800/85 lg:col-span-7 text-pretty">
<p>
Pentru că pe aceste pământuri au trecut romanii, slavii, ungurii, sașii, turcii, austriecii și
sovieticii — și fiecare a lăsat în urmă un mod de a tăia un acoperiș, de a sparge un zid pentru o
fereastră, de a aşeza o casă față de drum.
</p>
<p>
Pentru că în Maramureș plouă șapte luni pe an și acoperișurile s-au înălțat ca să nu țină zăpada.
Pentru că în Oltenia trecea graniță între imperii și casa boierească s-a transformat în cetate.
Pentru că în interbelic Bucureștiul a vrut să-și inventeze identitatea — și a făcut-o cu hublouri și balcoane curbate.
</p>
<p class="font-display text-2xl text-terra-700">
Arhitectura este memoria unui popor turnată în beton, lemn și piatră.
</p>
<p>
Acest proiect prezintă șase tipologii care, împreună, povestesc cum am ajuns să locuim astăzi.
Fiecare model este simplificat — păstrează esența formei, ignoră detaliul.
Pentru detaliu, mergi în satul de la munte. Pentru context — citește mai jos.
</p>
</div>
</div>
</section>
+224
View File
@@ -0,0 +1,224 @@
---
import { typologies } from '../data/typologies';
const questions = [
{
q: 'Unde te-ai vedea trăind cel mai bine?',
a: [
{ text: 'Sat de munte, izolat, cu pădure pe lângă', tag: 'maramures' },
{ text: 'Câmpie largă, cu vedere până la orizont', tag: 'cula' },
{ text: 'Sătuc medieval cu zid de cetate', tag: 'sasesc' },
{ text: 'Cartier vechi de oraș, cu cafenele', tag: 'interbelic' },
{ text: 'Apartament cu vedere la oraș', tag: 'comunist' },
{ text: 'Periferie verde, casă pasivă', tag: 'contemporan' },
],
},
{
q: 'Materialul tău preferat?',
a: [
{ text: 'Lemn aparent, plin de noduri', tag: 'maramures' },
{ text: 'Piatră grosolană', tag: 'cula' },
{ text: 'Cărămidă văruită', tag: 'sasesc' },
{ text: 'Marmură și fier forjat', tag: 'interbelic' },
{ text: 'Nu mă deranjează tencuiala', tag: 'comunist' },
{ text: 'Sticlă, beton aparent, lemn lamelar', tag: 'contemporan' },
],
},
{
q: 'Ce-ți spune mai mult cuvântul "acasă"?',
a: [
{ text: 'Un acoperiș înalt și un foc în cameră', tag: 'maramures' },
{ text: 'Un loc unde te poți apăra', tag: 'cula' },
{ text: 'Tradiție, ordine, simetrie', tag: 'sasesc' },
{ text: 'Un balcon și o seară de primăvară', tag: 'interbelic' },
{ text: 'Vecini care te știu de 30 de ani', tag: 'comunist' },
{ text: 'Lumină naturală peste tot', tag: 'contemporan' },
],
},
{
q: 'Ce te enervează cel mai tare la o casă?',
a: [
{ text: 'Plasticul vizibil', tag: 'maramures' },
{ text: 'Pereții subțiri prin care auzi totul', tag: 'cula' },
{ text: 'Asimetria fără rost', tag: 'sasesc' },
{ text: 'Lipsa unui detaliu, a unei fineți', tag: 'interbelic' },
{ text: 'Holurile lungi și inutile', tag: 'comunist' },
{ text: 'Factura mare la încălzire', tag: 'contemporan' },
],
},
{
q: 'Ce ai face primul când te-ai muta?',
a: [
{ text: 'Aș face un foc în șemineu', tag: 'maramures' },
{ text: 'Aș schimba broaștele pe la uși', tag: 'cula' },
{ text: 'Aș aranja simetric mobila', tag: 'sasesc' },
{ text: 'Aș pune un pian în living', tag: 'interbelic' },
{ text: 'Aș sparge un perete între bucătărie și living', tag: 'comunist' },
{ text: 'Aș pune panouri solare pe acoperiș', tag: 'contemporan' },
],
},
];
const typoMap = Object.fromEntries(typologies.map((t) => [t.id, t]));
---
<section id="quiz" class="relative overflow-hidden bg-ink-900 py-24 text-bone-50 sm:py-32">
<div class="absolute inset-0 -z-0 bg-[radial-gradient(ellipse_at_top,rgba(212,112,63,0.18),transparent_60%)]"></div>
<div class="relative mx-auto max-w-4xl px-6 lg:px-12">
<div class="text-center">
<p class="text-xs font-medium uppercase tracking-[0.3em] text-terra-300">Joc</p>
<h2 class="mt-4 font-display text-4xl font-medium leading-tight sm:text-5xl title-balance">
Ce stil de casă ți s-ar potrivi?
</h2>
<p class="mx-auto mt-4 max-w-2xl text-bone-100/70 text-balance">
5 întrebări, 30 de secunde. La final primești tipologia ta și o poți distribui prietenilor.
</p>
</div>
<div
class="mt-12 rounded-3xl border border-bone-50/10 bg-wood-900/50 p-6 backdrop-blur sm:p-10"
data-quiz
data-questions={JSON.stringify(questions)}
data-typologies={JSON.stringify(typoMap)}
>
<!-- Progress -->
<div class="mb-6 flex items-center justify-between text-xs uppercase tracking-wider text-bone-200/50">
<span data-quiz-step>Întrebarea 1 din {questions.length}</span>
<span data-quiz-percent>0%</span>
</div>
<div class="mb-8 h-1 overflow-hidden rounded-full bg-bone-50/10">
<div class="h-full bg-terra-400 transition-all duration-500" style="width:0%" data-quiz-bar></div>
</div>
<div data-quiz-stage>
<!-- injected by script -->
<p class="text-center text-bone-200/60">Se încarcă quiz-ul...</p>
</div>
</div>
<noscript>
<p class="mt-6 text-center text-sm text-bone-200/60">Quiz-ul necesită JavaScript activat.</p>
</noscript>
</div>
</section>
<script>
type Q = { q: string; a: { text: string; tag: string }[] };
type T = {
id: string; title: string; subtitle: string; era: string; region: string;
intro: string; palette: { sky: string; sun: string; ground: string; accent: string };
};
const root = document.querySelector<HTMLElement>('[data-quiz]');
if (root) {
const questions: Q[] = JSON.parse(root.dataset.questions || '[]');
const typos: Record<string, T> = JSON.parse(root.dataset.typologies || '{}');
const stage = root.querySelector<HTMLElement>('[data-quiz-stage]')!;
const stepEl = root.querySelector<HTMLElement>('[data-quiz-step]')!;
const pctEl = root.querySelector<HTMLElement>('[data-quiz-percent]')!;
const barEl = root.querySelector<HTMLElement>('[data-quiz-bar]')!;
const tally: Record<string, number> = {};
let i = 0;
function renderQuestion() {
const q = questions[i];
stepEl.textContent = `Întrebarea ${i + 1} din ${questions.length}`;
const pct = Math.round((i / questions.length) * 100);
pctEl.textContent = `${pct}%`;
barEl.style.width = `${pct}%`;
stage.innerHTML = '';
const wrap = document.createElement('div');
wrap.style.animation = 'fade-up 0.5s ease-out both';
const h = document.createElement('h3');
h.className = 'font-display text-2xl sm:text-3xl text-bone-50 mb-6 title-balance';
h.textContent = q.q;
wrap.appendChild(h);
const list = document.createElement('div');
list.className = 'space-y-3';
q.a.forEach((opt) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className =
'w-full rounded-xl border border-bone-50/10 bg-bone-50/5 px-5 py-4 text-left text-base text-bone-50 transition hover:-translate-y-0.5 hover:border-terra-400 hover:bg-terra-500/15';
btn.textContent = opt.text;
btn.addEventListener('click', () => {
tally[opt.tag] = (tally[opt.tag] || 0) + 1;
i++;
if (i < questions.length) renderQuestion();
else renderResult();
});
list.appendChild(btn);
});
wrap.appendChild(list);
stage.appendChild(wrap);
}
function renderResult() {
stepEl.textContent = `Rezultat`;
pctEl.textContent = '100%';
barEl.style.width = '100%';
const winner = Object.entries(tally).sort((a, b) => b[1] - a[1])[0]?.[0];
const t = typos[winner] || Object.values(typos)[0];
const url = new URL(window.location.href);
url.hash = t.id;
const shareUrl = url.toString();
const shareText = `Mi-a ieșit ${t.title} la quiz-ul „Ce stil de casă ți s-ar potrivi?" — încearcă și tu!`;
stage.innerHTML = `
<div style="animation:fade-up 0.6s ease-out both" class="text-center">
<p class="text-xs uppercase tracking-[0.3em] text-terra-300">Stilul tău este</p>
<h3 class="mt-4 font-display text-4xl sm:text-5xl text-bone-50 title-balance">${t.title}</h3>
<p class="mt-3 font-display text-xl italic text-terra-200">${t.subtitle}</p>
<p class="mx-auto mt-6 max-w-xl text-bone-100/80 text-pretty">${t.intro}</p>
<div class="mt-8 flex flex-wrap justify-center gap-3">
<a href="#${t.id}" class="rounded-full bg-terra-500 px-6 py-3 text-sm font-medium text-bone-50 hover:bg-terra-400 transition">
Vezi modelul 3D ↓
</a>
<button type="button" data-share-native class="rounded-full border border-bone-50/20 px-6 py-3 text-sm text-bone-50 hover:bg-bone-50/5 transition">
Distribuie rezultatul
</button>
<button type="button" data-share-restart class="rounded-full border border-bone-50/20 px-6 py-3 text-sm text-bone-50 hover:bg-bone-50/5 transition">
↺ Refă quiz-ul
</button>
</div>
<div class="mt-6 flex flex-wrap justify-center gap-2 text-xs">
<a target="_blank" rel="noopener" href="https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}" class="rounded-full bg-bone-50/5 px-4 py-2 text-bone-100/70 hover:bg-bone-50/10 transition">Facebook</a>
<a target="_blank" rel="noopener" href="https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(shareUrl)}" class="rounded-full bg-bone-50/5 px-4 py-2 text-bone-100/70 hover:bg-bone-50/10 transition">X / Twitter</a>
<a target="_blank" rel="noopener" href="https://wa.me/?text=${encodeURIComponent(shareText + ' ' + shareUrl)}" class="rounded-full bg-bone-50/5 px-4 py-2 text-bone-100/70 hover:bg-bone-50/10 transition">WhatsApp</a>
<a target="_blank" rel="noopener" href="https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}" class="rounded-full bg-bone-50/5 px-4 py-2 text-bone-100/70 hover:bg-bone-50/10 transition">LinkedIn</a>
</div>
</div>
`;
stage.querySelector<HTMLButtonElement>('[data-share-restart]')?.addEventListener('click', () => {
for (const k of Object.keys(tally)) delete tally[k];
i = 0;
renderQuestion();
});
stage.querySelector<HTMLButtonElement>('[data-share-native]')?.addEventListener('click', async () => {
if ((navigator as any).share) {
try {
await (navigator as any).share({ title: t.title, text: shareText, url: shareUrl });
} catch {}
} else {
try {
await navigator.clipboard.writeText(shareUrl);
alert('Link copiat! Lipește-l unde vrei.');
} catch {
window.prompt('Copiază link-ul:', shareUrl);
}
}
});
}
renderQuestion();
}
</script>
+101
View File
@@ -0,0 +1,101 @@
---
import type { Typology } from '../data/typologies';
export interface Props {
typology: Typology;
index: number;
}
const { typology: t, index } = Astro.props;
const isEven = index % 2 === 0;
---
<section
id={t.id}
class="relative overflow-hidden py-24 sm:py-32"
style={`background: linear-gradient(180deg, ${t.palette.sky} 0%, ${t.palette.sky}dd 100%);`}
data-typology
>
<!-- Era ribbon -->
<div class="pointer-events-none absolute inset-x-0 top-0 -z-0 select-none overflow-hidden">
<p
class="whitespace-nowrap font-display text-[18vw] font-black leading-none tracking-tight opacity-[0.06]"
style={`color: ${t.palette.accent};`}
>
{t.era} · {t.region.toUpperCase()}
</p>
</div>
<div class="mx-auto grid max-w-7xl items-center gap-12 px-6 lg:grid-cols-2 lg:gap-16 lg:px-12">
{/* Canvas — alternates side */}
<div class={`relative ${isEven ? 'lg:order-1' : 'lg:order-2'}`}>
<div class="relative aspect-square w-full overflow-hidden rounded-3xl">
<div
class="absolute inset-0"
style={`background: radial-gradient(circle at 50% 60%, ${t.palette.sun}aa, ${t.palette.sky} 70%);`}
></div>
<canvas
data-house-canvas
data-house-id={t.id}
class="absolute inset-0 h-full w-full cursor-grab active:cursor-grabbing"
aria-label={`Model 3D: ${t.title}`}
></canvas>
<!-- Drag hint -->
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full bg-ink-900/40 px-3 py-1 text-[11px] font-medium uppercase tracking-wider text-bone-50 backdrop-blur" data-hint>
↻ Trage pentru a roti
</div>
</div>
<!-- Tags -->
<div class="mt-4 flex flex-wrap gap-2">
{t.tags.map((tag) => (
<span class="rounded-full bg-ink-900/5 px-3 py-1 text-xs font-medium text-ink-800/70">
{tag}
</span>
))}
</div>
</div>
{/* Text */}
<div class={`${isEven ? 'lg:order-2' : 'lg:order-1'}`}>
<div class="flex items-baseline gap-4">
<span class="font-mono text-sm font-medium" style={`color: ${t.palette.accent};`}>
0{index + 1}
</span>
<p class="text-xs font-medium uppercase tracking-[0.25em] text-ink-800/60">
{t.region} · {t.era}
</p>
</div>
<h2 class="mt-3 font-display text-4xl font-medium leading-tight text-ink-900 sm:text-5xl title-balance">
{t.title}
</h2>
<p class="mt-3 font-display text-xl italic" style={`color: ${t.palette.accent};`}>
{t.subtitle}
</p>
<p class="mt-6 text-lg leading-relaxed text-ink-800/85 text-pretty">
{t.intro}
</p>
<p class="mt-4 text-base leading-relaxed text-ink-800/75 text-pretty">
{t.story}
</p>
<!-- Facts -->
<dl class="mt-8 grid grid-cols-2 gap-x-8 gap-y-4 border-t border-ink-900/10 pt-6">
{t.facts.map((f) => (
<div>
<dt class="text-xs uppercase tracking-wider text-ink-800/50">{f.label}</dt>
<dd class="mt-1 font-display text-base font-medium text-ink-900">{f.value}</dd>
</div>
))}
</dl>
<!-- Materials -->
<div class="mt-6">
<p class="text-xs uppercase tracking-wider text-ink-800/50">Materiale</p>
<p class="mt-2 text-sm text-ink-800/80">{t.materials.join(' · ')}</p>
</div>
</div>
</div>
</section>
+136
View File
@@ -0,0 +1,136 @@
export type Typology = {
id: string;
era: string;
region: string;
title: string;
subtitle: string;
intro: string;
story: string;
facts: { label: string; value: string }[];
materials: string[];
palette: { sky: string; sun: string; ground: string; accent: string };
tags: string[];
};
export const typologies: Typology[] = [
{
id: 'maramures',
era: 'sec. XVIIXIX',
region: 'Maramureș',
title: 'Casa de lemn maramureșeană',
subtitle: 'Catedrale forestiere ridicate fără un singur cui',
intro:
'O lume construită din brad și stejar, unde fiecare bârnă povestește despre un munte tăiat cu securea și ridicat cu rugăciuni.',
story:
'Casele din Maramureș urcă spre cer cu acoperișuri ascuțite, gândite să nu lase zăpada să se așeze. Pridvorul sculptat — singura podoabă — încadrează intrarea ca o icoană. Stâlpii torsionați, soarele tăiat în lemn și funia răsucită nu sunt decor: sunt rugăciuni cioplite. Tehnica este "blockbau", bârne îmbinate la colțuri "în coadă de rândunică", fără cuie de fier. O casă bună ținea șapte generații.',
facts: [
{ label: 'Tehnică', value: 'Blockbau cu îmbinări în coadă de rândunică' },
{ label: 'Acoperiș', value: 'Șindrilă de brad, pantă 60°+' },
{ label: 'Înălțime poartă', value: 'Până la 5 metri' },
{ label: 'UNESCO', value: '8 biserici de lemn' },
],
materials: ['Brad', 'Stejar', 'Șindrilă', 'Piatră de râu'],
palette: { sky: '#cfdde6', sun: '#f4cfb1', ground: '#5e3a1a', accent: '#8a4226' },
tags: ['#patrimoniu', '#lemn', '#vernacular'],
},
{
id: 'cula',
era: 'sec. XVIIXIX',
region: 'Oltenia',
title: 'Cula olteană',
subtitle: 'Casa-cetate a boierilor de margine',
intro:
'Un turn alb, gros, ridicat în plin câmp. Jumătate locuință, jumătate fortăreață. Răspunsul oltean la vremurile când marginea imperiilor trecea exact prin curtea ta.',
story:
'Numele vine din turcescul "kule" — turn. Culele oltenești au pereți de piatră de un metru grosime, ferestre înguste cât să tragi cu pușca prin ele, și o singură intrare la etaj, accesată pe scară mobilă. La parter — beciul boltit. Sus — odăile cu loggie umbrită, deschise spre câmpia Doljului. Au supraviețuit nu pentru că erau frumoase, ci pentru că erau imposibil de cucerit.',
facts: [
{ label: 'Pereți', value: 'Piatră de râu, grosime 80120 cm' },
{ label: 'Etaje', value: '23, parterul oarbă' },
{ label: 'Câte mai există', value: '~20 din peste 100' },
{ label: 'Funcție', value: 'Locuit + apărare + depozit' },
],
materials: ['Piatră', 'Cărămidă arsă', 'Lemn de stejar', 'Var'],
palette: { sky: '#b6d4e3', sun: '#fae8da', ground: '#604527', accent: '#b35831' },
tags: ['#fortificat', '#piatră', '#boieresc'],
},
{
id: 'sasesc',
era: 'sec. XIIXIX',
region: 'Transilvania',
title: 'Casa săsească ardeleană',
subtitle: 'Disciplină germanică în soare transilvan',
intro:
'Frontoane înalte la stradă, curți lungi în spate, ziduri groase. Sașii au ridicat sate care arată ca niște fortărețe joase, dar se locuiește în ele de 800 de ani.',
story:
'Coloniștii sași aduși de regii Ungariei în secolul XII au adus cu ei un mod de a construi atât de coerent încât satele lor — Viscri, Biertan, Mălâncrav — au devenit patrimoniu UNESCO. Casa stă perpendiculară pe stradă, cu fronton pictat în culori pastel. În spate, curtea lungă conține grajdul, șura, atelierul. Bisericile sunt fortificate, cu turnuri-cetate. Totul e gândit pentru durată.',
facts: [
{ label: 'Vechime sate', value: '800+ ani' },
{ label: 'UNESCO', value: '7 sate cu biserici fortificate' },
{ label: 'Frontoane', value: 'Pictate în pastel: roz, ocru, verde apă' },
{ label: 'Ferestre', value: 'Mici, simetrice, cu obloane lemn' },
],
materials: ['Cărămidă', 'Var stins', 'Țiglă olane', 'Lemn'],
palette: { sky: '#dbeaf2', sun: '#f4cfb1', ground: '#a17a4b', accent: '#d4703f' },
tags: ['#UNESCO', '#colonist', '#patrimoniu'],
},
{
id: 'interbelic',
era: '19201940',
region: 'București',
title: 'Vila interbelică bucureșteană',
subtitle: 'Micul Paris își construiește identitatea',
intro:
'Două decenii de neliniștită modernitate. Bucureștiul se reinventează — neoromânesc, art deco, modernism cubist — într-o cursă pentru identitatea noii Românii Mari.',
story:
'Între războaie, București a construit cu o pasiune rar întâlnită. Stilul neoromânesc al lui Mincu — pridvoare cioplite, ceramică smălțuită, arcade trilobate — căuta o "românitate" arhitecturală. Apoi a venit modernismul: Horia Creangă, Marcel Iancu, Henrieta Delavrancea au ridicat vile cubiste cu ferestre orizontale, terase și balcoane curbate ca punțile unui transatlantic. Multe stau încă, ascunse după garduri de fier forjat.',
facts: [
{ label: 'Arhitecți cheie', value: 'Mincu, Creangă, Iancu, Delavrancea' },
{ label: 'Stiluri', value: 'Neoromânesc, Art Deco, Modernist' },
{ label: 'Câte au rămas', value: '~5000 protejate, mii nemarcate' },
{ label: 'Detaliu signature', value: 'Hublou, terasă, ferestre bandă' },
],
materials: ['Beton armat', 'Cărămidă', 'Marmură', 'Fier forjat'],
palette: { sky: '#f1f7fa', sun: '#fdf6f1', ground: '#cfbf94', accent: '#347697' },
tags: ['#interbelic', '#modernism', '#București'],
},
{
id: 'comunist',
era: '19601989',
region: 'Național',
title: 'Blocul comunist',
subtitle: 'Un acoperiș peste cap pentru douăzeci de milioane',
intro:
'Iubit, urât, omniprezent. Blocul de prefabricate a strâns jumătate din populația țării într-o singură generație. Astăzi îl reciclăm, îl izolăm, îl reinventăm.',
story:
'Industrializarea forțată a mutat țăranii în orașe. Răspunsul: blocuri de panouri prefabricate, ridicate săptămânal, după modele tipizate (T744, OD16, IPCT). Apartamentul de două camere — 47 m², bucătărie de 4 m², debara cât o cabină telefonică — a devenit format standard. Astăzi, acoperit cu polistiren și vopsit roz-galben, se restructurează: fațade ventilate, terase verzi, pereți deschiși între bucătărie și living. Banalul devine teren de proiectare.',
facts: [
{ label: 'Construite', value: '~2.3 milioane apartamente (196589)' },
{ label: 'Format tipic', value: 'Panou prefabricat, 910 etaje' },
{ label: 'Suprafață medie', value: '47 m² (2 cam.) / 67 m² (3 cam.)' },
{ label: 'Reabilitare azi', value: 'Termoizolație + restructurare interior' },
],
materials: ['Beton prefabricat', 'BCA', 'Polistiren', 'Tencuială'],
palette: { sky: '#dbeaf2', sun: '#f1ecdf', ground: '#7e5d36', accent: '#285d79' },
tags: ['#prefabricat', '#urban', '#reabilitare'],
},
{
id: 'contemporan',
era: '2000prezent',
region: 'Național',
title: 'Contemporanul: lemn, sticlă, lumină',
subtitle: 'Locuință care învață din vernacular',
intro:
'O nouă generație de arhitecți români privește înapoi spre maramureș, cula, casa săsească — și înainte, spre Passive House, BIM, lemn lamelar. Rezultatul: case calde, eficiente, ancorate.',
story:
'După 2000, arhitectura românească contemporană a încetat să mai imite. Birouri ca SAMI, ADN, Igloo, Starh sau studiouri tinere din Cluj și București au redescoperit acoperișul în două ape, lemnul aparent, pridvorul. Le-au combinat cu pereți de sticlă pe sud, izolație de 30 cm, panouri solare, ventilație cu recuperare. O casă "tradițională" de azi consumă mai puțin decât un apartament. Patrimoniul nu mai e copiat — e tradus.',
facts: [
{ label: 'Standarde', value: 'Passive House, nZEB, BREEAM' },
{ label: 'Materiale-cheie', value: 'CLT (lemn lamelar), triplu vitraj' },
{ label: 'Consum', value: '15 kWh/m²·an (vs. 150 la blocul vechi)' },
{ label: 'Tehnologii', value: 'BIM, gemeni digitali, prefabricate' },
],
materials: ['CLT', 'Sticlă triplă', 'Beton aparent', 'Țiglă ceramică'],
palette: { sky: '#b6d4e3', sun: '#fae8da', ground: '#bf9b6f', accent: '#8a4226' },
tags: ['#sustenabil', '#BIM', '#contemporan'],
},
];
+203
View File
@@ -0,0 +1,203 @@
---
import '../styles/global.css';
export interface Props {
title?: string;
description?: string;
ogImage?: string;
canonical?: string;
lang?: 'ro' | 'en';
noIndex?: boolean;
}
const {
title = 'Arhitectura României — De la cula la zgârie-nori',
description = 'Șase tipologii arhitecturale care au definit România, povestite în 3D interactiv: casa maramureșeană, cula olteană, casa săsească, vila interbelică, blocul comunist și casa contemporană.',
ogImage = '/og.png',
canonical = Astro.url.href,
lang = 'ro',
noIndex = false,
} = Astro.props;
const siteName = '2D3D — Arhitectura României';
const siteUrl = 'https://2d3d.ro';
const fullTitle = title.includes('2D3D') || title.includes('Arhitectura') ? title : `${title} · ${siteName}`;
const jsonLd = {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'WebSite',
'@id': `${siteUrl}/#website`,
url: siteUrl,
name: siteName,
description,
inLanguage: lang === 'ro' ? 'ro-RO' : 'en-US',
publisher: { '@id': `${siteUrl}/#org` },
},
{
'@type': 'Organization',
'@id': `${siteUrl}/#org`,
name: 'Beletage SRL',
url: 'https://beletage.ro',
logo: `${siteUrl}/beletage-logo.svg`,
sameAs: ['https://beletage.ro'],
description: 'Birou de arhitectură din Cluj-Napoca, autorul acestui proiect de divulgare a patrimoniului arhitectural românesc.',
address: {
'@type': 'PostalAddress',
addressLocality: 'Cluj-Napoca',
addressCountry: 'RO',
},
},
{
'@type': 'Article',
'@id': `${siteUrl}/#article`,
headline: title,
description,
image: `${siteUrl}${ogImage}`,
author: { '@id': `${siteUrl}/#org` },
publisher: { '@id': `${siteUrl}/#org` },
datePublished: '2026-04-20',
dateModified: '2026-04-20',
inLanguage: lang === 'ro' ? 'ro-RO' : 'en-US',
mainEntityOfPage: { '@id': `${siteUrl}/#website` },
},
],
};
---
<!doctype html>
<html lang={lang === 'ro' ? 'ro-RO' : 'en-US'} class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="generator" content={Astro.generator} />
<title>{fullTitle}</title>
<meta name="description" content={description} />
<meta name="theme-color" content="#3a1c10" />
<meta name="color-scheme" content="light" />
{noIndex && <meta name="robots" content="noindex, nofollow" />}
{!noIndex && <meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1" />}
<link rel="canonical" href={canonical} />
<link rel="alternate" hreflang="ro" href={`${siteUrl}/`} />
<link rel="alternate" hreflang="en" href={`${siteUrl}/en/`} />
<link rel="alternate" hreflang="x-default" href={`${siteUrl}/`} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500;9..144,700;9..144,900&family=Inter:wght@400;500;600;700&display=swap"
/>
{/* Open Graph */}
<meta property="og:type" content="website" />
<meta property="og:site_name" content={siteName} />
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={`${siteUrl}${ogImage}`} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="2D3D — Arhitectura României în 3D" />
<meta property="og:locale" content={lang === 'ro' ? 'ro_RO' : 'en_US'} />
{lang === 'ro' && <meta property="og:locale:alternate" content="en_US" />}
{lang === 'en' && <meta property="og:locale:alternate" content="ro_RO" />}
{/* Twitter */}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={fullTitle} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={`${siteUrl}${ogImage}`} />
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
<script>
// Smooth scroll with Lenis (loaded as island)
import('lenis').then(({ default: Lenis }) => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const lenis = new Lenis({ lerp: 0.1, smoothWheel: true });
function raf(time: number) {
lenis.raf(time);
requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
(window as any).__lenis = lenis;
});
</script>
</head>
<body class="bg-bone-50 text-ink-900 antialiased">
<a
href="#main"
class="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:rounded focus:bg-terra-700 focus:px-4 focus:py-2 focus:text-bone-50"
>
Sari la conținut
</a>
<slot name="header">
<!-- header injected by page if needed -->
</slot>
<main id="main">
<slot />
</main>
<footer class="relative mt-32 border-t border-wood-200/60 bg-wood-900 text-bone-100">
<div class="mx-auto max-w-7xl px-6 py-16 lg:px-12 lg:py-20">
<div class="grid gap-12 md:grid-cols-3">
<div>
<p class="font-display text-2xl font-medium text-terra-200">2D3D.ro</p>
<p class="mt-3 text-sm leading-relaxed text-bone-200/80 text-pretty">
Un proiect de divulgare a patrimoniului arhitectural românesc.
De la cula olteană la casa pasivă contemporană — povestite în 3D.
</p>
</div>
<div>
<p class="text-xs font-medium uppercase tracking-[0.2em] text-bone-200/60">Realizat de</p>
<a
href="https://beletage.ro"
rel="dofollow"
class="mt-3 inline-flex items-baseline gap-2 font-display text-2xl text-bone-50 transition hover:text-terra-300"
>
Beletage <span class="text-base text-terra-300">→</span>
</a>
<p class="mt-2 text-sm leading-relaxed text-bone-200/80 text-pretty">
Birou de arhitectură din Cluj-Napoca. Proiectăm case, locuințe colective și
spații publice cu atenție la context, lumină și durabilitate.
<a href="https://beletage.ro" rel="dofollow" class="underline decoration-terra-400 underline-offset-4 hover:text-terra-200">
Vezi portofoliul Beletage →
</a>
</p>
</div>
<div>
<p class="text-xs font-medium uppercase tracking-[0.2em] text-bone-200/60">Surse & inspirație</p>
<ul class="mt-3 space-y-2 text-sm text-bone-200/80">
<li>Institutul Național al Patrimoniului</li>
<li>UNESCO World Heritage Centre</li>
<li>Ordinul Arhitecților din România</li>
<li>Studiouri membre OAR Cluj</li>
</ul>
</div>
</div>
<div class="mt-12 flex flex-col items-start justify-between gap-4 border-t border-bone-200/10 pt-6 text-xs text-bone-200/50 md:flex-row md:items-center">
<p>© 2026 <a href="https://beletage.ro" rel="dofollow" class="underline-offset-2 hover:underline">Beletage SRL</a>. Conținut sub licență CC-BY 4.0.</p>
<div class="flex gap-6">
<a href="/sitemap-index.xml" class="hover:text-bone-50">Sitemap</a>
<a href="/en/" class="hover:text-bone-50">English</a>
<a href="https://beletage.ro/contact" rel="dofollow" class="hover:text-bone-50">Contact Beletage</a>
</div>
</div>
</div>
</footer>
</body>
</html>
+77
View File
@@ -0,0 +1,77 @@
---
import Layout from '../../layouts/Layout.astro';
import Header from '../../components/Header.astro';
---
<Layout
title="Romanian Architecture in 3D — From the Oltenian tower-house to today's skyscraper"
description="Six architectural typologies that shaped how Romanians live — explored in interactive 3D. By Beletage, an architecture office in Cluj-Napoca."
lang="en"
canonical="https://2d3d.ro/en/"
>
<Header slot="header" />
<section class="relative isolate min-h-screen overflow-hidden bg-gradient-to-b from-wood-900 via-wood-800 to-terra-950 text-bone-50">
<div class="absolute inset-0 -z-10 bg-[radial-gradient(ellipse_60%_50%_at_50%_30%,rgba(244,207,177,0.18),transparent_70%)]"></div>
<div class="relative mx-auto flex min-h-screen max-w-6xl flex-col justify-center px-6 pt-32 pb-24 lg:px-12">
<p class="text-sm font-medium uppercase tracking-[0.32em] text-terra-300">Built heritage · 2026</p>
<h1 class="mt-6 max-w-4xl font-display text-5xl font-medium leading-[1.05] tracking-tight sm:text-6xl md:text-7xl title-balance">
Romanian architecture, <span class="shimmer-text">in 3D</span>.
</h1>
<p class="mt-8 max-w-2xl text-lg leading-relaxed text-bone-100/80 sm:text-xl text-balance">
Six typologies that shaped how Romanians live — from the wooden churches of Maramureș
to the panel blocks of late socialism. Interactive low-poly 3D models, short stories, facts.
</p>
<p class="mt-6 max-w-2xl text-base leading-relaxed text-bone-100/60 text-pretty">
The full experience is currently in Romanian. An English version is coming.
Until then, the visuals speak for themselves — head to the
<a href="/" class="underline decoration-terra-400 underline-offset-4 hover:text-bone-50">Romanian homepage</a>.
</p>
<div class="mt-12 flex flex-wrap items-center gap-4">
<a href="/" class="rounded-full bg-terra-500 px-7 py-4 text-base font-medium text-bone-50 hover:bg-terra-400 transition">
Open the experience →
</a>
<a href="https://beletage.ro" rel="dofollow" class="rounded-full border border-bone-50/20 px-6 py-4 text-base text-bone-50 hover:bg-bone-50/5 transition">
About Beletage
</a>
</div>
<dl class="mt-16 grid max-w-2xl grid-cols-3 gap-6 border-t border-bone-50/10 pt-8">
<div>
<dt class="text-xs uppercase tracking-wider text-bone-200/60">Typologies</dt>
<dd class="mt-1 font-display text-3xl text-terra-200">6</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wider text-bone-200/60">Years of history</dt>
<dd class="mt-1 font-display text-3xl text-terra-200">800+</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wider text-bone-200/60">UNESCO sites</dt>
<dd class="mt-1 font-display text-3xl text-terra-200">15</dd>
</div>
</dl>
</div>
</section>
<section class="bg-bone-50 py-24">
<div class="mx-auto max-w-3xl px-6 text-center lg:px-12">
<p class="text-xs font-medium uppercase tracking-[0.3em] text-terra-700">Why this exists</p>
<h2 class="mt-4 font-display text-3xl font-medium sm:text-4xl title-balance">
A small love letter to Romanian built heritage.
</h2>
<p class="mt-6 text-lg leading-relaxed text-ink-800/80 text-pretty">
2D3D.ro is a free, open project by <a href="https://beletage.ro" rel="dofollow" class="underline decoration-terra-500 underline-offset-4 hover:text-terra-700">Beletage</a>,
an architecture office based in Cluj-Napoca. We design houses, residential ensembles and
public spaces — and along the way, we built this site to share what we love about
the buildings of Romania.
</p>
<p class="mt-6">
<a href="https://beletage.ro" rel="dofollow" class="inline-flex items-center gap-2 rounded-full bg-ink-900 px-6 py-3 text-sm font-medium text-bone-50 hover:bg-terra-700 transition">
Visit beletage.ro →
</a>
</p>
</div>
</section>
</Layout>
+31
View File
@@ -0,0 +1,31 @@
---
import Layout from '../layouts/Layout.astro';
import Header from '../components/Header.astro';
import Hero from '../components/Hero.astro';
import Intro from '../components/Intro.astro';
import Typology from '../components/Typology.astro';
import Quiz from '../components/Quiz.astro';
import Beletage from '../components/Beletage.astro';
import { typologies } from '../data/typologies';
---
<Layout
title="Arhitectura României — De la cula olteană la zgârie-norul de azi"
description="Șase tipologii care au definit cum locuiesc românii — casa maramureșeană, cula olteană, casa săsească, vila interbelică, blocul comunist, casa contemporană. Modele 3D interactive, povești, fapte. Realizat de Beletage."
>
<Header slot="header" />
<Hero />
<Intro />
<section id="tipologii" class="relative">
{typologies.map((t, i) => <Typology typology={t} index={i} />)}
</section>
<Quiz />
<Beletage />
</Layout>
<script>
// Bootstrap all 3D scenes for typology canvases
import('../three/bootstrap');
</script>
+137
View File
@@ -0,0 +1,137 @@
@import "tailwindcss";
@theme {
/* Romanian earth & sky palette */
--color-terra-50: #fdf6f1;
--color-terra-100: #fae8da;
--color-terra-200: #f4cfb1;
--color-terra-300: #ecae82;
--color-terra-400: #e08c5a;
--color-terra-500: #d4703f;
--color-terra-600: #b35831;
--color-terra-700: #8a4226;
--color-terra-800: #5e2d1a;
--color-terra-900: #3a1c10;
--color-terra-950: #1f0e08;
--color-wood-50: #f9f5ef;
--color-wood-100: #ede2cf;
--color-wood-200: #d8bf9e;
--color-wood-300: #bf9b6f;
--color-wood-400: #a17a4b;
--color-wood-500: #7e5d36;
--color-wood-600: #604527;
--color-wood-700: #41301a;
--color-wood-800: #261c0f;
--color-wood-900: #110c06;
--color-sky-mist-50: #f1f7fa;
--color-sky-mist-100: #dbeaf2;
--color-sky-mist-200: #b6d4e3;
--color-sky-mist-300: #84b6cd;
--color-sky-mist-400: #5394b3;
--color-sky-mist-500: #347697;
--color-sky-mist-600: #285d79;
--color-sky-mist-700: #1f475c;
--color-sky-mist-800: #142e3c;
--color-sky-mist-900: #0a1820;
--color-bone-50: #faf8f3;
--color-bone-100: #f1ecdf;
--color-bone-200: #e2d8be;
--color-bone-300: #cfbf94;
--color-bone-400: #b9a06a;
--color-ink-900: #15110c;
--color-ink-800: #261f17;
--font-display: "Fraunces", "Georgia", "Times New Roman", serif;
--font-sans: "Inter", "system-ui", sans-serif;
--font-mono: "JetBrains Mono", "Menlo", monospace;
--animate-float: float 6s ease-in-out infinite;
--animate-shimmer: shimmer 2.4s linear infinite;
--animate-fade-up: fade-up 0.8s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-12px); }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes fade-up {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
html {
scroll-behavior: smooth;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
background: linear-gradient(180deg, #faf8f3 0%, #f1ecdf 100%);
color: var(--color-ink-900);
font-family: var(--font-sans);
overflow-x: hidden;
}
::selection {
background: var(--color-terra-500);
color: var(--color-bone-50);
}
.font-display {
font-family: var(--font-display);
font-feature-settings: "ss01", "ss02";
letter-spacing: -0.02em;
}
.title-balance { text-wrap: balance; }
.text-balance { text-wrap: balance; }
.text-pretty { text-wrap: pretty; }
.grain {
position: relative;
isolation: isolate;
}
.grain::after {
content: "";
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.6 0 0 0 0 0.4 0 0 0 0 0.2 0 0 0 0.18 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
opacity: 0.45;
mix-blend-mode: multiply;
pointer-events: none;
z-index: 1;
}
.shimmer-text {
background: linear-gradient(90deg, var(--color-terra-700) 0%, var(--color-terra-400) 50%, var(--color-terra-700) 100%);
background-size: 200% auto;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: var(--animate-shimmer);
}
.canvas-fade-edges {
-webkit-mask-image: radial-gradient(ellipse at center, #000 60%, transparent 95%);
mask-image: radial-gradient(ellipse at center, #000 60%, transparent 95%);
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
+61
View File
@@ -0,0 +1,61 @@
import { createHouseScene, type HouseId, type SceneCtx } from './houses';
const sceneMap = new WeakMap<HTMLCanvasElement, SceneCtx>();
function ensureScene(canvas: HTMLCanvasElement) {
if (sceneMap.has(canvas)) return sceneMap.get(canvas)!;
const id = canvas.dataset.houseId as HouseId;
if (!id) return null;
const ctx = createHouseScene(canvas, id);
const resize = () => {
const r = canvas.getBoundingClientRect();
ctx.setSize(r.width, r.height);
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(canvas);
sceneMap.set(canvas, ctx);
return ctx;
}
function init() {
const canvases = document.querySelectorAll<HTMLCanvasElement>('canvas[data-house-canvas]');
if (!canvases.length) return;
// Use IntersectionObserver to lazy-init scenes only when scrolled into view
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
ensureScene(entry.target as HTMLCanvasElement);
}
}
},
{ rootMargin: '300px 0px' }
);
canvases.forEach((c) => io.observe(c));
// Hide drag hint after first user interaction
document.querySelectorAll<HTMLElement>('[data-typology]').forEach((sec) => {
const canvas = sec.querySelector<HTMLCanvasElement>('canvas[data-house-canvas]');
const hint = sec.querySelector<HTMLElement>('[data-hint]');
if (!canvas || !hint) return;
let hidden = false;
const hide = () => {
if (hidden) return;
hidden = true;
hint.style.transition = 'opacity 0.4s';
hint.style.opacity = '0';
setTimeout(() => hint.remove(), 500);
};
canvas.addEventListener('pointerdown', hide, { once: true });
setTimeout(hide, 6000);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
+529
View File
@@ -0,0 +1,529 @@
import * as THREE from 'three';
export type HouseId = 'maramures' | 'cula' | 'sasesc' | 'interbelic' | 'comunist' | 'contemporan';
const TAU = Math.PI * 2;
function makeRoofMaterial(color: string) {
return new THREE.MeshStandardMaterial({ color, roughness: 0.85, metalness: 0.05, flatShading: true });
}
function makeWallMaterial(color: string) {
return new THREE.MeshStandardMaterial({ color, roughness: 0.95, metalness: 0, flatShading: true });
}
function makeWindowMaterial() {
return new THREE.MeshStandardMaterial({ color: '#fdf6f1', emissive: '#f4cfb1', emissiveIntensity: 0.45, roughness: 0.4 });
}
/* ----------- Maramureș: tall steep-roofed wooden church-like house ----------- */
function buildMaramures(): THREE.Group {
const g = new THREE.Group();
const wood = makeWallMaterial('#5e3a1a');
const darkWood = makeWallMaterial('#3a1c10');
const shingle = makeRoofMaterial('#2d1810');
// Stone foundation
const found = new THREE.Mesh(new THREE.BoxGeometry(2.4, 0.3, 1.6), makeWallMaterial('#b9a06a'));
found.position.y = 0.15;
g.add(found);
// Log walls
for (let i = 0; i < 8; i++) {
const log = new THREE.Mesh(new THREE.BoxGeometry(2.3, 0.15, 1.5), i % 2 ? wood : darkWood);
log.position.y = 0.35 + i * 0.16;
g.add(log);
}
// Pridvor posts
const postGeo = new THREE.CylinderGeometry(0.07, 0.07, 1.6, 8);
for (const x of [-1.05, 1.05]) {
const p = new THREE.Mesh(postGeo, darkWood);
p.position.set(x, 1.05, 0.85);
g.add(p);
}
// Steep gable roof (very tall)
const roofH = 3.2;
const roofGeo = new THREE.ConeGeometry(1.85, roofH, 4, 1);
roofGeo.rotateY(Math.PI / 4);
const roof = new THREE.Mesh(roofGeo, shingle);
roof.position.y = 1.85 + roofH / 2;
roof.scale.set(1, 1, 0.62);
g.add(roof);
// Tiny tower on top
const tower = new THREE.Mesh(new THREE.ConeGeometry(0.15, 0.6, 8), shingle);
tower.position.y = 1.85 + roofH + 0.3;
g.add(tower);
// Door
const door = new THREE.Mesh(new THREE.BoxGeometry(0.55, 1.0, 0.05), darkWood);
door.position.set(0, 0.8, 0.78);
g.add(door);
// Carved sun on door lintel
const sun = new THREE.Mesh(new THREE.RingGeometry(0.12, 0.18, 12), darkWood);
sun.position.set(0, 1.5, 0.79);
g.add(sun);
return g;
}
/* ----------- Cula olteană: tall stone tower house ----------- */
function buildCula(): THREE.Group {
const g = new THREE.Group();
const stone = makeWallMaterial('#e2d8be');
const stoneDark = makeWallMaterial('#cfbf94');
const roof = makeRoofMaterial('#7e5d36');
const woodDark = makeWallMaterial('#4a2818');
// Base stone block
const base = new THREE.Mesh(new THREE.BoxGeometry(2, 1.6, 1.8), stoneDark);
base.position.y = 0.8;
g.add(base);
// Upper floor (slightly inset)
const upper = new THREE.Mesh(new THREE.BoxGeometry(2.2, 1.2, 2), stone);
upper.position.y = 2.2;
g.add(upper);
// Top floor with loggia
const top = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.95, 2), stone);
top.position.y = 3.3;
g.add(top);
// Loggia arches (front)
for (const x of [-0.7, 0, 0.7]) {
const arch = new THREE.Mesh(new THREE.BoxGeometry(0.45, 0.65, 0.3), woodDark);
arch.position.set(x, 3.2, 0.95);
g.add(arch);
}
// Hipped roof
const r = new THREE.Mesh(new THREE.ConeGeometry(1.7, 1, 4, 1), roof);
r.rotateY(Math.PI / 4);
r.scale.set(1, 1, 0.92);
r.position.y = 4.3;
g.add(r);
// Narrow defense windows on lower
for (const y of [0.7, 1.3]) {
for (const x of [-0.6, 0, 0.6]) {
const w = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.25, 0.05), woodDark);
w.position.set(x, y, 0.92);
g.add(w);
}
}
// Door at upper level (defensive entry)
const door = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.85, 0.05), woodDark);
door.position.set(0, 2.05, 1.02);
g.add(door);
return g;
}
/* ----------- Casa săsească: gabled long house perpendicular to street ----------- */
function buildSasesc(): THREE.Group {
const g = new THREE.Group();
const wall = makeWallMaterial('#f1ecdf');
const trim = makeWallMaterial('#d4703f');
const roof = makeRoofMaterial('#8a4226');
const wood = makeWallMaterial('#5e2d1a');
const window = makeWindowMaterial();
// Long body
const body = new THREE.Mesh(new THREE.BoxGeometry(2, 1.7, 4), wall);
body.position.y = 0.85;
g.add(body);
// Gable triangle (front)
const gable = new THREE.Shape();
gable.moveTo(-1, 0);
gable.lineTo(1, 0);
gable.lineTo(0, 1.3);
gable.closePath();
const gableGeo = new THREE.ExtrudeGeometry(gable, { depth: 0.05, bevelEnabled: false });
const gFront = new THREE.Mesh(gableGeo, wall);
gFront.position.set(0, 1.7, 2.0);
g.add(gFront);
const gBack = gFront.clone();
gBack.position.z = -2.05;
g.add(gBack);
// Pitched roof — two planes
const roofPlaneGeo = new THREE.PlaneGeometry(2.35, 4.2);
const angle = Math.atan(1.3 / 1);
const left = new THREE.Mesh(roofPlaneGeo, roof);
left.rotation.x = -Math.PI / 2;
left.rotation.y = -angle;
left.position.set(-0.55, 2.32, 0);
g.add(left);
const right = left.clone();
right.rotation.y = angle;
right.position.x = 0.55;
g.add(right);
// Door on gable
const door = new THREE.Mesh(new THREE.BoxGeometry(0.5, 1.0, 0.06), wood);
door.position.set(0, 0.5, 2.04);
g.add(door);
// Windows symmetric on gable
for (const [x, y] of [[-0.5, 1.1], [0.5, 1.1], [0, 2.4]] as [number, number][]) {
const w = new THREE.Mesh(new THREE.BoxGeometry(0.4, 0.5, 0.06), window);
w.position.set(x, y, 2.04);
g.add(w);
const tr = new THREE.Mesh(new THREE.BoxGeometry(0.46, 0.06, 0.07), trim);
tr.position.set(x, y - 0.3, 2.05);
g.add(tr);
}
// Side windows
for (let i = -1; i <= 1; i++) {
const w = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.5, 0.4), window);
w.position.set(1.02, 1.1, i * 1.1);
g.add(w);
const wL = w.clone();
wL.position.x = -1.02;
g.add(wL);
}
// Chimney
const chim = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.7, 0.3), trim);
chim.position.set(0, 2.6, -1.2);
g.add(chim);
return g;
}
/* ----------- Interbelic: cubist modernist villa with curved balcony ----------- */
function buildInterbelic(): THREE.Group {
const g = new THREE.Group();
const wall = makeWallMaterial('#faf8f3');
const trim = makeWallMaterial('#cfbf94');
const accent = makeWallMaterial('#347697');
const window = makeWindowMaterial();
const dark = makeWallMaterial('#15110c');
// Ground floor
const f1 = new THREE.Mesh(new THREE.BoxGeometry(3, 1.4, 2.4), wall);
f1.position.y = 0.7;
g.add(f1);
// First floor
const f2 = new THREE.Mesh(new THREE.BoxGeometry(3.4, 1.3, 2.4), wall);
f2.position.y = 2.05;
g.add(f2);
// Curved balcony (cylinder section)
const balcony = new THREE.Mesh(
new THREE.CylinderGeometry(0.9, 0.9, 1.3, 24, 1, true, -Math.PI / 2, Math.PI),
wall
);
balcony.position.set(0, 2.05, 1.4);
balcony.scale.set(1, 1, 0.55);
g.add(balcony);
// Balcony rail
const rail = new THREE.Mesh(new THREE.TorusGeometry(0.9, 0.04, 8, 24, Math.PI), dark);
rail.rotation.x = Math.PI / 2;
rail.position.set(0, 2.7, 1.4);
rail.scale.set(1, 0.55, 1);
g.add(rail);
// Flat roof + parapet
const roofTop = new THREE.Mesh(new THREE.BoxGeometry(3.5, 0.1, 2.5), trim);
roofTop.position.y = 2.75;
g.add(roofTop);
// Hublou (porthole) accent
const hublou = new THREE.Mesh(new THREE.RingGeometry(0.16, 0.22, 24), accent);
hublou.position.set(-1.3, 1.9, 1.21);
g.add(hublou);
// Horizontal window bands
for (let i = 0; i < 4; i++) {
const w = new THREE.Mesh(new THREE.BoxGeometry(0.55, 0.55, 0.04), window);
w.position.set(-1.0 + i * 0.65, 0.85, 1.21);
g.add(w);
}
// Upper band
const longW = new THREE.Mesh(new THREE.BoxGeometry(2.4, 0.45, 0.04), window);
longW.position.set(0, 2.15, -1.21);
g.add(longW);
// Door
const door = new THREE.Mesh(new THREE.BoxGeometry(0.5, 1.1, 0.05), accent);
door.position.set(1.1, 0.55, 1.22);
g.add(door);
return g;
}
/* ----------- Comunist: panel block ----------- */
function buildComunist(): THREE.Group {
const g = new THREE.Group();
const wall = makeWallMaterial('#e2d8be');
const panelLine = makeWallMaterial('#a17a4b');
const balcony = makeWallMaterial('#b35831');
const window = makeWindowMaterial();
const floors = 9;
const cols = 5;
const W = 4;
const D = 1.3;
const H = floors * 0.55;
// Main mass
const block = new THREE.Mesh(new THREE.BoxGeometry(W, H, D), wall);
block.position.y = H / 2;
g.add(block);
// Window grid + balconies
for (let f = 0; f < floors; f++) {
for (let c = 0; c < cols; c++) {
const x = -W / 2 + (c + 0.5) * (W / cols);
const y = 0.18 + f * 0.55;
const win = new THREE.Mesh(new THREE.BoxGeometry(W / cols - 0.18, 0.32, 0.04), window);
win.position.set(x, y, D / 2 + 0.01);
g.add(win);
const winB = win.clone();
winB.position.z = -D / 2 - 0.01;
g.add(winB);
// Every other column has tiny balcony
if (c % 2 === 1 && f > 0) {
const bal = new THREE.Mesh(new THREE.BoxGeometry(W / cols - 0.05, 0.12, 0.25), balcony);
bal.position.set(x, y - 0.22, D / 2 + 0.13);
g.add(bal);
}
}
// Horizontal panel line
const line = new THREE.Mesh(new THREE.BoxGeometry(W + 0.04, 0.04, D + 0.04), panelLine);
line.position.y = (f + 1) * 0.55 - 0.275;
g.add(line);
}
// Flat roof + small water tank
const tank = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.4, 0.6), panelLine);
tank.position.set(W / 4, H + 0.2, 0);
g.add(tank);
return g;
}
/* ----------- Contemporary: low-pitch wood + glass ----------- */
function buildContemporan(): THREE.Group {
const g = new THREE.Group();
const wall = makeWallMaterial('#f1ecdf');
const wood = makeWallMaterial('#7e5d36');
const woodLight = makeWallMaterial('#bf9b6f');
const glass = new THREE.MeshPhysicalMaterial({
color: '#84b6cd',
transmission: 0.6,
transparent: true,
opacity: 0.55,
roughness: 0.05,
metalness: 0.1,
clearcoat: 1,
});
const roof = makeRoofMaterial('#3a1c10');
// Concrete base / plinth
const plinth = new THREE.Mesh(new THREE.BoxGeometry(4, 0.25, 2.4), makeWallMaterial('#cfbf94'));
plinth.position.y = 0.125;
g.add(plinth);
// Volume A (white plaster, larger)
const volA = new THREE.Mesh(new THREE.BoxGeometry(2.6, 1.6, 2.2), wall);
volA.position.set(-0.6, 1.05, 0);
g.add(volA);
// Volume B (wood-clad, smaller, offset)
const volB = new THREE.Mesh(new THREE.BoxGeometry(1.8, 1.4, 2.2), woodLight);
volB.position.set(1.3, 0.95, 0);
g.add(volB);
// Wood slat pattern on volume B (front face)
for (let i = -7; i <= 7; i++) {
const slat = new THREE.Mesh(new THREE.BoxGeometry(0.05, 1.35, 0.04), wood);
slat.position.set(1.3 + i * 0.12, 0.95, 1.13);
g.add(slat);
}
// Big glass wall (south facing on volume A)
const glazing = new THREE.Mesh(new THREE.BoxGeometry(2.4, 1.4, 0.04), glass);
glazing.position.set(-0.6, 1.05, 1.12);
g.add(glazing);
// Glass mullions
for (let i = -2; i <= 2; i++) {
const m = new THREE.Mesh(new THREE.BoxGeometry(0.04, 1.4, 0.06), wood);
m.position.set(-0.6 + i * 0.6, 1.05, 1.13);
g.add(m);
}
// Top + bottom mullion
const topM = new THREE.Mesh(new THREE.BoxGeometry(2.4, 0.05, 0.06), wood);
topM.position.set(-0.6, 1.75, 1.13);
g.add(topM);
const botM = topM.clone();
botM.position.y = 0.35;
g.add(botM);
// Low pitch roof on A (single slope)
const roofGeo = new THREE.BoxGeometry(2.7, 0.08, 2.3);
const roofA = new THREE.Mesh(roofGeo, roof);
roofA.position.set(-0.6, 1.92, 0);
roofA.rotation.x = -0.18;
g.add(roofA);
// Flat roof on B with overhang
const roofB = new THREE.Mesh(new THREE.BoxGeometry(2.0, 0.08, 2.4), roof);
roofB.position.set(1.3, 1.7, 0);
g.add(roofB);
// Solar panel rectangle on roof A
const solar = new THREE.Mesh(new THREE.BoxGeometry(1.4, 0.04, 0.9), new THREE.MeshStandardMaterial({ color: '#15110c', metalness: 0.4, roughness: 0.3 }));
solar.position.set(-0.6, 2.0, -0.4);
solar.rotation.x = -0.18;
g.add(solar);
return g;
}
const builders: Record<HouseId, () => THREE.Group> = {
maramures: buildMaramures,
cula: buildCula,
sasesc: buildSasesc,
interbelic: buildInterbelic,
comunist: buildComunist,
contemporan: buildContemporan,
};
export function buildHouse(id: HouseId): THREE.Group {
return builders[id]();
}
export type SceneCtx = {
renderer: THREE.WebGLRenderer;
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
house: THREE.Group;
destroy: () => void;
setSize: (w: number, h: number) => void;
};
export function createHouseScene(canvas: HTMLCanvasElement, id: HouseId, opts?: { background?: string; autoRotate?: boolean }): SceneCtx {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setPixelRatio(dpr);
renderer.setClearColor(0x000000, 0);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
const scene = new THREE.Scene();
if (opts?.background) scene.background = new THREE.Color(opts.background);
const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 100);
camera.position.set(6, 4.2, 7);
camera.lookAt(0, 1.6, 0);
// Lighting — warm key + cool fill + rim
const hemi = new THREE.HemisphereLight(0xfdf6f1, 0x3a1c10, 0.6);
scene.add(hemi);
const key = new THREE.DirectionalLight(0xffeacc, 1.4);
key.position.set(5, 8, 5);
scene.add(key);
const fill = new THREE.DirectionalLight(0x84b6cd, 0.45);
fill.position.set(-6, 4, -3);
scene.add(fill);
const rim = new THREE.DirectionalLight(0xffffff, 0.4);
rim.position.set(0, 3, -8);
scene.add(rim);
// Ground disc
const ground = new THREE.Mesh(
new THREE.CircleGeometry(8, 48),
new THREE.MeshStandardMaterial({ color: '#ede2cf', roughness: 1, metalness: 0 })
);
ground.rotation.x = -Math.PI / 2;
ground.position.y = 0;
scene.add(ground);
const house = builders[id]();
scene.add(house);
// Soft shadow circle under house
const shadow = new THREE.Mesh(
new THREE.CircleGeometry(2.2, 32),
new THREE.MeshBasicMaterial({ color: 0x261f17, transparent: true, opacity: 0.18 })
);
shadow.rotation.x = -Math.PI / 2;
shadow.position.y = 0.005;
scene.add(shadow);
let raf = 0;
const clock = new THREE.Clock();
let scrollAdd = 0;
function tick() {
const t = clock.getElapsedTime();
if (opts?.autoRotate !== false) {
house.rotation.y = t * 0.25 + scrollAdd;
}
renderer.render(scene, camera);
raf = requestAnimationFrame(tick);
}
tick();
function setSize(w: number, h: number) {
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
// Pointer interaction — drag to rotate
let dragging = false;
let lastX = 0;
const onDown = (e: PointerEvent) => {
dragging = true;
lastX = e.clientX;
canvas.setPointerCapture(e.pointerId);
};
const onMove = (e: PointerEvent) => {
if (!dragging) return;
const dx = e.clientX - lastX;
lastX = e.clientX;
scrollAdd += dx * 0.01;
};
const onUp = (e: PointerEvent) => {
dragging = false;
try { canvas.releasePointerCapture(e.pointerId); } catch {}
};
canvas.addEventListener('pointerdown', onDown);
canvas.addEventListener('pointermove', onMove);
canvas.addEventListener('pointerup', onUp);
canvas.addEventListener('pointercancel', onUp);
return {
renderer,
scene,
camera,
house,
setSize,
destroy() {
cancelAnimationFrame(raf);
canvas.removeEventListener('pointerdown', onDown);
canvas.removeEventListener('pointermove', onMove);
canvas.removeEventListener('pointerup', onUp);
canvas.removeEventListener('pointercancel', onUp);
renderer.dispose();
scene.traverse((o) => {
const m = o as THREE.Mesh;
if (m.geometry) m.geometry.dispose();
const mat = (m as any).material;
if (Array.isArray(mat)) mat.forEach((x: any) => x.dispose && x.dispose());
else if (mat?.dispose) mat.dispose();
});
},
};
}
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}