Skip to content

Commit 124de9f

Browse files
Add building massing authoring workflow (#2020)
* feat: add building massing workflow * Address Claude review feedback - Keep ordinary polygon sketches flat by leaving height defaults to massing mode. - Reset only the auto-managed extrusion style after the last massing feature is removed. - Add regression coverage for massing detection and style transitions. * fix: harden massing style synchronization * fix: refine massing interaction on globe * fix: preserve mixed sketch rendering * Address Claude review feedback - Respect a manual switch away from extrusion: the Style panel's 2D and 3D-elevation radios clear `extrusionEnabled` without clearing `extrusionHeightExpression`, so the old "already customized" guard (which required `extrusionEnabled`) was skipped and the next sync re-enabled massing extrusion, silently reverting the user's choice. The auto-managed expression paired with extrusion off is now treated as user-owned, with a regression test for the draw -> switch to 2D -> edit sequence. - Remove `sketchesIdleDisplayOverride`, which became dead code in e8bd03b (the only `= true` assignment was dropped to fix the second-footprint rubber-band regression). Its doc comment described behavior that can no longer happen, and both of its checks were constant. - Clear `preMassingExtrusionStyles` in `deactivate()` so entries for old Sketches layer ids do not linger alongside the other module-state resets. * Address Claude review feedback - Gate the "user's own extrusion expression" guard on `extrusionAdvancedStyleEnabled`, mirroring `extrusionHeightValue`, which ignores `extrusionHeightExpression` unless advanced mode is on. Without it a stray expression left behind by a since-disabled advanced mode blocked massing auto-management for good, with no feedback. Tested both ways. - Localize the two user-facing strings `GEO_EDITOR_OPTIONS` carries as copy. The plugins package cannot call `t()`, so `setGeoEditorLabels` follows the host-push pattern of `maplibre-graticule`/`maplibre-reverse-geocode`: TopToolbar pushes `geoEditorPlugin.*` on mount and on every language change, and `getGeoEditorOptions` reads the current values. The third-party control reads its options once at construction, so a mid-session language switch applies on the next activation; that is documented on the setter. * fix: show massing extrusion while the draw tool stays armed Drawing a massing footprint left the map looking unchanged: the Sketches store layer is the only thing that renders the extrusion, and it is suppressed for as long as the editor is in an interaction mode. The massing tool stays armed after each completed footprint so the next one can be drawn, so the user saw Geoman's flat polygon until they explicitly disabled the tool. `sketchesIdleDisplayOverride` used to cover this by treating the editor as idle after a create, but that hid *all* of Geoman including the in-progress rubber band, which is why e8bd03b dropped it. Split the two instead: while the Sketches layer carries the auto-managed massing extrusion and a draw tool (not an edit tool, whose handles hit-test against them) is armed, hide only Geoman's `gm_main-*` committed-feature layers and show the Sketches layer, leaving the transient aids visible. Verified in the browser: a footprint extrudes as soon as it is completed with the tool still armed, the second footprint still rubber-bands while being drawn, and a height edit re-renders live. * Address Claude review feedback - Let a manual 2D switch outrank the pre-massing snapshot. Switching the layer out of extrusion while massing features are present is already honored by the enable path; the reset path then restored the pre-massing style when the last footprint went away, silently turning extrusion back on for a layer that had one before. The snapshot is now dropped and the user's style left alone, with a test for that sequence. * fix: avoid low-zoom massing artifacts Render zoom-gated building massing as a normal flat fill below the extrusion cutoff so globe triangulation cannot create world-scale shards. * style: auto-format (ruff + oxfmt) [pre-commit.ci] --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 1554fbc commit 124de9f

10 files changed

Lines changed: 583 additions & 37 deletions

File tree

apps/geolibre-desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@
8282
"maplibre-gl-enviroatlas": "^0.1.1",
8383
"maplibre-gl-esri-wayback": "^0.2.2",
8484
"maplibre-gl-fema-wms": "^0.1.2",
85-
"maplibre-gl-geo-editor": "^0.10.3",
85+
"maplibre-gl-geo-editor": "^0.10.4",
8686
"maplibre-gl-geoagent": "^0.5.6",
8787
"maplibre-gl-lidar": "^0.16.2",
8888
"maplibre-gl-nasa-earthdata": "^0.1.4",

apps/geolibre-desktop/src/components/layout/TopToolbar.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
openVectorLayerPanel,
2828
setAnnotationLabels,
2929
setBasemapControlLabels,
30+
setGeoEditorLabels,
3031
setGraticuleLabels,
3132
setH3Labels,
3233
setS2Labels,
@@ -585,6 +586,10 @@ export function TopToolbar({
585586
engineTitiler: t("huggingFace.engineTitiler"),
586587
resetDefaults: t("huggingFace.resetDefaults"),
587588
});
589+
setGeoEditorLabels({
590+
attributePanelTitle: t("geoEditorPlugin.attributePanelTitle"),
591+
massingHeight: t("geoEditorPlugin.massingHeight"),
592+
});
588593
setGraticuleLabels({
589594
title: t("graticule.title"),
590595
getTitle: () => i18n.t("graticule.title"),

apps/geolibre-desktop/src/i18n/locales/en.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3677,6 +3677,10 @@
36773677
"confirmStyleReplace_one": "Switching to \"{{name}}\" replaces the whole map style and will remove the stacked basemap you added. Continue?",
36783678
"confirmStyleReplace_other": "Switching to \"{{name}}\" replaces the whole map style and will remove the {{count}} stacked basemaps you added. Continue?"
36793679
},
3680+
"geoEditorPlugin": {
3681+
"attributePanelTitle": "Feature properties",
3682+
"massingHeight": "Height (m)"
3683+
},
36803684
"annotations": {
36813685
"toolbar": "Annotation tools",
36823686
"layerName": "Annotations",

package-lock.json

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/map/src/layer-sync.ts

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,33 @@ function styleLayerZoomRange(style: LayerStyle): {
367367
};
368368
}
369369

370+
/**
371+
* Return the first zoom where a zoom-stepped extrusion becomes non-flat.
372+
* A zero-height fill-extrusion is still triangulated as 3D geometry by
373+
* MapLibre and can produce large tile-boundary shards on the globe. Callers
374+
* use this cutoff to render an ordinary fill below it instead.
375+
*/
376+
function flatExtrusionCutoff(style: LayerStyle): number | null {
377+
if (!style.extrusionAdvancedStyleEnabled || !style.extrusionHeightExpression) return null;
378+
try {
379+
const expression: unknown = JSON.parse(style.extrusionHeightExpression);
380+
if (
381+
Array.isArray(expression) &&
382+
expression[0] === "step" &&
383+
Array.isArray(expression[1]) &&
384+
expression[1][0] === "zoom" &&
385+
expression[2] === 0 &&
386+
typeof expression[3] === "number" &&
387+
Number.isFinite(expression[3])
388+
) {
389+
return clampLayerZoom(expression[3], MIN_LAYER_ZOOM);
390+
}
391+
} catch {
392+
// Invalid expressions are handled by the existing style-expression path.
393+
}
394+
return null;
395+
}
396+
370397
// Intersect a native layer's source-declared zoom range with the user-configured
371398
// style range, taking the tighter bound on each end. This keeps a tile
372399
// service's zoom floor/ceiling intact while still letting the user narrow the
@@ -1908,15 +1935,50 @@ function applyVectorDataRenderLayers(
19081935

19091936
if (profile.hasPolygon) {
19101937
if (layer.style.extrusionEnabled) {
1911-
removeIfExists(map, fillLayerId(layer.id));
1938+
const zoomRange = styleLayerZoomRange(layer.style);
1939+
const flatBelowZoom = flatExtrusionCutoff(layer.style);
1940+
const hasFlatRange = flatBelowZoom !== null && zoomRange.minzoom < flatBelowZoom;
1941+
if (hasFlatRange) {
1942+
ensureLayer(
1943+
map,
1944+
fillLayerId(layer.id),
1945+
{
1946+
id: fillLayerId(layer.id),
1947+
type: "fill",
1948+
...sourceSpec,
1949+
minzoom: zoomRange.minzoom,
1950+
maxzoom: Math.min(zoomRange.maxzoom, flatBelowZoom),
1951+
filter: withFeatureFilters(layer, [
1952+
"match",
1953+
["geometry-type"],
1954+
["Polygon", "MultiPolygon"],
1955+
true,
1956+
false,
1957+
]),
1958+
paint: {
1959+
...fillPaint(layer.style, opacity),
1960+
"fill-pattern": (fillPatternId ?? null) as unknown as string,
1961+
},
1962+
layout: { visibility },
1963+
},
1964+
beforeId,
1965+
);
1966+
} else {
1967+
removeIfExists(map, fillLayerId(layer.id));
1968+
}
19121969
ensureLayer(
19131970
map,
19141971
fillExtrusionLayerId(layer.id),
19151972
{
19161973
id: fillExtrusionLayerId(layer.id),
19171974
type: "fill-extrusion",
19181975
...sourceSpec,
1919-
...styleLayerZoomRange(layer.style),
1976+
...zoomRange,
1977+
...(flatBelowZoom !== null
1978+
? {
1979+
minzoom: Math.min(zoomRange.maxzoom, Math.max(zoomRange.minzoom, flatBelowZoom)),
1980+
}
1981+
: {}),
19201982
filter: withFeatureFilters(layer, [
19211983
"match",
19221984
["geometry-type"],
@@ -2020,7 +2082,7 @@ function applyVectorDataRenderLayers(
20202082
removeSourceIfExists(map, invertedSourceId(layer.id));
20212083
}
20222084

2023-
if (!layer.style.extrusionEnabled && (profile.hasLine || profile.hasPolygon)) {
2085+
if (profile.hasLine || (!layer.style.extrusionEnabled && profile.hasPolygon)) {
20242086
ensureLayer(
20252087
map,
20262088
lineLayerId(layer.id),
@@ -2032,7 +2094,9 @@ function applyVectorDataRenderLayers(
20322094
filter: withFeatureFilters(layer, [
20332095
"match",
20342096
["geometry-type"],
2035-
["LineString", "MultiLineString", "Polygon", "MultiPolygon"],
2097+
layer.style.extrusionEnabled
2098+
? ["LineString", "MultiLineString"]
2099+
: ["LineString", "MultiLineString", "Polygon", "MultiPolygon"],
20362100
true,
20372101
false,
20382102
]),
@@ -2092,7 +2156,7 @@ function applyVectorDataRenderLayers(
20922156
removeIfExists(map, lineDecorationLayerId(layer.id));
20932157
}
20942158

2095-
if (!layer.style.extrusionEnabled && profile.hasPoint && renderer === "heatmap") {
2159+
if (profile.hasPoint && renderer === "heatmap") {
20962160
// Heatmap renderer: one density layer, no circle/cluster/marker layers.
20972161
removeIfExists(map, circleLayerId(layer.id));
20982162
removeIfExists(map, markerLayerId(layer.id));
@@ -2117,7 +2181,7 @@ function applyVectorDataRenderLayers(
21172181
},
21182182
beforeId,
21192183
);
2120-
} else if (!layer.style.extrusionEnabled && profile.hasPoint && renderer === "cluster") {
2184+
} else if (profile.hasPoint && renderer === "cluster") {
21212185
// Cluster renderer: a bubble + count for aggregated clusters, plus a circle
21222186
// for the individual (unclustered) points. The source carries clusters
21232187
// (geojson source-level clustering, or supercluster tiles on the tiled path).
@@ -2177,7 +2241,7 @@ function applyVectorDataRenderLayers(
21772241
},
21782242
beforeId,
21792243
);
2180-
} else if (!layer.style.extrusionEnabled && profile.hasPoint) {
2244+
} else if (profile.hasPoint) {
21812245
// Single (default) renderer: a marker icon per point when a marker is
21822246
// configured, otherwise one circle per point.
21832247
removeIfExists(map, heatmapLayerId(layer.id));
@@ -2259,7 +2323,7 @@ function applyVectorDataRenderLayers(
22592323
removeIfExists(map, clusterCountLayerId(layer.id));
22602324
}
22612325

2262-
if (!layer.style.extrusionEnabled && hasTextMarkers) {
2326+
if (hasTextMarkers) {
22632327
ensureLayer(
22642328
map,
22652329
textLayerId(layer.id),

packages/plugins/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
"maplibre-gl-enviroatlas": "^0.1.1",
5656
"maplibre-gl-esri-wayback": "^0.2.2",
5757
"maplibre-gl-fema-wms": "^0.1.2",
58-
"maplibre-gl-geo-editor": "^0.10.3",
58+
"maplibre-gl-geo-editor": "^0.10.4",
5959
"maplibre-gl-geoagent": "^0.5.6",
6060
"maplibre-gl-lidar": "^0.16.2",
6161
"maplibre-gl-nasa-earthdata": "^0.1.4",

packages/plugins/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,9 @@ export { maplibreFemaWmsPlugin } from "./plugins/maplibre-fema-wms";
387387
export {
388388
maplibreGeoEditorPlugin,
389389
GEO_EDITOR_PLUGIN_ID,
390+
DEFAULT_GEO_EDITOR_LABELS,
391+
setGeoEditorLabels,
392+
type GeoEditorLabels,
390393
canEditLayerGeometry,
391394
SKETCHES_SOURCE_KIND,
392395
startLayerGeometryEdit,

0 commit comments

Comments
 (0)