From 6f873100efa736d0f6ef6f420af4484186c2e0ce Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Mon, 2 Mar 2026 22:39:20 +0000 Subject: [PATCH] Load real GLB models in the MapLibre demo instead of placeholder geometry. This restores true 3D rendering from backend asset links and keeps runtime var data out of git/agent workflows. Made-with: Cursor --- .gitignore | 1 + AGENTS.md | 14 ++++ web/maplibre-demo.js | 185 ++++++++++++++++++++++++++++++++++++++----- 3 files changed, 179 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index 4233cb2..5a8a4c1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ libs/geo-api-client/node_modules/ api +var/ var/logs/ diff --git a/AGENTS.md b/AGENTS.md index 5a07cd0..f0eccb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,8 +47,22 @@ bun run build ## Editing guidance for agents - Prefer minimal changes and avoid unrelated refactors. +- Treat `var/` as runtime data only; ignore it for code changes and commits. - Add tests when behavior changes. - Verify Go tests after backend changes. - Verify Bun tests after TS client changes. - For DB-required tests, prefer embedded/ephemeral Postgres fixtures over relying on an externally managed database. - If CI fails due runner/network infrastructure, keep logs explicit in workflow output. + +## Agent skill memory (current behavior) + +- **Asset downloads stay on backend domain:** `GET /v1/assets/{id}/download` streams bytes from backend (no redirect to MinIO/internal URL). +- **Asset uploads are backend-routed:** signed upload endpoint returns backend URL (`/v1/assets/{id}/upload`), browser never uploads directly to MinIO. +- **Public features API exists:** use `GET /v1/features/public` with optional `kind` query (`3d` or `image`) to fetch globally visible features/assets. +- **Feature geometry update API exists:** `PATCH /v1/features/{id}` updates point geometry (owner only). +- **MapLibre demo expectations (`web/maplibre-demo.js`):** + - uses raster OSM tiles (not vector style), + - loads all public 3D features on map start, + - after login merges all owner collections, + - owner feature markers are draggable and persisted via `PATCH /v1/features/{id}`. +- **Share-link behavior in demos:** "Copy Share Link" generates map URLs with coordinates so recipients open map context, not only raw asset URL. diff --git a/web/maplibre-demo.js b/web/maplibre-demo.js index 518311d..0772b78 100644 --- a/web/maplibre-demo.js +++ b/web/maplibre-demo.js @@ -1,4 +1,5 @@ import * as THREE from "https://unpkg.com/three@0.168.0/build/three.module.js"; +import { GLTFLoader } from "https://unpkg.com/three@0.168.0/examples/jsm/loaders/GLTFLoader.js"; import { GeoApiClient } from "../libs/geo-api-client/dist/index.js"; class BrowserStorage { @@ -36,8 +37,11 @@ let threeScene; let threeRenderer; let threeCamera; const featureMeshes = new Map(); +const modelTemplateCache = new Map(); +const gltfLoader = new GLTFLoader(); const ownFeatureMarkers = new Map(); const ownFeatureCoords = new Map(); +let renderCycle = 0; function setStatus(message) { statusEl.textContent = message; @@ -53,6 +57,12 @@ function extFromFilename(name) { return name.slice(idx + 1).toLowerCase(); } +function normalizeExt(value) { + const text = String(value || "").trim().toLowerCase(); + if (!text) return ""; + return text.includes(".") ? extFromFilename(text) : text; +} + function kindFromExt(ext) { return ext === "gltf" || ext === "glb" ? "3d" : "image"; } @@ -110,7 +120,24 @@ function buildMapShareLink(feature, asset) { return url.toString(); } -function renderSharedAssetFromQuery() { +function extFromLink(link) { + if (!link) return ""; + try { + const url = new URL(link, `${currentApiBase()}/`); + return extFromFilename(url.pathname); + } catch { + return extFromFilename(link.split("?")[0]); + } +} + +function is3DAsset(asset) { + if (!asset) return false; + const kind = String(asset.kind || "").toLowerCase(); + const ext = normalizeExt(asset.ext) || extFromLink(asset.link); + return kind === "3d" && (ext === "glb" || ext === "gltf"); +} + +async function renderSharedAssetFromQuery() { const params = new URLSearchParams(window.location.search); if (params.get("shared") !== "1") return; const lng = Number(params.get("lng")); @@ -119,12 +146,25 @@ function renderSharedAssetFromQuery() { const kind = params.get("kind") === "image" ? "image" : "3d"; const isPublic = params.get("public") !== "0"; const assetId = params.get("assetId") || "shared-asset"; + const assetLink = params.get("assetLink") || ""; - addObjectMesh(`shared-${assetId}`, lng, lat, isPublic, kind); + const loadedModel = await addObjectMeshFromAsset( + `shared-${assetId}`, + lng, + lat, + { + id: assetId, + kind, + ext: extFromLink(assetLink) || (kind === "3d" ? "glb" : "png"), + isPublic, + link: assetLink, + }, + renderCycle + ); if (pendingMarker) pendingMarker.remove(); pendingMarker = new maplibregl.Marker({ color: "#f59e0b" }).setLngLat({ lng, lat }).addTo(map); map.easeTo({ center: [lng, lat], zoom: Math.max(map.getZoom(), 14), pitch: 55, bearing: -15 }); - setStatus("Shared object loaded on map."); + setStatus(loadedModel ? "Shared 3D model loaded on map." : "Shared object loaded with fallback shape."); } function createThreeLayer() { @@ -159,22 +199,33 @@ function createThreeLayer() { }; } -function disposeMesh(mesh) { - if (!mesh) return; - if (mesh.geometry) mesh.geometry.dispose(); - if (mesh.material) { - if (Array.isArray(mesh.material)) { - for (const mat of mesh.material) mat.dispose(); - } else { - mesh.material.dispose(); - } +function disposeMaterial(material) { + if (!material) return; + for (const value of Object.values(material)) { + if (value && value.isTexture) value.dispose(); } + material.dispose(); +} + +function disposeObject3D(object) { + if (!object) return; + object.traverse((node) => { + if (!node.isMesh) return; + if (node.geometry) node.geometry.dispose(); + if (node.material) { + if (Array.isArray(node.material)) { + for (const material of node.material) disposeMaterial(material); + } else { + disposeMaterial(node.material); + } + } + }); } function clearFeatureMeshes() { - for (const mesh of featureMeshes.values()) { - threeScene.remove(mesh); - disposeMesh(mesh); + for (const object of featureMeshes.values()) { + threeScene.remove(object); + disposeObject3D(object); } featureMeshes.clear(); } @@ -187,21 +238,110 @@ function clearOwnFeatureMarkers() { ownFeatureCoords.clear(); } -function addObjectMesh(featureId, lng, lat, isPublic, kind) { +function positionObjectOnMap(object, lng, lat, scaleMeters) { const merc = maplibregl.MercatorCoordinate.fromLngLat({ lng, lat }, 0); const meters = merc.meterInMercatorCoordinateUnits(); + object.position.set(merc.x, merc.y, merc.z); + object.scale.setScalar(meters * scaleMeters); +} + +function addFallbackMesh(featureId, lng, lat, isPublic, kind) { const is3D = kind === "3d"; const geometry = is3D ? new THREE.BoxGeometry(1.2, 1.2, 2.2) : new THREE.PlaneGeometry(1.8, 1.8); const color = isPublic ? 0x44dd88 : 0xdd5566; const material = new THREE.MeshStandardMaterial({ color, transparent: true, opacity: 0.92 }); const mesh = new THREE.Mesh(geometry, material); - mesh.position.set(merc.x, merc.y, merc.z); - mesh.scale.setScalar(meters * (is3D ? 18 : 24)); + positionObjectOnMap(mesh, lng, lat, is3D ? 18 : 24); if (!is3D) { mesh.rotation.x = Math.PI / 2; } threeScene.add(mesh); featureMeshes.set(featureId, mesh); + map.triggerRepaint(); +} + +function prepareModelRoot(modelRoot) { + const box = new THREE.Box3().setFromObject(modelRoot); + if (box.isEmpty()) return; + const center = new THREE.Vector3(); + const size = new THREE.Vector3(); + box.getCenter(center); + box.getSize(size); + modelRoot.position.x -= center.x; + modelRoot.position.y -= center.y; + modelRoot.position.z -= box.min.z; + modelRoot.userData.baseSize = Math.max(size.x, size.y, size.z) || 1; +} + +function cloneModelTemplate(modelRoot) { + const clone = modelRoot.clone(true); + clone.traverse((node) => { + if (!node.isMesh) return; + node.castShadow = true; + node.receiveShadow = true; + }); + return clone; +} + +async function loadModelTemplate(modelURL) { + const cached = modelTemplateCache.get(modelURL); + if (cached) return cached; + + const pending = new Promise((resolve, reject) => { + gltfLoader.setRequestHeader(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}); + gltfLoader.load( + modelURL, + (gltf) => { + const root = gltf.scene || gltf.scenes?.[0]; + if (!root) { + reject(new Error("GLTF file has no scene graph.")); + return; + } + prepareModelRoot(root); + resolve(root); + }, + undefined, + reject + ); + }); + + modelTemplateCache.set(modelURL, pending); + try { + return await pending; + } catch (error) { + modelTemplateCache.delete(modelURL); + throw error; + } +} + +async function addObjectMeshFromAsset(featureId, lng, lat, asset, cycleID) { + if (!asset) { + addFallbackMesh(featureId, lng, lat, true, "3d"); + return false; + } + + if (!is3DAsset(asset) || !asset.link) { + addFallbackMesh(featureId, lng, lat, Boolean(asset.isPublic), asset.kind || "3d"); + return false; + } + + try { + const modelURL = client.resolveRelativeLink(asset.link); + const template = await loadModelTemplate(modelURL); + if (cycleID !== renderCycle) return false; + + const modelRoot = cloneModelTemplate(template); + const baseSize = Number(template.userData.baseSize) || 1; + positionObjectOnMap(modelRoot, lng, lat, 12 / baseSize); + threeScene.add(modelRoot); + featureMeshes.set(featureId, modelRoot); + map.triggerRepaint(); + return true; + } catch (error) { + console.warn(`Failed to load model for feature ${featureId}:`, error); + addFallbackMesh(featureId, lng, lat, Boolean(asset.isPublic), asset.kind || "3d"); + return false; + } } async function moveOwnFeature(featureID, lng, lat) { @@ -317,6 +457,8 @@ function renderAssets(features) { } async function refreshFeatures() { + renderCycle += 1; + const cycleID = renderCycle; const publicResp = await fetch(`${currentApiBase()}/v1/features/public?kind=3d`); if (!publicResp.ok) { throw new Error(`Failed to load public features: HTTP ${publicResp.status}`); @@ -353,9 +495,10 @@ async function refreshFeatures() { const lat = coords[1]; ownFeatureCoords.set(feature.id, coords); const assets = Array.isArray(feature.properties?.assets) ? feature.properties.assets : []; - const first = assets[0]; + const preferred3D = assets.find((asset) => is3DAsset(asset)); + const first = preferred3D || assets[0]; if (first) { - addObjectMesh(feature.id, lng, lat, first.isPublic, first.kind); + await addObjectMeshFromAsset(feature.id, lng, lat, first, cycleID); } if (ownFeatureIDs.has(feature.id) && accessToken) { addOwnFeatureMarker(feature.id, lng, lat); @@ -470,7 +613,7 @@ map.addControl(new maplibregl.NavigationControl(), "top-right"); map.on("load", () => { threeLayer = createThreeLayer(); map.addLayer(threeLayer); - renderSharedAssetFromQuery(); + renderSharedAssetFromQuery().catch((error) => setStatus(error.message)); refreshFeatures().catch((error) => setStatus(error.message)); });