Load real GLB models in the MapLibre demo instead of placeholder geometry.
CI / test (push) Successful in 4s
CI / test (push) Successful in 4s
This restores true 3D rendering from backend asset links and keeps runtime var data out of git/agent workflows. Made-with: Cursor
This commit is contained in:
Vendored
+1
@@ -1,3 +1,4 @@
|
||||
libs/geo-api-client/node_modules/
|
||||
api
|
||||
var/
|
||||
var/logs/
|
||||
|
||||
@@ -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.
|
||||
|
||||
+162
-19
@@ -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();
|
||||
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 {
|
||||
mesh.material.dispose();
|
||||
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));
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user