feat: add BufferGeometryUtils for advanced geometry manipulation
continuous-integration/drone/push Build is passing

- Implemented computeMikkTSpaceTangents for consistent tangent generation.
- Added mergeGeometries to combine multiple BufferGeometries.
- Introduced mergeAttributes for merging BufferAttributes.
- Created deepCloneAttribute for cloning BufferAttributes.
- Developed interleaveAttributes and deinterleaveAttribute for attribute management.
- Added estimateBytesUsed to calculate memory usage of geometries.
- Implemented mergeVertices to optimize vertex data.
- Created toTrianglesDrawMode for converting geometries to triangle mode.
- Added computeMorphedAttributes for handling morph targets in geometries.
- Implemented mergeGroups to consolidate geometry groups.
- Developed toCreasedNormals for generating smooth normals with crease angles.
This commit is contained in:
martin.fencl
2026-11-02 13:38:01 +01:00
parent d01bd43f6b
commit f0a6480c86
18 changed files with 84615 additions and 47 deletions
+42 -33
View File
@@ -1,3 +1,4 @@
import { installInterior } from './interior-view.js';
let THREE, OrbitControls, GLTFExporter, solarPosition;
try {
const modules = await Promise.all([
@@ -14,7 +15,7 @@ try {
const loading = document.querySelector('#loading');
if (loading) {
loading.classList.add('error');
loading.innerHTML = `3D model se nepodařilo načíst.<br>${error.message}<br><small>Spusť web přes místní server a zkontroluj připojení k CDN Three.js.</small>`;
loading.innerHTML = `3D model se nepodařilo načíst.<br>${error.message}<br><small>Spusť web přes místní server a ověř přítomnost složky vendor/three.</small>`;
}
throw error;
}
@@ -43,7 +44,7 @@ const solar = {
const scene = new THREE.Scene();
scene.background = new THREE.Color('#e7ebe3');
const camera = new THREE.PerspectiveCamera(36, 1, 0.1, 220);
let camera = new THREE.PerspectiveCamera(36, 1, 0.1, 220);
camera.position.set(28, 21, 36);
let renderer;
@@ -100,6 +101,11 @@ const boundaryGroup = new THREE.Group(); boundaryGroup.name = 'Orientační hran
const parkingGroup = new THREE.Group(); parkingGroup.name = 'Příjezd a parkování'; modelRoot.add(parkingGroup);
const houseGroup = new THREE.Group(); houseGroup.name = 'Rodinný dům – pracovní hmota'; houseGroup.position.set(0, 0, 8.4); modelRoot.add(houseGroup);
const shellGroup = new THREE.Group(); shellGroup.name = 'Obvodové stěny a otvory'; houseGroup.add(shellGroup);
const roofGroup = new THREE.Group(); roofGroup.name = 'Střecha, podbití a komín'; houseGroup.add(roofGroup);
const roofTimber = new THREE.Group(); roofTimber.name = 'Trámy krytých ploch'; houseGroup.add(roofTimber);
let interior = null;
const mats = {
grass: new THREE.MeshStandardMaterial({ color: 0x9ba78f, roughness: 1 }),
road: new THREE.MeshStandardMaterial({ color: 0x676d6b, roughness: 0.98 }),
@@ -301,7 +307,7 @@ function makeWallEdge(p1, p2, openings = []) {
const wallSegment = (from, to, bottom, top, name = 'Obvodová stěna') => {
if (to - from < 0.02 || top - bottom < 0.02) return;
const [x, z] = positionAt((from + to) / 2);
box(houseGroup, name, x, (bottom + top) / 2, z, to - from, top - bottom, thickness, mats.wall, angle);
box(shellGroup, name, x, (bottom + top) / 2, z, to - from, top - bottom, thickness, mats.wall, angle);
};
const ordered = [...openings].sort((a, b) => a.center - b.center);
let cursor = 0;
@@ -317,21 +323,21 @@ function makeWallEdge(p1, p2, openings = []) {
const centerY = opening.bottom + (top - opening.bottom) / 2;
const paneMaterial = opening.kind === 'door' ? mats.door : mats.glass;
const paneName = opening.kind === 'door' ? 'Vstupní dveře – zjednodušené' : opening.kind === 'glazedDoor' ? 'Prosklené dveře – zjednodušené' : 'Okno – zjednodušené';
box(houseGroup, paneName, x, centerY, z, right - left - 0.10, Math.max(0.12, top - opening.bottom - 0.10), 0.055, paneMaterial, angle);
box(shellGroup, paneName, x, centerY, z, right - left - 0.10, Math.max(0.12, top - opening.bottom - 0.10), 0.055, paneMaterial, angle);
const frameWidth = 0.055;
const jambHeight = top - opening.bottom;
for (const offset of [-1, 1]) {
const [jx, jz] = positionAt((left + right) / 2 + offset * (right - left - frameWidth) / 2);
box(houseGroup, 'Rám otvoru', jx, opening.bottom + jambHeight / 2, jz, frameWidth, jambHeight, 0.09, mats.frame, angle);
box(shellGroup, 'Rám otvoru', jx, opening.bottom + jambHeight / 2, jz, frameWidth, jambHeight, 0.09, mats.frame, angle);
}
for (let pane = 1; pane < (opening.panes || 1); pane++) {
const offset = (pane / opening.panes - 0.5) * (right - left);
const mullionX = x + dx / length * offset;
const mullionZ = z + dz / length * offset;
box(houseGroup, 'Svislý rám okna', mullionX, opening.bottom + jambHeight / 2, mullionZ, frameWidth, jambHeight, 0.09, mats.frame, angle);
box(shellGroup, 'Svislý rám okna', mullionX, opening.bottom + jambHeight / 2, mullionZ, frameWidth, jambHeight, 0.09, mats.frame, angle);
}
box(houseGroup, 'Horní rám otvoru', x, top - frameWidth / 2, z, right - left, frameWidth, 0.09, mats.frame, angle);
if (opening.bottom > 0) box(houseGroup, 'Parapet okna', x, opening.bottom + frameWidth / 2, z, right - left, frameWidth, 0.09, mats.frame, angle);
box(shellGroup, 'Horní rám otvoru', x, top - frameWidth / 2, z, right - left, frameWidth, 0.09, mats.frame, angle);
if (opening.bottom > 0) box(shellGroup, 'Parapet okna', x, opening.bottom + frameWidth / 2, z, right - left, frameWidth, 0.09, mats.frame, angle);
cursor = right;
}
wallSegment(cursor, length, wallBottom, height);
@@ -364,7 +370,7 @@ function roofFace(name, outline, distance) {
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.setIndex(triangles.flat());
geometry.computeVertexNormals();
return mesh(geometry, mats.roof, houseGroup, name);
return mesh(geometry, mats.roof, roofGroup, name);
}
function buildRoof() {
@@ -389,21 +395,21 @@ function buildRoof() {
.map(([px, py]) => planPoint(px, py));
const soffitGeometry = new THREE.ShapeGeometry(new THREE.Shape(eaves.map(([x, z]) => new THREE.Vector2(x, z))));
soffitGeometry.rotateX(Math.PI / 2);
const soffit = mesh(soffitGeometry, mats.soffit, houseGroup, 'Podbití střechy');
const soffit = mesh(soffitGeometry, mats.soffit, roofGroup, 'Podbití střechy');
soffit.position.y = SOFFIT;
soffit.castShadow = false;
for (let i = 0; i < eaves.length; i++) {
const [ax, az] = eaves[i], [bx, bz] = eaves[(i + 1) % eaves.length];
box(houseGroup, 'Čelní prkno a okap', (ax + bx) / 2, (SOFFIT + EAVE) / 2, (az + bz) / 2,
box(roofGroup, 'Čelní prkno a okap', (ax + bx) / 2, (SOFFIT + EAVE) / 2, (az + bz) / 2,
Math.hypot(bx - ax, bz - az) + 0.06, EAVE - SOFFIT + 0.04, 0.06, mats.frame, Math.atan2(-(bz - az), bx - ax));
}
// The chimney stands over the stove flue (plan 7.58 / 4.76); its top at +6.500 is 1.255 m above the roof on the low side.
const [chimneyX, chimneyZ] = planPoint(7.582, 4.763);
const roofAt = (x, z) => roofHeight(z + 6.525, 8.7 - x);
slopedBox(houseGroup, 'Oplechování komína', chimneyX, chimneyZ, 0.5, 0.5, roofAt, (x, z) => roofAt(x, z) + 0.06, mats.metal);
slopedBox(houseGroup, 'Komín', chimneyX, chimneyZ, 0.36, 0.36, (x, z) => roofAt(x, z) + 0.04, () => 6.38, mats.foundation);
box(houseGroup, 'Hlavice komína', chimneyX, 6.44, chimneyZ, 0.46, 0.12, 0.46, mats.frame);
slopedBox(roofGroup, 'Oplechování komína', chimneyX, chimneyZ, 0.5, 0.5, roofAt, (x, z) => roofAt(x, z) + 0.06, mats.metal);
slopedBox(roofGroup, 'Komín', chimneyX, chimneyZ, 0.36, 0.36, (x, z) => roofAt(x, z) + 0.04, () => 6.38, mats.foundation);
box(roofGroup, 'Hlavice komína', chimneyX, 6.44, chimneyZ, 0.46, 0.12, 0.46, mats.frame);
}
function timberPost(parent, px, py, base) {
@@ -412,7 +418,7 @@ function timberPost(parent, px, py, base) {
}
function timberBeam(parent, from, to) {
const [ax, az] = planPoint(...from), [bx, bz] = planPoint(...to);
box(parent, 'Trám', (ax + bx) / 2, SOFFIT - 0.1, (az + bz) / 2, Math.hypot(bx - ax, bz - az), 0.2, 0.14, mats.timber, Math.atan2(-(bz - az), bx - ax));
box(roofTimber, 'Trám', (ax + bx) / 2, SOFFIT - 0.1, (az + bz) / 2, Math.hypot(bx - ax, bz - az), 0.2, 0.14, mats.timber, Math.atan2(-(bz - az), bx - ax));
}
function buildHouse() {
@@ -460,18 +466,8 @@ buildGround();
buildHouse();
function viewportSize() {
let w = window.innerWidth, h = window.innerHeight;
const infoOpen = document.body.classList.contains('info-open');
const sunOpen = document.body.classList.contains('sun-open');
if (w <= 850 && (infoOpen || sunOpen)) {
const panelHeight = Math.min((infoOpen ? 0.72 : 0.68) * h, 570);
h = Math.max(120, h - panelHeight - 16);
} else if (sunOpen) {
w = Math.max(240, w - 380);
} else if (infoOpen) {
w = Math.max(240, w - 390);
}
return [w, h];
const rect = canvas.getBoundingClientRect();
return [Math.max(1, rect.width), Math.max(1, rect.height)];
}
function resize() {
const [w, h] = viewportSize();
@@ -479,6 +475,7 @@ function resize() {
camera.updateProjectionMatrix();
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(Math.round(w), Math.round(h), false);
interior?.resize();
}
window.addEventListener('resize', resize);
resize();
@@ -503,6 +500,7 @@ const presets = {
top: { position: [0.8, 90, -1], target: [0.8, 0.1, -1], label: 'Pozemek / shora' }
};
function setPreset(name) {
if (interior?.enabled) interior.exit();
const p = presets[name] || presets.entry;
camera.position.fromArray(p.position);
controls.target.fromArray(p.target);
@@ -790,13 +788,20 @@ async function saveSunImage() {
$('#sunSavePng').addEventListener('click', saveSunImage);
$('#savePng').addEventListener('click', saveSunImage);
$('#saveGlb').addEventListener('click', () => {
let exporting = false;
function exportGlb() {
if (exporting) return;
exporting = true;
const button = $('#saveGlb'); button.disabled = true;
const exporter = new GLTFExporter();
modelRoot.updateMatrixWorld(true);
exporter.parse(modelRoot, (result) => {
saveBlob(new Blob([result], { type: 'model/gltf-binary' }), 'klabava-pracovni-model.glb');
saveBlob(new Blob([result], { type: 'model/gltf-binary' }), interior?.enabled ? 'klabava-interier.glb' : 'klabava-pracovni-model.glb');
toast('Model Klabava GLB je připravený ke stažení.');
}, (error) => { console.error(error); toast('Model se nepodařilo exportovat.'); }, { binary: true, onlyVisible: true, trs: false });
});
exporting = false; button.disabled = false;
}, (error) => { console.error(error); toast('Model se nepodařilo exportovat.'); exporting = false; button.disabled = false; }, { binary: true, onlyVisible: true, trs: false });
}
$('#saveGlb').addEventListener('click', exportGlb);
function drawSunPath() {
const svg = $('#sunOverlay');
@@ -850,7 +855,8 @@ function animate(now = 0) {
const dt = Math.min(0.1, Math.max(0, (now - (animate.last || now)) / 1000));
animate.last = now;
solarFrame(dt);
controls.update();
if (!interior?.walking) controls.update();
interior?.frame(dt);
const cameraHeading = Math.atan2(camera.position.x - controls.target.x, camera.position.z - controls.target.z) / RAD;
// North is +z, so a camera on the north side looking south shows north at the bottom of the compass.
$('#compassGroup').setAttribute('transform', `rotate(${cameraHeading + 180},42,42)`);
@@ -859,10 +865,12 @@ function animate(now = 0) {
}
window.addEventListener('keydown', event => {
if (interior?.enabled) { interior.keydown(event); return; }
if (event.key === 'Escape') { toggleInfo(false); toggleSun(false); }
if (['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON', 'SUMMARY'].includes(event.target.tagName)) return;
if (event.code === 'Space') { event.preventDefault(); setSolarPlaying(!solar.playing); }
if (event.key.toLowerCase() === 's') toggleSun();
if (event.key.toLowerCase() === 'i') interior?.enter();
if (event.key === '1') setPreset('entry');
if (event.key === '2') setPreset('garden');
if (event.key === '3') setPreset('top');
@@ -878,6 +886,7 @@ window.SUN_SIM = {
};
try {
interior = installInterior({THREE,houseGroup,modelRoot,shellGroup,roofGroup,roofTimber,controls,scene,renderer,planPoint,box,mesh,group,groundGroup,parkingGroup,boundaryGroup,resize,saveBlob,exportGlb,solar,setSolarPlaying,toggleInfo,toggleSun,getCamera:()=>camera,setCamera:value=>{camera=value;}});
rebuildSolarDay(false);
$('#sunLat').value = site.latitude; $('#sunLon').value = site.longitude; $('#siteBearing').value = site.bearing;
toggleSun(false); toggleInfo(false);
@@ -887,6 +896,6 @@ try {
} catch (error) {
console.error(error);
loading.classList.add('error');
loading.innerHTML = `3D model se nepodařilo načíst.<br>${error.message}<br><small>Spusť web přes místní server a zkontroluj připojení k CDN Three.js.</small>`;
loading.innerHTML = `3D model se nepodařilo načíst.<br>${error.message}<br><small>Spusť web přes místní server a ověř přítomnost složky vendor/three.</small>`;
$('#webStatus').textContent = '3D není dostupné';
}