#!/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`); }