feat: add BufferGeometryUtils for advanced geometry manipulation
continuous-integration/drone/push Build is passing
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:
+42
-33
@@ -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é';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user