diff --git a/frontend/scripts/generate-falukant-house-models.mjs b/frontend/scripts/generate-falukant-house-models.mjs new file mode 100644 index 0000000..40ca4f9 --- /dev/null +++ b/frontend/scripts/generate-falukant-house-models.mjs @@ -0,0 +1,187 @@ +#!/usr/bin/env node +/** + * Creates the nine small, dependency-free Falukant house source models. + * The generated GLBs are intentionally low-poly; deployment optimizes them + * further with the regular Draco pipeline. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as THREE from 'three'; +import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'; + +// GLTFExporter uses the browser FileReader API for binary output. Node has +// Blob but not FileReader, so provide the tiny compatible subset we need. +if (typeof globalThis.FileReader === 'undefined') { + globalThis.FileReader = class FileReader { + result = null; + onloadend = null; + + readAsArrayBuffer(blob) { + blob.arrayBuffer().then((result) => { + this.result = result; + this.onloadend?.(); + }); + } + }; +} + +const here = path.dirname(fileURLToPath(import.meta.url)); +const outputDir = path.resolve(process.argv[2] || path.join(here, '..', 'models-src', '3d', 'falukant', 'houses')); +fs.mkdirSync(outputDir, { recursive: true }); + +const colors = { + plaster: 0xe9d7b5, + wood: 0x8a593b, + darkWood: 0x533526, + roof: 0x9b4635, + straw: 0xcba75b, + stone: 0x9a958b, + window: 0x75a8b7, + door: 0x4a3024, + grass: 0x78965a, +}; + +function material(color) { + return new THREE.MeshStandardMaterial({ color, roughness: 0.82, metalness: 0 }); +} + +function box(group, width, height, depth, color, x = 0, y = height / 2, z = 0) { + const mesh = new THREE.Mesh(new THREE.BoxGeometry(width, height, depth), material(color)); + mesh.position.set(x, y, z); + group.add(mesh); + return mesh; +} + +function cylinder(group, radiusTop, radiusBottom, height, color, x = 0, y = height / 2, z = 0, segments = 6) { + const mesh = new THREE.Mesh(new THREE.CylinderGeometry(radiusTop, radiusBottom, height, segments), material(color)); + mesh.position.set(x, y, z); + group.add(mesh); + return mesh; +} + +function roof(group, width, depth, baseY, color, x = 0, z = 0) { + const mesh = new THREE.Mesh(new THREE.ConeGeometry(Math.max(width, depth) * 0.78, 1.15, 4), material(color)); + mesh.rotation.y = Math.PI / 4; + mesh.scale.set(width / Math.max(width, depth), 1, depth / Math.max(width, depth)); + mesh.position.set(x, baseY + 0.575, z); + group.add(mesh); +} + +function doorAndWindows(group, { width, height, z = 0.511, offset = 0 }) { + box(group, 0.38, height * 0.48, 0.035, colors.door, offset, height * 0.24, z); + for (const side of [-1, 1]) box(group, 0.28, 0.3, 0.035, colors.window, offset + side * width * 0.29, height * 0.62, z); +} + +function base(name) { + const group = new THREE.Group(); + group.name = name; + box(group, 4.8, 0.16, 4.4, colors.grass, 0, 0.08, 0); + return group; +} + +function underBridge() { + const g = base('under_bridge'); + box(g, 4.4, 0.24, 1.15, colors.stone, 0, 2.5, 0); + for (const x of [-1.65, 1.65]) cylinder(g, 0.28, 0.38, 2.4, colors.stone, x, 1.28, 0, 6); + box(g, 1.45, 1.0, 1.65, colors.wood, 0, 0.58, 0.45); + roof(g, 1.8, 2.0, 1.1, colors.straw, 0, 0.45); + return g; +} + +function strawHut() { + const g = base('straw_hut'); + cylinder(g, 1.34, 1.48, 1.18, colors.wood, 0, 0.67, 0, 8); + const roofMesh = new THREE.Mesh(new THREE.ConeGeometry(1.8, 1.2, 8), material(colors.straw)); + roofMesh.position.y = 1.78; + g.add(roofMesh); + box(g, 0.42, 0.62, 0.04, colors.door, 0, 0.38, 1.48); + return g; +} + +function woodenHouse() { + const g = base('wooden_house'); + box(g, 2.55, 1.48, 2.12, colors.wood, 0, 0.82, 0); + roof(g, 2.9, 2.45, 1.55, colors.darkWood); + doorAndWindows(g, { width: 2.55, height: 1.48, z: 1.078 }); + return g; +} + +function backyardRoom() { + const g = base('backyard_room'); + box(g, 1.65, 1.12, 1.5, colors.plaster, 0, 0.64, 0); + box(g, 1.86, 0.16, 1.7, colors.darkWood, 0, 1.28, 0); + doorAndWindows(g, { width: 1.65, height: 1.12, z: 0.77 }); + return g; +} + +function familyHouse() { + const g = base('family_house'); + box(g, 3.15, 2.05, 2.5, colors.plaster, 0, 1.1, 0); + roof(g, 3.55, 2.9, 2.1, colors.roof); + doorAndWindows(g, { width: 3.15, height: 2.05, z: 1.27 }); + return g; +} + +function townhouse() { + const g = base('townhouse'); + box(g, 2.0, 3.05, 1.8, colors.plaster, 0, 1.6, 0); + box(g, 2.18, 0.16, 1.96, colors.roof, 0, 3.17, 0); + doorAndWindows(g, { width: 2.0, height: 1.45, z: 0.92, offset: 0 }); + for (const y of [1.86, 2.53]) { + for (const x of [-0.5, 0.5]) box(g, 0.3, 0.32, 0.04, colors.window, x, y, 0.92); + } + return g; +} + +function villa() { + const g = base('villa'); + box(g, 3.7, 1.75, 2.5, colors.plaster, 0, 0.95, 0); + box(g, 1.3, 1.3, 1.9, colors.plaster, -2.0, 0.72, 0.1); + roof(g, 4.05, 2.9, 1.8, colors.roof); + roof(g, 1.55, 2.2, 1.35, colors.roof, -2.0, 0.1); + doorAndWindows(g, { width: 3.7, height: 1.75, z: 1.27 }); + return g; +} + +function mansion() { + const g = base('mansion'); + box(g, 4.1, 2.75, 2.75, colors.plaster, 0, 1.45, 0); + roof(g, 4.55, 3.15, 2.8, colors.roof); + for (const x of [-1.55, 1.55]) cylinder(g, 0.48, 0.52, 3.2, colors.stone, x, 1.7, 0, 6); + for (const x of [-1.55, 1.55]) { + const cap = new THREE.Mesh(new THREE.ConeGeometry(0.67, 0.75, 6), material(colors.roof)); + cap.position.set(x, 3.68, 0); + g.add(cap); + } + doorAndWindows(g, { width: 4.1, height: 2.1, z: 1.395 }); + return g; +} + +function castle() { + const g = base('castle'); + box(g, 3.2, 3.0, 2.6, colors.stone, 0, 1.58, 0); + for (const x of [-1.75, 1.75]) { + for (const z of [-1.35, 1.35]) cylinder(g, 0.48, 0.55, 3.75, colors.stone, x, 1.96, z, 6); + } + for (const x of [-1.75, 1.75]) { + for (const z of [-1.35, 1.35]) { + const cap = new THREE.Mesh(new THREE.ConeGeometry(0.72, 0.85, 6), material(colors.roof)); + cap.position.set(x, 4.27, z); + g.add(cap); + } + } + box(g, 0.64, 1.2, 0.04, colors.door, 0, 0.68, 1.32); + return g; +} + +const models = { under_bridge: underBridge, straw_hut: strawHut, wooden_house: woodenHouse, backyard_room: backyardRoom, family_house: familyHouse, townhouse, villa, mansion, castle }; +const exporter = new GLTFExporter(); + +for (const [name, make] of Object.entries(models)) { + const scene = new THREE.Scene(); + scene.add(make()); + const binary = await new Promise((resolve, reject) => exporter.parse(scene, resolve, reject, { binary: true, onlyVisible: true })); + fs.writeFileSync(path.join(outputDir, `${name}.glb`), Buffer.from(binary)); + console.log(`Created ${name}.glb`); +}