web-3Dviever-glb/index.html (lines 1–1331)
- Repository
- web-3Dviever-glb
- Path
web-3Dviever-glb/index.html
<!doctype html>
<html lang="pl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Scaniverse Model Viewer</title>
<link rel="icon" href="data:," />
<style>
html,
body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #1b1b1b;
font-family: Arial, sans-serif;
}
canvas {
display: block;
width: 100%;
height: 100%;
outline: none;
touch-action: none;
}
#status {
position: fixed;
top: 16px;
left: 16px;
z-index: 20;
min-width: 230px;
max-width: 470px;
padding: 11px 14px;
color: #ffffff;
background: rgba(0, 0, 0, 0.72);
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 8px;
font-size: 14px;
line-height: 1.45;
white-space: pre-line;
pointer-events: none;
}
#controls-info {
position: fixed;
left: 16px;
bottom: 16px;
z-index: 20;
padding: 9px 12px;
color: rgba(255, 255, 255, 0.84);
background: rgba(0, 0, 0, 0.58);
border-radius: 7px;
font-size: 12px;
line-height: 1.45;
pointer-events: none;
}
#reset-camera {
position: fixed;
top: 16px;
right: 16px;
z-index: 20;
padding: 9px 13px;
color: #ffffff;
background: rgba(0, 0, 0, 0.7);
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 7px;
cursor: pointer;
font-size: 13px;
}
#model-picker {
position: fixed;
top: 16px;
right: 140px;
z-index: 20;
display: flex;
gap: 8px;
align-items: center;
padding: 8px;
background: rgba(0, 0, 0, 0.7);
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 7px;
}
#model-select,
#load-model {
height: 32px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.22);
color: #ffffff;
background: rgba(0, 0, 0, 0.78);
font-size: 13px;
}
#model-select {
min-width: 230px;
padding: 0 8px;
}
#load-model {
padding: 0 12px;
cursor: pointer;
}
#reset-camera:hover,
#load-model:hover {
background: rgba(50, 50, 50, 0.9);
}
#reset-camera:disabled,
#load-model:disabled,
#model-select:disabled,
.mobile-zoom-button:disabled {
opacity: 0.5;
cursor: default;
}
#mobile-zoom,
#mobile-controls-info {
display: none;
}
#mobile-zoom {
position: fixed;
right: max(12px, env(safe-area-inset-right));
bottom: calc(84px + env(safe-area-inset-bottom));
z-index: 24;
flex-direction: column;
gap: 10px;
}
.mobile-zoom-button {
width: 54px;
height: 54px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 50%;
color: #ffffff;
background: rgba(0, 0, 0, 0.72);
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.3);
font-size: 30px;
font-weight: 300;
line-height: 1;
cursor: pointer;
touch-action: manipulation;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
}
.mobile-zoom-button:active {
transform: scale(0.94);
background: rgba(55, 55, 55, 0.92);
}
#mobile-controls-info {
position: fixed;
left: max(12px, env(safe-area-inset-left));
right: 78px;
bottom: calc(86px + env(safe-area-inset-bottom));
z-index: 20;
max-width: 330px;
padding: 8px 10px;
color: rgba(255, 255, 255, 0.84);
background: rgba(0, 0, 0, 0.58);
border-radius: 7px;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
body.mobile-device #mobile-zoom {
display: flex;
}
body.mobile-device #mobile-controls-info {
display: block;
}
@media (max-width: 760px), (pointer: coarse) {
#status {
top: calc(54px + env(safe-area-inset-top));
left: max(10px, env(safe-area-inset-left));
right: max(10px, env(safe-area-inset-right));
min-width: 0;
max-width: none;
padding: 9px 11px;
font-size: 12px;
}
#reset-camera {
top: max(10px, env(safe-area-inset-top));
right: max(10px, env(safe-area-inset-right));
padding: 8px 10px;
font-size: 12px;
}
#model-picker {
top: auto;
left: max(10px, env(safe-area-inset-left));
right: max(10px, env(safe-area-inset-right));
bottom: max(10px, env(safe-area-inset-bottom));
gap: 6px;
padding: 6px;
}
#model-select {
min-width: 0;
flex: 1;
font-size: 12px;
}
#load-model {
padding: 0 10px;
font-size: 12px;
}
#controls-info {
display: none;
}
}
@media (max-width: 430px) {
#mobile-controls-info {
max-width: 230px;
font-size: 10px;
}
.mobile-zoom-button {
width: 50px;
height: 50px;
}
}
</style>
</head>
<body>
<div id="status">Uruchamianie viewera...</div>
<button id="reset-camera" type="button">
Reset kamery
</button>
<div id="model-picker">
<select id="model-select"></select>
<button id="load-model" type="button">Załaduj</button>
</div>
<div id="controls-info">
Lewy przycisk: obrót wokół punktu<br />
Prawy przycisk: przesuwanie<br />
Oba przyciski: rozglądanie się w miejscu<br />
Rolka: przybliżanie<br />
Strzałki: ruch widza<br />
Dwuklik: przelot do miejsca
</div>
<div id="mobile-controls-info">
1 palec: obrót • 2 palce: przesuwanie<br />
Dwukrotne stuknięcie: przelot do miejsca
</div>
<div id="mobile-zoom" aria-label="Przybliżanie modelu">
<button
id="zoom-in"
class="mobile-zoom-button"
type="button"
aria-label="Przybliż"
title="Przybliż"
>+</button>
<button
id="zoom-out"
class="mobile-zoom-button"
type="button"
aria-label="Oddal"
title="Oddal"
>−</button>
</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/",
"@sparkjsdev/spark": "https://sparkjs.dev/releases/spark/2.1.0/spark.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { PLYLoader } from "three/addons/loaders/PLYLoader.js";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { SparkRenderer, SplatMesh } from "@sparkjsdev/spark";
const statusElement = document.getElementById("status");
const resetButton = document.getElementById("reset-camera");
const modelSelect = document.getElementById("model-select");
const loadButton = document.getElementById("load-model");
const zoomInButton = document.getElementById("zoom-in");
const zoomOutButton = document.getElementById("zoom-out");
const PREFER_MATCHING_GLB_WHEN_HIDDEN_BY_OLD_SERVER = true;
const GLB_RENDER_AS_UNLIT_TEXTURE = true;
const KEYBOARD_MOVE_STEP = 0.15;
const DOUBLE_CLICK_FLIGHT_MS = 900;
const IN_PLACE_LOOK_SENSITIVITY = 0.0038;
const MOBILE_ZOOM_FACTOR = 0.84;
const DOUBLE_TAP_MAX_DELAY_MS = 330;
const DOUBLE_TAP_MAX_DISTANCE_PX = 34;
const TAP_MAX_MOVE_PX = 14;
function setStatus(message, isError = false) {
statusElement.textContent = message;
statusElement.style.background = isError
? "rgba(120, 0, 0, 0.86)"
: "rgba(0, 0, 0, 0.72)";
console.log(message);
}
function showStatusOverlay() {
statusElement.style.display = "block";
statusElement.style.opacity = "1";
statusElement.style.transition = "none";
}
function hideStatusOverlay(delay = 3500) {
window.setTimeout(() => {
statusElement.style.transition = "opacity 0.5s";
statusElement.style.opacity = "0";
window.setTimeout(() => {
statusElement.style.display = "none";
}, 550);
}, delay);
}
function setLoadingState(isLoading) {
loadButton.disabled = isLoading;
modelSelect.disabled = isLoading;
resetButton.disabled = isLoading;
zoomInButton.disabled = isLoading;
zoomOutButton.disabled = isLoading;
controls.enabled = !isLoading;
}
const coarsePointerQuery = window.matchMedia("(pointer: coarse)");
function updateMobileMode() {
const isMobile =
coarsePointerQuery.matches ||
(navigator.maxTouchPoints > 0 && window.innerWidth <= 1024);
document.body.classList.toggle("mobile-device", isMobile);
}
updateMobileMode();
coarsePointerQuery.addEventListener?.("change", updateMobileMode);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1b1b1b);
const camera = new THREE.PerspectiveCamera(
55,
window.innerWidth / window.innerHeight,
0.01,
1000
);
camera.up.set(0, 1, 0);
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: false,
powerPreference: "high-performance"
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
document.body.appendChild(renderer.domElement);
const sparkRenderer = new SparkRenderer({ renderer });
scene.add(sparkRenderer);
scene.add(new THREE.HemisphereLight(0xffffff, 0x303030, 2.2));
const directionalLight = new THREE.DirectionalLight(0xffffff, 2.2);
directionalLight.position.set(-3, 5, -4);
scene.add(directionalLight);
/*
* Sterowanie zostało przywrócone do zachowania z poprzedniego HTML:
* te same parametry OrbitControls i stały krok ruchu strzałkami.
*/
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.enableRotate = true;
controls.enablePan = true;
controls.enableZoom = true;
controls.screenSpacePanning = true;
controls.minDistance = 0.05;
controls.maxDistance = 30;
controls.rotateSpeed = 0.55;
controls.panSpeed = 0.7;
controls.zoomSpeed = 0.8;
/*
* Mobile:
* - jeden palec zachowuje obrót OrbitControls,
* - dwa palce wykonują wyłącznie przesuwanie, jak prawy przycisk myszy,
* - zoom realizują widoczne przyciski + i −.
*/
controls.touches.ONE = THREE.TOUCH.ROTATE;
controls.touches.TWO = THREE.TOUCH.PAN;
let homeCameraPosition = new THREE.Vector3(0, 0, -3.2);
let homeCameraTarget = new THREE.Vector3(0, 0, -0.05);
camera.position.copy(homeCameraPosition);
controls.target.copy(homeCameraTarget);
controls.update();
let loadedObject = null;
let loadedKind = null;
let loadedModelRadius = 1;
let loadedModelName = null;
let loadGeneration = 0;
let availableModelNames = new Set();
let flightAnimation = null;
function resetCamera() {
cancelFlight();
camera.position.copy(homeCameraPosition);
camera.up.set(0, 1, 0);
controls.target.copy(homeCameraTarget);
controls.update();
}
resetButton.addEventListener("click", resetCamera);
function zoomCameraByFactor(factor) {
if (!loadedObject || loadButton.disabled) {
return;
}
cancelFlight();
const offset = camera.position.clone().sub(controls.target);
let distance = offset.length();
if (distance < 0.0001) {
camera.getWorldDirection(offset);
offset.multiplyScalar(-1);
distance = 0.0001;
} else {
offset.normalize();
}
const nextDistance = THREE.MathUtils.clamp(
distance * factor,
controls.minDistance,
controls.maxDistance
);
camera.position
.copy(controls.target)
.addScaledVector(offset, nextDistance);
controls.update();
}
let zoomHoldDelay = null;
let zoomHoldInterval = null;
function stopZoomHold() {
if (zoomHoldDelay !== null) {
clearTimeout(zoomHoldDelay);
zoomHoldDelay = null;
}
if (zoomHoldInterval !== null) {
clearInterval(zoomHoldInterval);
zoomHoldInterval = null;
}
}
function bindZoomButton(button, factor) {
button.addEventListener("pointerdown", (event) => {
if (button.disabled) {
return;
}
event.preventDefault();
event.stopPropagation();
stopZoomHold();
zoomCameraByFactor(factor);
zoomHoldDelay = window.setTimeout(() => {
zoomHoldInterval = window.setInterval(() => {
zoomCameraByFactor(factor);
}, 85);
}, 260);
});
button.addEventListener("click", (event) => {
/* Klik z klawiatury nie generuje pointerdown. */
if (event.detail === 0) {
zoomCameraByFactor(factor);
}
});
for (const eventName of [
"pointerup",
"pointercancel",
"pointerleave",
"lostpointercapture"
]) {
button.addEventListener(eventName, stopZoomHold);
}
}
bindZoomButton(zoomInButton, MOBILE_ZOOM_FACTOR);
bindZoomButton(zoomOutButton, 1 / MOBILE_ZOOM_FACTOR);
window.addEventListener("pointerup", stopZoomHold);
window.addEventListener("blur", stopZoomHold);
function moveCameraHorizontally(rightDelta, forwardDelta) {
cancelFlight();
const forward = new THREE.Vector3();
camera.getWorldDirection(forward);
forward.y = 0;
if (forward.lengthSq() === 0) {
return;
}
forward.normalize();
const right = new THREE.Vector3(
forward.z,
0,
-forward.x
);
const moveVector = new THREE.Vector3()
.addScaledVector(right, rightDelta)
.addScaledVector(forward, forwardDelta);
camera.position.add(moveVector);
controls.target.add(moveVector);
controls.update();
}
window.addEventListener("keydown", (event) => {
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement ||
event.target instanceof HTMLSelectElement ||
event.metaKey ||
event.ctrlKey ||
event.altKey
) {
return;
}
switch (event.key) {
case "ArrowLeft":
event.preventDefault();
moveCameraHorizontally(KEYBOARD_MOVE_STEP, 0);
break;
case "ArrowRight":
event.preventDefault();
moveCameraHorizontally(-KEYBOARD_MOVE_STEP, 0);
break;
case "ArrowUp":
event.preventDefault();
moveCameraHorizontally(0, KEYBOARD_MOVE_STEP);
break;
case "ArrowDown":
event.preventDefault();
moveCameraHorizontally(0, -KEYBOARD_MOVE_STEP);
break;
case "r":
case "R":
resetCamera();
break;
}
});
/*
* Oba przyciski myszy: kamera nie zatacza okręgu wokół celu.
* Jej pozycja pozostaje nieruchoma, a zmienia się wyłącznie kierunek
* patrzenia — jak rozglądanie się głową stojąc w jednym miejscu.
*/
const inPlaceLook = {
active: false,
suppressUntilAllButtonsReleased: false,
lastX: 0,
lastY: 0,
targetDistance: 1
};
const lookSpherical = new THREE.Spherical();
const lookDirection = new THREE.Vector3();
function beginInPlaceLook(event) {
if (loadButton.disabled || !loadedObject) {
return;
}
cancelFlight();
inPlaceLook.active = true;
inPlaceLook.suppressUntilAllButtonsReleased = true;
inPlaceLook.lastX = event.clientX;
inPlaceLook.lastY = event.clientY;
inPlaceLook.targetDistance = Math.max(
camera.position.distanceTo(controls.target),
0.05
);
controls.enabled = false;
}
function updateInPlaceLook(event) {
const deltaX = event.clientX - inPlaceLook.lastX;
const deltaY = event.clientY - inPlaceLook.lastY;
inPlaceLook.lastX = event.clientX;
inPlaceLook.lastY = event.clientY;
camera.getWorldDirection(lookDirection);
lookSpherical.setFromVector3(lookDirection);
lookSpherical.theta += deltaX * IN_PLACE_LOOK_SENSITIVITY;
lookSpherical.phi += deltaY * IN_PLACE_LOOK_SENSITIVITY;
lookSpherical.phi = THREE.MathUtils.clamp(
lookSpherical.phi,
0.02,
Math.PI - 0.02
);
lookDirection.setFromSpherical(lookSpherical).normalize();
controls.target
.copy(camera.position)
.addScaledVector(lookDirection, inPlaceLook.targetDistance);
camera.lookAt(controls.target);
}
function finishInPlaceLook() {
inPlaceLook.active = false;
inPlaceLook.suppressUntilAllButtonsReleased = false;
controls.enabled = !loadButton.disabled;
controls.update();
}
renderer.domElement.addEventListener(
"mousedown",
(event) => {
if ((event.buttons & 3) === 3) {
beginInPlaceLook(event);
event.preventDefault();
event.stopImmediatePropagation();
}
},
true
);
renderer.domElement.addEventListener(
"pointermove",
(event) => {
if (event.pointerType !== "mouse") {
return;
}
if (
!inPlaceLook.suppressUntilAllButtonsReleased &&
(event.buttons & 3) === 3
) {
beginInPlaceLook(event);
}
if (!inPlaceLook.suppressUntilAllButtonsReleased) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
if ((event.buttons & 3) === 3) {
inPlaceLook.active = true;
updateInPlaceLook(event);
} else {
inPlaceLook.active = false;
}
},
true
);
window.addEventListener(
"pointerup",
(event) => {
if (
event.pointerType === "mouse" &&
inPlaceLook.suppressUntilAllButtonsReleased &&
event.buttons === 0
) {
finishInPlaceLook();
}
},
true
);
window.addEventListener("blur", () => {
if (inPlaceLook.suppressUntilAllButtonsReleased) {
finishInPlaceLook();
}
});
renderer.domElement.addEventListener("contextmenu", (event) => {
event.preventDefault();
});
function cancelFlight() {
if (!flightAnimation) {
return;
}
flightAnimation = null;
controls.enabled = !loadButton.disabled;
}
controls.addEventListener("start", cancelFlight);
function easeInOutCubic(value) {
return value < 0.5
? 4 * value * value * value
: 1 - Math.pow(-2 * value + 2, 3) / 2;
}
function startFlightTo(point) {
if (!loadedObject) {
return;
}
const startPosition = camera.position.clone();
const startTarget = controls.target.clone();
const currentOffset = startPosition.clone().sub(startTarget);
const currentDistance = Math.max(currentOffset.length(), 0.001);
const directionFromTarget = currentOffset.normalize();
const minimumDistance = Math.max(loadedModelRadius * 0.025, 0.18);
const maximumDistance = Math.max(loadedModelRadius * 0.35, 0.8);
const endDistance = THREE.MathUtils.clamp(
currentDistance * 0.38,
minimumDistance,
maximumDistance
);
const endTarget = point.clone();
const endPosition = endTarget.clone().addScaledVector(
directionFromTarget,
endDistance
);
flightAnimation = {
startTime: performance.now(),
duration: DOUBLE_CLICK_FLIGHT_MS,
startPosition,
endPosition,
startTarget,
endTarget
};
controls.enabled = false;
}
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
function pickPointAt(clientX, clientY) {
if (!loadedObject) {
return null;
}
const rect = renderer.domElement.getBoundingClientRect();
pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
raycaster.params.Points.threshold = Math.max(
loadedModelRadius * 0.012,
0.025
);
try {
const hits = raycaster.intersectObject(loadedObject, true);
if (hits.length > 0) {
return hits[0].point.clone();
}
} catch (error) {
console.warn("Raycast modelu nie jest dostępny:", error);
}
/*
* Spark nie gwarantuje raycastu dla każdego wariantu splatów.
* W takim przypadku przelot trafia w punkt na płaszczyźnie
* przechodzącej przez aktualny cel kamery.
*/
if (loadedKind === "splat") {
const normal = new THREE.Vector3();
camera.getWorldDirection(normal);
const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(
normal,
controls.target
);
return raycaster.ray.intersectPlane(plane, new THREE.Vector3());
}
return null;
}
function flyToScreenPoint(clientX, clientY) {
const point = pickPointAt(clientX, clientY);
if (point) {
startFlightTo(point);
}
}
let lastTouchDoubleTapAt = -Infinity;
renderer.domElement.addEventListener("dblclick", (event) => {
event.preventDefault();
/* Nie uruchamiaj drugiego lotu, jeśli przeglądarka po double-tap
* wygenerowała dodatkowo syntetyczny dblclick. */
if (performance.now() - lastTouchDoubleTapAt < 600) {
return;
}
flyToScreenPoint(event.clientX, event.clientY);
});
/*
* Mobilny odpowiednik dwukliku: dwa krótkie stuknięcia jednym palcem.
* Gest z dwoma palcami nigdy nie jest interpretowany jako double-tap.
*/
const activeTouchPointers = new Map();
let touchGestureHadMultiplePointers = false;
let previousTap = null;
renderer.domElement.addEventListener("pointerdown", (event) => {
if (event.pointerType !== "touch") {
return;
}
if (activeTouchPointers.size === 0) {
touchGestureHadMultiplePointers = false;
}
activeTouchPointers.set(event.pointerId, {
startX: event.clientX,
startY: event.clientY,
startTime: performance.now(),
moved: false
});
if (activeTouchPointers.size > 1) {
touchGestureHadMultiplePointers = true;
}
});
renderer.domElement.addEventListener("pointermove", (event) => {
if (event.pointerType !== "touch") {
return;
}
const touch = activeTouchPointers.get(event.pointerId);
if (!touch) {
return;
}
if (
Math.hypot(
event.clientX - touch.startX,
event.clientY - touch.startY
) > TAP_MAX_MOVE_PX
) {
touch.moved = true;
}
});
function finishTouchPointer(event, allowTap) {
const touch = activeTouchPointers.get(event.pointerId);
if (!touch) {
return;
}
const now = performance.now();
const canBeTap =
allowTap &&
!touchGestureHadMultiplePointers &&
!touch.moved &&
now - touch.startTime < 520;
activeTouchPointers.delete(event.pointerId);
if (canBeTap) {
const currentTap = {
time: now,
x: event.clientX,
y: event.clientY
};
if (
previousTap &&
now - previousTap.time <= DOUBLE_TAP_MAX_DELAY_MS &&
Math.hypot(
currentTap.x - previousTap.x,
currentTap.y - previousTap.y
) <= DOUBLE_TAP_MAX_DISTANCE_PX
) {
previousTap = null;
lastTouchDoubleTapAt = now;
/* Pozwól OrbitControls najpierw zakończyć pointerup. */
window.setTimeout(() => {
flyToScreenPoint(currentTap.x, currentTap.y);
}, 0);
} else {
previousTap = currentTap;
}
}
if (activeTouchPointers.size === 0) {
touchGestureHadMultiplePointers = false;
}
}
renderer.domElement.addEventListener("pointerup", (event) => {
if (event.pointerType === "touch") {
finishTouchPointer(event, true);
}
});
renderer.domElement.addEventListener("pointercancel", (event) => {
if (event.pointerType === "touch") {
finishTouchPointer(event, false);
}
});
function updateFlightAnimation(now) {
if (!flightAnimation) {
return;
}
const rawProgress = Math.min(
(now - flightAnimation.startTime) / flightAnimation.duration,
1
);
const progress = easeInOutCubic(rawProgress);
camera.position.lerpVectors(
flightAnimation.startPosition,
flightAnimation.endPosition,
progress
);
controls.target.lerpVectors(
flightAnimation.startTarget,
flightAnimation.endTarget,
progress
);
controls.update();
if (rawProgress >= 1) {
flightAnimation = null;
controls.enabled = !loadButton.disabled;
}
}
function fitCameraToObject(object, viewDirection = new THREE.Vector3(0, 0, -1)) {
object.updateMatrixWorld(true);
const box = new THREE.Box3().setFromObject(object);
if (box.isEmpty()) {
homeCameraPosition.set(0, 0, -3.2);
homeCameraTarget.set(0, 0, -0.05);
loadedModelRadius = 1;
resetCamera();
return;
}
const sphere = box.getBoundingSphere(new THREE.Sphere());
const radius = Math.max(sphere.radius, 0.001);
loadedModelRadius = radius;
const verticalFov = THREE.MathUtils.degToRad(camera.fov);
const horizontalFov = 2 * Math.atan(
Math.tan(verticalFov / 2) * camera.aspect
);
const limitingFov = Math.min(verticalFov, horizontalFov);
const distance = (radius / Math.sin(limitingFov / 2)) * 1.08;
homeCameraTarget.copy(sphere.center);
const direction = viewDirection.clone().normalize();
homeCameraPosition
.copy(sphere.center)
.addScaledVector(direction, distance)
.add(new THREE.Vector3(0, radius * 0.08, 0));
camera.near = Math.max(radius / 5000, 0.001);
camera.far = Math.max(radius * 100, 1000);
camera.updateProjectionMatrix();
/*
* Nie zmieniamy parametrów OrbitControls. Zachowujemy dokładnie
* wcześniejsze minDistance/maxDistance i charakter sterowania.
*/
resetCamera();
}
function disposeMaterial(material, disposedTextures) {
if (!material) {
return;
}
for (const key of Object.keys(material)) {
const value = material[key];
if (value?.isTexture && !disposedTextures.has(value)) {
value.dispose();
disposedTextures.add(value);
}
}
material.dispose?.();
}
function disposeThreeObject(object) {
const disposedTextures = new Set();
object.traverse?.((child) => {
child.geometry?.dispose?.();
if (Array.isArray(child.material)) {
for (const material of child.material) {
disposeMaterial(material, disposedTextures);
}
} else {
disposeMaterial(child.material, disposedTextures);
}
});
}
function removeLoadedObject() {
cancelFlight();
if (!loadedObject) {
return;
}
scene.remove(loadedObject);
if (loadedKind === "splat") {
loadedObject.dispose?.();
} else {
disposeThreeObject(loadedObject);
}
loadedObject = null;
loadedKind = null;
loadedModelName = null;
loadedModelRadius = 1;
}
function parsePlyHeader(headerText) {
const lines = headerText.split(/\r?\n/);
const parsed = {
format: null,
comments: [],
elements: []
};
let currentElement = null;
for (const rawLine of lines) {
const line = rawLine.trim();
const parts = line.split(/\s+/);
if (parts[0] === "format") {
parsed.format = parts[1] ?? null;
} else if (parts[0] === "comment") {
parsed.comments.push(line.slice("comment".length).trim());
} else if (parts[0] === "element") {
currentElement = {
name: parts[1],
count: Number(parts[2]),
properties: []
};
parsed.elements.push(currentElement);
} else if (parts[0] === "property" && currentElement) {
currentElement.properties.push(
parts[1] === "list"
? { kind: "list", name: parts[4], itemType: parts[3] }
: { kind: "scalar", name: parts[2], type: parts[1] }
);
}
}
return parsed;
}
function classifyPly(parsedHeader) {
const vertexElement = parsedHeader.elements.find(
(element) => element.name === "vertex"
);
const faceElement = parsedHeader.elements.find(
(element) => element.name === "face"
);
const propertyNames = new Set(
vertexElement?.properties.map((property) => property.name) ?? []
);
const hasGaussianCore =
propertyNames.has("opacity") &&
propertyNames.has("scale_0") &&
propertyNames.has("scale_1") &&
propertyNames.has("scale_2") &&
propertyNames.has("rot_0") &&
propertyNames.has("rot_1") &&
propertyNames.has("rot_2") &&
propertyNames.has("rot_3");
const hasSphericalHarmonics =
propertyNames.has("f_dc_0") ||
[...propertyNames].some((name) => name.startsWith("f_rest_"));
if (hasGaussianCore || hasSphericalHarmonics) {
return {
kind: "splat",
vertexCount: vertexElement?.count ?? 0,
faceCount: faceElement?.count ?? 0
};
}
if ((faceElement?.count ?? 0) > 0) {
return {
kind: "mesh",
vertexCount: vertexElement?.count ?? 0,
faceCount: faceElement.count
};
}
return {
kind: "points",
vertexCount: vertexElement?.count ?? 0,
faceCount: 0
};
}
async function fetchPlyHeader(url, maxHeaderBytes = 1024 * 1024) {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
throw new Error(
`HTTP ${response.status}: nie udało się pobrać nagłówka PLY.`
);
}
if (!response.body) {
const buffer = new Uint8Array(await response.arrayBuffer());
const text = new TextDecoder("ascii").decode(buffer);
const endIndex = text.indexOf("end_header");
if (endIndex === -1) {
throw new Error("Plik nie zawiera poprawnego end_header.");
}
return parsePlyHeader(text.slice(0, endIndex + "end_header".length));
}
const reader = response.body.getReader();
const chunks = [];
let total = 0;
try {
while (total < maxHeaderBytes) {
const { value, done } = await reader.read();
if (done) {
break;
}
chunks.push(value);
total += value.byteLength;
const merged = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
merged.set(chunk, offset);
offset += chunk.byteLength;
}
const text = new TextDecoder("ascii").decode(merged);
const endIndex = text.indexOf("end_header");
if (endIndex !== -1) {
await reader.cancel();
return parsePlyHeader(
text.slice(0, endIndex + "end_header".length)
);
}
}
} finally {
reader.releaseLock();
}
throw new Error(
`Nie znaleziono end_header w pierwszych ${Math.round(maxHeaderBytes / 1024)} KB.`
);
}
async function fetchArrayBufferWithProgress(url, onProgress) {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
throw new Error(`HTTP ${response.status}: nie udało się pobrać modelu.`);
}
const total = Number(response.headers.get("content-length"));
if (!response.body) {
return response.arrayBuffer();
}
const reader = response.body.getReader();
const chunks = [];
let loaded = 0;
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
chunks.push(value);
loaded += value.byteLength;
onProgress?.(
loaded,
Number.isFinite(total) && total > 0 ? total : null
);
}
const merged = new Uint8Array(loaded);
let offset = 0;
for (const chunk of chunks) {
merged.set(chunk, offset);
offset += chunk.byteLength;
}
return merged.buffer;
}
function makeCircularPointsMaterial({ size, vertexColors }) {
const material = new THREE.PointsMaterial({
size,
vertexColors,
sizeAttenuation: true,
depthWrite: true,
transparent: false
});
material.onBeforeCompile = (shader) => {
shader.fragmentShader = shader.fragmentShader.replace(
"#include <clipping_planes_fragment>",
`#include <clipping_planes_fragment>\n` +
`if (distance(gl_PointCoord, vec2(0.5)) > 0.5) discard;`
);
};
material.customProgramCacheKey = () => "circular-points-v2";
return material;
}
function applyScaniversePlyOrientation(object, parsedHeader) {
const isScaniverse = parsedHeader.comments.some((comment) =>
comment.toLowerCase().includes("scaniverse")
);
if (isScaniverse) {
object.rotation.set(0, 0, Math.PI);
}
}
function createUnlitMaterialFrom(material) {
const map = material?.map ?? null;
if (map) {
map.colorSpace = THREE.SRGBColorSpace;
map.anisotropy = Math.min(
renderer.capabilities.getMaxAnisotropy(),
16
);
map.needsUpdate = true;
}
return new THREE.MeshBasicMaterial({
name: material?.name ?? "Scaniverse texture",
color: material?.color?.clone?.() ?? new THREE.Color(0xffffff),
map,
vertexColors: material?.vertexColors ?? false,
transparent: material?.transparent ?? false,
opacity: material?.opacity ?? 1,
alphaTest: material?.alphaTest ?? 0,
side: THREE.DoubleSide,
depthWrite: material?.depthWrite ?? true,
depthTest: material?.depthTest ?? true
});
}
function prepareGlbForScaniverse(root) {
let meshCount = 0;
let triangleCount = 0;
let vertexCount = 0;
let texturedMeshCount = 0;
root.traverse((child) => {
if (!child.isMesh) {
return;