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:
+24
@@ -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/
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["astro-build.astro-vscode"],
|
||||||
|
"unwantedRecommendations": []
|
||||||
|
}
|
||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"command": "./node_modules/.bin/astro dev",
|
||||||
|
"name": "Development server",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "node-terminal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+5527
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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 |
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://2d3d.ro/sitemap-index.xml
|
||||||
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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. XVII–XIX',
|
||||||
|
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. XVII–XIX',
|
||||||
|
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 80–120 cm' },
|
||||||
|
{ label: 'Etaje', value: '2–3, 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. XII–XIX',
|
||||||
|
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: '1920–1940',
|
||||||
|
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: '1960–1989',
|
||||||
|
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 (1965–89)' },
|
||||||
|
{ label: 'Format tipic', value: 'Panou prefabricat, 9–10 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: '2000–prezent',
|
||||||
|
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'],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"include": [".astro/types.d.ts", "**/*"],
|
||||||
|
"exclude": ["dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user