diff --git a/internal/app/service.go b/internal/app/service.go
index b31c5a0..17fbce2 100644
--- a/internal/app/service.go
+++ b/internal/app/service.go
@@ -443,6 +443,23 @@ func (s *Service) DeleteFeature(ownerKey, featureID string) error {
return s.store.DeleteFeature(featureID)
}
+func (s *Service) UpdateFeatureGeometry(ownerKey, featureID string, geometry store.Point) (store.Feature, error) {
+ feature, err := s.store.GetFeature(featureID)
+ if err != nil {
+ return store.Feature{}, ErrFeatureMiss
+ }
+ if feature.OwnerKey != ownerKey {
+ return store.Feature{}, ErrForbidden
+ }
+ if err := validatePoint(geometry); err != nil {
+ return store.Feature{}, err
+ }
+ feature.Geometry = geometry
+ feature.UpdatedAt = time.Now().UTC()
+ s.store.SaveFeature(feature)
+ return feature, nil
+}
+
type CreateAssetInput struct {
FeatureID string
Checksum string
diff --git a/internal/http/api_test.go b/internal/http/api_test.go
index 7bda8f5..2a6b390 100644
--- a/internal/http/api_test.go
+++ b/internal/http/api_test.go
@@ -308,6 +308,28 @@ func TestCollectionOwnershipIsolation(t *testing.T) {
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403, got %d", resp.StatusCode)
}
+
+ featureID := createFeatureData["id"].(string)
+
+ patchOwnResp, patchOwnData := patchJSON(t, client, server.URL+"/v1/features/"+featureID, map[string]interface{}{
+ "geometry": map[string]interface{}{
+ "type": "Point",
+ "coordinates": []float64{-16.6299, 28.4639, 11},
+ },
+ }, user1Token)
+ if patchOwnResp.StatusCode != http.StatusOK {
+ t.Fatalf("owner patch feature status=%d body=%v", patchOwnResp.StatusCode, patchOwnData)
+ }
+
+ patchOtherResp, patchOtherData := patchJSON(t, client, server.URL+"/v1/features/"+featureID, map[string]interface{}{
+ "geometry": map[string]interface{}{
+ "type": "Point",
+ "coordinates": []float64{-16.6301, 28.4641, 12},
+ },
+ }, user2Token)
+ if patchOtherResp.StatusCode != http.StatusForbidden {
+ t.Fatalf("non-owner patch feature status=%d body=%v", patchOtherResp.StatusCode, patchOtherData)
+ }
}
func TestAssetLifecycleAndVisibility(t *testing.T) {
diff --git a/internal/http/handlers.go b/internal/http/handlers.go
index c9d93bb..1b6408d 100644
--- a/internal/http/handlers.go
+++ b/internal/http/handlers.go
@@ -42,6 +42,7 @@ func (a *API) Routes() http.Handler {
mux.HandleFunc("POST /v1/collections/{id}/features", a.createFeature)
mux.HandleFunc("GET /v1/collections/{id}/features", a.listFeatures)
mux.HandleFunc("GET /v1/features/public", a.listPublicFeatures)
+ mux.HandleFunc("PATCH /v1/features/{id}", a.patchFeature)
mux.HandleFunc("DELETE /v1/features/{id}", a.deleteFeature)
mux.HandleFunc("POST /v1/assets", a.createAsset)
mux.HandleFunc("PATCH /v1/assets/{id}", a.patchAsset)
@@ -393,6 +394,28 @@ func (a *API) deleteFeature(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
+func (a *API) patchFeature(w http.ResponseWriter, r *http.Request) {
+ user, err := a.authUser(r)
+ if err != nil {
+ writeErr(w, err)
+ return
+ }
+ featureID := r.PathValue("id")
+ var req struct {
+ Geometry store.Point `json:"geometry"`
+ }
+ if err := readJSON(r, &req); err != nil {
+ writeErr(w, app.ErrBadRequest)
+ return
+ }
+ feature, err := a.service.UpdateFeatureGeometry(user, featureID, req.Geometry)
+ if err != nil {
+ writeErr(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, feature)
+}
+
func (a *API) createAsset(w http.ResponseWriter, r *http.Request) {
user, err := a.authUser(r)
if err != nil {
diff --git a/web/maplibre-demo.html b/web/maplibre-demo.html
index 8bef86b..328482b 100644
--- a/web/maplibre-demo.html
+++ b/web/maplibre-demo.html
@@ -99,7 +99,7 @@
MapLibre GL + Three.js Demo
- Vector tiles + 3D object placement. Click map to place, upload GLB/GLTF, store and share.
+ Raster tiles + 3D object placement. Click map to place, upload GLB/GLTF, store and share.
Connection
diff --git a/web/maplibre-demo.js b/web/maplibre-demo.js
index e37b60c..518311d 100644
--- a/web/maplibre-demo.js
+++ b/web/maplibre-demo.js
@@ -36,6 +36,8 @@ let threeScene;
let threeRenderer;
let threeCamera;
const featureMeshes = new Map();
+const ownFeatureMarkers = new Map();
+const ownFeatureCoords = new Map();
function setStatus(message) {
statusEl.textContent = message;
@@ -71,6 +73,27 @@ function setClientBase(baseUrl) {
setStatus(`API base updated: ${normalized}`);
}
+function buildRasterStyle() {
+ return {
+ version: 8,
+ sources: {
+ "osm-raster": {
+ type: "raster",
+ tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
+ tileSize: 256,
+ attribution: "© OpenStreetMap contributors",
+ },
+ },
+ layers: [
+ {
+ id: "osm-raster-layer",
+ type: "raster",
+ source: "osm-raster",
+ },
+ ],
+ };
+}
+
function buildMapShareLink(feature, asset) {
const coords = feature?.geometry?.coordinates;
if (!Array.isArray(coords) || coords.length < 2) {
@@ -156,6 +179,14 @@ function clearFeatureMeshes() {
featureMeshes.clear();
}
+function clearOwnFeatureMarkers() {
+ for (const marker of ownFeatureMarkers.values()) {
+ marker.remove();
+ }
+ ownFeatureMarkers.clear();
+ ownFeatureCoords.clear();
+}
+
function addObjectMesh(featureId, lng, lat, isPublic, kind) {
const merc = maplibregl.MercatorCoordinate.fromLngLat({ lng, lat }, 0);
const meters = merc.meterInMercatorCoordinateUnits();
@@ -173,6 +204,40 @@ function addObjectMesh(featureId, lng, lat, isPublic, kind) {
featureMeshes.set(featureId, mesh);
}
+async function moveOwnFeature(featureID, lng, lat) {
+ const existing = ownFeatureCoords.get(featureID);
+ const alt = Array.isArray(existing) && existing.length >= 3 ? existing[2] : 0;
+ const res = await fetch(`${currentApiBase()}/v1/features/${featureID}`, {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${accessToken}`,
+ },
+ body: JSON.stringify({
+ geometry: { type: "Point", coordinates: [lng, lat, alt] },
+ }),
+ });
+ if (!res.ok) {
+ const maybeJson = await res.json().catch(() => ({}));
+ throw new Error(maybeJson.error || `Failed to move feature (${res.status})`);
+ }
+}
+
+function addOwnFeatureMarker(featureID, lng, lat) {
+ const marker = new maplibregl.Marker({ color: "#2563eb", draggable: true }).setLngLat({ lng, lat }).addTo(map);
+ marker.on("dragend", async () => {
+ const p = marker.getLngLat();
+ try {
+ await moveOwnFeature(featureID, p.lng, p.lat);
+ await refreshFeatures();
+ setStatus(`Feature ${featureID} moved.`);
+ } catch (error) {
+ setStatus(error.message);
+ }
+ });
+ ownFeatureMarkers.set(featureID, marker);
+}
+
async function ensureKeys() {
keys = await client.ensureKeysInStorage();
publicKeyPreviewEl.textContent = `Public key: ${keys.publicKey.slice(0, 24)}...`;
@@ -259,6 +324,7 @@ async function refreshFeatures() {
const publicData = await publicResp.json();
const byID = new Map((publicData.features || []).map((feature) => [feature.id, feature]));
+ const ownFeatureIDs = new Set();
if (accessToken) {
const { collections } = await client.listCollections();
if (!collectionId && collections.length > 0) {
@@ -273,21 +339,27 @@ async function refreshFeatures() {
);
for (const feature of ownFeatureSets.flat()) {
byID.set(feature.id, feature);
+ ownFeatureIDs.add(feature.id);
}
}
const features = Array.from(byID.values());
clearFeatureMeshes();
+ clearOwnFeatureMarkers();
for (const feature of features) {
const coords = feature.geometry?.coordinates;
if (!coords || coords.length < 2) continue;
const lng = coords[0];
const lat = coords[1];
+ ownFeatureCoords.set(feature.id, coords);
const assets = Array.isArray(feature.properties?.assets) ? feature.properties.assets : [];
const first = assets[0];
if (first) {
addObjectMesh(feature.id, lng, lat, first.isPublic, first.kind);
}
+ if (ownFeatureIDs.has(feature.id) && accessToken) {
+ addOwnFeatureMarker(feature.id, lng, lat);
+ }
}
renderAssets(features);
}
@@ -385,7 +457,7 @@ document.getElementById("uploadAsset").onclick = async () => {
map = new maplibregl.Map({
container: "map",
- style: "./osm-liberty-gl-style/style.json",
+ style: buildRasterStyle(),
center: [-16.2518, 28.4636],
zoom: 12,
pitch: 55,