Initial commit

This commit is contained in:
2026-04-15 17:08:39 +02:00
parent ae164c47a8
commit 47fd1c2b7a
1819 changed files with 685388 additions and 0 deletions
+907
View File
@@ -0,0 +1,907 @@
.maplibregl-map {
font: 12px/20px "Helvetica Neue", Arial, Helvetica, sans-serif;
overflow: hidden;
position: relative;
-webkit-tap-highlight-color: rgb(0, 0, 0, 0);
}
.maplibregl-canvas {
position: absolute;
left: 0;
top: 0;
}
.maplibregl-map:fullscreen {
width: 100%;
height: 100%;
}
.maplibregl-ctrl-group button.maplibregl-ctrl-compass {
touch-action: none;
}
.maplibregl-canvas-container.maplibregl-interactive,
.maplibregl-ctrl-group button.maplibregl-ctrl-compass {
cursor: grab;
user-select: none;
}
.maplibregl-canvas-container.maplibregl-interactive.maplibregl-track-pointer {
cursor: pointer;
}
.maplibregl-canvas-container.maplibregl-interactive:active,
.maplibregl-ctrl-group button.maplibregl-ctrl-compass:active {
cursor: grabbing;
}
.maplibregl-canvas-container.maplibregl-touch-zoom-rotate,
.maplibregl-canvas-container.maplibregl-touch-zoom-rotate .maplibregl-canvas {
touch-action: pan-x pan-y;
}
.maplibregl-canvas-container.maplibregl-touch-drag-pan,
.maplibregl-canvas-container.maplibregl-touch-drag-pan .maplibregl-canvas {
touch-action: pinch-zoom;
}
.maplibregl-canvas-container.maplibregl-touch-zoom-rotate.maplibregl-touch-drag-pan,
.maplibregl-canvas-container.maplibregl-touch-zoom-rotate.maplibregl-touch-drag-pan .maplibregl-canvas {
touch-action: none;
}
.maplibregl-canvas-container.maplibregl-touch-drag-pan.maplibregl-cooperative-gestures,
.maplibregl-canvas-container.maplibregl-touch-drag-pan.maplibregl-cooperative-gestures .maplibregl-canvas {
touch-action: pan-x pan-y;
}
.maplibregl-ctrl-top-left,
.maplibregl-ctrl-top-right,
.maplibregl-ctrl-bottom-left,
.maplibregl-ctrl-bottom-right {
position: absolute;
pointer-events: none;
z-index: 2;
}
.maplibregl-ctrl-top-left {
top: 0;
left: 0;
}
.maplibregl-ctrl-top-right {
top: 0;
right: 0;
}
.maplibregl-ctrl-bottom-left {
bottom: 0;
left: 0;
}
.maplibregl-ctrl-bottom-right {
right: 0;
bottom: 0;
}
.maplibregl-ctrl {
clear: both;
pointer-events: auto;
/* workaround for a Safari bug https://github.com/mapbox/mapbox-gl-js/issues/8185 */
transform: translate(0, 0);
}
.maplibregl-ctrl-top-left .maplibregl-ctrl {
margin: 10px 0 0 10px;
float: left;
}
.maplibregl-ctrl-top-right .maplibregl-ctrl {
margin: 10px 10px 0 0;
float: right;
}
.maplibregl-ctrl-bottom-left .maplibregl-ctrl {
margin: 0 0 10px 10px;
float: left;
}
.maplibregl-ctrl-bottom-right .maplibregl-ctrl {
margin: 0 10px 10px 0;
float: right;
}
.maplibregl-ctrl-group {
border-radius: 4px;
background: #fff;
}
.maplibregl-ctrl-group:not(:empty) {
box-shadow: 0 0 0 2px rgb(0, 0, 0, 0.1);
}
@media (forced-colors: active) {
.maplibregl-ctrl-group:not(:empty) {
box-shadow: 0 0 0 2px ButtonText;
}
}
.maplibregl-ctrl-group button {
width: 29px;
height: 29px;
display: block;
padding: 0;
outline: none;
border: 0;
box-sizing: border-box;
background-color: transparent;
cursor: pointer;
}
.maplibregl-ctrl-group button + button {
border-top: 1px solid #ddd;
}
.maplibregl-ctrl button .maplibregl-ctrl-icon {
display: block;
width: 100%;
height: 100%;
background-repeat: no-repeat;
background-position: center center;
}
@media (forced-colors: active) {
.maplibregl-ctrl-icon {
background-color: transparent;
}
.maplibregl-ctrl-group button + button {
border-top: 1px solid ButtonText;
}
}
/* https://bugzilla.mozilla.org/show_bug.cgi?id=140562 */
.maplibregl-ctrl button::-moz-focus-inner {
border: 0;
padding: 0;
}
.maplibregl-ctrl-attrib-button:focus,
.maplibregl-ctrl-group button:focus {
box-shadow: 0 0 2px 2px rgb(0, 150, 255, 1);
}
.maplibregl-ctrl button:disabled {
cursor: not-allowed;
}
.maplibregl-ctrl button:disabled .maplibregl-ctrl-icon {
opacity: 0.25;
}
@media (hover: hover) {
.maplibregl-ctrl button:not(:disabled):hover {
background-color: rgb(0, 0, 0, 0.05);
}
}
.maplibregl-ctrl button:not(:disabled):active {
background-color: rgb(0, 0, 0, 0.05);
}
.maplibregl-ctrl-group button:focus:focus-visible {
box-shadow: 0 0 2px 2px rgb(0, 150, 255, 1);
}
.maplibregl-ctrl-group button:focus:not(:focus-visible) {
box-shadow: none;
}
.maplibregl-ctrl-group button:focus:first-child {
border-radius: 4px 4px 0 0;
}
.maplibregl-ctrl-group button:focus:last-child {
border-radius: 0 0 4px 4px;
}
.maplibregl-ctrl-group button:focus:only-child {
border-radius: inherit;
}
.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon {
background-image: svg-load("svg/maplibregl-ctrl-zoom-out.svg", fill: #333);
}
.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon {
background-image: svg-load("svg/maplibregl-ctrl-zoom-in.svg", fill: #333);
}
@media (forced-colors: active) {
.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon {
background-image: svg-load("svg/maplibregl-ctrl-zoom-out.svg", fill: #fff);
}
.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon {
background-image: svg-load("svg/maplibregl-ctrl-zoom-in.svg", fill: #fff);
}
}
@media (forced-colors: active) and (prefers-color-scheme: light) {
.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon {
background-image: svg-load("svg/maplibregl-ctrl-zoom-out.svg", fill: #000);
}
.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon {
background-image: svg-load("svg/maplibregl-ctrl-zoom-in.svg", fill: #000);
}
}
.maplibregl-ctrl button.maplibregl-ctrl-fullscreen .maplibregl-ctrl-icon {
background-image: svg-load("svg/maplibregl-ctrl-fullscreen.svg", fill: #333);
}
.maplibregl-ctrl button.maplibregl-ctrl-shrink .maplibregl-ctrl-icon {
background-image: svg-load("svg/maplibregl-ctrl-shrink.svg");
}
@media (forced-colors: active) {
.maplibregl-ctrl button.maplibregl-ctrl-fullscreen .maplibregl-ctrl-icon {
background-image: svg-load("svg/maplibregl-ctrl-fullscreen.svg", fill: #fff);
}
.maplibregl-ctrl button.maplibregl-ctrl-shrink .maplibregl-ctrl-icon {
background-image: svg-load("svg/maplibregl-ctrl-shrink.svg", fill: #fff);
}
}
@media (forced-colors: active) and (prefers-color-scheme: light) {
.maplibregl-ctrl button.maplibregl-ctrl-fullscreen .maplibregl-ctrl-icon {
background-image: svg-load("svg/maplibregl-ctrl-fullscreen.svg", fill: #000);
}
.maplibregl-ctrl button.maplibregl-ctrl-shrink .maplibregl-ctrl-icon {
background-image: svg-load("svg/maplibregl-ctrl-shrink.svg", fill: #000);
}
}
.maplibregl-ctrl button.maplibregl-ctrl-compass .maplibregl-ctrl-icon {
background-image: svg-load("svg/maplibregl-ctrl-compass.svg", fill: #333);
}
@media (forced-colors: active) {
.maplibregl-ctrl button.maplibregl-ctrl-compass .maplibregl-ctrl-icon {
@svg-load ctrl-compass-white url("svg/maplibregl-ctrl-compass.svg") {
fill: #fff;
#south { fill: #999; }
}
background-image: svg-inline(ctrl-compass-white);
}
}
@media (forced-colors: active) and (prefers-color-scheme: light) {
.maplibregl-ctrl button.maplibregl-ctrl-compass .maplibregl-ctrl-icon {
background-image: svg-load("svg/maplibregl-ctrl-compass.svg", fill: #000);
}
}
@svg-load ctrl-globe url("svg/maplibregl-ctrl-globe.svg") {
stroke: #333;
}
@svg-load ctrl-globe-enabled url("svg/maplibregl-ctrl-globe.svg") {
stroke: #33b5e5;
}
.maplibregl-ctrl button.maplibregl-ctrl-globe .maplibregl-ctrl-icon {
background-image: svg-inline(ctrl-globe);
}
.maplibregl-ctrl button.maplibregl-ctrl-globe-enabled .maplibregl-ctrl-icon {
background-image: svg-inline(ctrl-globe-enabled);
}
@svg-load ctrl-terrain url("svg/maplibregl-ctrl-terrain.svg") {
fill: #333;
}
@svg-load ctrl-terrain-enabled url("svg/maplibregl-ctrl-terrain.svg") {
fill: #33b5e5;
}
.maplibregl-ctrl button.maplibregl-ctrl-terrain .maplibregl-ctrl-icon {
background-image: svg-inline(ctrl-terrain);
}
.maplibregl-ctrl button.maplibregl-ctrl-terrain-enabled .maplibregl-ctrl-icon {
background-image: svg-inline(ctrl-terrain-enabled);
}
@svg-load ctrl-geolocate url("svg/maplibregl-ctrl-geolocate.svg") {
fill: #333;
#stroke { display: none; }
}
@svg-load ctrl-geolocate-white url("svg/maplibregl-ctrl-geolocate.svg") {
fill: #fff;
#stroke { display: none; }
}
@svg-load ctrl-geolocate-black url("svg/maplibregl-ctrl-geolocate.svg") {
fill: #000;
#stroke { display: none; }
}
@svg-load ctrl-geolocate-disabled url("svg/maplibregl-ctrl-geolocate.svg") {
fill: #aaa;
#stroke { fill: #f00; }
}
@svg-load ctrl-geolocate-disabled-white url("svg/maplibregl-ctrl-geolocate.svg") {
fill: #999;
#stroke { fill: #f00; }
}
@svg-load ctrl-geolocate-disabled-black url("svg/maplibregl-ctrl-geolocate.svg") {
fill: #666;
#stroke { fill: #f00; }
}
@svg-load ctrl-geolocate-active url("svg/maplibregl-ctrl-geolocate.svg") {
fill: #33b5e5;
#stroke { display: none; }
}
@svg-load ctrl-geolocate-active-error url("svg/maplibregl-ctrl-geolocate.svg") {
fill: #e58978;
#stroke { display: none; }
}
@svg-load ctrl-geolocate-background url("svg/maplibregl-ctrl-geolocate.svg") {
fill: #33b5e5;
#stroke { display: none; }
#dot { display: none; }
}
@svg-load ctrl-geolocate-background-error url("svg/maplibregl-ctrl-geolocate.svg") {
fill: #e54e33;
#stroke { display: none; }
#dot { display: none; }
}
.maplibregl-ctrl button.maplibregl-ctrl-geolocate .maplibregl-ctrl-icon {
background-image: svg-inline(ctrl-geolocate);
}
.maplibregl-ctrl button.maplibregl-ctrl-geolocate:disabled .maplibregl-ctrl-icon {
background-image: svg-inline(ctrl-geolocate-disabled);
}
.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active .maplibregl-ctrl-icon {
background-image: svg-inline(ctrl-geolocate-active);
}
.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active-error .maplibregl-ctrl-icon {
background-image: svg-inline(ctrl-geolocate-active-error);
}
.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background .maplibregl-ctrl-icon {
background-image: svg-inline(ctrl-geolocate-background);
}
.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background-error .maplibregl-ctrl-icon {
background-image: svg-inline(ctrl-geolocate-background-error);
}
.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-waiting .maplibregl-ctrl-icon {
animation: maplibregl-spin 2s infinite linear;
}
@media (forced-colors: active) {
.maplibregl-ctrl button.maplibregl-ctrl-geolocate .maplibregl-ctrl-icon {
background-image: svg-inline(ctrl-geolocate-white);
}
.maplibregl-ctrl button.maplibregl-ctrl-geolocate:disabled .maplibregl-ctrl-icon {
background-image: svg-inline(ctrl-geolocate-disabled-white);
}
.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active .maplibregl-ctrl-icon {
background-image: svg-inline(ctrl-geolocate-active);
}
.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active-error .maplibregl-ctrl-icon {
background-image: svg-inline(ctrl-geolocate-active-error);
}
.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background .maplibregl-ctrl-icon {
background-image: svg-inline(ctrl-geolocate-background);
}
.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background-error .maplibregl-ctrl-icon {
background-image: svg-inline(ctrl-geolocate-background-error);
}
}
@media (forced-colors: active) and (prefers-color-scheme: light) {
.maplibregl-ctrl button.maplibregl-ctrl-geolocate .maplibregl-ctrl-icon {
background-image: svg-inline(ctrl-geolocate-black);
}
.maplibregl-ctrl button.maplibregl-ctrl-geolocate:disabled .maplibregl-ctrl-icon {
background-image: svg-inline(ctrl-geolocate-disabled-black);
}
}
@keyframes maplibregl-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
a.maplibregl-ctrl-logo {
width: 88px;
height: 23px;
margin: 0 0 -4px -4px;
display: block;
background-repeat: no-repeat;
cursor: pointer;
overflow: hidden;
background-image: svg-load("svg/maplibregl-ctrl-logo.svg");
}
a.maplibregl-ctrl-logo.maplibregl-compact {
width: 14px;
}
@media (forced-colors: active) {
a.maplibregl-ctrl-logo {
@svg-load ctrl-logo-white url("svg/maplibregl-ctrl-logo.svg") {
#outline { opacity: 1; }
#fill { opacity: 1; }
}
background-color: transparent;
background-image: svg-inline(ctrl-logo-white);
}
}
@media (forced-colors: active) and (prefers-color-scheme: light) {
a.maplibregl-ctrl-logo {
@svg-load ctrl-logo-black url("svg/maplibregl-ctrl-logo.svg") {
#outline { opacity: 1; fill: #fff; stroke: #fff; }
#fill { opacity: 1; fill: #000; }
}
background-image: svg-inline(ctrl-logo-black);
}
}
.maplibregl-ctrl.maplibregl-ctrl-attrib {
padding: 0 5px;
background-color: rgb(255, 255, 255, 0.5);
margin: 0;
}
@media screen {
.maplibregl-ctrl-attrib.maplibregl-compact {
min-height: 20px;
padding: 2px 24px 2px 0;
margin: 10px;
position: relative;
background-color: #fff;
color: #000;
border-radius: 12px;
box-sizing: content-box;
}
.maplibregl-ctrl-attrib.maplibregl-compact-show {
padding: 2px 28px 2px 8px;
visibility: visible;
}
.maplibregl-ctrl-top-left > .maplibregl-ctrl-attrib.maplibregl-compact-show,
.maplibregl-ctrl-bottom-left > .maplibregl-ctrl-attrib.maplibregl-compact-show {
padding: 2px 8px 2px 28px;
border-radius: 12px;
}
.maplibregl-ctrl-attrib.maplibregl-compact .maplibregl-ctrl-attrib-inner {
display: none;
}
.maplibregl-ctrl-attrib-button {
display: none;
cursor: pointer;
position: absolute;
background-image: svg-load("svg/maplibregl-ctrl-attrib.svg");
background-color: rgb(255, 255, 255, 0.5);
width: 24px;
height: 24px;
box-sizing: border-box;
border-radius: 12px;
outline: none;
top: 0;
right: 0;
border: 0;
}
.maplibregl-ctrl-attrib summary.maplibregl-ctrl-attrib-button {
appearance: none;
list-style: none;
}
.maplibregl-ctrl-attrib summary.maplibregl-ctrl-attrib-button::-webkit-details-marker {
display: none;
}
.maplibregl-ctrl-top-left .maplibregl-ctrl-attrib-button,
.maplibregl-ctrl-bottom-left .maplibregl-ctrl-attrib-button {
left: 0;
}
.maplibregl-ctrl-attrib.maplibregl-compact .maplibregl-ctrl-attrib-button,
.maplibregl-ctrl-attrib.maplibregl-compact-show .maplibregl-ctrl-attrib-inner {
display: block;
}
.maplibregl-ctrl-attrib.maplibregl-compact-show .maplibregl-ctrl-attrib-button {
background-color: rgb(0, 0, 0, 0.05);
}
.maplibregl-ctrl-bottom-right > .maplibregl-ctrl-attrib.maplibregl-compact::after {
bottom: 0;
right: 0;
}
.maplibregl-ctrl-top-right > .maplibregl-ctrl-attrib.maplibregl-compact::after {
top: 0;
right: 0;
}
.maplibregl-ctrl-top-left > .maplibregl-ctrl-attrib.maplibregl-compact::after {
top: 0;
left: 0;
}
.maplibregl-ctrl-bottom-left > .maplibregl-ctrl-attrib.maplibregl-compact::after {
bottom: 0;
left: 0;
}
}
@media screen and (forced-colors: active) {
.maplibregl-ctrl-attrib.maplibregl-compact::after {
background-image: svg-load("svg/maplibregl-ctrl-attrib.svg", fill=#fff);
}
}
@media screen and (forced-colors: active) and (prefers-color-scheme: light) {
.maplibregl-ctrl-attrib.maplibregl-compact::after {
background-image: svg-load("svg/maplibregl-ctrl-attrib.svg");
}
}
.maplibregl-ctrl-attrib a {
color: rgb(0, 0, 0, 0.75);
text-decoration: none;
}
.maplibregl-ctrl-attrib a:hover {
color: inherit;
text-decoration: underline;
}
.maplibregl-attrib-empty {
display: none;
}
.maplibregl-ctrl-scale {
background-color: rgb(255, 255, 255, 0.75);
font-size: 10px;
white-space: nowrap;
border-width: medium 2px 2px;
border-style: none solid solid;
border-color: #333;
padding: 0 5px;
color: #333;
box-sizing: border-box;
}
.maplibregl-popup {
position: absolute;
top: 0;
left: 0;
display: flex;
will-change: transform;
pointer-events: none;
}
.maplibregl-popup-anchor-top,
.maplibregl-popup-anchor-top-left,
.maplibregl-popup-anchor-top-right {
flex-direction: column;
}
.maplibregl-popup-anchor-bottom,
.maplibregl-popup-anchor-bottom-left,
.maplibregl-popup-anchor-bottom-right {
flex-direction: column-reverse;
}
.maplibregl-popup-anchor-left {
flex-direction: row;
}
.maplibregl-popup-anchor-right {
flex-direction: row-reverse;
}
.maplibregl-popup-tip {
width: 0;
height: 0;
border: 10px solid transparent;
z-index: 1;
}
.maplibregl-popup-anchor-top .maplibregl-popup-tip {
align-self: center;
border-top: none;
border-bottom-color: #fff;
}
.maplibregl-popup-anchor-top-left .maplibregl-popup-tip {
align-self: flex-start;
border-top: none;
border-left: none;
border-bottom-color: #fff;
}
.maplibregl-popup-anchor-top-right .maplibregl-popup-tip {
align-self: flex-end;
border-top: none;
border-right: none;
border-bottom-color: #fff;
}
.maplibregl-popup-anchor-bottom .maplibregl-popup-tip {
align-self: center;
border-bottom: none;
border-top-color: #fff;
}
.maplibregl-popup-anchor-bottom-left .maplibregl-popup-tip {
align-self: flex-start;
border-bottom: none;
border-left: none;
border-top-color: #fff;
}
.maplibregl-popup-anchor-bottom-right .maplibregl-popup-tip {
align-self: flex-end;
border-bottom: none;
border-right: none;
border-top-color: #fff;
}
.maplibregl-popup-anchor-left .maplibregl-popup-tip {
align-self: center;
border-left: none;
border-right-color: #fff;
}
.maplibregl-popup-anchor-right .maplibregl-popup-tip {
align-self: center;
border-right: none;
border-left-color: #fff;
}
[dir="rtl"] .maplibregl-popup-anchor-left {
flex-direction: row-reverse;
}
[dir="rtl"] .maplibregl-popup-anchor-right {
flex-direction: row;
}
[dir="rtl"] .maplibregl-popup-anchor-top-left .maplibregl-popup-tip {
align-self: flex-end;
}
[dir="rtl"] .maplibregl-popup-anchor-top-right .maplibregl-popup-tip {
align-self: flex-start;
}
[dir="rtl"] .maplibregl-popup-anchor-bottom-left .maplibregl-popup-tip {
align-self: flex-end;
}
[dir="rtl"] .maplibregl-popup-anchor-bottom-right .maplibregl-popup-tip {
align-self: flex-start;
}
.maplibregl-popup-close-button {
position: absolute;
right: 0;
top: 0;
border: 0;
border-radius: 0 3px 0 0;
cursor: pointer;
background-color: transparent;
}
.maplibregl-popup-close-button:hover {
background-color: rgb(0, 0, 0, 0.05);
}
.maplibregl-popup-content {
position: relative;
background: #fff;
border-radius: 3px;
box-shadow: 0 1px 2px rgb(0, 0, 0, 0.1);
padding: 15px 10px;
pointer-events: auto;
}
.maplibregl-popup-anchor-top-left .maplibregl-popup-content {
border-top-left-radius: 0;
}
.maplibregl-popup-anchor-top-right .maplibregl-popup-content {
border-top-right-radius: 0;
}
.maplibregl-popup-anchor-bottom-left .maplibregl-popup-content {
border-bottom-left-radius: 0;
}
.maplibregl-popup-anchor-bottom-right .maplibregl-popup-content {
border-bottom-right-radius: 0;
}
.maplibregl-popup-track-pointer {
display: none;
}
.maplibregl-popup-track-pointer * {
pointer-events: none;
user-select: none;
}
.maplibregl-map:hover .maplibregl-popup-track-pointer {
display: flex;
}
.maplibregl-map:active .maplibregl-popup-track-pointer {
display: none;
}
.maplibregl-marker {
position: absolute;
top: 0;
left: 0;
will-change: transform;
transition: opacity 0.2s;
}
.maplibregl-user-location-dot {
background-color: #1da1f2;
width: 15px;
height: 15px;
border-radius: 50%;
}
.maplibregl-user-location-dot::before {
background-color: #1da1f2;
content: "";
width: 15px;
height: 15px;
border-radius: 50%;
position: absolute;
animation: maplibregl-user-location-dot-pulse 2s infinite;
}
.maplibregl-user-location-dot::after {
border-radius: 50%;
border: 2px solid #fff;
content: "";
height: 19px;
left: -2px;
position: absolute;
top: -2px;
width: 19px;
box-sizing: border-box;
box-shadow: 0 0 3px rgb(0, 0, 0, 0.35);
}
@media (prefers-reduced-motion: reduce) {
.maplibregl-user-location-dot::before {
animation: none;
}
}
@keyframes maplibregl-user-location-dot-pulse {
0% { transform: scale(1); opacity: 1; }
70% { transform: scale(3); opacity: 0; }
100% { transform: scale(1); opacity: 0; }
}
.maplibregl-user-location-dot-stale {
background-color: #aaa;
}
.maplibregl-user-location-dot-stale::after {
display: none;
}
.maplibregl-user-location-accuracy-circle {
background-color: #1da1f233;
width: 1px;
height: 1px;
border-radius: 100%;
}
.maplibregl-crosshair,
.maplibregl-crosshair .maplibregl-interactive,
.maplibregl-crosshair .maplibregl-interactive:active {
cursor: crosshair;
}
.maplibregl-boxzoom {
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
background: #fff;
border: 2px dotted #202020;
opacity: 0.5;
}
.maplibregl-cooperative-gesture-screen {
background: rgb(0, 0, 0, 0.4);
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
color: white;
padding: 1rem;
font-size: 1.4em;
line-height: 1.2;
opacity: 0;
pointer-events: none;
transition: opacity 1s ease 1s;
z-index: 99999;
}
.maplibregl-cooperative-gesture-screen.maplibregl-show {
opacity: 1;
transition: opacity 0.05s;
}
.maplibregl-cooperative-gesture-screen .maplibregl-mobile-message {
display: none;
}
@media (hover: none), (pointer: coarse) {
.maplibregl-cooperative-gesture-screen .maplibregl-desktop-message {
display: none;
}
.maplibregl-cooperative-gesture-screen .maplibregl-mobile-message {
display: block;
}
}
.maplibregl-pseudo-fullscreen {
position: fixed !important;
width: 100% !important;
height: 100% !important;
top: 0 !important;
left: 0 !important;
z-index: 99999;
}
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill-rule="evenodd" viewBox="0 0 20 20">
<path d="M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0"/>
</svg>

After

Width:  |  Height:  |  Size: 229 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="29" height="29" viewBox="0 0 29 29">
<path d="m10.5 14 4-8 4 8z"/>
<path fill="#ccc" d="m10.5 16 4 8 4-8z"/>
</svg>

After

Width:  |  Height:  |  Size: 171 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="29" height="29" viewBox="0 0 29 29">
<path d="M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1z"/>
</svg>

After

Width:  |  Height:  |  Size: 370 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="29" height="29" viewBox="0 0 20 20">
<path d="M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7"/>
<circle id="dot" cx="10" cy="10" r="2"/>
<path id="stroke" d="m14 5 1 1-9 9-1-1z"/>
</svg>

After

Width:  |  Height:  |  Size: 408 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22">
<circle cx="11" cy="11" r="8.5" />
<path d="M17.5 11c0 4.819-3.02 8.5-6.5 8.5S4.5 15.819 4.5 11 7.52 2.5 11 2.5s6.5 3.681 6.5 8.5Z"/>
<path d="M13.5 11c0 2.447-.331 4.64-.853 6.206-.262.785-.562 1.384-.872 1.777-.314.399-.58.517-.775.517-.195 0-.461-.118-.775-.517-.31-.393-.61-.992-.872-1.777C8.831 15.64 8.5 13.446 8.5 11c0-2.447.331-4.64.853-6.206.262-.785.562-1.384.872-1.777.314-.399.58-.517.775-.517.195 0 .461.118.775.517.31.393.61.992.872 1.777.522 1.565.853 3.76.853 6.206Z"/>
<path d="M11 7.5c-1.909 0-3.622-.166-4.845-.428-.616-.132-1.08-.283-1.379-.434a1.333 1.333 0 0 1-.224-.138c.047-.038.12-.085.224-.138.299-.151.763-.302 1.379-.434C7.378 5.666 9.091 5.5 11 5.5c1.909 0 3.622.166 4.845.428.616.132 1.08.283 1.379.434.105.053.177.1.224.138-.047.038-.12.085-.224.138-.299.151-.763.302-1.379.434-1.223.262-2.936.428-4.845.428ZM4.486 6.436ZM11 16.5c-1.909 0-3.622-.166-4.845-.428-.616-.132-1.08-.283-1.379-.434a1.33 1.33 0 0 1-.224-.138 1.33 1.33 0 0 1 .224-.138c.299-.151.763-.302 1.379-.434C7.378 14.666 9.091 14.5 11 14.5c1.909 0 3.622.166 4.845.428.616.132 1.08.283 1.379.434.105.053.177.1.224.138a1.33 1.33 0 0 1-.224.138c-.299.151-.763.302-1.379.434-1.223.262-2.936.428-4.845.428Zm-6.514-1.064ZM11 12.5c-2.46 0-4.672-.222-6.255-.574-.796-.177-1.406-.38-1.805-.59a1.465 1.465 0 0 1-.39-.272.293.293 0 0 1-.047-.064.293.293 0 0 1 .048-.064c.066-.073.189-.167.389-.272.399-.21 1.009-.413 1.805-.59C6.328 9.722 8.54 9.5 11 9.5s4.672.222 6.256.574c.795.177 1.405.38 1.804.59.2.105.323.2.39.272a.33.33 0 0 1 .047.064.293.293 0 0 1-.048.064 1.435 1.435 0 0 1-.389.272c-.399.21-1.009.413-1.804.59-1.584.352-3.796.574-6.256.574Zm-8.501-1.51v.002-.002Zm0 .018v.002-.002Zm17.002.002v-.002.002Zm0-.018v-.002.002Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="29" height="29" viewBox="0 0 29 29">
<path d="M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1z"/>
</svg>

After

Width:  |  Height:  |  Size: 370 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 22 22">
<path d="m1.754 13.406 4.453-4.851 3.09 3.09 3.281 3.277.969-.969-3.309-3.312 3.844-4.121 6.148 6.886h1.082v-.855l-7.207-8.07-4.84 5.187L6.169 6.57l-5.48 5.965v.871ZM.688 16.844h20.625v1.375H.688Zm0 0"/>
</svg>

After

Width:  |  Height:  |  Size: 299 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="29" height="29" viewBox="0 0 29 29">
<path d="M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 290 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="29" height="29" viewBox="0 0 29 29">
<path d="M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13z"/>
</svg>

After

Width:  |  Height:  |  Size: 190 B

File diff suppressed because it is too large Load Diff
+130
View File
@@ -0,0 +1,130 @@
import type {CollisionBoxArray} from './array_types.g';
import type {Style} from '../style/style';
import type {TypedStyleLayer} from '../style/style_layer/typed_style_layer';
import type {FeatureIndex} from './feature_index';
import type {Context} from '../gl/context';
import type {FeatureStates} from '../source/source_state';
import type {ImagePosition} from '../render/image_atlas';
import type {CanonicalTileID} from '../tile/tile_id';
import type Point from '@mapbox/point-geometry';
import type {SubdivisionGranularitySetting} from '../render/subdivision_granularity_settings';
import type {DashEntry} from '../render/line_atlas';
import type {Feature as StyleFeature} from '@maplibre/maplibre-gl-style-spec';
import type {VectorTileFeatureLike, VectorTileLayerLike} from '@maplibre/vt-pbf';
export type BucketParameters<Layer extends TypedStyleLayer> = {
index: number;
layers: Array<Layer>;
zoom: number;
pixelRatio: number;
overscaling: number;
collisionBoxArray: CollisionBoxArray;
sourceLayerIndex: number;
sourceID: string;
};
export type PopulateParameters = {
featureIndex: FeatureIndex;
iconDependencies: {};
patternDependencies: {};
glyphDependencies: {};
dashDependencies: Record<string, {round: boolean; dasharray: Array<number>}>;
availableImages: Array<string>;
subdivisionGranularity: SubdivisionGranularitySetting;
};
export type IndexedFeature = {
feature: VectorTileFeatureLike;
id: number | string;
index: number;
sourceLayerIndex: number;
};
export type BucketFeature = {
index: number;
sourceLayerIndex: number;
geometry: Array<Array<Point>>;
properties: any;
type: 0 | 1 | 2 | 3;
id?: any;
readonly patterns: {
[_: string]: {
'min': string;
'mid': string;
'max': string;
};
};
readonly dashes?: NonNullable<StyleFeature['dashes']>;
sortKey?: number;
};
/**
* @hidden
* The `Bucket` interface is the single point of knowledge about turning vector
* tiles into WebGL buffers.
*
* `Bucket` is an abstract interface. An implementation exists for each style layer type.
* Create a bucket via the `StyleLayer.createBucket` method.
*
* The concrete bucket types, using layout options from the style layer,
* transform feature geometries into vertex and index data for use by the
* vertex shader. They also (via `ProgramConfiguration`) use feature
* properties and the zoom level to populate the attributes needed for
* data-driven styling.
*
* Buckets are designed to be built on a worker thread and then serialized and
* transferred back to the main thread for rendering. On the worker side, a
* bucket's vertex, index, and attribute data is stored in `bucket.arrays: ArrayGroup`.
* When a bucket's data is serialized and sent back to the main thread,
* is gets deserialized (using `new Bucket(serializedBucketData)`, with
* the array data now stored in `bucket.buffers: BufferGroup`. BufferGroups
* hold the same data as ArrayGroups, but are tuned for consumption by WebGL.
*/
export interface Bucket {
layerIds: Array<string>;
hasDependencies: boolean;
readonly layers: Array<any>;
readonly stateDependentLayers: Array<any>;
readonly stateDependentLayerIds: Array<string>;
populate(features: Array<IndexedFeature>, options: PopulateParameters, canonical: CanonicalTileID): void;
update(states: FeatureStates, vtLayer: VectorTileLayerLike, imagePositions: {[_: string]: ImagePosition}, dashPositions: Record<string, DashEntry>): void;
isEmpty(): boolean;
upload(context: Context): void;
uploadPending(): boolean;
/**
* Release the WebGL resources associated with the buffers. Note that because
* buckets are shared between layers having the same layout properties, they
* must be destroyed in groups (all buckets for a tile, or all symbol buckets).
*/
destroy(): void;
}
export function deserialize(input: Array<Bucket>, style: Style): {[_: string]: Bucket} {
const output = {};
// Guard against the case where the map's style has been set to null while
// this bucket has been parsing.
if (!style) return output;
for (const bucket of input) {
const layers = bucket.layerIds
.map((id) => style.getLayer(id))
.filter(Boolean);
if (layers.length === 0) {
continue;
}
// look up StyleLayer objects from layer ids (since we don't
// want to waste time serializing/copying them from the worker)
(bucket as any).layers = layers;
if (bucket.stateDependentLayerIds) {
(bucket as any).stateDependentLayers = bucket.stateDependentLayerIds.map((lId) => layers.filter((l) => l.id === lId)[0]);
}
for (const layer of layers) {
output[layer.id] = bucket;
}
}
return output;
}
+8
View File
@@ -0,0 +1,8 @@
import {createLayout} from '../../util/struct_array';
const layout = createLayout([
{name: 'a_pos', components: 2, type: 'Int16'}
], 4);
export default layout;
export const {members, size, alignment} = layout;
+239
View File
@@ -0,0 +1,239 @@
import {CircleLayoutArray} from '../array_types.g';
import {members as layoutAttributes} from './circle_attributes';
import {SegmentVector} from '../segment';
import {ProgramConfigurationSet} from '../program_configuration';
import {TriangleIndexArray} from '../index_array_type';
import {loadGeometry} from '../load_geometry';
import {toEvaluationFeature} from '../evaluation_feature';
import {EXTENT} from '../extent';
import {register} from '../../util/web_worker_transfer';
import {EvaluationParameters} from '../../style/evaluation_parameters';
import type {CanonicalTileID} from '../../tile/tile_id';
import type {
Bucket,
BucketParameters,
BucketFeature,
IndexedFeature,
PopulateParameters
} from '../bucket';
import type {CircleStyleLayer} from '../../style/style_layer/circle_style_layer';
import type {HeatmapStyleLayer} from '../../style/style_layer/heatmap_style_layer';
import type {Context} from '../../gl/context';
import type {IndexBuffer} from '../../gl/index_buffer';
import type {VertexBuffer} from '../../gl/vertex_buffer';
import type Point from '@mapbox/point-geometry';
import type {FeatureStates} from '../../source/source_state';
import type {ImagePosition} from '../../render/image_atlas';
import {type CircleGranularity} from '../../render/subdivision_granularity_settings';
import type {VectorTileLayerLike} from '@maplibre/vt-pbf';
const VERTEX_MIN_VALUE = -32768; // -(2^15)
// Extrude is in range 0..7, which will be mapped to -1..1 in the shader.
function addCircleVertex(layoutVertexArray, x, y, extrudeX, extrudeY) {
// We pack circle position and extrude into range 0..65535, but vertices are stored as *signed* 16-bit integers, so we need to offset the number by 2^15.
layoutVertexArray.emplaceBack(
VERTEX_MIN_VALUE + (x * 8) + extrudeX,
VERTEX_MIN_VALUE + (y * 8) + extrudeY);
}
/**
* @internal
* Circles are represented by two triangles.
*
* Each corner has a pos that is the center of the circle and an extrusion
* vector that is where it points.
*/
export class CircleBucket<Layer extends CircleStyleLayer | HeatmapStyleLayer> implements Bucket {
index: number;
zoom: number;
overscaling: number;
layerIds: Array<string>;
layers: Array<Layer>;
stateDependentLayers: Array<Layer>;
stateDependentLayerIds: Array<string>;
layoutVertexArray: CircleLayoutArray;
layoutVertexBuffer: VertexBuffer;
indexArray: TriangleIndexArray;
indexBuffer: IndexBuffer;
hasDependencies: boolean;
programConfigurations: ProgramConfigurationSet<Layer>;
segments: SegmentVector;
uploaded: boolean;
constructor(options: BucketParameters<Layer>) {
this.zoom = options.zoom;
this.overscaling = options.overscaling;
this.layers = options.layers;
this.layerIds = this.layers.map(layer => layer.id);
this.index = options.index;
this.hasDependencies = false;
this.layoutVertexArray = new CircleLayoutArray();
this.indexArray = new TriangleIndexArray();
this.segments = new SegmentVector();
this.programConfigurations = new ProgramConfigurationSet(options.layers, options.zoom);
this.stateDependentLayerIds = this.layers.filter((l) => l.isStateDependent()).map((l) => l.id);
}
populate(features: Array<IndexedFeature>, options: PopulateParameters, canonical: CanonicalTileID) {
const styleLayer = this.layers[0];
const bucketFeatures: BucketFeature[] = [];
let circleSortKey = null;
let sortFeaturesByKey = false;
// Heatmap circles are usually large (and map-pitch-aligned), tessellate them to allow curvature along the globe.
let subdivide = styleLayer.type === 'heatmap';
// Heatmap layers are handled in this bucket and have no evaluated properties, so we check our access
if (styleLayer.type === 'circle') {
const circleStyle = (styleLayer as CircleStyleLayer);
circleSortKey = circleStyle.layout.get('circle-sort-key');
sortFeaturesByKey = !circleSortKey.isConstant();
// Circles that are "printed" onto the map surface should be tessellated to follow the globe's curvature.
subdivide = subdivide || circleStyle.paint.get('circle-pitch-alignment') === 'map';
}
const granularity = subdivide ? options.subdivisionGranularity.circle : 1;
for (const {feature, id, index, sourceLayerIndex} of features) {
const needGeometry = this.layers[0]._featureFilter.needGeometry;
const evaluationFeature = toEvaluationFeature(feature, needGeometry);
if (!this.layers[0]._featureFilter.filter(new EvaluationParameters(this.zoom), evaluationFeature, canonical)) continue;
const sortKey = sortFeaturesByKey ?
circleSortKey.evaluate(evaluationFeature, {}, canonical) :
undefined;
const bucketFeature: BucketFeature = {
id,
properties: feature.properties,
type: feature.type,
sourceLayerIndex,
index,
geometry: needGeometry ? evaluationFeature.geometry : loadGeometry(feature),
patterns: {},
sortKey
};
bucketFeatures.push(bucketFeature);
}
if (sortFeaturesByKey) {
bucketFeatures.sort((a, b) => a.sortKey - b.sortKey);
}
for (const bucketFeature of bucketFeatures) {
const {geometry, index, sourceLayerIndex} = bucketFeature;
const feature = features[index].feature;
this.addFeature(bucketFeature, geometry, index, canonical, granularity);
options.featureIndex.insert(feature, geometry, index, sourceLayerIndex, this.index);
}
}
update(states: FeatureStates, vtLayer: VectorTileLayerLike, imagePositions: {[_: string]: ImagePosition}) {
if (!this.stateDependentLayers.length) return;
this.programConfigurations.updatePaintArrays(states, vtLayer, this.stateDependentLayers, {
imagePositions
});
}
isEmpty() {
return this.layoutVertexArray.length === 0;
}
uploadPending() {
return !this.uploaded || this.programConfigurations.needsUpload;
}
upload(context: Context) {
if (!this.uploaded) {
this.layoutVertexBuffer = context.createVertexBuffer(this.layoutVertexArray, layoutAttributes);
this.indexBuffer = context.createIndexBuffer(this.indexArray);
}
this.programConfigurations.upload(context);
this.uploaded = true;
}
destroy() {
if (!this.layoutVertexBuffer) return;
this.layoutVertexBuffer.destroy();
this.indexBuffer.destroy();
this.programConfigurations.destroy();
this.segments.destroy();
}
addFeature(feature: BucketFeature, geometry: Array<Array<Point>>, index: number, canonical: CanonicalTileID, granularity: CircleGranularity = 1) {
// Since we store the circle's center in each vertex, we only have 3 bits for actual vertex position in each axis.
// Thus the valid range of positions is 0..7.
// This gives us 4 possible granularity settings that are symmetrical.
// This array stores vertex positions that should by used by the tessellated quad.
let extrudes: Array<number>;
switch (granularity) {
case 1:
extrudes = [0, 7];
break;
case 3:
extrudes = [0, 2, 5, 7];
break;
case 5:
extrudes = [0, 1, 3, 4, 6, 7];
break;
case 7:
extrudes = [0, 1, 2, 3, 4, 5, 6, 7];
break;
default:
throw new Error(`Invalid circle bucket granularity: ${granularity}; valid values are 1, 3, 5, 7.`);
}
const verticesPerAxis = extrudes.length;
for (const ring of geometry) {
for (const point of ring) {
const vx = point.x;
const vy = point.y;
// Do not include points that are outside the tile boundaries.
if (vx < 0 || vx >= EXTENT || vy < 0 || vy >= EXTENT) {
continue;
}
const segment = this.segments.prepareSegment(verticesPerAxis * verticesPerAxis, this.layoutVertexArray, this.indexArray, feature.sortKey);
const index = segment.vertexLength;
for (let y = 0; y < verticesPerAxis; y++) {
for (let x = 0; x < verticesPerAxis; x++) {
addCircleVertex(this.layoutVertexArray, vx, vy, extrudes[x], extrudes[y]);
}
}
for (let y = 0; y < verticesPerAxis - 1; y++) {
for (let x = 0; x < verticesPerAxis - 1; x++) {
const lowerIndex = index + y * verticesPerAxis + x;
const upperIndex = index + (y + 1) * verticesPerAxis + x;
this.indexArray.emplaceBack(lowerIndex, upperIndex + 1, lowerIndex + 1);
this.indexArray.emplaceBack(lowerIndex, upperIndex, upperIndex + 1);
}
}
segment.vertexLength += verticesPerAxis * verticesPerAxis;
segment.primitiveLength += (verticesPerAxis - 1) * (verticesPerAxis - 1) * 2;
}
}
this.programConfigurations.populatePaintArrays(this.layoutVertexArray.length, feature, index, {imagePositions: {}, canonical});
}
}
register('CircleBucket', CircleBucket, {omit: ['layers']});
+7
View File
@@ -0,0 +1,7 @@
import {createLayout} from '../../util/struct_array';
export const dashAttributes = createLayout([
// [0, y, height, width]
{name: 'a_dasharray_from', components: 4, type: 'Uint16'},
{name: 'a_dasharray_to', components: 4, type: 'Uint16'},
]);
+8
View File
@@ -0,0 +1,8 @@
import {createLayout} from '../../util/struct_array';
const layout = createLayout([
{name: 'a_pos', components: 2, type: 'Int16'}
], 4);
export default layout;
export const {members, size, alignment} = layout;
+119
View File
@@ -0,0 +1,119 @@
import {test, expect, describe, beforeAll} from 'vitest';
import {type CreateBucketParameters, createPopulateOptions, getFeaturesFromLayer, loadVectorTile} from '../../../test/unit/lib/tile';
import Point from '@mapbox/point-geometry';
import {SegmentVector} from '../segment';
import {FillBucket} from './fill_bucket';
import {FillStyleLayer} from '../../style/style_layer/fill_style_layer';
import {type LayerSpecification} from '@maplibre/maplibre-gl-style-spec';
import {type EvaluationParameters} from '../../style/evaluation_parameters';
import {type ZoomHistory} from '../../style/zoom_history';
import {type BucketFeature, type BucketParameters} from '../bucket';
import {SubdivisionGranularitySetting} from '../../render/subdivision_granularity_settings';
import {CanonicalTileID} from '../../tile/tile_id';
import type {VectorTileLayerLike} from '@maplibre/vt-pbf';
function createPolygon(numPoints) {
const points = [];
for (let i = 0; i < numPoints; i++) {
points.push(new Point(2048 + 256 * Math.cos(i / numPoints * 2 * Math.PI), 2048 + 256 * Math.sin(i / numPoints * 2 * Math.PI)));
}
return points;
}
function createFillBucket({id, layout, paint, globalState, availableImages}: CreateBucketParameters): FillBucket {
const layer = new FillStyleLayer({
id,
type: 'fill',
layout,
paint
} as LayerSpecification, globalState);
layer.recalculate({zoom: 0, zoomHistory: {} as ZoomHistory} as EvaluationParameters,
availableImages as Array<string>);
return new FillBucket({layers: [layer]} as BucketParameters<FillStyleLayer>);
}
describe('FillBucket', () => {
let sourceLayer: VectorTileLayerLike;
let canonicalTileID;
beforeAll(() => {
// Load fill features from fixture tile.
sourceLayer = loadVectorTile().layers.water;
canonicalTileID = new CanonicalTileID(20, 1, 1);
});
test('FillBucket', () => {
expect(() => {
const bucket = createFillBucket({id: 'test', layout: {}});
bucket.addFeature({} as BucketFeature, [[
new Point(0, 0),
new Point(10, 10)
]], undefined, canonicalTileID, undefined, SubdivisionGranularitySetting.noSubdivision);
bucket.addFeature({} as BucketFeature, [[
new Point(0, 0),
new Point(10, 10),
new Point(10, 20)
]], undefined, canonicalTileID, undefined, SubdivisionGranularitySetting.noSubdivision);
const feature = sourceLayer.feature(0);
bucket.addFeature(feature as any, feature.loadGeometry(), undefined, canonicalTileID, undefined, SubdivisionGranularitySetting.noSubdivision);
}).not.toThrow();
});
test('FillBucket segmentation', () => {
// Stub MAX_VERTEX_ARRAY_LENGTH so we can test features
// breaking across array groups without tests taking a _long_ time.
Object.defineProperty(SegmentVector, 'MAX_VERTEX_ARRAY_LENGTH', {value: 256});
const bucket = createFillBucket({id: 'test', layout: {}, paint: {
'fill-color': ['to-color', ['get', 'foo'], '#000']
}});
// first add an initial, small feature to make sure the next one starts at
// a non-zero offset
bucket.addFeature({} as BucketFeature, [createPolygon(10)], undefined, canonicalTileID, undefined, SubdivisionGranularitySetting.noSubdivision);
// add a feature that will break across the group boundary
bucket.addFeature({} as BucketFeature, [
createPolygon(128),
createPolygon(128)
], undefined, canonicalTileID, undefined, SubdivisionGranularitySetting.noSubdivision);
// Each polygon must fit entirely within a segment, so we expect the
// first segment to include the first feature and the first polygon
// of the second feature, and the second segment to include the
// second polygon of the second feature.
expect(bucket.layoutVertexArray).toHaveLength(266);
expect(bucket.segments.get()[0]).toEqual({
vertexOffset: 0,
vertexLength: 138,
vaos: {},
primitiveOffset: 0,
primitiveLength: 134
});
expect(bucket.segments.get()[1]).toEqual({
vertexOffset: 138,
vertexLength: 128,
vaos: {},
primitiveOffset: 134,
primitiveLength: 126
});
});
test('FillBucket fill-pattern with global-state', () => {
const availableImages = [];
const bucket = createFillBucket({id: 'test', paint: {
'fill-pattern': ['coalesce', ['get', 'pattern'], ['global-state', 'pattern']]
}, globalState: {pattern: 'test-pattern'}, availableImages});
bucket.populate(getFeaturesFromLayer(sourceLayer), createPopulateOptions(availableImages), undefined);
expect(bucket.patternFeatures.length).toBeGreaterThan(0);
expect(bucket.patternFeatures[0].patterns).toEqual({
test: {min: 'test-pattern', mid: 'test-pattern', max: 'test-pattern'}
});
});
});
+199
View File
@@ -0,0 +1,199 @@
import {FillLayoutArray} from '../array_types.g';
import {members as layoutAttributes} from './fill_attributes';
import {SegmentVector} from '../segment';
import {ProgramConfigurationSet} from '../program_configuration';
import {LineIndexArray, TriangleIndexArray} from '../index_array_type';
import {classifyRings} from '@maplibre/maplibre-gl-style-spec';
const EARCUT_MAX_RINGS = 500;
import {register} from '../../util/web_worker_transfer';
import {hasPattern, addPatternDependencies} from './pattern_bucket_features';
import {loadGeometry} from '../load_geometry';
import {toEvaluationFeature} from '../evaluation_feature';
import {EvaluationParameters} from '../../style/evaluation_parameters';
import type {CanonicalTileID} from '../../tile/tile_id';
import type {
Bucket,
BucketParameters,
BucketFeature,
IndexedFeature,
PopulateParameters
} from '../bucket';
import type {FillStyleLayer} from '../../style/style_layer/fill_style_layer';
import type {Context} from '../../gl/context';
import type {IndexBuffer} from '../../gl/index_buffer';
import type {VertexBuffer} from '../../gl/vertex_buffer';
import type Point from '@mapbox/point-geometry';
import type {FeatureStates} from '../../source/source_state';
import type {ImagePosition} from '../../render/image_atlas';
import {subdividePolygon} from '../../render/subdivision';
import type {SubdivisionGranularitySetting} from '../../render/subdivision_granularity_settings';
import {fillLargeMeshArrays} from '../../render/fill_large_mesh_arrays';
import type {VectorTileLayerLike} from '@maplibre/vt-pbf';
export class FillBucket implements Bucket {
index: number;
zoom: number;
overscaling: number;
layers: Array<FillStyleLayer>;
layerIds: Array<string>;
stateDependentLayers: Array<FillStyleLayer>;
stateDependentLayerIds: Array<string>;
patternFeatures: Array<BucketFeature>;
layoutVertexArray: FillLayoutArray;
layoutVertexBuffer: VertexBuffer;
indexArray: TriangleIndexArray;
indexBuffer: IndexBuffer;
indexArray2: LineIndexArray;
indexBuffer2: IndexBuffer;
hasDependencies: boolean;
programConfigurations: ProgramConfigurationSet<FillStyleLayer>;
segments: SegmentVector;
segments2: SegmentVector;
uploaded: boolean;
constructor(options: BucketParameters<FillStyleLayer>) {
this.zoom = options.zoom;
this.overscaling = options.overscaling;
this.layers = options.layers;
this.layerIds = this.layers.map(layer => layer.id);
this.index = options.index;
this.hasDependencies = false;
this.patternFeatures = [];
this.layoutVertexArray = new FillLayoutArray();
this.indexArray = new TriangleIndexArray();
this.indexArray2 = new LineIndexArray();
this.programConfigurations = new ProgramConfigurationSet(options.layers, options.zoom);
this.segments = new SegmentVector();
this.segments2 = new SegmentVector();
this.stateDependentLayerIds = this.layers.filter((l) => l.isStateDependent()).map((l) => l.id);
}
populate(features: Array<IndexedFeature>, options: PopulateParameters, canonical: CanonicalTileID) {
this.hasDependencies = hasPattern('fill', this.layers, options);
const fillSortKey = this.layers[0].layout.get('fill-sort-key');
const sortFeaturesByKey = !fillSortKey.isConstant();
const bucketFeatures: BucketFeature[] = [];
for (const {feature, id, index, sourceLayerIndex} of features) {
const needGeometry = this.layers[0]._featureFilter.needGeometry;
const evaluationFeature = toEvaluationFeature(feature, needGeometry);
if (!this.layers[0]._featureFilter.filter(new EvaluationParameters(this.zoom), evaluationFeature, canonical)) continue;
const sortKey = sortFeaturesByKey ?
fillSortKey.evaluate(evaluationFeature, {}, canonical, options.availableImages) :
undefined;
const bucketFeature: BucketFeature = {
id,
properties: feature.properties,
type: feature.type,
sourceLayerIndex,
index,
geometry: needGeometry ? evaluationFeature.geometry : loadGeometry(feature),
patterns: {},
sortKey
};
bucketFeatures.push(bucketFeature);
}
if (sortFeaturesByKey) {
bucketFeatures.sort((a, b) => a.sortKey - b.sortKey);
}
for (const bucketFeature of bucketFeatures) {
const {geometry, index, sourceLayerIndex} = bucketFeature;
if (this.hasDependencies) {
const patternFeature = addPatternDependencies('fill', this.layers, bucketFeature, {zoom: this.zoom}, options);
// pattern features are added only once the pattern is loaded into the image atlas
// so are stored during populate until later updated with positions by tile worker in addFeatures
this.patternFeatures.push(patternFeature);
} else {
this.addFeature(bucketFeature, geometry, index, canonical, {}, options.subdivisionGranularity);
}
const feature = features[index].feature;
options.featureIndex.insert(feature, geometry, index, sourceLayerIndex, this.index);
}
}
update(states: FeatureStates, vtLayer: VectorTileLayerLike, imagePositions: {
[_: string]: ImagePosition;
}) {
if (!this.stateDependentLayers.length) return;
this.programConfigurations.updatePaintArrays(states, vtLayer, this.stateDependentLayers, {
imagePositions
});
}
addFeatures(options: PopulateParameters, canonical: CanonicalTileID, imagePositions: {
[_: string]: ImagePosition;
}) {
for (const feature of this.patternFeatures) {
this.addFeature(feature, feature.geometry, feature.index, canonical, imagePositions, options.subdivisionGranularity);
}
}
isEmpty() {
return this.layoutVertexArray.length === 0;
}
uploadPending(): boolean {
return !this.uploaded || this.programConfigurations.needsUpload;
}
upload(context: Context) {
if (!this.uploaded) {
this.layoutVertexBuffer = context.createVertexBuffer(this.layoutVertexArray, layoutAttributes);
this.indexBuffer = context.createIndexBuffer(this.indexArray);
this.indexBuffer2 = context.createIndexBuffer(this.indexArray2);
}
this.programConfigurations.upload(context);
this.uploaded = true;
}
destroy() {
if (!this.layoutVertexBuffer) return;
this.layoutVertexBuffer.destroy();
this.indexBuffer.destroy();
this.indexBuffer2.destroy();
this.programConfigurations.destroy();
this.segments.destroy();
this.segments2.destroy();
}
addFeature(feature: BucketFeature, geometry: Array<Array<Point>>, index: number, canonical: CanonicalTileID, imagePositions: {
[_: string]: ImagePosition;
}, subdivisionGranularity: SubdivisionGranularitySetting) {
for (const polygon of classifyRings(geometry, EARCUT_MAX_RINGS)) {
const subdivided = subdividePolygon(polygon, canonical, subdivisionGranularity.fill.getGranularityForZoomLevel(canonical.z));
const vertexArray = this.layoutVertexArray;
fillLargeMeshArrays(
(x, y) => {
vertexArray.emplaceBack(x, y);
},
this.segments,
this.layoutVertexArray,
this.indexArray,
subdivided.verticesFlattened,
subdivided.indicesTriangles,
this.segments2,
this.indexArray2,
subdivided.indicesLineList,
);
}
this.programConfigurations.populatePaintArrays(this.layoutVertexArray.length, feature, index, {imagePositions, canonical});
}
}
register('FillBucket', FillBucket, {omit: ['layers', 'patternFeatures']});
+13
View File
@@ -0,0 +1,13 @@
import {createLayout} from '../../util/struct_array';
const layout = createLayout([
{name: 'a_pos', components: 2, type: 'Int16'},
{name: 'a_normal_ed', components: 4, type: 'Int16'},
], 4);
export const centroidAttributes = createLayout([
{name: 'a_centroid', components: 2, type: 'Int16'}
], 4);
export default layout;
export const {members, size, alignment} = layout;
+46
View File
@@ -0,0 +1,46 @@
import {beforeAll, describe, test, expect} from 'vitest';
import {FillExtrusionBucket} from './fill_extrusion_bucket';
import {FillExtrusionStyleLayer} from '../../style/style_layer/fill_extrusion_style_layer';
import {type LayerSpecification} from '@maplibre/maplibre-gl-style-spec';
import {type EvaluationParameters} from '../../style/evaluation_parameters';
import {type ZoomHistory} from '../../style/zoom_history';
import {type BucketParameters} from '../bucket';
import {type CreateBucketParameters, createPopulateOptions, getFeaturesFromLayer, loadVectorTile} from '../../../test/unit/lib/tile';
import {type VectorTileLayerLike} from '@maplibre/vt-pbf';
function createFillExtrusionBucket({id, layout, paint, globalState, availableImages}: CreateBucketParameters): FillExtrusionBucket {
const layer = new FillExtrusionStyleLayer({
id,
type: 'fill-extrusion',
layout,
paint
} as LayerSpecification, globalState);
layer.recalculate({zoom: 0, zoomHistory: {} as ZoomHistory} as EvaluationParameters,
availableImages as Array<string>);
return new FillExtrusionBucket({layers: [layer]} as BucketParameters<FillExtrusionStyleLayer>);
}
describe('FillExtrusionBucket', () => {
let sourceLayer: VectorTileLayerLike;
beforeAll(() => {
// Load fill extrusion features from fixture tile.
sourceLayer = loadVectorTile().layers.water;
});
test('FillExtrusionBucket fill-pattern with global-state', () => {
const availableImages = [];
const bucket = createFillExtrusionBucket({id: 'test',
paint: {'fill-extrusion-pattern': ['coalesce', ['get', 'pattern'], ['global-state', 'pattern']]},
globalState: {pattern: 'test-pattern'},
availableImages
});
bucket.populate(getFeaturesFromLayer(sourceLayer), createPopulateOptions(availableImages), undefined);
expect(bucket.features.length).toBeGreaterThan(0);
expect(bucket.features[0].patterns).toEqual({
test: {min: 'test-pattern', mid: 'test-pattern', max: 'test-pattern'}
});
});
});
+336
View File
@@ -0,0 +1,336 @@
import {FillExtrusionLayoutArray, PosArray} from '../array_types.g';
import {members as layoutAttributes, centroidAttributes} from './fill_extrusion_attributes';
import {type Segment, SegmentVector} from '../segment';
import {ProgramConfigurationSet} from '../program_configuration';
import {TriangleIndexArray} from '../index_array_type';
import {EXTENT} from '../extent';
import {VectorTileFeature} from '@mapbox/vector-tile';
import {classifyRings} from '@maplibre/maplibre-gl-style-spec';
const EARCUT_MAX_RINGS = 500;
import {register} from '../../util/web_worker_transfer';
import {hasPattern, addPatternDependencies} from './pattern_bucket_features';
import {loadGeometry} from '../load_geometry';
import {toEvaluationFeature} from '../evaluation_feature';
import {EvaluationParameters} from '../../style/evaluation_parameters';
import type {CanonicalTileID} from '../../tile/tile_id';
import type {
Bucket,
BucketParameters,
BucketFeature,
IndexedFeature,
PopulateParameters
} from '../bucket';
import type {FillExtrusionStyleLayer} from '../../style/style_layer/fill_extrusion_style_layer';
import type {Context} from '../../gl/context';
import type {IndexBuffer} from '../../gl/index_buffer';
import type {VertexBuffer} from '../../gl/vertex_buffer';
import type Point from '@mapbox/point-geometry';
import type {FeatureStates} from '../../source/source_state';
import type {ImagePosition} from '../../render/image_atlas';
import {subdividePolygon, subdivideVertexLine} from '../../render/subdivision';
import type {SubdivisionGranularitySetting} from '../../render/subdivision_granularity_settings';
import {fillLargeMeshArrays} from '../../render/fill_large_mesh_arrays';
import type {VectorTileLayerLike} from '@maplibre/vt-pbf';
const FACTOR = Math.pow(2, 13);
function addVertex(vertexArray, x, y, nx, ny, nz, t, e) {
vertexArray.emplaceBack(
// a_pos
x,
y,
// a_normal_ed: 3-component normal and 1-component edgedistance
Math.floor(nx * FACTOR) * 2 + t,
ny * FACTOR * 2,
nz * FACTOR * 2,
// edgedistance (used for wrapping patterns around extrusion sides)
Math.round(e)
);
}
type CentroidAccumulator = {
x: number;
y: number;
sampleCount: number;
};
export class FillExtrusionBucket implements Bucket {
index: number;
zoom: number;
overscaling: number;
layers: Array<FillExtrusionStyleLayer>;
layerIds: Array<string>;
stateDependentLayers: Array<FillExtrusionStyleLayer>;
stateDependentLayerIds: Array<string>;
layoutVertexArray: FillExtrusionLayoutArray;
layoutVertexBuffer: VertexBuffer;
centroidVertexArray: PosArray;
centroidVertexBuffer: VertexBuffer;
indexArray: TriangleIndexArray;
indexBuffer: IndexBuffer;
hasDependencies: boolean;
programConfigurations: ProgramConfigurationSet<FillExtrusionStyleLayer>;
segments: SegmentVector;
uploaded: boolean;
features: Array<BucketFeature>;
constructor(options: BucketParameters<FillExtrusionStyleLayer>) {
this.zoom = options.zoom;
this.overscaling = options.overscaling;
this.layers = options.layers;
this.layerIds = this.layers.map(layer => layer.id);
this.index = options.index;
this.hasDependencies = false;
this.layoutVertexArray = new FillExtrusionLayoutArray();
this.centroidVertexArray = new PosArray();
this.indexArray = new TriangleIndexArray();
this.programConfigurations = new ProgramConfigurationSet(options.layers, options.zoom);
this.segments = new SegmentVector();
this.stateDependentLayerIds = this.layers.filter((l) => l.isStateDependent()).map((l) => l.id);
}
populate(features: Array<IndexedFeature>, options: PopulateParameters, canonical: CanonicalTileID) {
this.features = [];
this.hasDependencies = hasPattern('fill-extrusion', this.layers, options);
for (const {feature, id, index, sourceLayerIndex} of features) {
const needGeometry = this.layers[0]._featureFilter.needGeometry;
const evaluationFeature = toEvaluationFeature(feature, needGeometry);
if (!this.layers[0]._featureFilter.filter(new EvaluationParameters(this.zoom), evaluationFeature, canonical)) continue;
const bucketFeature: BucketFeature = {
id,
sourceLayerIndex,
index,
geometry: needGeometry ? evaluationFeature.geometry : loadGeometry(feature),
properties: feature.properties,
type: feature.type,
patterns: {}
};
if (this.hasDependencies) {
this.features.push(addPatternDependencies('fill-extrusion', this.layers, bucketFeature, {zoom: this.zoom}, options));
} else {
this.addFeature(bucketFeature, bucketFeature.geometry, index, canonical, {}, options.subdivisionGranularity);
}
options.featureIndex.insert(feature, bucketFeature.geometry, index, sourceLayerIndex, this.index, true);
}
}
addFeatures(options: PopulateParameters, canonical: CanonicalTileID, imagePositions: {[_: string]: ImagePosition}) {
for (const feature of this.features) {
const {geometry} = feature;
this.addFeature(feature, geometry, feature.index, canonical, imagePositions, options.subdivisionGranularity);
}
}
update(states: FeatureStates, vtLayer: VectorTileLayerLike, imagePositions: {[_: string]: ImagePosition}) {
if (!this.stateDependentLayers.length) return;
this.programConfigurations.updatePaintArrays(states, vtLayer, this.stateDependentLayers, {
imagePositions
});
}
isEmpty() {
return this.layoutVertexArray.length === 0 && this.centroidVertexArray.length === 0;
}
uploadPending() {
return !this.uploaded || this.programConfigurations.needsUpload;
}
upload(context: Context) {
if (!this.uploaded) {
this.layoutVertexBuffer = context.createVertexBuffer(this.layoutVertexArray, layoutAttributes);
this.centroidVertexBuffer = context.createVertexBuffer(this.centroidVertexArray, centroidAttributes.members, true);
this.indexBuffer = context.createIndexBuffer(this.indexArray);
}
this.programConfigurations.upload(context);
this.uploaded = true;
}
destroy() {
if (!this.layoutVertexBuffer) return;
this.layoutVertexBuffer.destroy();
this.indexBuffer.destroy();
this.programConfigurations.destroy();
this.segments.destroy();
this.centroidVertexBuffer.destroy();
}
addFeature(feature: BucketFeature, geometry: Array<Array<Point>>, index: number, canonical: CanonicalTileID, imagePositions: {[_: string]: ImagePosition}, subdivisionGranularity: SubdivisionGranularitySetting) {
for (const polygon of classifyRings(geometry, EARCUT_MAX_RINGS)) {
// Compute polygon centroid to calculate elevation in GPU
const centroid: CentroidAccumulator = {x: 0, y: 0, sampleCount: 0};
const oldVertexCount = this.layoutVertexArray.length;
this.processPolygon(centroid, canonical, feature, polygon, subdivisionGranularity);
const addedVertices = this.layoutVertexArray.length - oldVertexCount;
const centroidX = Math.floor(centroid.x / centroid.sampleCount);
const centroidY = Math.floor(centroid.y / centroid.sampleCount);
for (let i = 0; i < addedVertices; i++) {
this.centroidVertexArray.emplaceBack(
centroidX,
centroidY
);
}
}
this.programConfigurations.populatePaintArrays(this.layoutVertexArray.length, feature, index, {imagePositions, canonical});
}
private processPolygon(
centroid: CentroidAccumulator,
canonical: CanonicalTileID,
feature: BucketFeature,
polygon: Array<Array<Point>>,
subdivisionGranularity: SubdivisionGranularitySetting
): void {
if (polygon.length < 1) {
return;
}
if (isEntirelyOutside(polygon[0])) {
return;
}
// Only consider the un-subdivided polygon outer ring for centroid calculation
for (const ring of polygon) {
if (ring.length === 0) {
continue;
}
// Here we don't mind if a hole ring is entirely outside, unlike when generating geometry later.
accumulatePointsToCentroid(centroid, ring);
}
const segmentReference = {
segment: this.segments.prepareSegment(4, this.layoutVertexArray, this.indexArray)
};
const granularity = subdivisionGranularity.fill.getGranularityForZoomLevel(canonical.z);
const isPolygon = VectorTileFeature.types[feature.type] === 'Polygon';
for (const ring of polygon) {
if (ring.length === 0) {
continue;
}
if (isEntirelyOutside(ring)) {
continue;
}
const subdividedRing = subdivideVertexLine(ring, granularity, isPolygon);
this._generateSideFaces(subdividedRing, segmentReference);
}
// Only triangulate and draw the area of the feature if it is a polygon
// Other feature types (e.g. LineString) do not have area, so triangulation is pointless / undefined
if (!isPolygon)
return;
// Do not generate outlines, since outlines already got subdivided earlier.
const subdividedPolygon = subdividePolygon(polygon, canonical, granularity, false);
const vertexArray = this.layoutVertexArray;
fillLargeMeshArrays(
(x, y) => {
addVertex(vertexArray, x, y, 0, 0, 1, 1, 0);
},
this.segments,
this.layoutVertexArray,
this.indexArray,
subdividedPolygon.verticesFlattened,
subdividedPolygon.indicesTriangles
);
}
/**
* Generates side faces for the supplied geometry. Assumes `geometry` to be a line string, like the output of {@link subdivideVertexLine}.
* For rings, it is assumed that the first and last vertex of `geometry` are equal.
*/
private _generateSideFaces(geometry: Array<Point>, segmentReference: {segment: Segment}) {
let edgeDistance = 0;
for (let p = 1; p < geometry.length; p++) {
const p1 = geometry[p];
const p2 = geometry[p - 1];
if (isBoundaryEdge(p1, p2)) {
continue;
}
if (segmentReference.segment.vertexLength + 4 > SegmentVector.MAX_VERTEX_ARRAY_LENGTH) {
segmentReference.segment = this.segments.prepareSegment(4, this.layoutVertexArray, this.indexArray);
}
const perp = p1.sub(p2)._perp()._unit();
const dist = p2.dist(p1);
if (edgeDistance + dist > 32768) edgeDistance = 0;
addVertex(this.layoutVertexArray, p1.x, p1.y, perp.x, perp.y, 0, 0, edgeDistance);
addVertex(this.layoutVertexArray, p1.x, p1.y, perp.x, perp.y, 0, 1, edgeDistance);
edgeDistance += dist;
addVertex(this.layoutVertexArray, p2.x, p2.y, perp.x, perp.y, 0, 0, edgeDistance);
addVertex(this.layoutVertexArray, p2.x, p2.y, perp.x, perp.y, 0, 1, edgeDistance);
const bottomRight = segmentReference.segment.vertexLength;
// ┌──────┐
// │ 0 1 │ Counter-clockwise winding order.
// │ │ Triangle 1: 0 => 2 => 1
// │ 2 3 │ Triangle 2: 1 => 2 => 3
// └──────┘
this.indexArray.emplaceBack(bottomRight, bottomRight + 2, bottomRight + 1);
this.indexArray.emplaceBack(bottomRight + 1, bottomRight + 2, bottomRight + 3);
segmentReference.segment.vertexLength += 4;
segmentReference.segment.primitiveLength += 2;
}
}
}
/**
* Accumulates geometry to centroid. Geometry can be either a polygon ring, a line string or a closed line string.
* In case of a polygon ring or line ring, the last vertex is ignored if it is the same as the first vertex.
*/
function accumulatePointsToCentroid(centroid: CentroidAccumulator, geometry: Array<Point>): void {
for (let i = 0; i < geometry.length; i++) {
const p = geometry[i];
if (i === geometry.length - 1 && geometry[0].x === p.x && geometry[0].y === p.y) {
continue;
}
centroid.x += p.x;
centroid.y += p.y;
centroid.sampleCount++;
}
}
register('FillExtrusionBucket', FillExtrusionBucket, {omit: ['layers', 'features']});
function isBoundaryEdge(p1, p2) {
return (p1.x === p2.x && (p1.x < 0 || p1.x > EXTENT)) ||
(p1.y === p2.y && (p1.y < 0 || p1.y > EXTENT));
}
function isEntirelyOutside(ring) {
return ring.every(p => p.x < 0) ||
ring.every(p => p.x > EXTENT) ||
ring.every(p => p.y < 0) ||
ring.every(p => p.y > EXTENT);
}
+12
View File
@@ -0,0 +1,12 @@
import {CircleBucket} from './circle_bucket';
import {register} from '../../util/web_worker_transfer';
import type {HeatmapStyleLayer} from '../../style/style_layer/heatmap_style_layer';
export class HeatmapBucket extends CircleBucket<HeatmapStyleLayer> {
// Needed for flow to accept omit: ['layers'] below, due to
// https://github.com/facebook/flow/issues/4262
layers: Array<HeatmapStyleLayer>;
}
register('HeatmapBucket', HeatmapBucket, {omit: ['layers']});
+8
View File
@@ -0,0 +1,8 @@
import {createLayout} from '../../util/struct_array';
export const lineLayoutAttributes = createLayout([
{name: 'a_pos_normal', components: 2, type: 'Int16'},
{name: 'a_data', components: 4, type: 'Uint8'}
], 4);
export const {members, size, alignment} = lineLayoutAttributes;
+8
View File
@@ -0,0 +1,8 @@
import {createLayout} from '../../util/struct_array';
export const lineLayoutAttributesExt = createLayout([
{name: 'a_uv_x', components: 1, type: 'Float32'},
{name: 'a_split_index', components: 1, type: 'Float32'},
]);
export const {members, size, alignment} = lineLayoutAttributesExt;
+191
View File
@@ -0,0 +1,191 @@
import {beforeAll, describe, test, expect, vi} from 'vitest';
import Point from '@mapbox/point-geometry';
import {SegmentVector} from '../segment';
import {LineBucket} from './line_bucket';
import {LineStyleLayer} from '../../style/style_layer/line_style_layer';
import {type LayerSpecification} from '@maplibre/maplibre-gl-style-spec';
import {type EvaluationParameters} from '../../style/evaluation_parameters';
import {type ZoomHistory} from '../../../src/style/zoom_history';
import {type BucketFeature, type BucketParameters} from '../bucket';
import {SubdivisionGranularitySetting} from '../../render/subdivision_granularity_settings';
import {type CreateBucketParameters, createPopulateOptions, getFeaturesFromLayer, loadVectorTile} from '../../../test/unit/lib/tile';
import type {VectorTileLayerLike} from '@maplibre/vt-pbf';
const {noSubdivision} = SubdivisionGranularitySetting;
function createLine(numPoints) {
const points = [];
for (let i = 0; i < numPoints; i++) {
points.push(new Point(i / numPoints, i / numPoints));
}
return points;
}
function createLineBucket({id, layout, paint, globalState, availableImages}: CreateBucketParameters): LineBucket {
const layer = new LineStyleLayer({
id,
type: 'line',
layout,
paint
} as LayerSpecification, globalState);
layer.recalculate({zoom: 0, zoomHistory: {} as ZoomHistory} as EvaluationParameters,
availableImages as Array<string>);
return new LineBucket({layers: [layer]} as BucketParameters<LineStyleLayer>);
}
describe('LineBucket', () => {
let sourceLayer: VectorTileLayerLike;
beforeAll(() => {
// Load line features from fixture tile.
sourceLayer = loadVectorTile().layers.road;
});
test('LineBucket', () => {
expect(() => {
const bucket = createLineBucket({
id: 'test'
});
const line = {
type: 2,
properties: {}
} as BucketFeature;
const polygon = {
type: 3,
properties: {}
} as BucketFeature;
bucket.addLine([
new Point(0, 0)
], line, undefined, undefined, undefined, undefined, undefined, noSubdivision);
bucket.addLine([
new Point(0, 0)
], polygon, undefined, undefined, undefined, undefined, undefined, noSubdivision);
bucket.addLine([
new Point(0, 0),
new Point(0, 0)
], line, undefined, undefined, undefined, undefined, undefined, noSubdivision);
bucket.addLine([
new Point(0, 0),
new Point(0, 0)
], polygon, undefined, undefined, undefined, undefined, undefined, noSubdivision);
bucket.addLine([
new Point(0, 0),
new Point(10, 10),
new Point(0, 0)
], line, undefined, undefined, undefined, undefined, undefined, noSubdivision);
bucket.addLine([
new Point(0, 0),
new Point(10, 10),
new Point(0, 0)
], polygon, undefined, undefined, undefined, undefined, undefined, noSubdivision);
bucket.addLine([
new Point(0, 0),
new Point(10, 10),
new Point(10, 20)
], line, undefined, undefined, undefined, undefined, undefined, noSubdivision);
bucket.addLine([
new Point(0, 0),
new Point(10, 10),
new Point(10, 20)
], polygon, undefined, undefined, undefined, undefined, undefined, noSubdivision);
bucket.addLine([
new Point(0, 0),
new Point(10, 10),
new Point(10, 20),
new Point(0, 0)
], line, undefined, undefined, undefined, undefined, undefined, noSubdivision);
bucket.addLine([
new Point(0, 0),
new Point(10, 10),
new Point(10, 20),
new Point(0, 0)
], polygon, undefined, undefined, undefined, undefined, undefined, noSubdivision);
const feature = sourceLayer.feature(0);
bucket.addFeature(feature as any, feature.loadGeometry(), undefined, undefined, undefined, undefined, noSubdivision);
}).not.toThrow();
});
test('LineBucket segmentation', () => {
vi.spyOn(console, 'warn').mockImplementation(() => { });
// Stub MAX_VERTEX_ARRAY_LENGTH so we can test features
// breaking across array groups without tests taking a _long_ time.
SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 256;
const bucket = createLineBucket({
id: 'test'
});
// first add an initial, small feature to make sure the next one starts at
// a non-zero offset
bucket.addFeature({} as BucketFeature, [createLine(10)], undefined, undefined, undefined, undefined, noSubdivision);
// add a feature that will break across the group boundary
bucket.addFeature({} as BucketFeature, [createLine(128)], undefined, undefined, undefined, undefined, noSubdivision);
// Each polygon must fit entirely within a segment, so we expect the
// first segment to include the first feature and the first polygon
// of the second feature, and the second segment to include the
// second polygon of the second feature.
expect(bucket.layoutVertexArray).toHaveLength(276);
expect(bucket.segments.get()).toEqual([{
vertexOffset: 0,
vertexLength: 20,
vaos: {},
primitiveOffset: 0,
primitiveLength: 18
}, {
vertexOffset: 20,
vertexLength: 256,
vaos: {},
primitiveOffset: 18,
primitiveLength: 254
}]);
expect(console.warn).toHaveBeenCalledTimes(1);
});
test('LineBucket line-pattern with global-state', () => {
const availableImages = [];
const bucket = createLineBucket({id: 'test',
paint: {'line-pattern': ['coalesce', ['get', 'pattern'], ['global-state', 'pattern']]},
globalState: {pattern: 'test-pattern'},
availableImages
});
bucket.populate(getFeaturesFromLayer(sourceLayer), createPopulateOptions(availableImages), undefined);
expect(bucket.patternFeatures.length).toBeGreaterThan(0);
expect(bucket.patternFeatures[0].patterns).toEqual({
test: {min: 'test-pattern', mid: 'test-pattern', max: 'test-pattern'}
});
});
test('LineBucket line-dasharray with global-state', () => {
const bucket = createLineBucket({id: 'test',
paint: {'line-dasharray': ['coalesce', ['get', 'dasharray'], ['global-state', 'dasharray']]},
globalState: {'dasharray': [3, 3]},
availableImages: []
});
bucket.populate(getFeaturesFromLayer(sourceLayer), createPopulateOptions([]), undefined);
expect(bucket.patternFeatures.length).toBeGreaterThan(0);
expect(bucket.patternFeatures[0].dashes).toEqual({
test: {min: '3,3,false', mid: '3,3,false', max: '3,3,false'}
});
});
});
+652
View File
@@ -0,0 +1,652 @@
import {LineLayoutArray, LineExtLayoutArray} from '../array_types.g';
import {GEOJSONVT_CLIP_END, GEOJSONVT_CLIP_START} from '@maplibre/geojson-vt';
import {members as layoutAttributes} from './line_attributes';
import {members as layoutAttributesExt} from './line_attributes_ext';
import {SegmentVector} from '../segment';
import {ProgramConfigurationSet} from '../program_configuration';
import {TriangleIndexArray} from '../index_array_type';
import {EXTENT} from '../extent';
import {VectorTileFeature} from '@mapbox/vector-tile';
import {register} from '../../util/web_worker_transfer';
import {hasPattern, addPatternDependencies} from './pattern_bucket_features';
import {loadGeometry} from '../load_geometry';
import {toEvaluationFeature} from '../evaluation_feature';
import {EvaluationParameters} from '../../style/evaluation_parameters';
import {subdivideVertexLine} from '../../render/subdivision';
import type {CanonicalTileID} from '../../tile/tile_id';
import type {
Bucket,
BucketParameters,
BucketFeature,
IndexedFeature,
PopulateParameters
} from '../bucket';
import type {LineStyleLayer} from '../../style/style_layer/line_style_layer';
import type Point from '@mapbox/point-geometry';
import type {Segment} from '../segment';
import type {RGBAImage} from '../../util/image';
import type {Context} from '../../gl/context';
import type {Texture} from '../../render/texture';
import type {IndexBuffer} from '../../gl/index_buffer';
import type {VertexBuffer} from '../../gl/vertex_buffer';
import type {FeatureStates} from '../../source/source_state';
import type {ImagePosition} from '../../render/image_atlas';
import type {SubdivisionGranularitySetting} from '../../render/subdivision_granularity_settings';
import type {DashEntry} from '../../render/line_atlas';
import type {VectorTileLayerLike} from '@maplibre/vt-pbf';
// NOTE ON EXTRUDE SCALE:
// scale the extrusion vector so that the normal length is this value.
// contains the "texture" normals (-1..1). this is distinct from the extrude
// normals for line joins, because the x-value remains 0 for the texture
// normal array, while the extrude normal actually moves the vertex to create
// the acute/bevelled line join.
const EXTRUDE_SCALE = 63;
/*
* Sharp corners cause dashed lines to tilt because the distance along the line
* is the same at both the inner and outer corners. To improve the appearance of
* dashed lines we add extra points near sharp corners so that a smaller part
* of the line is tilted.
*
* COS_HALF_SHARP_CORNER controls how sharp a corner has to be for us to add an
* extra vertex. The default is 75 degrees.
*
* The newly created vertices are placed SHARP_CORNER_OFFSET pixels from the corner.
*/
const COS_HALF_SHARP_CORNER = Math.cos(75 / 2 * (Math.PI / 180));
const SHARP_CORNER_OFFSET = 15;
// Angle per triangle for approximating round line joins.
const DEG_PER_TRIANGLE = 20;
// The number of bits that is used to store the line distance in the buffer.
const LINE_DISTANCE_BUFFER_BITS = 15;
// We don't have enough bits for the line distance as we'd like to have, so
// use this value to scale the line distance (in tile units) down to a smaller
// value. This lets us store longer distances while sacrificing precision.
const LINE_DISTANCE_SCALE = 1 / 2;
// The maximum line distance, in tile units, that fits in the buffer.
const MAX_LINE_DISTANCE = Math.pow(2, LINE_DISTANCE_BUFFER_BITS - 1) / LINE_DISTANCE_SCALE;
type LineClips = {
start: number;
end: number;
};
type GradientTexture = {
texture?: Texture;
gradient?: RGBAImage;
version?: number;
};
/**
* @internal
* Line bucket class
*/
export class LineBucket implements Bucket {
distance: number;
totalDistance: number;
maxLineLength: number;
scaledDistance: number;
lineClips?: LineClips;
e1: number;
e2: number;
index: number;
zoom: number;
overscaling: number;
layers: Array<LineStyleLayer>;
layerIds: Array<string>;
gradients: {[x: string]: GradientTexture};
stateDependentLayers: Array<any>;
stateDependentLayerIds: Array<string>;
patternFeatures: Array<BucketFeature>;
lineClipsArray: Array<LineClips>;
layoutVertexArray: LineLayoutArray;
layoutVertexBuffer: VertexBuffer;
layoutVertexArray2: LineExtLayoutArray;
layoutVertexBuffer2: VertexBuffer;
indexArray: TriangleIndexArray;
indexBuffer: IndexBuffer;
hasDependencies: boolean;
programConfigurations: ProgramConfigurationSet<LineStyleLayer>;
segments: SegmentVector;
uploaded: boolean;
constructor(options: BucketParameters<LineStyleLayer>) {
this.zoom = options.zoom;
this.overscaling = options.overscaling;
this.layers = options.layers;
this.layerIds = this.layers.map(layer => layer.id);
this.index = options.index;
this.hasDependencies = false;
this.patternFeatures = [];
this.lineClipsArray = [];
this.gradients = {};
this.layers.forEach(layer => {
this.gradients[layer.id] = {};
});
this.layoutVertexArray = new LineLayoutArray();
this.layoutVertexArray2 = new LineExtLayoutArray();
this.indexArray = new TriangleIndexArray();
this.programConfigurations = new ProgramConfigurationSet(options.layers, options.zoom);
this.segments = new SegmentVector();
this.maxLineLength = 0;
this.stateDependentLayerIds = this.layers.filter((l) => l.isStateDependent()).map((l) => l.id);
}
populate(features: Array<IndexedFeature>, options: PopulateParameters, canonical: CanonicalTileID) {
this.hasDependencies = hasPattern('line', this.layers, options) || this.hasLineDasharray(this.layers);
const lineSortKey = this.layers[0].layout.get('line-sort-key');
const sortFeaturesByKey = !lineSortKey.isConstant();
const bucketFeatures: BucketFeature[] = [];
for (const {feature, id, index, sourceLayerIndex} of features) {
const needGeometry = this.layers[0]._featureFilter.needGeometry;
const evaluationFeature = toEvaluationFeature(feature, needGeometry);
if (!this.layers[0]._featureFilter.filter(new EvaluationParameters(this.zoom), evaluationFeature, canonical)) continue;
const sortKey = sortFeaturesByKey ?
lineSortKey.evaluate(evaluationFeature, {}, canonical) :
undefined;
const bucketFeature: BucketFeature = {
id,
properties: feature.properties,
type: feature.type,
sourceLayerIndex,
index,
geometry: needGeometry ? evaluationFeature.geometry : loadGeometry(feature),
patterns: {},
dashes: {},
sortKey
};
bucketFeatures.push(bucketFeature);
}
if (sortFeaturesByKey) {
bucketFeatures.sort((a, b) => {
return (a.sortKey) - (b.sortKey);
});
}
for (const bucketFeature of bucketFeatures) {
const {geometry, index, sourceLayerIndex} = bucketFeature;
if (this.hasDependencies) {
if (hasPattern('line', this.layers, options)) {
addPatternDependencies('line', this.layers, bucketFeature, {zoom: this.zoom}, options);
} else if (this.hasLineDasharray(this.layers)) {
this.addLineDashDependencies(this.layers, bucketFeature, this.zoom, options);
}
// pattern features are added only once the pattern is loaded into the image atlas
// so are stored during populate until later updated with positions by tile worker in addFeatures
this.patternFeatures.push(bucketFeature);
} else {
this.addFeature(bucketFeature, geometry, index, canonical, {}, {}, options.subdivisionGranularity);
}
const feature = features[index].feature;
options.featureIndex.insert(feature, geometry, index, sourceLayerIndex, this.index);
}
}
update(states: FeatureStates, vtLayer: VectorTileLayerLike, imagePositions: {[_: string]: ImagePosition}, dashPositions: {[_: string]: DashEntry}) {
if (!this.stateDependentLayers.length) return;
this.programConfigurations.updatePaintArrays(states, vtLayer, this.stateDependentLayers, {
imagePositions,
dashPositions
});
}
addFeatures(options: PopulateParameters, canonical: CanonicalTileID, imagePositions: {[_: string]: ImagePosition}, dashPositions?: {[_: string]: DashEntry}) {
for (const feature of this.patternFeatures) {
this.addFeature(feature, feature.geometry, feature.index, canonical, imagePositions, dashPositions, options.subdivisionGranularity);
}
}
isEmpty() {
return this.layoutVertexArray.length === 0;
}
uploadPending() {
return !this.uploaded || this.programConfigurations.needsUpload;
}
upload(context: Context) {
if (!this.uploaded) {
if (this.layoutVertexArray2.length !== 0) {
this.layoutVertexBuffer2 = context.createVertexBuffer(this.layoutVertexArray2, layoutAttributesExt);
}
this.layoutVertexBuffer = context.createVertexBuffer(this.layoutVertexArray, layoutAttributes);
this.indexBuffer = context.createIndexBuffer(this.indexArray);
}
this.programConfigurations.upload(context);
this.uploaded = true;
}
destroy() {
if (!this.layoutVertexBuffer) return;
this.layoutVertexBuffer.destroy();
this.indexBuffer.destroy();
this.programConfigurations.destroy();
this.segments.destroy();
}
lineFeatureClips(feature: BucketFeature): LineClips | undefined {
if (!!feature.properties && Object.prototype.hasOwnProperty.call(feature.properties, GEOJSONVT_CLIP_START) && Object.prototype.hasOwnProperty.call(feature.properties, GEOJSONVT_CLIP_END)) {
const start = +feature.properties[GEOJSONVT_CLIP_START];
const end = +feature.properties[GEOJSONVT_CLIP_END];
return {start, end};
}
}
addFeature(feature: BucketFeature, geometry: Array<Array<Point>>, index: number, canonical: CanonicalTileID, imagePositions: {[_: string]: ImagePosition}, dashPositions: Record<string, DashEntry>, subdivisionGranularity: SubdivisionGranularitySetting) {
const layout = this.layers[0].layout;
const join = layout.get('line-join').evaluate(feature, {});
const cap = layout.get('line-cap').evaluate(feature, {});
const miterLimit = layout.get('line-miter-limit').evaluate(feature, {});
const roundLimit = layout.get('line-round-limit').evaluate(feature, {});
this.lineClips = this.lineFeatureClips(feature);
for (const line of geometry) {
this.addLine(line, feature, join, cap, miterLimit, roundLimit, canonical, subdivisionGranularity);
}
this.programConfigurations.populatePaintArrays(this.layoutVertexArray.length, feature, index, {imagePositions, dashPositions, canonical});
}
addLine(vertices: Array<Point>, feature: BucketFeature, join: string, cap: string, miterLimit: number, roundLimit: number, canonical: CanonicalTileID | undefined, subdivisionGranularity: SubdivisionGranularitySetting) {
this.distance = 0;
this.scaledDistance = 0;
this.totalDistance = 0;
// First, subdivide the line if needed (mostly for globe rendering)
const granularity = canonical ? subdivisionGranularity.line.getGranularityForZoomLevel(canonical.z) : 1;
vertices = subdivideVertexLine(vertices, granularity);
if (this.lineClips) {
this.lineClipsArray.push(this.lineClips);
// Calculate the total distance, in tile units, of this tiled line feature
for (let i = 0; i < vertices.length - 1; i++) {
this.totalDistance += vertices[i].dist(vertices[i + 1]);
}
this.updateScaledDistance();
this.maxLineLength = Math.max(this.maxLineLength, this.totalDistance);
}
const isPolygon = VectorTileFeature.types[feature.type] === 'Polygon';
// If the line has duplicate vertices at the ends, adjust start/length to remove them.
let len = vertices.length;
while (len >= 2 && vertices[len - 1].equals(vertices[len - 2])) {
len--;
}
let first = 0;
while (first < len - 1 && vertices[first].equals(vertices[first + 1])) {
first++;
}
// Ignore invalid geometry.
if (len < (isPolygon ? 3 : 2)) return;
if (join === 'bevel') miterLimit = 1.05;
const sharpCornerOffset = this.overscaling <= 16 ?
SHARP_CORNER_OFFSET * EXTENT / (512 * this.overscaling) :
0;
// we could be more precise, but it would only save a negligible amount of space
const segment = this.segments.prepareSegment(len * 10, this.layoutVertexArray, this.indexArray);
let currentVertex: Point;
let prevVertex: Point;
let nextVertex: Point;
let prevNormal: Point;
let nextNormal: Point;
// the last two vertices added
this.e1 = this.e2 = -1;
if (isPolygon) {
currentVertex = vertices[len - 2];
nextNormal = vertices[first].sub(currentVertex)._unit()._perp();
}
for (let i = first; i < len; i++) {
nextVertex = i === len - 1 ?
(isPolygon ? vertices[first + 1] : undefined) : // if it's a polygon, treat the last vertex like the first
vertices[i + 1]; // just the next vertex
// if two consecutive vertices exist, skip the current one
if (nextVertex && vertices[i].equals(nextVertex)) continue;
if (nextNormal) prevNormal = nextNormal;
if (currentVertex) prevVertex = currentVertex;
currentVertex = vertices[i];
// Calculate the normal towards the next vertex in this line. In case
// there is no next vertex, pretend that the line is continuing straight,
// meaning that we are just using the previous normal.
nextNormal = nextVertex ? nextVertex.sub(currentVertex)._unit()._perp() : prevNormal;
// If we still don't have a previous normal, this is the beginning of a
// non-closed line, so we're doing a straight "join".
prevNormal = prevNormal || nextNormal;
// Determine the normal of the join extrusion. It is the angle bisector
// of the segments between the previous line and the next line.
// In the case of 180° angles, the prev and next normals cancel each other out:
// prevNormal + nextNormal = (0, 0), its magnitude is 0, so the unit vector would be
// undefined. In that case, we're keeping the joinNormal at (0, 0), so that the cosHalfAngle
// below will also become 0 and miterLength will become Infinity.
let joinNormal = prevNormal.add(nextNormal);
if (joinNormal.x !== 0 || joinNormal.y !== 0) {
joinNormal._unit();
}
/* joinNormal prevNormal
* ↖ ↑
* .________. prevVertex
* |
* nextNormal ← | currentVertex
* |
* nextVertex !
*
*/
// calculate cosines of the angle (and its half) using dot product
const cosAngle = prevNormal.x * nextNormal.x + prevNormal.y * nextNormal.y;
const cosHalfAngle = joinNormal.x * nextNormal.x + joinNormal.y * nextNormal.y;
// Calculate the length of the miter (the ratio of the miter to the width)
// as the inverse of cosine of the angle between next and join normals
const miterLength = cosHalfAngle !== 0 ? 1 / cosHalfAngle : Infinity;
// approximate angle from cosine
const approxAngle = 2 * Math.sqrt(2 - 2 * cosHalfAngle);
const isSharpCorner = cosHalfAngle < COS_HALF_SHARP_CORNER && prevVertex && nextVertex;
const lineTurnsLeft = prevNormal.x * nextNormal.y - prevNormal.y * nextNormal.x > 0;
if (isSharpCorner && i > first) {
const prevSegmentLength = currentVertex.dist(prevVertex);
if (prevSegmentLength > 2 * sharpCornerOffset) {
const newPrevVertex = currentVertex.sub(currentVertex.sub(prevVertex)._mult(sharpCornerOffset / prevSegmentLength)._round());
this.updateDistance(prevVertex, newPrevVertex);
this.addCurrentVertex(newPrevVertex, prevNormal, 0, 0, segment);
prevVertex = newPrevVertex;
}
}
// The join if a middle vertex, otherwise the cap.
const middleVertex = prevVertex && nextVertex;
let currentJoin = middleVertex ? join : isPolygon ? 'butt' : cap;
if (middleVertex && currentJoin === 'round') {
if (miterLength < roundLimit) {
currentJoin = 'miter';
} else if (miterLength <= 2) {
currentJoin = 'fakeround';
}
}
if (currentJoin === 'miter' && miterLength > miterLimit) {
currentJoin = 'bevel';
}
if (currentJoin === 'bevel') {
// The maximum extrude length is 128 / 63 = 2 times the width of the line
// so if miterLength >= 2 we need to draw a different type of bevel here.
if (miterLength > 2) currentJoin = 'flipbevel';
// If the miterLength is really small and the line bevel wouldn't be visible,
// just draw a miter join to save a triangle.
if (miterLength < miterLimit) currentJoin = 'miter';
}
// Calculate how far along the line the currentVertex is
if (prevVertex) this.updateDistance(prevVertex, currentVertex);
if (currentJoin === 'miter') {
joinNormal._mult(miterLength);
this.addCurrentVertex(currentVertex, joinNormal, 0, 0, segment);
} else if (currentJoin === 'flipbevel') {
// miter is too big, flip the direction to make a beveled join
if (miterLength > 100) {
// Almost parallel lines
joinNormal = nextNormal.mult(-1);
} else {
const bevelLength = miterLength * prevNormal.add(nextNormal).mag() / prevNormal.sub(nextNormal).mag();
joinNormal._perp()._mult(bevelLength * (lineTurnsLeft ? -1 : 1));
}
this.addCurrentVertex(currentVertex, joinNormal, 0, 0, segment);
this.addCurrentVertex(currentVertex, joinNormal.mult(-1), 0, 0, segment);
} else if (currentJoin === 'bevel' || currentJoin === 'fakeround') {
const offset = -Math.sqrt(miterLength * miterLength - 1);
const offsetA = lineTurnsLeft ? offset : 0;
const offsetB = lineTurnsLeft ? 0 : offset;
// Close previous segment with a bevel
if (prevVertex) {
this.addCurrentVertex(currentVertex, prevNormal, offsetA, offsetB, segment);
}
if (currentJoin === 'fakeround') {
// The join angle is sharp enough that a round join would be visible.
// Bevel joins fill the gap between segments with a single pie slice triangle.
// Create a round join by adding multiple pie slices. The join isn't actually round, but
// it looks like it is at the sizes we render lines at.
// pick the number of triangles for approximating round join by based on the angle between normals
const n = Math.round((approxAngle * 180 / Math.PI) / DEG_PER_TRIANGLE);
for (let m = 1; m < n; m++) {
let t = m / n;
if (t !== 0.5) {
// approximate spherical interpolation https://observablehq.com/@mourner/approximating-geometric-slerp
const t2 = t - 0.5;
const A = 1.0904 + cosAngle * (-3.2452 + cosAngle * (3.55645 - cosAngle * 1.43519));
const B = 0.848013 + cosAngle * (-1.06021 + cosAngle * 0.215638);
t = t + t * t2 * (t - 1) * (A * t2 * t2 + B);
}
const extrude = nextNormal.sub(prevNormal)._mult(t)._add(prevNormal)._unit()._mult(lineTurnsLeft ? -1 : 1);
this.addHalfVertex(currentVertex, extrude.x, extrude.y, false, lineTurnsLeft, 0, segment);
}
}
if (nextVertex) {
// Start next segment
this.addCurrentVertex(currentVertex, nextNormal, -offsetA, -offsetB, segment);
}
} else if (currentJoin === 'butt') {
this.addCurrentVertex(currentVertex, joinNormal, 0, 0, segment); // butt cap
} else if (currentJoin === 'square') {
const offset = prevVertex ? 1 : -1; // closing or starting square cap
this.addCurrentVertex(currentVertex, joinNormal, offset, offset, segment);
} else if (currentJoin === 'round') {
if (prevVertex) {
// Close previous segment with butt
this.addCurrentVertex(currentVertex, prevNormal, 0, 0, segment);
// Add round cap or linejoin at end of segment
this.addCurrentVertex(currentVertex, prevNormal, 1, 1, segment, true);
}
if (nextVertex) {
// Add round cap before first segment
this.addCurrentVertex(currentVertex, nextNormal, -1, -1, segment, true);
// Start next segment with a butt
this.addCurrentVertex(currentVertex, nextNormal, 0, 0, segment);
}
}
if (isSharpCorner && i < len - 1) {
const nextSegmentLength = currentVertex.dist(nextVertex);
if (nextSegmentLength > 2 * sharpCornerOffset) {
const newCurrentVertex = currentVertex.add(nextVertex.sub(currentVertex)._mult(sharpCornerOffset / nextSegmentLength)._round());
this.updateDistance(currentVertex, newCurrentVertex);
this.addCurrentVertex(newCurrentVertex, nextNormal, 0, 0, segment);
currentVertex = newCurrentVertex;
}
}
}
}
/**
* Add two vertices to the buffers.
*
* @param p - the line vertex to add buffer vertices for
* @param normal - vertex normal
* @param endLeft - extrude to shift the left vertex along the line
* @param endRight - extrude to shift the left vertex along the line
* @param segment - the segment object to add the vertex to
* @param round - whether this is a round cap
*/
addCurrentVertex(p: Point, normal: Point, endLeft: number, endRight: number, segment: Segment, round: boolean = false) {
// left and right extrude vectors, perpendicularly shifted by endLeft/endRight
const leftX = normal.x + normal.y * endLeft;
const leftY = normal.y - normal.x * endLeft;
const rightX = -normal.x + normal.y * endRight;
const rightY = -normal.y - normal.x * endRight;
this.addHalfVertex(p, leftX, leftY, round, false, endLeft, segment);
this.addHalfVertex(p, rightX, rightY, round, true, -endRight, segment);
// There is a maximum "distance along the line" that we can store in the buffers.
// When we get close to the distance, reset it to zero and add the vertex again with
// a distance of zero. The max distance is determined by the number of bits we allocate
// to `linesofar`.
if (this.distance > MAX_LINE_DISTANCE / 2 && this.totalDistance === 0) {
this.distance = 0;
this.updateScaledDistance();
this.addCurrentVertex(p, normal, endLeft, endRight, segment, round);
}
}
addHalfVertex({x, y}: Point, extrudeX: number, extrudeY: number, round: boolean, up: boolean, dir: number, segment: Segment) {
const totalDistance = this.lineClips ? this.scaledDistance * (MAX_LINE_DISTANCE - 1) : this.scaledDistance;
// scale down so that we can store longer distances while sacrificing precision.
const linesofarScaled = totalDistance * LINE_DISTANCE_SCALE;
this.layoutVertexArray.emplaceBack(
// a_pos_normal
// Encode round/up the least significant bits
(x << 1) + (round ? 1 : 0),
(y << 1) + (up ? 1 : 0),
// a_data
// add 128 to store a byte in an unsigned byte
Math.round(EXTRUDE_SCALE * extrudeX) + 128,
Math.round(EXTRUDE_SCALE * extrudeY) + 128,
// Encode the -1/0/1 direction value into the first two bits of .z of a_data.
// Combine it with the lower 6 bits of `linesofarScaled` (shifted by 2 bits to make
// room for the direction value). The upper 8 bits of `linesofarScaled` are placed in
// the `w` component.
((dir === 0 ? 0 : (dir < 0 ? -1 : 1)) + 1) | ((linesofarScaled & 0x3F) << 2),
linesofarScaled >> 6);
// Constructs a second vertex buffer with higher precision line progress
if (this.lineClips) {
const progressRealigned = this.scaledDistance - this.lineClips.start;
const endClipRealigned = this.lineClips.end - this.lineClips.start;
const uvX = progressRealigned / endClipRealigned;
this.layoutVertexArray2.emplaceBack(uvX, this.lineClipsArray.length);
}
const e = segment.vertexLength++;
if (this.e1 >= 0 && this.e2 >= 0) {
this.indexArray.emplaceBack(this.e1, e, this.e2);
segment.primitiveLength++;
}
if (up) {
this.e2 = e;
} else {
this.e1 = e;
}
}
updateScaledDistance() {
// Knowing the ratio of the full linestring covered by this tiled feature, as well
// as the total distance (in tile units) of this tiled feature, and the distance
// (in tile units) of the current vertex, we can determine the relative distance
// of this vertex along the full linestring feature and scale it to [0, 2^15)
this.scaledDistance = this.lineClips ?
this.lineClips.start + (this.lineClips.end - this.lineClips.start) * this.distance / this.totalDistance :
this.distance;
}
updateDistance(prev: Point, next: Point) {
this.distance += prev.dist(next);
this.updateScaledDistance();
}
private hasLineDasharray(layers: Array<LineStyleLayer>): boolean {
for (const layer of layers) {
const dasharrayProperty = layer.paint.get('line-dasharray');
if (dasharrayProperty && !dasharrayProperty.isConstant()) {
return true;
}
}
return false;
}
private addLineDashDependencies(layers: Array<LineStyleLayer>, bucketFeature: BucketFeature, zoom: number, options: PopulateParameters) {
for (const layer of layers) {
const dasharrayProperty = layer.paint.get('line-dasharray');
if (!dasharrayProperty || dasharrayProperty.value.kind === 'constant') {
continue;
}
const round = layer.layout.get('line-cap').evaluate(bucketFeature, {}) === 'round';
const min = {
dasharray: dasharrayProperty.value.evaluate({zoom: zoom - 1}, bucketFeature, {}),
round
};
const mid = {
dasharray: dasharrayProperty.value.evaluate({zoom}, bucketFeature, {}),
round
};
const max = {
dasharray: dasharrayProperty.value.evaluate({zoom: zoom + 1}, bucketFeature, {}),
round
};
const minKey = `${min.dasharray.join(',')},${min.round}`;
const midKey = `${mid.dasharray.join(',')},${mid.round}`;
const maxKey = `${max.dasharray.join(',')},${max.round}`;
options.dashDependencies[minKey] = min;
options.dashDependencies[midKey] = mid;
options.dashDependencies[maxKey] = max;
bucketFeature.dashes[layer.id] = {min: minKey, mid: midKey, max: maxKey};
}
}
}
register('LineBucket', LineBucket, {omit: ['layers', 'patternFeatures']});
+9
View File
@@ -0,0 +1,9 @@
import {createLayout} from '../../util/struct_array';
export const patternAttributes = createLayout([
// [tl.x, tl.y, br.x, br.y]
{name: 'a_pattern_from', components: 4, type: 'Uint16'},
{name: 'a_pattern_to', components: 4, type: 'Uint16'},
{name: 'a_pixel_ratio_from', components: 1, type: 'Uint16'},
{name: 'a_pixel_ratio_to', components: 1, type: 'Uint16'},
]);
+58
View File
@@ -0,0 +1,58 @@
import type {FillStyleLayer} from '../../style/style_layer/fill_style_layer';
import type {FillExtrusionStyleLayer} from '../../style/style_layer/fill_extrusion_style_layer';
import type {LineStyleLayer} from '../../style/style_layer/line_style_layer';
import type {
BucketFeature,
PopulateParameters
} from '../bucket';
import {type PossiblyEvaluated} from '../../style/properties';
type PatternStyleLayers = Array<LineStyleLayer> | Array<FillStyleLayer> | Array<FillExtrusionStyleLayer>;
export function hasPattern(type: string, layers: PatternStyleLayers, options: PopulateParameters) {
const patterns = options.patternDependencies;
let hasPattern = false;
for (const layer of layers) {
const patternProperty = (layer.paint as PossiblyEvaluated<any, any>).get(`${type}-pattern`);
if (!patternProperty.isConstant()) {
hasPattern = true;
}
const constantPattern = patternProperty.constantOr(null);
if (constantPattern) {
hasPattern = true;
patterns[constantPattern.to] = true;
patterns[constantPattern.from] = true;
}
}
return hasPattern;
}
export function addPatternDependencies(type: string, layers: PatternStyleLayers, patternFeature: BucketFeature, parameters: { zoom: number }, options: PopulateParameters) {
const {zoom} = parameters;
const patterns = options.patternDependencies;
for (const layer of layers) {
const patternProperty = (layer.paint as PossiblyEvaluated<any, any>).get(`${type}-pattern`);
const patternPropertyValue = patternProperty.value;
if (patternPropertyValue.kind !== 'constant') {
let min = patternPropertyValue.evaluate({zoom: zoom - 1}, patternFeature, {}, options.availableImages);
let mid = patternPropertyValue.evaluate({zoom}, patternFeature, {}, options.availableImages);
let max = patternPropertyValue.evaluate({zoom: zoom + 1}, patternFeature, {}, options.availableImages);
min = min && min.name ? min.name : min;
mid = mid && mid.name ? mid.name : mid;
max = max && max.name ? max.name : max;
// add to patternDependencies
patterns[min] = true;
patterns[mid] = true;
patterns[max] = true;
// save for layout
patternFeature.patterns[layer.id] = {min, mid, max};
}
}
return patternFeature;
}
+122
View File
@@ -0,0 +1,122 @@
import {createLayout} from '../../util/struct_array';
export const symbolLayoutAttributes = createLayout([
{name: 'a_pos_offset', components: 4, type: 'Int16'},
{name: 'a_data', components: 4, type: 'Uint16'},
{name: 'a_pixeloffset', components: 4, type: 'Int16'}
], 4);
export const dynamicLayoutAttributes = createLayout([
{name: 'a_projected_pos', components: 3, type: 'Float32'}
], 4);
export const placementOpacityAttributes = createLayout([
{name: 'a_fade_opacity', components: 1, type: 'Uint32'}
], 4);
export const collisionVertexAttributes = createLayout([
{name: 'a_placed', components: 2, type: 'Uint8'},
{name: 'a_shift', components: 2, type: 'Float32'},
{name: 'a_box_real', components: 2, type: 'Int16'},
]);
export const collisionBox = createLayout([
// the box is centered around the anchor point
{type: 'Int16', name: 'anchorPointX'},
{type: 'Int16', name: 'anchorPointY'},
// distances to the edges from the anchor
{type: 'Int16', name: 'x1'},
{type: 'Int16', name: 'y1'},
{type: 'Int16', name: 'x2'},
{type: 'Int16', name: 'y2'},
// the index of the feature in the original vectortile
{type: 'Uint32', name: 'featureIndex'},
// the source layer the feature appears in
{type: 'Uint16', name: 'sourceLayerIndex'},
// the bucket the feature appears in
{type: 'Uint16', name: 'bucketIndex'},
]);
export const collisionBoxLayout = createLayout([ // used to render collision boxes for debugging purposes
{name: 'a_pos', components: 2, type: 'Int16'},
{name: 'a_anchor_pos', components: 2, type: 'Int16'},
{name: 'a_extrude', components: 2, type: 'Int16'}
], 4);
export const collisionCircleLayout = createLayout([ // used to render collision circles for debugging purposes
{name: 'a_pos', components: 2, type: 'Float32'},
{name: 'a_radius', components: 1, type: 'Float32'},
{name: 'a_flags', components: 2, type: 'Int16'}
], 4);
export const quadTriangle = createLayout([
{name: 'triangle', components: 3, type: 'Uint16'},
]);
export const placement = createLayout([
{type: 'Int16', name: 'anchorX'},
{type: 'Int16', name: 'anchorY'},
{type: 'Uint16', name: 'glyphStartIndex'},
{type: 'Uint16', name: 'numGlyphs'},
{type: 'Uint32', name: 'vertexStartIndex'},
{type: 'Uint32', name: 'lineStartIndex'},
{type: 'Uint32', name: 'lineLength'},
{type: 'Uint16', name: 'segment'},
{type: 'Uint16', name: 'lowerSize'},
{type: 'Uint16', name: 'upperSize'},
{type: 'Float32', name: 'lineOffsetX'},
{type: 'Float32', name: 'lineOffsetY'},
{type: 'Uint8', name: 'writingMode'},
{type: 'Uint8', name: 'placedOrientation'},
{type: 'Uint8', name: 'hidden'},
{type: 'Uint32', name: 'crossTileID'},
{type: 'Int16', name: 'associatedIconIndex'}
]);
export const symbolInstance = createLayout([
{type: 'Int16', name: 'anchorX'},
{type: 'Int16', name: 'anchorY'},
{type: 'Int16', name: 'rightJustifiedTextSymbolIndex'},
{type: 'Int16', name: 'centerJustifiedTextSymbolIndex'},
{type: 'Int16', name: 'leftJustifiedTextSymbolIndex'},
{type: 'Int16', name: 'verticalPlacedTextSymbolIndex'},
{type: 'Int16', name: 'placedIconSymbolIndex'},
{type: 'Int16', name: 'verticalPlacedIconSymbolIndex'},
{type: 'Uint16', name: 'key'},
{type: 'Uint16', name: 'textBoxStartIndex'},
{type: 'Uint16', name: 'textBoxEndIndex'},
{type: 'Uint16', name: 'verticalTextBoxStartIndex'},
{type: 'Uint16', name: 'verticalTextBoxEndIndex'},
{type: 'Uint16', name: 'iconBoxStartIndex'},
{type: 'Uint16', name: 'iconBoxEndIndex'},
{type: 'Uint16', name: 'verticalIconBoxStartIndex'},
{type: 'Uint16', name: 'verticalIconBoxEndIndex'},
{type: 'Uint16', name: 'featureIndex'},
{type: 'Uint16', name: 'numHorizontalGlyphVertices'},
{type: 'Uint16', name: 'numVerticalGlyphVertices'},
{type: 'Uint16', name: 'numIconVertices'},
{type: 'Uint16', name: 'numVerticalIconVertices'},
{type: 'Uint16', name: 'useRuntimeCollisionCircles'},
{type: 'Uint32', name: 'crossTileID'},
{type: 'Float32', name: 'textBoxScale'},
{type: 'Float32', name: 'collisionCircleDiameter'},
{type: 'Uint16', name: 'textAnchorOffsetStartIndex'},
{type: 'Uint16', name: 'textAnchorOffsetEndIndex'}
]);
export const glyphOffset = createLayout([
{type: 'Float32', name: 'offsetX'}
]);
export const lineVertex = createLayout([
{type: 'Int16', name: 'x'},
{type: 'Int16', name: 'y'},
{type: 'Int16', name: 'tileUnitDistanceFromAnchor'}
]);
export const textAnchorOffset = createLayout([
{type: 'Uint16', name: 'textAnchor'},
{type: 'Float32', components: 2, name: 'textOffset'}
]);
+244
View File
@@ -0,0 +1,244 @@
import {describe, test, expect, vi, beforeAll} from 'vitest';
import {SymbolBucket} from './symbol_bucket';
import {CollisionBoxArray} from '../../data/array_types.g';
import {performSymbolLayout} from '../../symbol/symbol_layout';
import {Placement} from '../../symbol/placement';
import {type CanonicalTileID, OverscaledTileID} from '../../tile/tile_id';
import {Tile} from '../../tile/tile';
import {CrossTileSymbolIndex} from '../../symbol/cross_tile_symbol_index';
import {FeatureIndex} from '../../data/feature_index';
import {createSymbolBucket, createSymbolIconBucket} from '../../../test/unit/lib/create_symbol_layer';
import {RGBAImage} from '../../util/image';
import {ImagePosition} from '../../render/image_atlas';
import {type IndexedFeature, type PopulateParameters} from '../bucket';
import {type StyleImage} from '../../style/style_image';
import glyphs from '../../../test/unit/assets/fontstack-glyphs.json' with {type: 'json'};
import {type StyleGlyph} from '../../style/style_glyph';
import {SubdivisionGranularitySetting} from '../../render/subdivision_granularity_settings';
import {MercatorTransform} from '../../geo/projection/mercator_transform';
import {createPopulateOptions, loadVectorTile} from '../../../test/unit/lib/tile';
const collisionBoxArray = new CollisionBoxArray();
const transform = new MercatorTransform();
transform.resize(100, 100);
const stacks = {'Test': glyphs} as any as {
[_: string]: {
[x: number]: StyleGlyph;
};
};
function bucketSetup(text = 'abcde') {
return createSymbolBucket('test', 'Test', text, collisionBoxArray);
}
function createIndexedFeature(id: number, index: number, iconId: string): IndexedFeature {
return {
feature: {
extent: 8192,
type: 1,
id,
properties: {
icon: iconId
},
loadGeometry() {
return [[{x: 0, y: 0}]];
}
},
id,
index,
sourceLayerIndex: 0
} as any as IndexedFeature;
}
describe('SymbolBucket', () => {
let features: IndexedFeature[];
beforeAll(() => {
// Load point features from fixture tile.
const sourceLayer = loadVectorTile().layers.place_label;
features = [{feature: sourceLayer.feature(10)} as unknown as IndexedFeature];
});
test('SymbolBucket', () => {
const bucketA = bucketSetup();
const bucketB = bucketSetup();
const options = createPopulateOptions([]);
const placement = new Placement(transform, undefined as any, 0, true);
const tileID = new OverscaledTileID(0, 0, 0, 0, 0);
const crossTileSymbolIndex = new CrossTileSymbolIndex();
// add feature from bucket A
bucketA.populate(features, options, undefined as any);
performSymbolLayout(
{
bucket: bucketA,
glyphMap: stacks,
glyphPositions: {},
subdivisionGranularity: SubdivisionGranularitySetting.noSubdivision
} as any);
const tileA = new Tile(tileID, 512);
tileA.latestFeatureIndex = new FeatureIndex(tileID);
tileA.buckets = {test: bucketA};
tileA.collisionBoxArray = collisionBoxArray;
// add same feature from bucket B
bucketB.populate(features, options, undefined as any);
performSymbolLayout({
bucket: bucketB, glyphMap: stacks, glyphPositions: {}, subdivisionGranularity: SubdivisionGranularitySetting.noSubdivision
} as any);
const tileB = new Tile(tileID, 512);
tileB.buckets = {test: bucketB};
tileB.collisionBoxArray = collisionBoxArray;
crossTileSymbolIndex.addLayer(bucketA.layers[0], [tileA, tileB], undefined as any);
const place = (layer, tile) => {
const parts = [];
placement.getBucketParts(parts, layer, tile, false);
for (const part of parts) {
placement.placeLayerBucketPart(part, {}, false);
}
};
const a = placement.collisionIndex.grid.keysLength();
place(bucketA.layers[0], tileA);
const b = placement.collisionIndex.grid.keysLength();
expect(a).not.toBe(b);
const a2 = placement.collisionIndex.grid.keysLength();
place(bucketB.layers[0], tileB);
const b2 = placement.collisionIndex.grid.keysLength();
expect(b2).toBe(a2);
});
test('SymbolBucket integer overflow', () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => { });
SymbolBucket.MAX_GLYPHS = 5;
const bucket = bucketSetup();
const options = {iconDependencies: {}, glyphDependencies: {}} as PopulateParameters;
bucket.populate(features, options, undefined as any);
const fakeGlyph = {rect: {w: 10, h: 10}, metrics: {left: 10, top: 10, advance: 10}};
performSymbolLayout({
bucket,
glyphMap: stacks,
glyphPositions: {'Test': {97: fakeGlyph, 98: fakeGlyph, 99: fakeGlyph, 100: fakeGlyph, 101: fakeGlyph, 102: fakeGlyph} as any},
subdivisionGranularity: SubdivisionGranularitySetting.noSubdivision
} as any);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy.mock.calls[0][0].includes('Too many glyphs being rendered in a tile.')).toBeTruthy();
});
test('SymbolBucket image undefined sdf', () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => { });
spy.mockReset();
const imageMap = {
a: {
data: new RGBAImage({width: 0, height: 0})
},
b: {
data: new RGBAImage({width: 0, height: 0}),
sdf: false
}
} as any as { [_: string]: StyleImage };
const imagePos = {
a: new ImagePosition({x: 0, y: 0, w: 10, h: 10}, 1 as any as StyleImage),
b: new ImagePosition({x: 10, y: 0, w: 10, h: 10}, 1 as any as StyleImage)
};
const bucket = createSymbolIconBucket('test', 'icon', collisionBoxArray);
const options = createPopulateOptions([]);
bucket.populate(
[
createIndexedFeature(0, 0, 'a'),
createIndexedFeature(1, 1, 'b'),
createIndexedFeature(2, 2, 'a')
] as any as IndexedFeature[],
options, undefined as any
);
const icons = options.iconDependencies as any;
expect(icons.a).toBe(true);
expect(icons.b).toBe(true);
performSymbolLayout({
bucket, imageMap, imagePositions: imagePos,
subdivisionGranularity: SubdivisionGranularitySetting.noSubdivision
} as any);
// undefined SDF should be treated the same as false SDF - no warning raised
expect(spy).not.toHaveBeenCalledTimes(1);
});
test('SymbolBucket image mismatched sdf', () => {
const originalWarn = console.warn;
console.warn = vi.fn();
const imageMap = {
a: {
data: new RGBAImage({width: 0, height: 0}),
sdf: true
},
b: {
data: new RGBAImage({width: 0, height: 0}),
sdf: false
}
} as any as { [_: string]: StyleImage };
const imagePos = {
a: new ImagePosition({x: 0, y: 0, w: 10, h: 10}, 1 as any as StyleImage),
b: new ImagePosition({x: 10, y: 0, w: 10, h: 10}, 1 as any as StyleImage)
};
const bucket = createSymbolIconBucket('test', 'icon', collisionBoxArray);
const options = createPopulateOptions([]);
bucket.populate(
[
createIndexedFeature(0, 0, 'a'),
createIndexedFeature(1, 1, 'b'),
createIndexedFeature(2, 2, 'a')
] as any as IndexedFeature[],
options, undefined as unknown as CanonicalTileID
);
const icons = options.iconDependencies as any;
expect(icons.a).toBe(true);
expect(icons.b).toBe(true);
performSymbolLayout({bucket, imageMap, imagePositions: imagePos, subdivisionGranularity: SubdivisionGranularitySetting.noSubdivision} as any);
// true SDF and false SDF in same bucket should trigger warning
expect(console.warn).toHaveBeenCalledTimes(1);
console.warn = originalWarn;
});
test('SymbolBucket detects rtl text', () => {
const rtlBucket = bucketSetup('مرحبا');
const ltrBucket = bucketSetup('hello');
const options = createPopulateOptions([]);
rtlBucket.populate(features, options, undefined as any);
ltrBucket.populate(features, options, undefined as any);
expect(rtlBucket.hasRTLText).toBeTruthy();
expect(ltrBucket.hasRTLText).toBeFalsy();
});
// Test to prevent symbol bucket with rtl from text being culled by worker serialization.
test('SymbolBucket with rtl text is NOT empty even though no symbol instances are created', () => {
const rtlBucket = bucketSetup('مرحبا');
const options = createPopulateOptions([]);
rtlBucket.createArrays();
rtlBucket.populate(features, options, undefined as any);
expect(rtlBucket.isEmpty()).toBeFalsy();
expect(rtlBucket.symbolInstances).toHaveLength(0);
});
test('SymbolBucket detects rtl text mixed with ltr text', () => {
const mixedBucket = bucketSetup('مرحبا translates to hello');
const options = createPopulateOptions([]);
mixedBucket.populate(features, options, undefined as any);
expect(mixedBucket.hasRTLText).toBeTruthy();
});
});
+973
View File
@@ -0,0 +1,973 @@
import {
symbolLayoutAttributes,
collisionVertexAttributes,
collisionBoxLayout,
dynamicLayoutAttributes,
} from './symbol_attributes';
import {SymbolLayoutArray,
SymbolDynamicLayoutArray,
SymbolOpacityArray,
CollisionBoxLayoutArray,
CollisionVertexArray,
PlacedSymbolArray,
SymbolInstanceArray,
GlyphOffsetArray,
SymbolLineVertexArray,
TextAnchorOffsetArray
} from '../array_types.g';
import Point from '@mapbox/point-geometry';
import {SegmentVector} from '../segment';
import {ProgramConfigurationSet} from '../program_configuration';
import {TriangleIndexArray, LineIndexArray} from '../index_array_type';
import {transformText} from '../../symbol/transform_text';
import {mergeLines} from '../../symbol/merge_lines';
import {allowsVerticalWritingMode, stringContainsRTLText} from '../../util/script_detection';
import {WritingMode} from '../../symbol/shaping';
import {loadGeometry} from '../load_geometry';
import {toEvaluationFeature} from '../evaluation_feature';
import {VectorTileFeature} from '@mapbox/vector-tile';
import {verticalizedCharacterMap} from '../../util/verticalize_punctuation';
import {type Anchor} from '../../symbol/anchor';
import {getSizeData, MAX_PACKED_SIZE} from '../../symbol/symbol_size';
import {register} from '../../util/web_worker_transfer';
import {EvaluationParameters} from '../../style/evaluation_parameters';
import {Formatted, ResolvedImage} from '@maplibre/maplibre-gl-style-spec';
import {rtlWorkerPlugin} from '../../source/rtl_text_plugin_worker';
import {getOverlapMode} from '../../style/style_layer/overlap_mode';
import type {CanonicalTileID} from '../../tile/tile_id';
import type {
Bucket,
BucketParameters,
IndexedFeature,
PopulateParameters
} from '../bucket';
import type {CollisionBoxArray, CollisionBox, SymbolInstance} from '../array_types.g';
import type {StructArray, StructArrayMember, ViewType} from '../../util/struct_array';
import type {SymbolStyleLayer} from '../../style/style_layer/symbol_style_layer';
import type {Context} from '../../gl/context';
import type {IndexBuffer} from '../../gl/index_buffer';
import type {VertexBuffer} from '../../gl/vertex_buffer';
import type {SymbolQuad} from '../../symbol/quads';
import type {SizeData} from '../../symbol/symbol_size';
import type {FeatureStates} from '../../source/source_state';
import type {ImagePosition} from '../../render/image_atlas';
import type {VectorTileLayerLike} from '@maplibre/vt-pbf';
export type SingleCollisionBox = {
x1: number;
y1: number;
x2: number;
y2: number;
anchorPointX: number;
anchorPointY: number;
};
export type CollisionArrays = {
textBox?: SingleCollisionBox;
verticalTextBox?: SingleCollisionBox;
iconBox?: SingleCollisionBox;
verticalIconBox?: SingleCollisionBox;
textFeatureIndex?: number;
verticalTextFeatureIndex?: number;
iconFeatureIndex?: number;
verticalIconFeatureIndex?: number;
};
export type SymbolFeature = {
sortKey: number | void;
text: Formatted | void;
icon: ResolvedImage;
index: number;
sourceLayerIndex: number;
geometry: Array<Array<Point>>;
properties: any;
type: 'Unknown' | 'Point' | 'LineString' | 'Polygon';
id?: any;
};
export type SortKeyRange = {
sortKey: number;
symbolInstanceStart: number;
symbolInstanceEnd: number;
};
// Opacity arrays are frequently updated but don't contain a lot of information, so we pack them
// tight. Each Uint32 is actually four duplicate Uint8s for the four corners of a glyph
// 7 bits are for the current opacity, and the lowest bit is the target opacity
// actually defined in symbol_attributes.js
// const placementOpacityAttributes = [
// { name: 'a_fade_opacity', components: 1, type: 'Uint32' }
// ];
const shaderOpacityAttributes = [
{name: 'a_fade_opacity', components: 1, type: 'Uint8' as ViewType, offset: 0}
];
function addVertex(
array: StructArray,
anchorX: number,
anchorY: number,
ox: number,
oy: number,
tx: number,
ty: number,
sizeVertex: number,
isSDF: boolean,
pixelOffsetX: number,
pixelOffsetY: number,
minFontScaleX: number,
minFontScaleY: number
) {
const aSizeX = sizeVertex ? Math.min(MAX_PACKED_SIZE, Math.round(sizeVertex[0])) : 0;
const aSizeY = sizeVertex ? Math.min(MAX_PACKED_SIZE, Math.round(sizeVertex[1])) : 0;
array.emplaceBack(
// a_pos_offset
anchorX,
anchorY,
Math.round(ox * 32),
Math.round(oy * 32),
// a_data
tx, // x coordinate of symbol on glyph atlas texture
ty, // y coordinate of symbol on glyph atlas texture
(aSizeX << 1) + (isSDF ? 1 : 0),
aSizeY,
pixelOffsetX * 16,
pixelOffsetY * 16,
minFontScaleX * 256,
minFontScaleY * 256
);
}
function addDynamicAttributes(dynamicLayoutVertexArray: StructArray, p: Point, angle: number) {
dynamicLayoutVertexArray.emplaceBack(p.x, p.y, angle);
dynamicLayoutVertexArray.emplaceBack(p.x, p.y, angle);
dynamicLayoutVertexArray.emplaceBack(p.x, p.y, angle);
dynamicLayoutVertexArray.emplaceBack(p.x, p.y, angle);
}
function containsRTLText(formattedText: Formatted): boolean {
for (const section of formattedText.sections) {
if (stringContainsRTLText(section.text)) {
return true;
}
}
return false;
}
export class SymbolBuffers {
layoutVertexArray: SymbolLayoutArray;
layoutVertexBuffer: VertexBuffer;
indexArray: TriangleIndexArray;
indexBuffer: IndexBuffer;
programConfigurations: ProgramConfigurationSet<SymbolStyleLayer>;
segments: SegmentVector;
dynamicLayoutVertexArray: SymbolDynamicLayoutArray;
dynamicLayoutVertexBuffer: VertexBuffer;
opacityVertexArray: SymbolOpacityArray;
opacityVertexBuffer: VertexBuffer;
hasVisibleVertices: boolean;
collisionVertexArray: CollisionVertexArray;
collisionVertexBuffer: VertexBuffer;
placedSymbolArray: PlacedSymbolArray;
constructor(programConfigurations: ProgramConfigurationSet<SymbolStyleLayer>) {
this.layoutVertexArray = new SymbolLayoutArray();
this.indexArray = new TriangleIndexArray();
this.programConfigurations = programConfigurations;
this.segments = new SegmentVector();
this.dynamicLayoutVertexArray = new SymbolDynamicLayoutArray();
this.opacityVertexArray = new SymbolOpacityArray();
this.hasVisibleVertices = false;
this.placedSymbolArray = new PlacedSymbolArray();
}
isEmpty() {
return this.layoutVertexArray.length === 0 &&
this.indexArray.length === 0 &&
this.dynamicLayoutVertexArray.length === 0 &&
this.opacityVertexArray.length === 0;
}
upload(context: Context, dynamicIndexBuffer: boolean, upload?: boolean, update?: boolean) {
if (this.isEmpty()) {
return;
}
if (upload) {
this.layoutVertexBuffer = context.createVertexBuffer(this.layoutVertexArray, symbolLayoutAttributes.members);
this.indexBuffer = context.createIndexBuffer(this.indexArray, dynamicIndexBuffer);
this.dynamicLayoutVertexBuffer = context.createVertexBuffer(this.dynamicLayoutVertexArray, dynamicLayoutAttributes.members, true);
this.opacityVertexBuffer = context.createVertexBuffer(this.opacityVertexArray, shaderOpacityAttributes, true);
// This is a performance hack so that we can write to opacityVertexArray with uint32s
// even though the shaders read uint8s
this.opacityVertexBuffer.itemSize = 1;
}
if (upload || update) {
this.programConfigurations.upload(context);
}
}
destroy() {
if (!this.layoutVertexBuffer) return;
this.layoutVertexBuffer.destroy();
this.indexBuffer.destroy();
this.programConfigurations.destroy();
this.segments.destroy();
this.dynamicLayoutVertexBuffer.destroy();
this.opacityVertexBuffer.destroy();
}
}
register('SymbolBuffers', SymbolBuffers);
class CollisionBuffers {
layoutVertexArray: StructArray;
layoutAttributes: Array<StructArrayMember>;
layoutVertexBuffer: VertexBuffer;
indexArray: TriangleIndexArray | LineIndexArray;
indexBuffer: IndexBuffer;
segments: SegmentVector;
collisionVertexArray: CollisionVertexArray;
collisionVertexBuffer: VertexBuffer;
constructor(LayoutArray: {
new (...args: any): StructArray;
},
layoutAttributes: Array<StructArrayMember>,
IndexArray: {
new (...args: any): TriangleIndexArray | LineIndexArray;
}) {
this.layoutVertexArray = new LayoutArray();
this.layoutAttributes = layoutAttributes;
this.indexArray = new IndexArray();
this.segments = new SegmentVector();
this.collisionVertexArray = new CollisionVertexArray();
}
upload(context: Context) {
this.layoutVertexBuffer = context.createVertexBuffer(this.layoutVertexArray, this.layoutAttributes);
this.indexBuffer = context.createIndexBuffer(this.indexArray);
this.collisionVertexBuffer = context.createVertexBuffer(this.collisionVertexArray, collisionVertexAttributes.members, true);
}
destroy() {
if (!this.layoutVertexBuffer) return;
this.layoutVertexBuffer.destroy();
this.indexBuffer.destroy();
this.segments.destroy();
this.collisionVertexBuffer.destroy();
}
}
register('CollisionBuffers', CollisionBuffers);
/**
* @internal
* Unlike other buckets, which simply implement `addFeature` with type-specific
* logic for (essentially) triangulating feature geometries, SymbolBucket
* requires specialized behavior:
*
* 1. WorkerTile.parse(), the logical owner of the bucket creation process,
* calls SymbolBucket.populate(), which resolves text and icon tokens on
* each feature, adds each glyphs and symbols needed to the passed-in
* collections options.glyphDependencies and options.iconDependencies, and
* stores the feature data for use in subsequent step (this.features).
*
* 2. WorkerTile asynchronously requests from the main thread all of the glyphs
* and icons needed (by this bucket and any others). When glyphs and icons
* have been received, the WorkerTile creates a CollisionIndex and invokes:
*
* 3. performSymbolLayout(bucket, stacks, icons) perform texts shaping and
* layout on a Symbol Bucket. This step populates:
* `this.symbolInstances`: metadata on generated symbols
* `this.collisionBoxArray`: collision data for use by foreground
* `this.text`: SymbolBuffers for text symbols
* `this.icons`: SymbolBuffers for icons
* `this.iconCollisionBox`: Debug SymbolBuffers for icon collision boxes
* `this.textCollisionBox`: Debug SymbolBuffers for text collision boxes
* The results are sent to the foreground for rendering
*
* 4. placement.ts is run on the foreground,
* and uses the CollisionIndex along with current camera settings to determine
* which symbols can actually show on the map. Collided symbols are hidden
* using a dynamic "OpacityVertexArray".
*/
export class SymbolBucket implements Bucket {
static MAX_GLYPHS: number;
static addDynamicAttributes: typeof addDynamicAttributes;
collisionBoxArray: CollisionBoxArray;
zoom: number;
overscaling: number;
layers: Array<SymbolStyleLayer>;
layerIds: Array<string>;
stateDependentLayers: Array<SymbolStyleLayer>;
stateDependentLayerIds: Array<string>;
index: number;
sdfIcons: boolean;
iconsInText: boolean;
iconsNeedLinear: boolean;
bucketInstanceId: number;
justReloaded: boolean;
hasDependencies: boolean;
textSizeData: SizeData;
iconSizeData: SizeData;
glyphOffsetArray: GlyphOffsetArray;
lineVertexArray: SymbolLineVertexArray;
features: Array<SymbolFeature>;
symbolInstances: SymbolInstanceArray;
textAnchorOffsets: TextAnchorOffsetArray;
collisionArrays: Array<CollisionArrays>;
sortKeyRanges: Array<SortKeyRange>;
pixelRatio: number;
tilePixelRatio: number;
compareText: {[_: string]: Array<Point>};
fadeStartTime: number;
sortFeaturesByKey: boolean;
sortFeaturesByY: boolean;
canOverlap: boolean;
sortedAngle: number;
featureSortOrder: Array<number>;
collisionCircleArray: Array<number>;
text: SymbolBuffers;
icon: SymbolBuffers;
textCollisionBox: CollisionBuffers;
iconCollisionBox: CollisionBuffers;
uploaded: boolean;
sourceLayerIndex: number;
sourceID: string;
symbolInstanceIndexes: Array<number>;
writingModes: WritingMode[];
allowVerticalPlacement: boolean;
hasRTLText: boolean;
constructor(options: BucketParameters<SymbolStyleLayer>) {
this.collisionBoxArray = options.collisionBoxArray;
this.zoom = options.zoom;
this.overscaling = options.overscaling;
this.layers = options.layers;
this.layerIds = this.layers.map(layer => layer.id);
this.index = options.index;
this.pixelRatio = options.pixelRatio;
this.sourceLayerIndex = options.sourceLayerIndex;
this.hasDependencies = false;
this.hasRTLText = false;
this.sortKeyRanges = [];
this.collisionCircleArray = [];
const layer = this.layers[0];
const unevaluatedLayoutValues = layer._unevaluatedLayout._values;
this.textSizeData = getSizeData(this.zoom, unevaluatedLayoutValues['text-size']);
this.iconSizeData = getSizeData(this.zoom, unevaluatedLayoutValues['icon-size']);
const layout = this.layers[0].layout;
const sortKey = layout.get('symbol-sort-key');
const zOrder = layout.get('symbol-z-order');
this.canOverlap =
getOverlapMode(layout, 'text-overlap', 'text-allow-overlap') !== 'never' ||
getOverlapMode(layout, 'icon-overlap', 'icon-allow-overlap') !== 'never' ||
layout.get('text-ignore-placement') ||
layout.get('icon-ignore-placement');
this.sortFeaturesByKey = zOrder !== 'viewport-y' && !sortKey.isConstant();
const zOrderByViewportY = zOrder === 'viewport-y' || (zOrder === 'auto' && !this.sortFeaturesByKey);
this.sortFeaturesByY = zOrderByViewportY && this.canOverlap;
if (layout.get('symbol-placement') === 'point') {
this.writingModes = layout.get('text-writing-mode').map(wm => WritingMode[wm]);
}
this.stateDependentLayerIds = this.layers.filter((l) => l.isStateDependent()).map((l) => l.id);
this.sourceID = options.sourceID;
}
createArrays() {
this.text = new SymbolBuffers(new ProgramConfigurationSet(this.layers, this.zoom, property => /^text/.test(property)));
this.icon = new SymbolBuffers(new ProgramConfigurationSet(this.layers, this.zoom, property => /^icon/.test(property)));
this.glyphOffsetArray = new GlyphOffsetArray();
this.lineVertexArray = new SymbolLineVertexArray();
this.symbolInstances = new SymbolInstanceArray();
this.textAnchorOffsets = new TextAnchorOffsetArray();
}
private calculateGlyphDependencies(
text: string,
stack: {[_: number]: boolean},
textAlongLine: boolean,
allowVerticalPlacement: boolean,
doesAllowVerticalWritingMode: boolean) {
for (const char of text) {
stack[char.codePointAt(0)] = true;
if ((textAlongLine || allowVerticalPlacement) && doesAllowVerticalWritingMode) {
const verticalChar = verticalizedCharacterMap[char];
if (verticalChar) {
stack[verticalChar.codePointAt(0)] = true;
}
}
}
}
populate(features: Array<IndexedFeature>, options: PopulateParameters, canonical: CanonicalTileID) {
const layer = this.layers[0];
const layout = layer.layout;
const textFont = layout.get('text-font');
const textField = layout.get('text-field');
const iconImage = layout.get('icon-image');
const hasText =
(textField.value.kind !== 'constant' ||
(textField.value.value instanceof Formatted && !textField.value.value.isEmpty()) ||
textField.value.value.toString().length > 0) &&
(textFont.value.kind !== 'constant' || textFont.value.value.length > 0);
// we should always resolve the icon-image value if the property was defined in the style
// this allows us to fire the styleimagemissing event if image evaluation returns null
// the only way to distinguish between null returned from a coalesce statement with no valid images
// and null returned because icon-image wasn't defined is to check whether or not iconImage.parameters is an empty object
const hasIcon = iconImage.value.kind !== 'constant' || !!iconImage.value.value || Object.keys(iconImage.parameters).length > 0;
const symbolSortKey = layout.get('symbol-sort-key');
this.features = [];
if (!hasText && !hasIcon) {
return;
}
const icons = options.iconDependencies;
const stacks = options.glyphDependencies;
const availableImages = options.availableImages;
const globalProperties = new EvaluationParameters(this.zoom);
for (const {feature, id, index, sourceLayerIndex} of features) {
const needGeometry = layer._featureFilter.needGeometry;
const evaluationFeature = toEvaluationFeature(feature, needGeometry);
if (!layer._featureFilter.filter(globalProperties, evaluationFeature, canonical)) {
continue;
}
if (!needGeometry) evaluationFeature.geometry = loadGeometry(feature);
let text: Formatted | void;
if (hasText) {
// Expression evaluation will automatically coerce to Formatted
// but plain string token evaluation skips that pathway so do the
// conversion here.
const resolvedTokens = layer.getValueAndResolveTokens('text-field', evaluationFeature, canonical, availableImages);
const formattedText = Formatted.factory(resolvedTokens);
// on this instance: if hasRTLText is already true, all future calls to containsRTLText can be skipped.
const bucketHasRTLText = this.hasRTLText = (this.hasRTLText || containsRTLText(formattedText));
if (
!bucketHasRTLText || // non-rtl text so can proceed safely
rtlWorkerPlugin.getRTLTextPluginStatus() === 'unavailable' || // We don't intend to lazy-load the rtl text plugin, so proceed with incorrect shaping
bucketHasRTLText && rtlWorkerPlugin.isParsed() // Use the rtlText plugin to shape text
) {
text = transformText(formattedText, layer, evaluationFeature);
}
}
let icon: ResolvedImage;
if (hasIcon) {
// Expression evaluation will automatically coerce to Image
// but plain string token evaluation skips that pathway so do the
// conversion here.
const resolvedTokens = layer.getValueAndResolveTokens('icon-image', evaluationFeature, canonical, availableImages);
if (resolvedTokens instanceof ResolvedImage) {
icon = resolvedTokens;
} else {
icon = ResolvedImage.fromString(resolvedTokens);
}
}
if (!text && !icon) {
continue;
}
const sortKey = this.sortFeaturesByKey ?
symbolSortKey.evaluate(evaluationFeature, {}, canonical) :
undefined;
const symbolFeature: SymbolFeature = {
id,
text,
icon,
index,
sourceLayerIndex,
geometry: evaluationFeature.geometry,
properties: feature.properties,
type: VectorTileFeature.types[feature.type],
sortKey
};
this.features.push(symbolFeature);
if (icon) {
icons[icon.name] = true;
}
if (text) {
const fontStack = textFont.evaluate(evaluationFeature, {}, canonical).join(',');
const textAlongLine = layout.get('text-rotation-alignment') !== 'viewport' && layout.get('symbol-placement') !== 'point';
this.allowVerticalPlacement = this.writingModes && this.writingModes.indexOf(WritingMode.vertical) >= 0;
for (const section of text.sections) {
if (!section.image) {
const doesAllowVerticalWritingMode = allowsVerticalWritingMode(text.toString());
const sectionFont = section.fontStack || fontStack;
const sectionStack = stacks[sectionFont] = stacks[sectionFont] || {};
this.calculateGlyphDependencies(section.text, sectionStack, textAlongLine, this.allowVerticalPlacement, doesAllowVerticalWritingMode);
} else {
// Add section image to the list of dependencies.
icons[section.image.name] = true;
}
}
}
}
if (layout.get('symbol-placement') === 'line') {
// Merge adjacent lines with the same text to improve labelling.
// It's better to place labels on one long line than on many short segments.
this.features = mergeLines(this.features);
}
if (this.sortFeaturesByKey) {
this.features.sort((a, b) => {
// a.sortKey is always a number when sortFeaturesByKey is true
return (a.sortKey as number) - (b.sortKey as number);
});
}
}
update(states: FeatureStates, vtLayer: VectorTileLayerLike, imagePositions: {[_: string]: ImagePosition}) {
if (!this.stateDependentLayers.length) return;
this.text.programConfigurations.updatePaintArrays(states, vtLayer, this.layers, {
imagePositions
});
this.icon.programConfigurations.updatePaintArrays(states, vtLayer, this.layers, {
imagePositions
});
}
isEmpty() {
// When the bucket encounters only rtl-text but the plugin isn't loaded, no symbol instances will be created.
// In order for the bucket to be serialized, and not discarded as an empty bucket both checks are necessary.
return this.symbolInstances.length === 0 && !this.hasRTLText;
}
uploadPending() {
return !this.uploaded || this.text.programConfigurations.needsUpload || this.icon.programConfigurations.needsUpload;
}
upload(context: Context) {
if (!this.uploaded && this.hasDebugData()) {
this.textCollisionBox.upload(context);
this.iconCollisionBox.upload(context);
}
this.text.upload(context, this.sortFeaturesByY, !this.uploaded, this.text.programConfigurations.needsUpload);
this.icon.upload(context, this.sortFeaturesByY, !this.uploaded, this.icon.programConfigurations.needsUpload);
this.uploaded = true;
}
destroyDebugData() {
this.textCollisionBox.destroy();
this.iconCollisionBox.destroy();
}
destroy() {
this.text.destroy();
this.icon.destroy();
if (this.hasDebugData()) {
this.destroyDebugData();
}
}
addToLineVertexArray(anchor: Anchor, line: Array<Point>) {
const lineStartIndex = this.lineVertexArray.length;
if (anchor.segment !== undefined) {
let sumForwardLength = anchor.dist(line[anchor.segment + 1]);
let sumBackwardLength = anchor.dist(line[anchor.segment]);
const vertices = {};
for (let i = anchor.segment + 1; i < line.length; i++) {
vertices[i] = {x: line[i].x, y: line[i].y, tileUnitDistanceFromAnchor: sumForwardLength};
if (i < line.length - 1) {
sumForwardLength += line[i + 1].dist(line[i]);
}
}
for (let i = anchor.segment || 0; i >= 0; i--) {
vertices[i] = {x: line[i].x, y: line[i].y, tileUnitDistanceFromAnchor: sumBackwardLength};
if (i > 0) {
sumBackwardLength += line[i - 1].dist(line[i]);
}
}
for (let i = 0; i < line.length; i++) {
const vertex = vertices[i];
this.lineVertexArray.emplaceBack(vertex.x, vertex.y, vertex.tileUnitDistanceFromAnchor);
}
}
return {
lineStartIndex,
lineLength: this.lineVertexArray.length - lineStartIndex
};
}
addSymbols(arrays: SymbolBuffers,
quads: Array<SymbolQuad>,
sizeVertex: any,
lineOffset: [number, number],
alongLine: boolean,
feature: SymbolFeature,
writingMode: WritingMode,
labelAnchor: Anchor,
lineStartIndex: number,
lineLength: number,
associatedIconIndex: number,
canonical: CanonicalTileID) {
const indexArray = arrays.indexArray;
const layoutVertexArray = arrays.layoutVertexArray;
const segment = arrays.segments.prepareSegment(4 * quads.length, layoutVertexArray, indexArray, this.canOverlap ? feature.sortKey as number : undefined);
const glyphOffsetArrayStart = this.glyphOffsetArray.length;
const vertexStartIndex = segment.vertexLength;
const angle = (this.allowVerticalPlacement && writingMode === WritingMode.vertical) ? Math.PI / 2 : 0;
const sections = feature.text && feature.text.sections;
for (let i = 0; i < quads.length; i++) {
const {tl, tr, bl, br, tex, pixelOffsetTL, pixelOffsetBR, minFontScaleX, minFontScaleY, glyphOffset, isSDF, sectionIndex} = quads[i];
const index = segment.vertexLength;
const y = glyphOffset[1];
addVertex(layoutVertexArray, labelAnchor.x, labelAnchor.y, tl.x, y + tl.y, tex.x, tex.y, sizeVertex, isSDF, pixelOffsetTL.x, pixelOffsetTL.y, minFontScaleX, minFontScaleY);
addVertex(layoutVertexArray, labelAnchor.x, labelAnchor.y, tr.x, y + tr.y, tex.x + tex.w, tex.y, sizeVertex, isSDF, pixelOffsetBR.x, pixelOffsetTL.y, minFontScaleX, minFontScaleY);
addVertex(layoutVertexArray, labelAnchor.x, labelAnchor.y, bl.x, y + bl.y, tex.x, tex.y + tex.h, sizeVertex, isSDF, pixelOffsetTL.x, pixelOffsetBR.y, minFontScaleX, minFontScaleY);
addVertex(layoutVertexArray, labelAnchor.x, labelAnchor.y, br.x, y + br.y, tex.x + tex.w, tex.y + tex.h, sizeVertex, isSDF, pixelOffsetBR.x, pixelOffsetBR.y, minFontScaleX, minFontScaleY);
addDynamicAttributes(arrays.dynamicLayoutVertexArray, labelAnchor, angle);
indexArray.emplaceBack(index, index + 2, index + 1);
indexArray.emplaceBack(index + 1, index + 2, index + 3);
segment.vertexLength += 4;
segment.primitiveLength += 2;
this.glyphOffsetArray.emplaceBack(glyphOffset[0]);
if (i === quads.length - 1 || sectionIndex !== quads[i + 1].sectionIndex) {
arrays.programConfigurations.populatePaintArrays(layoutVertexArray.length, feature, feature.index, {imagePositions: {}, canonical, formattedSection: sections && sections[sectionIndex]});
}
}
arrays.placedSymbolArray.emplaceBack(
labelAnchor.x, labelAnchor.y,
glyphOffsetArrayStart,
this.glyphOffsetArray.length - glyphOffsetArrayStart,
vertexStartIndex,
lineStartIndex,
lineLength,
labelAnchor.segment,
sizeVertex ? sizeVertex[0] : 0,
sizeVertex ? sizeVertex[1] : 0,
lineOffset[0], lineOffset[1],
writingMode,
// placedOrientation is null initially; will be updated to horizontal(1)/vertical(2) if placed
0,
false as unknown as number,
// The crossTileID is only filled/used on the foreground for dynamic text anchors
0,
associatedIconIndex
);
}
_addCollisionDebugVertex(layoutVertexArray: StructArray, collisionVertexArray: StructArray, point: Point, anchorX: number, anchorY: number, extrude: Point) {
collisionVertexArray.emplaceBack(0, 0);
return layoutVertexArray.emplaceBack(
// pos
point.x,
point.y,
// a_anchor_pos
anchorX,
anchorY,
// extrude
Math.round(extrude.x),
Math.round(extrude.y));
}
addCollisionDebugVertices(x1: number, y1: number, x2: number, y2: number, arrays: CollisionBuffers, boxAnchorPoint: Point, symbolInstance: SymbolInstance) {
const segment = arrays.segments.prepareSegment(4, arrays.layoutVertexArray, arrays.indexArray);
const index = segment.vertexLength;
const layoutVertexArray = arrays.layoutVertexArray;
const collisionVertexArray = arrays.collisionVertexArray;
const anchorX = symbolInstance.anchorX;
const anchorY = symbolInstance.anchorY;
this._addCollisionDebugVertex(layoutVertexArray, collisionVertexArray, boxAnchorPoint, anchorX, anchorY, new Point(x1, y1));
this._addCollisionDebugVertex(layoutVertexArray, collisionVertexArray, boxAnchorPoint, anchorX, anchorY, new Point(x2, y1));
this._addCollisionDebugVertex(layoutVertexArray, collisionVertexArray, boxAnchorPoint, anchorX, anchorY, new Point(x2, y2));
this._addCollisionDebugVertex(layoutVertexArray, collisionVertexArray, boxAnchorPoint, anchorX, anchorY, new Point(x1, y2));
segment.vertexLength += 4;
const indexArray = arrays.indexArray as LineIndexArray;
indexArray.emplaceBack(index, index + 1);
indexArray.emplaceBack(index + 1, index + 2);
indexArray.emplaceBack(index + 2, index + 3);
indexArray.emplaceBack(index + 3, index);
segment.primitiveLength += 4;
}
addDebugCollisionBoxes(startIndex: number, endIndex: number, symbolInstance: SymbolInstance, isText: boolean) {
for (let b = startIndex; b < endIndex; b++) {
const box: CollisionBox = this.collisionBoxArray.get(b);
const x1 = box.x1;
const y1 = box.y1;
const x2 = box.x2;
const y2 = box.y2;
this.addCollisionDebugVertices(x1, y1, x2, y2,
isText ? this.textCollisionBox : this.iconCollisionBox,
box.anchorPoint, symbolInstance);
}
}
generateCollisionDebugBuffers() {
if (this.hasDebugData()) {
this.destroyDebugData();
}
this.textCollisionBox = new CollisionBuffers(CollisionBoxLayoutArray, collisionBoxLayout.members, LineIndexArray);
this.iconCollisionBox = new CollisionBuffers(CollisionBoxLayoutArray, collisionBoxLayout.members, LineIndexArray);
for (let i = 0; i < this.symbolInstances.length; i++) {
const symbolInstance = this.symbolInstances.get(i);
this.addDebugCollisionBoxes(symbolInstance.textBoxStartIndex, symbolInstance.textBoxEndIndex, symbolInstance, true);
this.addDebugCollisionBoxes(symbolInstance.verticalTextBoxStartIndex, symbolInstance.verticalTextBoxEndIndex, symbolInstance, true);
this.addDebugCollisionBoxes(symbolInstance.iconBoxStartIndex, symbolInstance.iconBoxEndIndex, symbolInstance, false);
this.addDebugCollisionBoxes(symbolInstance.verticalIconBoxStartIndex, symbolInstance.verticalIconBoxEndIndex, symbolInstance, false);
}
}
// These flat arrays are meant to be quicker to iterate over than the source
// CollisionBoxArray
_deserializeCollisionBoxesForSymbol(
collisionBoxArray: CollisionBoxArray,
textStartIndex: number,
textEndIndex: number,
verticalTextStartIndex: number,
verticalTextEndIndex: number,
iconStartIndex: number,
iconEndIndex: number,
verticalIconStartIndex: number,
verticalIconEndIndex: number
): CollisionArrays {
const collisionArrays = {} as CollisionArrays;
for (let k = textStartIndex; k < textEndIndex; k++) {
const box: CollisionBox = collisionBoxArray.get(k);
collisionArrays.textBox = {x1: box.x1, y1: box.y1, x2: box.x2, y2: box.y2, anchorPointX: box.anchorPointX, anchorPointY: box.anchorPointY};
collisionArrays.textFeatureIndex = box.featureIndex;
break; // Only one box allowed per instance
}
for (let k = verticalTextStartIndex; k < verticalTextEndIndex; k++) {
const box: CollisionBox = collisionBoxArray.get(k);
collisionArrays.verticalTextBox = {x1: box.x1, y1: box.y1, x2: box.x2, y2: box.y2, anchorPointX: box.anchorPointX, anchorPointY: box.anchorPointY};
collisionArrays.verticalTextFeatureIndex = box.featureIndex;
break; // Only one box allowed per instance
}
for (let k = iconStartIndex; k < iconEndIndex; k++) {
// An icon can only have one box now, so this indexing is a bit vestigial...
const box: CollisionBox = collisionBoxArray.get(k);
collisionArrays.iconBox = {x1: box.x1, y1: box.y1, x2: box.x2, y2: box.y2, anchorPointX: box.anchorPointX, anchorPointY: box.anchorPointY};
collisionArrays.iconFeatureIndex = box.featureIndex;
break; // Only one box allowed per instance
}
for (let k = verticalIconStartIndex; k < verticalIconEndIndex; k++) {
// An icon can only have one box now, so this indexing is a bit vestigial...
const box: CollisionBox = collisionBoxArray.get(k);
collisionArrays.verticalIconBox = {x1: box.x1, y1: box.y1, x2: box.x2, y2: box.y2, anchorPointX: box.anchorPointX, anchorPointY: box.anchorPointY};
collisionArrays.verticalIconFeatureIndex = box.featureIndex;
break; // Only one box allowed per instance
}
return collisionArrays;
}
deserializeCollisionBoxes(collisionBoxArray: CollisionBoxArray) {
this.collisionArrays = [];
for (let i = 0; i < this.symbolInstances.length; i++) {
const symbolInstance = this.symbolInstances.get(i);
this.collisionArrays.push(this._deserializeCollisionBoxesForSymbol(
collisionBoxArray,
symbolInstance.textBoxStartIndex,
symbolInstance.textBoxEndIndex,
symbolInstance.verticalTextBoxStartIndex,
symbolInstance.verticalTextBoxEndIndex,
symbolInstance.iconBoxStartIndex,
symbolInstance.iconBoxEndIndex,
symbolInstance.verticalIconBoxStartIndex,
symbolInstance.verticalIconBoxEndIndex
));
}
}
hasTextData() {
return this.text.segments.get().length > 0;
}
hasIconData() {
return this.icon.segments.get().length > 0;
}
hasDebugData() {
return this.textCollisionBox && this.iconCollisionBox;
}
hasTextCollisionBoxData() {
return this.hasDebugData() && this.textCollisionBox.segments.get().length > 0;
}
hasIconCollisionBoxData() {
return this.hasDebugData() && this.iconCollisionBox.segments.get().length > 0;
}
addIndicesForPlacedSymbol(iconOrText: SymbolBuffers, placedSymbolIndex: number) {
const placedSymbol = iconOrText.placedSymbolArray.get(placedSymbolIndex);
const endIndex = placedSymbol.vertexStartIndex + placedSymbol.numGlyphs * 4;
for (let vertexIndex = placedSymbol.vertexStartIndex; vertexIndex < endIndex; vertexIndex += 4) {
iconOrText.indexArray.emplaceBack(vertexIndex, vertexIndex + 2, vertexIndex + 1);
iconOrText.indexArray.emplaceBack(vertexIndex + 1, vertexIndex + 2, vertexIndex + 3);
}
}
getSortedSymbolIndexes(angle: number) {
if (this.sortedAngle === angle && this.symbolInstanceIndexes !== undefined) {
return this.symbolInstanceIndexes;
}
const sin = Math.sin(angle);
const cos = Math.cos(angle);
const rotatedYs = [];
const featureIndexes = [];
const result = [];
for (let i = 0; i < this.symbolInstances.length; ++i) {
result.push(i);
const symbolInstance = this.symbolInstances.get(i);
rotatedYs.push(Math.round(sin * symbolInstance.anchorX + cos * symbolInstance.anchorY) | 0);
featureIndexes.push(symbolInstance.featureIndex);
}
result.sort((aIndex, bIndex) => {
return (rotatedYs[aIndex] - rotatedYs[bIndex]) ||
(featureIndexes[bIndex] - featureIndexes[aIndex]);
});
return result;
}
addToSortKeyRanges(symbolInstanceIndex: number, sortKey: number) {
const last = this.sortKeyRanges[this.sortKeyRanges.length - 1];
if (last && last.sortKey === sortKey) {
last.symbolInstanceEnd = symbolInstanceIndex + 1;
} else {
this.sortKeyRanges.push({
sortKey,
symbolInstanceStart: symbolInstanceIndex,
symbolInstanceEnd: symbolInstanceIndex + 1
});
}
}
sortFeatures(angle: number) {
if (!this.sortFeaturesByY) return;
if (this.sortedAngle === angle) return;
// The current approach to sorting doesn't sort across segments so don't try.
// Sorting within segments separately seemed not to be worth the complexity.
if (this.text.segments.get().length > 1 || this.icon.segments.get().length > 1) return;
// If the symbols are allowed to overlap sort them by their vertical screen position.
// The index array buffer is rewritten to reference the (unchanged) vertices in the
// sorted order.
// To avoid sorting the actual symbolInstance array we sort an array of indexes.
this.symbolInstanceIndexes = this.getSortedSymbolIndexes(angle);
this.sortedAngle = angle;
this.text.indexArray.clear();
this.icon.indexArray.clear();
this.featureSortOrder = [];
for (const i of this.symbolInstanceIndexes) {
const symbolInstance = this.symbolInstances.get(i);
this.featureSortOrder.push(symbolInstance.featureIndex);
[
symbolInstance.rightJustifiedTextSymbolIndex,
symbolInstance.centerJustifiedTextSymbolIndex,
symbolInstance.leftJustifiedTextSymbolIndex
].forEach((index, i, array) => {
// Only add a given index the first time it shows up,
// to avoid duplicate opacity entries when multiple justifications
// share the same glyphs.
if (index >= 0 && array.indexOf(index) === i) {
this.addIndicesForPlacedSymbol(this.text, index);
}
});
if (symbolInstance.verticalPlacedTextSymbolIndex >= 0) {
this.addIndicesForPlacedSymbol(this.text, symbolInstance.verticalPlacedTextSymbolIndex);
}
if (symbolInstance.placedIconSymbolIndex >= 0) {
this.addIndicesForPlacedSymbol(this.icon, symbolInstance.placedIconSymbolIndex);
}
if (symbolInstance.verticalPlacedIconSymbolIndex >= 0) {
this.addIndicesForPlacedSymbol(this.icon, symbolInstance.verticalPlacedIconSymbolIndex);
}
}
if (this.text.indexBuffer) this.text.indexBuffer.updateData(this.text.indexArray);
if (this.icon.indexBuffer) this.icon.indexBuffer.updateData(this.icon.indexArray);
}
}
register('SymbolBucket', SymbolBucket, {
omit: ['layers', 'collisionBoxArray', 'features', 'compareText']
});
// this constant is based on the size of StructArray indexes used in a symbol
// bucket--namely, glyphOffsetArrayStart
// eg the max valid UInt16 is 65,535
// See https://github.com/mapbox/mapbox-gl-js/issues/2907 for motivation
// lineStartIndex and textBoxStartIndex could potentially be concerns
// but we expect there to be many fewer boxes/lines than glyphs
SymbolBucket.MAX_GLYPHS = 65535;
SymbolBucket.addDynamicAttributes = addDynamicAttributes;
export {addDynamicAttributes};
+295
View File
@@ -0,0 +1,295 @@
import {describe, test, expect, vi} from 'vitest';
import {DEMData} from './dem_data';
import {RGBAImage} from '../util/image';
import {serialize, deserialize} from '../util/web_worker_transfer';
function createMockImage(height, width) {
// RGBAImage passed to constructor has uniform 1px padding on all sides.
height += 2;
width += 2;
const pixels = new Uint8Array(height * width * 4);
for (let i = 0; i < pixels.length; i++) {
pixels[i] = (i + 1) % 4 === 0 ? 1 : Math.floor(Math.random() * 256);
}
return new RGBAImage({height, width}, pixels);
}
function createMockClampImage(height, width) {
const pixels = new Uint8ClampedArray(height * width * 4);
for (let i = 0; i < pixels.length; i++) {
pixels[i] = (i + 1) % 4 === 0 ? 1 : Math.floor(Math.random() * 256);
}
return new RGBAImage({height, width}, pixels);
}
describe('DEMData', () => {
describe('constructor', () => {
test('Uint8Array', () => {
const imageData0 = createMockImage(4, 4);
const dem = new DEMData('0', imageData0, 'mapbox');
expect(dem.uid).toBe('0');
expect(dem.dim).toBe(4);
expect(dem.stride).toBe(6);
});
test('Uint8ClampedArray', () => {
const imageData0 = createMockClampImage(4, 4);
const dem = new DEMData('0', imageData0, 'mapbox');
expect(dem).not.toBeNull();
expect(dem['uid']).toBe('0');
expect(dem['dim']).toBe(2);
expect(dem['stride']).toBe(4);
});
test('otherEncoding', () => {
const spyOnWarnConsole = vi.spyOn(console, 'warn').mockImplementation(() => {});
const imageData0 = createMockImage(4, 4);
new DEMData('0', imageData0, 'otherEncoding' as any);
expect(spyOnWarnConsole).toHaveBeenCalledTimes(1);
expect(spyOnWarnConsole.mock.calls).toEqual([['\"otherEncoding\" is not a valid encoding type. Valid types include \"mapbox\", \"terrarium\" and \"custom\".']]);
});
});
});
function testDEMBorderRegion(dem: DEMData) {
return () => {
let nonempty = true;
for (let x = -1; x < 5; x++) {
for (let y = -1; y < 5; y++) {
if (dem.get(x, y) === -65536) {
nonempty = false;
break;
}
}
}
expect(nonempty).toBeTruthy();
let verticalBorderMatch = true;
for (const x of [-1, 4]) {
for (let y = 0; y < 4; y++) {
if (dem.get(x, y) !== dem.get(x < 0 ? x + 1 : x - 1, y)) {
verticalBorderMatch = false;
break;
}
}
}
expect(verticalBorderMatch).toBeTruthy();
// horizontal borders empty
let horizontalBorderMatch = true;
for (const y of [-1, 4]) {
for (let x = 0; x < 4; x++) {
if (dem.get(x, y) !== dem.get(x, y < 0 ? y + 1 : y - 1)) {
horizontalBorderMatch = false;
break;
}
}
}
expect(horizontalBorderMatch).toBeTruthy();
expect(dem.get(-1, 4) === dem.get(0, 3)).toBeTruthy();
expect(dem.get(4, 4) === dem.get(3, 3)).toBeTruthy();
expect(dem.get(-1, -1) === dem.get(0, 0)).toBeTruthy();
expect(dem.get(4, -1) === dem.get(3, 0)).toBeTruthy();
};
}
function testDEMBackfill(dem0: DEMData, dem1: DEMData) {
return () => {
dem0.backfillBorder(dem1, -1, 0);
for (let y = 0; y < 4; y++) {
// dx = -1, dy = 0, so the left edge of dem1 should equal the right edge of dem0
expect(dem0.get(-1, y) === dem1.get(3, y)).toBeTruthy();
}
dem0.backfillBorder(dem1, 0, -1);
for (let x = 0; x < 4; x++) {
expect(dem0.get(x, -1) === dem1.get(x, 3)).toBeTruthy();
}
dem0.backfillBorder(dem1, 1, 0);
for (let y = 0; y < 4; y++) {
expect(dem0.get(4, y) === dem1.get(0, y)).toBeTruthy();
}
dem0.backfillBorder(dem1, 0, 1);
for (let x = 0; x < 4; x++) {
expect(dem0.get(x, 4) === dem1.get(x, 0)).toBeTruthy();
}
dem0.backfillBorder(dem1, -1, 1);
expect(dem0.get(-1, 4) === dem1.get(3, 0)).toBeTruthy();
dem0.backfillBorder(dem1, 1, 1);
expect(dem0.get(4, 4) === dem1.get(0, 0)).toBeTruthy();
dem0.backfillBorder(dem1, -1, -1);
expect(dem0.get(-1, -1) === dem1.get(3, 3)).toBeTruthy();
dem0.backfillBorder(dem1, 1, -1);
expect(dem0.get(4, -1) === dem1.get(0, 3)).toBeTruthy();
};
}
describe('DEMData.backfillBorder with encoding', () => {
describe('mapbox encoding', () => {
const dem0 = new DEMData('0', createMockImage(4, 4), 'mapbox');
const dem1 = new DEMData('1', createMockImage(4, 4), 'mapbox');
test('border region is initially populated with neighboring data', testDEMBorderRegion(dem0));
test('backfillBorder correctly populates borders with neighboring data', testDEMBackfill(dem0, dem1));
});
describe('terrarium encoding', () => {
const dem0 = new DEMData('0', createMockImage(4, 4), 'terrarium');
const dem1 = new DEMData('1', createMockImage(4, 4), 'terrarium');
test('border region is initially populated with neighboring data', testDEMBorderRegion(dem0));
test('backfillBorder correctly populates borders with neighboring data', testDEMBackfill(dem0, dem1));
});
});
function testSerialization(dem0: DEMData, redFactor: number, greenFactor: number, blueFactor: number, baseShift: number) {
return () => {
const serialized = serialize(dem0);
// calculate min/max values
let min = Number.MAX_SAFE_INTEGER;
let max = Number.MIN_SAFE_INTEGER;
for (let x = 0; x < 4; x++) {
for (let y = 0; y < 4; y++) {
const ele = dem0.get(x, y);
if (ele > max) max = ele;
if (ele < min) min = ele;
}
}
expect(serialized).toEqual({
$name: 'DEMData',
uid: '0',
dim: 4,
stride: 6,
data: dem0.data,
redFactor,
greenFactor,
blueFactor,
baseShift,
max,
min,
});
const transferrables = [];
serialize(dem0, transferrables);
expect(new Uint32Array(transferrables[0])).toEqual(dem0.data);
};
}
function testDeserialization(dem0: DEMData) {
return () => {
const serialized = serialize(dem0);
const deserialized = deserialize(serialized);
expect(deserialized).toEqual(dem0);
};
}
describe('DEMData is correctly serialized and deserialized', () => {
const mapboxDEM = new DEMData('0', createMockImage(4, 4), 'mapbox');
const terrariumDEM = new DEMData('0', createMockImage(4, 4), 'terrarium');
const customDEM = new DEMData('0', createMockImage(4, 4), 'custom', 1.0, 2.0, 3.0, 4.0);
test('serialized - mapbox', testSerialization(mapboxDEM, 6553.6, 25.6, 0.1, 10000));
test('serialized - terrarium', testSerialization(terrariumDEM, 256.0, 1.0, 1.0 / 256.0, 32768.0));
test('serialized - custom', testSerialization(customDEM, 1.0, 2.0, 3.0, 4.0));
test('deserialized - mapbox', testDeserialization(mapboxDEM));
test('deserialized - terrarium', testDeserialization(terrariumDEM));
test('deserialized - custom', testDeserialization(customDEM));
});
describe('UnpackVector is correctly returned', () => {
test('terrarium, mapbox and custom', () => {
const mapboxDEM = new DEMData('0', createMockImage(4, 4), 'mapbox');
const terrariumDEM = new DEMData('0', createMockImage(4, 4), 'terrarium');
const customDEM = new DEMData('0', createMockImage(4, 4), 'custom', 1.0, 2.0, 3.0, 4.0);
expect(terrariumDEM.getUnpackVector()).toEqual([256.0, 1.0, 1.0 / 256.0, 32768.0]);
expect(mapboxDEM.getUnpackVector()).toEqual([6553.6, 25.6, 0.1, 10000.0]);
expect(customDEM.getUnpackVector()).toEqual([1.0, 2.0, 3.0, 4.0]);
});
});
function testGetPixels(dem: DEMData, imageData: RGBAImage) {
return () => {
expect(dem.getPixels()).toEqual(imageData);
};
}
describe('DEMData.getImage', () => {
const imageData = createMockImage(4, 4);
const mapboxDEM = new DEMData('0', imageData, 'terrarium');
const terrariumDEM = new DEMData('0', imageData, 'terrarium');
const customDEM = new DEMData('0', imageData, 'terrarium');
test('Image is correctly returned - mapbox', testGetPixels(mapboxDEM, imageData));
test('Image is correctly returned - terrarium', testGetPixels(terrariumDEM, imageData));
test('Image is correctly returned - custom', testGetPixels(customDEM, imageData));
});
describe('DEMData pack and unpack', () => {
const imageData = createMockImage(4, 4);
test('mapbox', () => {
const dem = new DEMData('0', imageData, 'mapbox');
expect(dem.unpack(123, 177, 215)).toEqual(800645.5);
expect(dem.pack(800645.5)).toEqual({r: 123, g: 177, b: 215});
expect(dem.unpack(0, 0, 0)).toEqual(-10000);
expect(dem.pack(-10000)).toEqual({r: 0, g: 0, b: 0});
expect(dem.unpack(1, 1, 1)).toBeCloseTo(-3420.7);
expect(dem.pack(-3420.7)).toEqual({r: 1, g: 1, b: 1});
expect(dem.unpack(255, 255, 255)).toEqual(1667721.5);
expect(dem.pack(1667721.5)).toEqual({r: 255, g: 255, b: 255});
expect(dem.unpack(255, 0, 255)).toEqual(1661193.5);
expect(dem.pack(1661193.5)).toEqual({r: 255, g: 0, b: 255});
});
test('terrarium', () => {
const dem = new DEMData('0', imageData, 'terrarium');
expect(dem.unpack(123, 177, 215)).toEqual(-1102.16015625);
expect(dem.pack(-1102.16015625)).toEqual({r: 123, g: 177, b: 215});
expect(dem.unpack(0, 0, 0)).toEqual(-32768);
expect(dem.pack(-32768)).toEqual({r: 0, g: 0, b: 0});
expect(dem.unpack(1, 1, 1)).toEqual(-32510.99609375);
expect(dem.pack(-32510.99609375)).toEqual({r: 1, g: 1, b: 1});
expect(dem.unpack(255, 255, 255)).toEqual(32767.99609375);
expect(dem.pack(32767.99609375)).toEqual({r: 255, g: 255, b: 255});
expect(dem.unpack(255, 0, 255)).toEqual(32512.99609375);
expect(dem.pack(32512.99609375)).toEqual({r: 255, g: 0, b: 255});
});
test('custom', () => {
const dem = new DEMData('0', imageData, 'custom', 0.25, 64, 16384, 7000.0);
expect(dem.unpack(123, 177, 215)).toEqual(3526918.75);
expect(dem.pack(3526918.75)).toEqual({r: 123, g: 177, b: 215});
expect(dem.unpack(0, 0, 0)).toEqual(-7000);
expect(dem.pack(-7000)).toEqual({r: 0, g: 0, b: 0});
expect(dem.unpack(1, 1, 1)).toEqual(9448.25);
expect(dem.pack(9448.25)).toEqual({r: 1, g: 1, b: 1});
expect(dem.unpack(255, 255, 255)).toEqual(4187303.75);
expect(dem.pack(4187303.75)).toEqual({r: 255, g: 255, b: 255});
expect(dem.unpack(255, 0, 255)).toEqual(4170983.75);
expect(dem.pack(4170983.75)).toEqual({r: 255, g: 0, b: 255});
});
});
+189
View File
@@ -0,0 +1,189 @@
import {RGBAImage} from '../util/image';
import {warnOnce} from '../util/util';
import {register} from '../util/web_worker_transfer';
/**
* The possible DEM encoding types
*/
export type DEMEncoding = 'mapbox' | 'terrarium' | 'custom';
/**
* DEMData is a data structure for decoding, backfilling, and storing elevation data for processing in the hillshade shaders
* data can be populated either from a png raw image tile or from serialized data sent back from a worker. When data is initially
* loaded from a image tile, we decode the pixel values using the appropriate decoding formula, but we store the
* elevation data as an Int32 value. we add 65536 (2^16) to eliminate negative values and enable the use of
* integer overflow when creating the texture used in the hillshadePrepare step.
*
* DEMData also handles the backfilling of data from a tile's neighboring tiles. This is necessary because we use a pixel's 8
* surrounding pixel values to compute the slope at that pixel, and we cannot accurately calculate the slope at pixels on a
* tile's edge without backfilling from neighboring tiles.
*/
export class DEMData {
uid: string | number;
data: Uint32Array;
stride: number;
dim: number;
min: number;
max: number;
redFactor: number;
greenFactor: number;
blueFactor: number;
baseShift: number;
/**
* Constructs a `DEMData` object
* @param uid - the tile's unique id
* @param data - RGBAImage data has uniform 1px padding on all sides: square tile edge size defines stride
// and dim is calculated as stride - 2.
* @param encoding - the encoding type of the data
* @param redFactor - the red channel factor used to unpack the data, used for `custom` encoding only
* @param greenFactor - the green channel factor used to unpack the data, used for `custom` encoding only
* @param blueFactor - the blue channel factor used to unpack the data, used for `custom` encoding only
* @param baseShift - the base shift used to unpack the data, used for `custom` encoding only
*/
constructor(uid: string | number, data: RGBAImage | ImageData, encoding: DEMEncoding, redFactor = 1.0, greenFactor = 1.0, blueFactor = 1.0, baseShift = 0.0) {
this.uid = uid;
if (data.height !== data.width) throw new RangeError('DEM tiles must be square');
if (encoding && !['mapbox', 'terrarium', 'custom'].includes(encoding)) {
warnOnce(`"${encoding}" is not a valid encoding type. Valid types include "mapbox", "terrarium" and "custom".`);
return;
}
this.stride = data.height;
const dim = this.dim = data.height - 2;
this.data = new Uint32Array(data.data.buffer);
switch (encoding) {
case 'terrarium':
// unpacking formula for mapzen terrarium:
// https://aws.amazon.com/public-datasets/terrain/
this.redFactor = 256.0;
this.greenFactor = 1.0;
this.blueFactor = 1.0 / 256.0;
this.baseShift = 32768.0;
break;
case 'custom':
this.redFactor = redFactor;
this.greenFactor = greenFactor;
this.blueFactor = blueFactor;
this.baseShift = baseShift;
break;
case 'mapbox':
default:
// unpacking formula for mapbox.terrain-rgb:
// https://www.mapbox.com/help/access-elevation-data/#mapbox-terrain-rgb
this.redFactor = 6553.6;
this.greenFactor = 25.6;
this.blueFactor = 0.1;
this.baseShift = 10000.0;
break;
}
// in order to avoid flashing seams between tiles, here we are initially populating a 1px border of pixels around the image
// with the data of the nearest pixel from the image. this data is eventually replaced when the tile's neighboring
// tiles are loaded and the accurate data can be backfilled using DEMData#backfillBorder
for (let x = 0; x < dim; x++) {
// left vertical border
this.data[this._idx(-1, x)] = this.data[this._idx(0, x)];
// right vertical border
this.data[this._idx(dim, x)] = this.data[this._idx(dim - 1, x)];
// left horizontal border
this.data[this._idx(x, -1)] = this.data[this._idx(x, 0)];
// right horizontal border
this.data[this._idx(x, dim)] = this.data[this._idx(x, dim - 1)];
}
// corners
this.data[this._idx(-1, -1)] = this.data[this._idx(0, 0)];
this.data[this._idx(dim, -1)] = this.data[this._idx(dim - 1, 0)];
this.data[this._idx(-1, dim)] = this.data[this._idx(0, dim - 1)];
this.data[this._idx(dim, dim)] = this.data[this._idx(dim - 1, dim - 1)];
// calculate min/max values
this.min = Number.MAX_SAFE_INTEGER;
this.max = Number.MIN_SAFE_INTEGER;
for (let x = 0; x < dim; x++) {
for (let y = 0; y < dim; y++) {
const ele = this.get(x, y);
if (ele > this.max) this.max = ele;
if (ele < this.min) this.min = ele;
}
}
}
get(x: number, y: number) {
const pixels = new Uint8Array(this.data.buffer);
const index = this._idx(x, y) * 4;
return this.unpack(pixels[index], pixels[index + 1], pixels[index + 2]);
}
getUnpackVector() {
return [this.redFactor, this.greenFactor, this.blueFactor, this.baseShift];
}
_idx(x: number, y: number) {
if (x < -1 || x >= this.dim + 1 || y < -1 || y >= this.dim + 1) throw new RangeError(`Out of range source coordinates for DEM data. x: ${x}, y: ${y}, dim: ${this.dim}`);
return (y + 1) * this.stride + (x + 1);
}
unpack(r: number, g: number, b: number) {
return (r * this.redFactor + g * this.greenFactor + b * this.blueFactor - this.baseShift);
}
pack(v: number): {r: number; g: number; b: number} {
return packDEMData(v, this.getUnpackVector());
}
getPixels() {
return new RGBAImage({width: this.stride, height: this.stride}, new Uint8Array(this.data.buffer));
}
backfillBorder(borderTile: DEMData, dx: number, dy: number) {
if (this.dim !== borderTile.dim) throw new Error('dem dimension mismatch');
let xMin = dx * this.dim,
xMax = dx * this.dim + this.dim,
yMin = dy * this.dim,
yMax = dy * this.dim + this.dim;
switch (dx) {
case -1:
xMin = xMax - 1;
break;
case 1:
xMax = xMin + 1;
break;
}
switch (dy) {
case -1:
yMin = yMax - 1;
break;
case 1:
yMax = yMin + 1;
break;
}
const ox = -dx * this.dim;
const oy = -dy * this.dim;
for (let y = yMin; y < yMax; y++) {
for (let x = xMin; x < xMax; x++) {
this.data[this._idx(x, y)] = borderTile.data[this._idx(x + ox, y + oy)];
}
}
}
}
export function packDEMData(v: number, unpackVector: number[]): {r: number; g: number; b: number} {
const redFactor = unpackVector[0];
const greenFactor = unpackVector[1];
const blueFactor = unpackVector[2];
const baseShift = unpackVector[3];
const minScale = Math.min(redFactor, greenFactor, blueFactor);
const vScaled = Math.round((v + baseShift)/minScale);
return {
r: Math.floor(vScaled*minScale/redFactor) % 256,
g: Math.floor(vScaled*minScale/greenFactor) % 256,
b: Math.floor(vScaled*minScale/blueFactor) % 256
};
}
register('DEMData', DEMData);
+18
View File
@@ -0,0 +1,18 @@
import {loadGeometry} from './load_geometry';
import type Point from '@mapbox/point-geometry';
import type {Feature} from '@maplibre/maplibre-gl-style-spec';
import type {VectorTileFeatureLike} from '@maplibre/vt-pbf';
type EvaluationFeature = Feature & { geometry: Array<Array<Point>> };
/**
* Construct a new feature based on a VectorTileFeatureLike for expression evaluation, the geometry of which
* will be loaded based on necessity.
* @param feature - the feature to evaluate
* @param needGeometry - if set to true this will load the geometry
*/
export function toEvaluationFeature(feature: VectorTileFeatureLike, needGeometry: boolean): EvaluationFeature {
return {type: feature.type,
id: feature.id,
properties: feature.properties,
geometry: needGeometry ? loadGeometry(feature) : []};
}
+13
View File
@@ -0,0 +1,13 @@
/**
* The maximum value of a coordinate in the internal tile coordinate system. Coordinates of
* all source features normalized to this extent upon load.
*
* The value is a consequence of the following:
*
* * Vertex buffer store positions as signed 16 bit integers.
* * One bit is lost for signedness to support tile buffers.
* * One bit is lost because the line vertex buffer used to pack 1 bit of other data into the int.
* * One bit is lost to support features extending past the extent on the right edge of the tile.
* * This leaves us with 2^13 = 8192
*/
export const EXTENT = 8192;
+8
View File
@@ -0,0 +1,8 @@
import Point from '@mapbox/point-geometry';
import {Bounds, type ReadOnlyBounds} from '../geo/bounds';
import {EXTENT} from './extent';
/**
* The bounding box covering the entire extent of a tile.
*/
export const EXTENT_BOUNDS = Bounds.fromPoints([new Point(0, 0), new Point(EXTENT, EXTENT)]) as ReadOnlyBounds;
+101
View File
@@ -0,0 +1,101 @@
import path from 'path';
import {readFileSync} from 'fs';
import {describe, expect, test} from 'vitest';
import {FeatureIndex, GEOJSON_TILE_LAYER_NAME} from './feature_index';
import {type Feature, fromVectorTileJs, GeoJSONWrapper, type VectorTileFeatureLike} from '@maplibre/vt-pbf';
import {MercatorTransform} from '../geo/projection/mercator_transform';
import {OverscaledTileID} from '../tile/tile_id';
import {CircleStyleLayer} from '../style/style_layer/circle_style_layer';
import Point from '@mapbox/point-geometry';
import type {LayerSpecification} from '@maplibre/maplibre-gl-style-spec';
import type {EvaluationParameters} from '../style/evaluation_parameters';
describe('FeatureIndex', () => {
describe('getId', () => {
const tileID = new OverscaledTileID(0, 0, 0, 0, 0);
test('uses cluster_id when cluster is true and id is undefined', () => {
const featureIndex = new FeatureIndex(tileID, 'someProperty');
const feature: VectorTileFeatureLike = {
id: 0,
properties: {
cluster: true,
cluster_id: '123',
promoteId: 'someProperty',
someProperty: undefined
},
extent: 4096,
type: 1,
loadGeometry: () => [],
};
expect(featureIndex.getId(feature, 'sourceLayer')).toBe(123); // cluster_id converted to number
});
});
describe('query', () => {
const tileID = new OverscaledTileID(3, 0, 2, 1, 2);
const transform = new MercatorTransform();
transform.resize(500, 500);
test('filter with global-state', () => {
const features = [
{
type: 1,
geometry: [0, 0],
tags: {cluster: true}
} as any as Feature
];
const geojsonWrapper = new GeoJSONWrapper(features);
geojsonWrapper.name = GEOJSON_TILE_LAYER_NAME;
const rawTileData = fromVectorTileJs({layers: {[GEOJSON_TILE_LAYER_NAME]: geojsonWrapper}});
const globalState = {isCluster: true};
const layer = new CircleStyleLayer({source: 'source', paint: {}} as LayerSpecification, globalState);
layer.recalculate({} as EvaluationParameters, []);
const featureIndex = new FeatureIndex(tileID);
featureIndex.rawTileData = rawTileData as any as ArrayBuffer;
featureIndex.bucketLayerIDs = [['layer']];
featureIndex.insert(geojsonWrapper.feature(0), [[new Point(1, 1)]], 0, 0, 0);
const result = featureIndex.query({
queryPadding: 0,
tileSize: 512,
scale: 1,
queryGeometry: [new Point(0, 0), new Point(10, 10)],
cameraQueryGeometry: [new Point(0, 0), new Point(10, 10)],
params: {
filter: ['==', ['get', 'cluster'], ['global-state', 'isCluster']],
globalState
},
transform
} as any, {
layer: layer,
}, [], undefined);
expect(result.layer[0].feature.properties).toEqual(features[0].tags);
});
test('query mlt tile', () => {
const layer = new CircleStyleLayer({source: 'source', paint: {}} as LayerSpecification, {});
layer.recalculate({} as EvaluationParameters, []);
const featureIndex = new FeatureIndex(tileID);
const mltRawData = readFileSync(path.join(__dirname, '../../test/integration/assets/tiles/mlt/5/17/10.mlt')).buffer.slice(0) as ArrayBuffer;
featureIndex.rawTileData = mltRawData;
featureIndex.encoding = 'mlt';
featureIndex.bucketLayerIDs = [['layer']];
featureIndex.insert({} as any, [[new Point(1, 1)]], 0, 0, 0);
const result = featureIndex.query({
queryPadding: 0,
tileSize: 512,
scale: 1,
queryGeometry: [new Point(0, 0), new Point(0, 2000), new Point(2000, 2000), new Point(2000, 0), new Point(0 ,0)],
cameraQueryGeometry: [new Point(0, 0), new Point(10, 10)],
params: {},
transform
} as any, {
layer: layer,
}, [], undefined);
expect(result.layer[0].feature.properties.admin_level).toBeDefined();
expect(result.layer[0].feature.geometry.type).toBe('LineString');
});
});
});
+354
View File
@@ -0,0 +1,354 @@
import type Point from '@mapbox/point-geometry';
import {loadGeometry} from './load_geometry';
import {toEvaluationFeature} from './evaluation_feature';
import {EXTENT} from './extent';
import {featureFilter} from '@maplibre/maplibre-gl-style-spec';
import {TransferableGridIndex} from '../util/transferable_grid_index';
import {DictionaryCoder} from '../util/dictionary_coder';
import Protobuf from 'pbf';
import {GeoJSONFeature} from '../util/vectortile_to_geojson';
import {mapObject, extend} from '../util/util';
import {register} from '../util/web_worker_transfer';
import {EvaluationParameters} from '../style/evaluation_parameters';
import {polygonIntersectsBox} from '../util/intersection_tests';
import {PossiblyEvaluated} from '../style/properties';
import {FeatureIndexArray} from './array_types.g';
import {MLTVectorTile} from '../source/vector_tile_mlt';
import {Bounds} from '../geo/bounds';
import type {OverscaledTileID} from '../tile/tile_id';
import type {SourceFeatureState} from '../source/source_state';
import type {mat4} from 'gl-matrix';
import type {MapGeoJSONFeature} from '../util/vectortile_to_geojson';
import type {StyleLayer} from '../style/style_layer';
import type {FeatureFilter, FeatureState, FilterSpecification, PromoteIdSpecification} from '@maplibre/maplibre-gl-style-spec';
import type {IReadonlyTransform} from '../geo/transform_interface';
import {type VectorTileFeatureLike, type VectorTileLayerLike, GEOJSON_TILE_LAYER_NAME} from '@maplibre/vt-pbf';
import {VectorTile} from '@mapbox/vector-tile';
export {GEOJSON_TILE_LAYER_NAME};
type QueryParameters = {
scale: number;
pixelPosMatrix: mat4;
transform: IReadonlyTransform;
tileSize: number;
queryGeometry: Array<Point>;
cameraQueryGeometry: Array<Point>;
queryPadding: number;
getElevation: undefined | ((x: number, y: number) => number);
params: {
filter?: FilterSpecification;
layers?: Set<string> | null;
availableImages?: Array<string>;
globalState?: Record<string, any>;
};
};
export type QueryResults = {
[_: string]: QueryResultsItem[];
};
export type QueryResultsItem = {
featureIndex: number;
feature: GeoJSONFeature;
intersectionZ?: boolean | number;
};
/**
* An in memory index class to allow fast interaction with features
*/
export class FeatureIndex {
tileID: OverscaledTileID;
x: number;
y: number;
z: number;
grid: TransferableGridIndex;
grid3D: TransferableGridIndex;
featureIndexArray: FeatureIndexArray;
promoteId?: PromoteIdSpecification;
encoding: string;
rawTileData: ArrayBuffer;
bucketLayerIDs: Array<Array<string>>;
vtLayers: {[_: string]: VectorTileLayerLike};
sourceLayerCoder: DictionaryCoder;
constructor(tileID: OverscaledTileID, promoteId?: PromoteIdSpecification | null) {
this.tileID = tileID;
this.x = tileID.canonical.x;
this.y = tileID.canonical.y;
this.z = tileID.canonical.z;
this.grid = new TransferableGridIndex(EXTENT, 16, 0);
this.grid3D = new TransferableGridIndex(EXTENT, 16, 0);
this.featureIndexArray = new FeatureIndexArray();
this.promoteId = promoteId;
}
insert(feature: VectorTileFeatureLike, geometry: Array<Array<Point>>, featureIndex: number, sourceLayerIndex: number, bucketIndex: number, is3D?: boolean) {
const key = this.featureIndexArray.length;
this.featureIndexArray.emplaceBack(featureIndex, sourceLayerIndex, bucketIndex);
const grid = is3D ? this.grid3D : this.grid;
for (let r = 0; r < geometry.length; r++) {
const ring = geometry[r];
const bbox = [Infinity, Infinity, -Infinity, -Infinity];
for (let i = 0; i < ring.length; i++) {
const p = ring[i];
bbox[0] = Math.min(bbox[0], p.x);
bbox[1] = Math.min(bbox[1], p.y);
bbox[2] = Math.max(bbox[2], p.x);
bbox[3] = Math.max(bbox[3], p.y);
}
if (bbox[0] < EXTENT &&
bbox[1] < EXTENT &&
bbox[2] >= 0 &&
bbox[3] >= 0) {
grid.insert(key, bbox[0], bbox[1], bbox[2], bbox[3]);
}
}
}
loadVTLayers(): {[_: string]: VectorTileLayerLike} {
if (!this.vtLayers) {
this.vtLayers = this.encoding !== 'mlt'
? new VectorTile(new Protobuf(this.rawTileData)).layers
: new MLTVectorTile(this.rawTileData).layers;
this.sourceLayerCoder = new DictionaryCoder(this.vtLayers ? Object.keys(this.vtLayers).sort() : [GEOJSON_TILE_LAYER_NAME]);
}
return this.vtLayers;
}
// Finds non-symbol features in this tile at a particular position.
query(
args: QueryParameters,
styleLayers: {[_: string]: StyleLayer},
serializedLayers: {[_: string]: any},
sourceFeatureState: SourceFeatureState
): QueryResults {
this.loadVTLayers();
const params = args.params;
const pixelsToTileUnits = EXTENT / args.tileSize / args.scale;
const filter = featureFilter(params.filter, params.globalState);
const queryGeometry = args.queryGeometry;
const queryPadding = args.queryPadding * pixelsToTileUnits;
const bounds = Bounds.fromPoints(queryGeometry);
const matching = this.grid.query(bounds.minX - queryPadding, bounds.minY - queryPadding, bounds.maxX + queryPadding, bounds.maxY + queryPadding);
const cameraBounds = Bounds.fromPoints(args.cameraQueryGeometry).expandBy(queryPadding);
const matching3D = this.grid3D.query(
cameraBounds.minX, cameraBounds.minY, cameraBounds.maxX, cameraBounds.maxY,
(bx1, by1, bx2, by2) => {
return polygonIntersectsBox(args.cameraQueryGeometry, bx1 - queryPadding, by1 - queryPadding, bx2 + queryPadding, by2 + queryPadding);
});
for (const key of matching3D) {
matching.push(key);
}
matching.sort(topDownFeatureComparator);
const result: QueryResults = {};
let previousIndex;
for (let k = 0; k < matching.length; k++) {
const index = matching[k];
// don't check the same feature more than once
if (index === previousIndex) continue;
previousIndex = index;
const match = this.featureIndexArray.get(index);
let featureGeometry = null;
this.loadMatchingFeature(
result,
match.bucketIndex,
match.sourceLayerIndex,
match.featureIndex,
filter,
params.layers,
params.availableImages,
styleLayers,
serializedLayers,
sourceFeatureState,
(feature: VectorTileFeatureLike, styleLayer: StyleLayer, featureState: FeatureState) => {
if (!featureGeometry) {
featureGeometry = loadGeometry(feature);
}
return styleLayer.queryIntersectsFeature({
queryGeometry,
feature,
featureState,
geometry: featureGeometry,
zoom: this.z,
transform: args.transform,
pixelsToTileUnits,
pixelPosMatrix: args.pixelPosMatrix,
unwrappedTileID: this.tileID.toUnwrapped(),
getElevation: args.getElevation
});
}
);
}
return result;
}
loadMatchingFeature(
result: QueryResults,
bucketIndex: number,
sourceLayerIndex: number,
featureIndex: number,
filter: FeatureFilter,
filterLayerIDs: Set<string> | undefined,
availableImages: Array<string>,
styleLayers: {[_: string]: StyleLayer},
serializedLayers: {[_: string]: any},
sourceFeatureState?: SourceFeatureState,
intersectionTest?: (
feature: VectorTileFeatureLike,
styleLayer: StyleLayer,
featureState: any,
id: string | number | void
) => boolean | number) {
const layerIDs = this.bucketLayerIDs[bucketIndex];
if (filterLayerIDs && !layerIDs.some(id => filterLayerIDs.has(id)))
return;
const sourceLayerName = this.sourceLayerCoder.decode(sourceLayerIndex);
const sourceLayer = this.vtLayers[sourceLayerName];
const feature = sourceLayer.feature(featureIndex);
if (filter.needGeometry) {
const evaluationFeature = toEvaluationFeature(feature, true);
if (!filter.filter(new EvaluationParameters(this.tileID.overscaledZ), evaluationFeature, this.tileID.canonical)) {
return;
}
} else if (!filter.filter(new EvaluationParameters(this.tileID.overscaledZ), feature)) {
return;
}
const id = this.getId(feature, sourceLayerName);
for (let l = 0; l < layerIDs.length; l++) {
const layerID = layerIDs[l];
if (filterLayerIDs && !filterLayerIDs.has(layerID)) {
continue;
}
const styleLayer = styleLayers[layerID];
if (!styleLayer) continue;
let featureState = {};
if (id && sourceFeatureState) {
// `feature-state` expression evaluation requires feature state to be available
featureState = sourceFeatureState.getState(styleLayer.sourceLayer || GEOJSON_TILE_LAYER_NAME, id);
}
const serializedLayer = extend({}, serializedLayers[layerID]);
serializedLayer.paint = evaluateProperties(serializedLayer.paint, styleLayer.paint, feature, featureState, availableImages);
serializedLayer.layout = evaluateProperties(serializedLayer.layout, styleLayer.layout, feature, featureState, availableImages);
const intersectionZ = !intersectionTest || intersectionTest(feature, styleLayer, featureState);
if (!intersectionZ) {
// Only applied for non-symbol features
continue;
}
const geojsonFeature = new GeoJSONFeature(feature, this.z, this.x, this.y, id) as MapGeoJSONFeature;
geojsonFeature.layer = serializedLayer;
let layerResult = result[layerID];
if (layerResult === undefined) {
layerResult = result[layerID] = [];
}
layerResult.push({featureIndex, feature: geojsonFeature, intersectionZ});
}
}
// Given a set of symbol indexes that have already been looked up,
// return a matching set of GeoJSONFeatures
lookupSymbolFeatures(symbolFeatureIndexes: Array<number>,
serializedLayers: {[_: string]: StyleLayer},
bucketIndex: number,
sourceLayerIndex: number,
filterParams: {
filterSpec: FilterSpecification;
globalState: Record<string, any>;
},
filterLayerIDs: Set<string> | null,
availableImages: Array<string>,
styleLayers: {[_: string]: StyleLayer}): QueryResults {
const result: QueryResults = {};
this.loadVTLayers();
const filter = featureFilter(filterParams.filterSpec, filterParams.globalState);
for (const symbolFeatureIndex of symbolFeatureIndexes) {
this.loadMatchingFeature(
result,
bucketIndex,
sourceLayerIndex,
symbolFeatureIndex,
filter,
filterLayerIDs,
availableImages,
styleLayers,
serializedLayers
);
}
return result;
}
hasLayer(id: string) {
for (const layerIDs of this.bucketLayerIDs) {
for (const layerID of layerIDs) {
if (id === layerID) return true;
}
}
return false;
}
getId(feature: VectorTileFeatureLike, sourceLayerId: string): string | number {
let id: string | number = feature.id;
if (this.promoteId) {
const propName = typeof this.promoteId === 'string' ? this.promoteId : this.promoteId[sourceLayerId];
id = feature.properties[propName] as string | number;
if (typeof id === 'boolean') id = Number(id);
// When cluster is true, the id is the cluster_id even though promoteId is set
if (id === undefined && feature.properties?.cluster && this.promoteId) {
id = Number(feature.properties.cluster_id);
}
}
return id;
}
}
register(
'FeatureIndex',
FeatureIndex,
{omit: ['rawTileData', 'sourceLayerCoder']}
);
function evaluateProperties(serializedProperties, styleLayerProperties, feature, featureState, availableImages) {
return mapObject(serializedProperties, (property, key) => {
const prop = styleLayerProperties instanceof PossiblyEvaluated ? styleLayerProperties.get(key) : null;
return prop && prop.evaluate ? prop.evaluate(feature, featureState, availableImages) : prop;
});
}
function topDownFeatureComparator(a, b) {
return b - a;
}
+34
View File
@@ -0,0 +1,34 @@
import {describe, test, expect} from 'vitest';
import {FeaturePositionMap} from './feature_position_map';
import {serialize, deserialize} from '../util/web_worker_transfer';
describe('FeaturePositionMap', () => {
test('Can be queried after serialization/deserialization', () => {
const featureMap = new FeaturePositionMap();
featureMap.add(7, 1, 0, 1);
featureMap.add(3, 2, 1, 2);
featureMap.add(7, 3, 2, 3);
featureMap.add(4, 4, 3, 4);
featureMap.add(2, 5, 4, 5);
featureMap.add(7, 6, 5, 7);
const featureMap2 = deserialize(serialize(featureMap, [])) as FeaturePositionMap;
const compareIndex = (a, b) => a.index - b.index;
expect(featureMap2.getPositions(7).sort(compareIndex)).toEqual([
{index: 1, start: 0, end: 1},
{index: 3, start: 2, end: 3},
{index: 6, start: 5, end: 7}
].sort(compareIndex));
});
test('Can not be queried before serialization/deserialization', () => {
const featureMap = new FeaturePositionMap();
featureMap.add(0, 1, 2, 3);
expect(() => {
featureMap.getPositions(0);
}).toThrow();
});
});
+126
View File
@@ -0,0 +1,126 @@
import murmur3 from 'murmurhash-js';
import {register} from '../util/web_worker_transfer';
type SerializedFeaturePositionMap = {
ids: Float64Array;
positions: Uint32Array;
};
type FeaturePosition = {
index: number;
start: number;
end: number;
};
// A transferable data structure that maps feature ids to their indices and buffer offsets
export class FeaturePositionMap {
ids: Array<number>;
positions: Array<number>;
indexed: boolean;
constructor() {
this.ids = [];
this.positions = [];
this.indexed = false;
}
add(id: unknown, index: number, start: number, end: number) {
this.ids.push(getNumericId(id));
this.positions.push(index, start, end);
}
getPositions(id: unknown): Array<FeaturePosition> {
if (!this.indexed) throw new Error('Trying to get index, but feature positions are not indexed');
const intId = getNumericId(id);
// binary search for the first occurrence of id in this.ids;
// relies on ids/positions being sorted by id, which happens in serialization
let i = 0;
let j = this.ids.length - 1;
while (i < j) {
const m = (i + j) >> 1;
if (this.ids[m] >= intId) {
j = m;
} else {
i = m + 1;
}
}
const positions = [];
while (this.ids[i] === intId) {
const index = this.positions[3 * i];
const start = this.positions[3 * i + 1];
const end = this.positions[3 * i + 2];
positions.push({index, start, end});
i++;
}
return positions;
}
static serialize(map: FeaturePositionMap, transferables: Array<ArrayBuffer>): SerializedFeaturePositionMap {
const ids = new Float64Array(map.ids);
const positions = new Uint32Array(map.positions);
sort(ids, positions, 0, ids.length - 1);
if (transferables) {
transferables.push(ids.buffer, positions.buffer);
}
return {ids, positions};
}
static deserialize(obj: SerializedFeaturePositionMap): FeaturePositionMap {
const map = new FeaturePositionMap();
// after transferring, we only use these arrays statically (no pushes),
// so TypedArray vs Array distinction that flow points out doesn't matter
map.ids = (obj.ids as any);
map.positions = (obj.positions as any);
map.indexed = true;
return map;
}
}
function getNumericId(value: unknown) {
const numValue = +value;
if (!isNaN(numValue) && numValue <= Number.MAX_SAFE_INTEGER) {
return numValue;
}
return murmur3(String(value));
}
// custom quicksort that sorts ids, indices and offsets together (by ids)
// uses Hoare partitioning & manual tail call optimization to avoid worst case scenarios
function sort(ids, positions, left, right) {
while (left < right) {
const pivot = ids[(left + right) >> 1];
let i = left - 1;
let j = right + 1;
while (true) {
do i++; while (ids[i] < pivot);
do j--; while (ids[j] > pivot);
if (i >= j) break;
swap(ids, i, j);
swap(positions, 3 * i, 3 * j);
swap(positions, 3 * i + 1, 3 * j + 1);
swap(positions, 3 * i + 2, 3 * j + 2);
}
if (j - left < right - j) {
sort(ids, positions, left, j);
left = j + 1;
} else {
sort(ids, positions, j + 1, right);
right = j;
}
}
}
function swap(arr, i, j) {
const tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
register('FeaturePositionMap', FeaturePositionMap);
+9
View File
@@ -0,0 +1,9 @@
import {LineIndexArray, TriangleIndexArray, LineStripIndexArray} from './array_types.g';
/**
* An index array stores Uint16 indices of vertexes in a corresponding vertex array. We use
* three kinds of index arrays: arrays storing groups of three indices, forming triangles;
* arrays storing pairs of indices, forming line segments; and arrays storing single indices,
* forming a line strip.
*/
export {LineIndexArray, TriangleIndexArray, LineStripIndexArray};
+51
View File
@@ -0,0 +1,51 @@
import {describe, test, expect, beforeAll} from 'vitest';
import {loadGeometry} from './load_geometry';
import {loadVectorTile} from '../../test/unit/lib/tile';
import type {VectorTileLayerLike} from '@maplibre/vt-pbf';
describe('loadGeometry', () => {
let sourceLayer: VectorTileLayerLike;
beforeAll(() => {
// Load line features from fixture tile.
sourceLayer = loadVectorTile().layers.road;
});
test('loadGeometry', () => {
const feature = sourceLayer.feature(0);
const originalGeometry = feature.loadGeometry();
const scaledGeometry = loadGeometry(feature);
expect(scaledGeometry[0][0].x).toBe(originalGeometry[0][0].x * 2);
expect(scaledGeometry[0][0].y).toBe(originalGeometry[0][0].y * 2);
});
test('loadGeometry warns and clamps when exceeding extent', () => {
const feature = sourceLayer.feature(0);
feature.extent = 2048;
let numWarnings = 0;
// Use a custom console.warn to count warnings
const warn = console.warn;
console.warn = (warning) => {
if (warning.match(/Geometry exceeds allowed extent, reduce your vector tile buffer size/)) {
numWarnings++;
}
};
const lines = loadGeometry(feature);
expect(numWarnings).toBe(1);
let maxValue = -Infinity;
for (const line of lines) {
for (const {x, y} of line) {
maxValue = Math.max(x, y, maxValue);
}
}
expect(maxValue).toBe(16383);
// Put it back
console.warn = warn;
});
});
+44
View File
@@ -0,0 +1,44 @@
import {warnOnce, clamp} from '../util/util';
import {EXTENT} from './extent';
import type Point from '@mapbox/point-geometry';
import type {VectorTileFeatureLike} from '@maplibre/vt-pbf';
// These bounds define the minimum and maximum supported coordinate values.
// While visible coordinates are within [0, EXTENT], tiles may theoretically
// contain coordinates within [-Infinity, Infinity]. Our range is limited by the
// number of bits used to represent the coordinate.
const BITS = 15;
const MAX = Math.pow(2, BITS - 1) - 1;
const MIN = -MAX - 1;
/**
* Loads a geometry from a VectorTileFeatureLike and scales it to the common extent
* used internally.
* @param feature - the vector tile feature to load
*/
export function loadGeometry(feature: VectorTileFeatureLike): Array<Array<Point>> {
const scale = EXTENT / feature.extent;
const geometry = feature.loadGeometry();
for (let r = 0; r < geometry.length; r++) {
const ring = geometry[r];
for (let p = 0; p < ring.length; p++) {
const point = ring[p];
// round here because mapbox-gl-native uses integers to represent
// points and we need to do the same to avoid rendering differences.
const x = Math.round(point.x * scale);
const y = Math.round(point.y * scale);
point.x = clamp(x, MIN, MAX);
point.y = clamp(y, MIN, MAX);
if (x < point.x || x > point.x + 1 || y < point.y || y > point.y + 1) {
// warn when exceeding allowed extent except for the 1-px-off case
// https://github.com/mapbox/mapbox-gl-js/issues/8992
warnOnce('Geometry exceeds allowed extent, reduce your vector tile buffer size');
}
}
}
return geometry;
}
+5
View File
@@ -0,0 +1,5 @@
import {createLayout} from '../util/struct_array';
export default createLayout([
{name: 'a_pos3d', type: 'Int16', components: 3}
]);
+5
View File
@@ -0,0 +1,5 @@
import {createLayout} from '../util/struct_array';
export default createLayout([
{name: 'a_pos', type: 'Int16', components: 2}
]);
+824
View File
@@ -0,0 +1,824 @@
import {packUint8ToFloat} from '../shaders/encode_attribute';
import {type Color, supportsPropertyExpression} from '@maplibre/maplibre-gl-style-spec';
import {register} from '../util/web_worker_transfer';
import {PossiblyEvaluatedPropertyValue} from '../style/properties';
import {StructArrayLayout1f4, StructArrayLayout2f8, StructArrayLayout4f16, PatternLayoutArray, DashLayoutArray} from './array_types.g';
import {clamp} from '../util/util';
import {patternAttributes} from './bucket/pattern_attributes';
import {dashAttributes} from './bucket/dash_attributes';
import {EvaluationParameters} from '../style/evaluation_parameters';
import {FeaturePositionMap} from './feature_position_map';
import {type Uniform, Uniform1f, UniformColor, Uniform4f} from '../render/uniform_binding';
import type {UniformLocations} from '../render/uniform_binding';
import type {CanonicalTileID} from '../tile/tile_id';
import type {Context} from '../gl/context';
import type {TypedStyleLayer} from '../style/style_layer/typed_style_layer';
import type {CrossfadeParameters} from '../style/evaluation_parameters';
import type {StructArray, StructArrayMember} from '../util/struct_array';
import type {VertexBuffer} from '../gl/vertex_buffer';
import type {ImagePosition} from '../render/image_atlas';
import type {
Feature,
FeatureState,
GlobalProperties,
SourceExpression,
CompositeExpression,
FormattedSection
} from '@maplibre/maplibre-gl-style-spec';
import type {FeatureStates} from '../source/source_state';
import type {DashEntry} from '../render/line_atlas';
import type {VectorTileLayerLike} from '@maplibre/vt-pbf';
export type BinderUniform = {
name: string;
property: string;
binding: Uniform<any>;
};
function packColor(color: Color): [number, number] {
return [
packUint8ToFloat(255 * color.r, 255 * color.g),
packUint8ToFloat(255 * color.b, 255 * color.a)
];
}
type PaintOptions = {
imagePositions: {
[_: string]: ImagePosition;
};
dashPositions?: {
[_: string]: DashEntry;
};
canonical?: CanonicalTileID;
formattedSection?: FormattedSection;
globalState?: Record<string, any>;
};
/**
* `Binder` is the interface definition for the strategies for constructing,
* uploading, and binding paint property data as GLSL attributes. Most style-
* spec properties have a 1:1 relationship to shader attribute/uniforms, but
* some require multiple values per feature to be passed to the GPU, and in
* those cases we bind multiple attributes/uniforms.
*
* It has three implementations, one for each of the three strategies we use:
*
* * For _constant_ properties -- those whose value is a constant, or the constant
* result of evaluating a camera expression at a particular camera position -- we
* don't need a vertex attribute buffer, and instead use a uniform.
* * For data expressions, we use a vertex buffer with a single attribute value,
* the evaluated result of the source function for the given feature.
* * For composite expressions, we use a vertex buffer with two attributes: min and
* max values covering the range of zooms at which we expect the tile to be
* displayed. These values are calculated by evaluating the composite expression for
* the given feature at strategically chosen zoom levels. In addition to this
* attribute data, we also use a uniform value which the shader uses to interpolate
* between the min and max value at the final displayed zoom level. The use of a
* uniform allows us to cheaply update the value on every frame.
*
* Note that the shader source varies depending on whether we're using a uniform or
* attribute. We dynamically compile shaders at runtime to accommodate this.
*/
interface AttributeBinder {
populatePaintArray(
length: number,
feature: Feature,
options: PaintOptions
): void;
updatePaintArray(
start: number,
length: number,
feature: Feature,
featureState: FeatureState,
options: PaintOptions
): void;
upload(a: Context): void;
destroy(): void;
}
interface UniformBinder {
uniformNames: Array<string>;
setUniform(
uniform: Uniform<any>,
globals: GlobalProperties,
currentValue: PossiblyEvaluatedPropertyValue<any>,
uniformName: string
): void;
getBinding(context: Context, location: WebGLUniformLocation, name: string): Partial<Uniform<any>>;
}
class ConstantBinder implements UniformBinder {
value: unknown;
type: string;
uniformNames: Array<string>;
constructor(value: unknown, names: Array<string>, type: string) {
this.value = value;
this.uniformNames = names.map(name => `u_${name}`);
this.type = type;
}
setUniform(
uniform: Uniform<any>,
globals: GlobalProperties,
currentValue: PossiblyEvaluatedPropertyValue<unknown>
): void {
uniform.set(currentValue.constantOr(this.value));
}
getBinding(context: Context, location: WebGLUniformLocation, _: string): Partial<Uniform<any>> {
return (this.type === 'color') ?
new UniformColor(context, location) :
new Uniform1f(context, location);
}
}
class CrossFadedConstantBinder implements UniformBinder {
uniformNames: Array<string>;
patternFrom: Array<number>;
patternTo: Array<number>;
dashFrom: Array<number>;
dashTo: Array<number>;
pixelRatioFrom: number;
pixelRatioTo: number;
constructor(value: unknown, names: Array<string>) {
this.uniformNames = names.map(name => `u_${name}`);
this.patternFrom = null;
this.patternTo = null;
this.pixelRatioFrom = 1.0;
this.pixelRatioTo = 1.0;
}
setConstantPatternPositions(posTo: ImagePosition, posFrom: ImagePosition) {
this.pixelRatioFrom = posFrom.pixelRatio;
this.pixelRatioTo = posTo.pixelRatio;
this.patternFrom = posFrom.tlbr;
this.patternTo = posTo.tlbr;
}
setConstantDashPositions(dashTo: DashEntry, dashFrom: DashEntry) {
this.dashTo = [0, dashTo.y, dashTo.height, dashTo.width];
this.dashFrom = [0, dashFrom.y, dashFrom.height, dashFrom.width];
}
setUniform(uniform: Uniform<any>, globals: GlobalProperties, currentValue: PossiblyEvaluatedPropertyValue<unknown>, uniformName: string) {
let value = null;
if (uniformName === 'u_pattern_to') {
value = this.patternTo;
} else if (uniformName === 'u_pattern_from') {
value = this.patternFrom;
} else if (uniformName === 'u_dasharray_to') {
value = this.dashTo;
} else if (uniformName === 'u_dasharray_from') {
value = this.dashFrom;
} else if (uniformName === 'u_pixel_ratio_to') {
value = this.pixelRatioTo;
} else if (uniformName === 'u_pixel_ratio_from') {
value = this.pixelRatioFrom;
}
if (value !== null) {
uniform.set(value);
}
}
getBinding(context: Context, location: WebGLUniformLocation, name: string): Partial<Uniform<any>> {
return (name.startsWith('u_pattern') || name.startsWith('u_dasharray_')) ?
new Uniform4f(context, location) :
new Uniform1f(context, location);
}
}
class SourceExpressionBinder implements AttributeBinder {
expression: SourceExpression;
type: string;
maxValue: number;
paintVertexArray: StructArray;
paintVertexAttributes: Array<StructArrayMember>;
paintVertexBuffer: VertexBuffer;
constructor(expression: SourceExpression, names: Array<string>, type: string, PaintVertexArray: {
new (...args: any): StructArray;
}) {
this.expression = expression;
this.type = type;
this.maxValue = 0;
this.paintVertexAttributes = names.map((name) => ({
name: `a_${name}`,
type: 'Float32',
components: type === 'color' ? 2 : 1,
offset: 0
}));
this.paintVertexArray = new PaintVertexArray();
}
populatePaintArray(newLength: number, feature: Feature, options: PaintOptions) {
const start = this.paintVertexArray.length;
const value = this.expression.evaluate(new EvaluationParameters(0, options), feature, {}, options.canonical, [], options.formattedSection);
this.paintVertexArray.resize(newLength);
this._setPaintValue(start, newLength, value);
}
updatePaintArray(start: number, end: number, feature: Feature, featureState: FeatureState, options: PaintOptions) {
const value = this.expression.evaluate(new EvaluationParameters(0, options), feature, featureState);
this._setPaintValue(start, end, value);
}
_setPaintValue(start, end, value) {
if (this.type === 'color') {
const color = packColor(value);
for (let i = start; i < end; i++) {
this.paintVertexArray.emplace(i, color[0], color[1]);
}
} else {
for (let i = start; i < end; i++) {
this.paintVertexArray.emplace(i, value);
}
this.maxValue = Math.max(this.maxValue, Math.abs(value));
}
}
upload(context: Context) {
if (this.paintVertexArray?.arrayBuffer.byteLength) {
if (this.paintVertexBuffer && this.paintVertexBuffer.buffer) {
this.paintVertexBuffer.updateData(this.paintVertexArray);
} else {
this.paintVertexBuffer = context.createVertexBuffer(this.paintVertexArray, this.paintVertexAttributes, this.expression.isStateDependent);
}
}
}
destroy() {
if (this.paintVertexBuffer) {
this.paintVertexBuffer.destroy();
}
}
}
class CompositeExpressionBinder implements AttributeBinder, UniformBinder {
expression: CompositeExpression;
uniformNames: Array<string>;
type: string;
useIntegerZoom: boolean;
zoom: number;
maxValue: number;
paintVertexArray: StructArray;
paintVertexAttributes: Array<StructArrayMember>;
paintVertexBuffer: VertexBuffer;
constructor(expression: CompositeExpression, names: Array<string>, type: string, useIntegerZoom: boolean, zoom: number, PaintVertexArray: {
new (...args: any): StructArray;
}) {
this.expression = expression;
this.uniformNames = names.map(name => `u_${name}_t`);
this.type = type;
this.useIntegerZoom = useIntegerZoom;
this.zoom = zoom;
this.maxValue = 0;
this.paintVertexAttributes = names.map((name) => ({
name: `a_${name}`,
type: 'Float32',
components: type === 'color' ? 4 : 2,
offset: 0
}));
this.paintVertexArray = new PaintVertexArray();
}
populatePaintArray(newLength: number, feature: Feature, options: PaintOptions) {
const min = this.expression.evaluate(new EvaluationParameters(this.zoom, options), feature, {}, options.canonical, [], options.formattedSection);
const max = this.expression.evaluate(new EvaluationParameters(this.zoom + 1, options), feature, {}, options.canonical, [], options.formattedSection);
const start = this.paintVertexArray.length;
this.paintVertexArray.resize(newLength);
this._setPaintValue(start, newLength, min, max);
}
updatePaintArray(start: number, end: number, feature: Feature, featureState: FeatureState, options: PaintOptions) {
const min = this.expression.evaluate(new EvaluationParameters(this.zoom, options), feature, featureState);
const max = this.expression.evaluate(new EvaluationParameters(this.zoom + 1, options), feature, featureState);
this._setPaintValue(start, end, min, max);
}
_setPaintValue(start, end, min, max) {
if (this.type === 'color') {
const minColor = packColor(min);
const maxColor = packColor(max);
for (let i = start; i < end; i++) {
this.paintVertexArray.emplace(i, minColor[0], minColor[1], maxColor[0], maxColor[1]);
}
} else {
for (let i = start; i < end; i++) {
this.paintVertexArray.emplace(i, min, max);
}
this.maxValue = Math.max(this.maxValue, Math.abs(min), Math.abs(max));
}
}
upload(context: Context) {
if (this.paintVertexArray?.arrayBuffer.byteLength) {
if (this.paintVertexBuffer && this.paintVertexBuffer.buffer) {
this.paintVertexBuffer.updateData(this.paintVertexArray);
} else {
this.paintVertexBuffer = context.createVertexBuffer(this.paintVertexArray, this.paintVertexAttributes, this.expression.isStateDependent);
}
}
}
destroy() {
if (this.paintVertexBuffer) {
this.paintVertexBuffer.destroy();
}
}
setUniform(uniform: Uniform<any>, globals: GlobalProperties): void {
const currentZoom = this.useIntegerZoom ? Math.floor(globals.zoom) : globals.zoom;
const factor = clamp(this.expression.interpolationFactor(currentZoom, this.zoom, this.zoom + 1), 0, 1);
uniform.set(factor);
}
getBinding(context: Context, location: WebGLUniformLocation, _: string): Uniform1f {
return new Uniform1f(context, location);
}
}
abstract class CrossFadedBinder<T> implements AttributeBinder {
expression: CompositeExpression;
type: string;
useIntegerZoom: boolean;
zoom: number;
layerId: string;
zoomInPaintVertexArray: StructArray;
zoomOutPaintVertexArray: StructArray;
zoomInPaintVertexBuffer: VertexBuffer;
zoomOutPaintVertexBuffer: VertexBuffer;
paintVertexAttributes: Array<StructArrayMember>;
constructor(expression: CompositeExpression, type: string, useIntegerZoom: boolean, zoom: number, PaintVertexArray: {
new (...args: any): StructArray;
}, layerId: string) {
this.expression = expression;
this.type = type;
this.useIntegerZoom = useIntegerZoom;
this.zoom = zoom;
this.layerId = layerId;
this.zoomInPaintVertexArray = new PaintVertexArray();
this.zoomOutPaintVertexArray = new PaintVertexArray();
}
populatePaintArray(length: number, feature: Feature, options: PaintOptions) {
const start = this.zoomInPaintVertexArray.length;
this.zoomInPaintVertexArray.resize(length);
this.zoomOutPaintVertexArray.resize(length);
this._setPaintValues(start, length, this.getPositionIds(feature), options);
}
updatePaintArray(start: number, end: number, feature: Feature, featureState: FeatureState, options: PaintOptions) {
this._setPaintValues(start, end, this.getPositionIds(feature), options);
}
abstract getVertexAttributes(): Array<StructArrayMember>;
protected abstract getPositionIds(feature: Feature): {min: string; mid: string; max: string};
protected abstract getPositions(options: PaintOptions): {[_: string]: T};
protected abstract emplace(array: StructArray, index: number, midPos: T, minMaxPos: T): void;
protected _setPaintValues(start: number, end: number, positionIds: {min: string; mid: string; max: string}, options: PaintOptions) {
const positions = this.getPositions(options);
if (!positions || !positionIds) return;
const min = positions[positionIds.min];
const mid = positions[positionIds.mid];
const max = positions[positionIds.max];
if (!min || !mid || !max) return;
// We populate two paint arrays because, for cross-faded properties, we don't know which direction
// we're cross-fading to at layout time. In order to keep vertex attributes to a minimum and not pass
// unnecessary vertex data to the shaders, we determine which to upload at draw time.
for (let i = start; i < end; i++) {
this.emplace(this.zoomInPaintVertexArray, i, mid, min);
this.emplace(this.zoomOutPaintVertexArray, i, mid, max);
}
}
upload(context: Context) {
if (this.zoomInPaintVertexArray?.arrayBuffer.byteLength && this.zoomOutPaintVertexArray?.arrayBuffer.byteLength) {
const attributes = this.getVertexAttributes();
this.zoomInPaintVertexBuffer = context.createVertexBuffer(this.zoomInPaintVertexArray, attributes, this.expression.isStateDependent);
this.zoomOutPaintVertexBuffer = context.createVertexBuffer(this.zoomOutPaintVertexArray, attributes, this.expression.isStateDependent);
}
}
destroy() {
if (this.zoomOutPaintVertexBuffer) this.zoomOutPaintVertexBuffer.destroy();
if (this.zoomInPaintVertexBuffer) this.zoomInPaintVertexBuffer.destroy();
}
}
class CrossFadedPatternBinder extends CrossFadedBinder<ImagePosition> {
protected getPositions(options: PaintOptions): {[_: string]: ImagePosition} {
return options.imagePositions;
}
protected getPositionIds(feature: Feature) {
return feature.patterns && feature.patterns[this.layerId];
}
getVertexAttributes(): Array<StructArrayMember> {
return patternAttributes.members;
}
protected emplace(array: StructArray, index: number, midPos: ImagePosition, minMaxPos: ImagePosition): void {
array.emplace(index,
midPos.tlbr[0], midPos.tlbr[1], midPos.tlbr[2], midPos.tlbr[3],
minMaxPos.tlbr[0], minMaxPos.tlbr[1], minMaxPos.tlbr[2], minMaxPos.tlbr[3],
midPos.pixelRatio,
minMaxPos.pixelRatio,
);
}
}
class CrossFadedDasharrayBinder extends CrossFadedBinder<DashEntry> {
protected getPositions(options: PaintOptions): {[_: string]: DashEntry} {
return options.dashPositions;
}
protected getPositionIds(feature: Feature) {
return feature.dashes && feature.dashes[this.layerId];
}
getVertexAttributes(): Array<StructArrayMember> {
return dashAttributes.members;
}
protected emplace(array: StructArray, index: number, midPos: DashEntry, minMaxPos: DashEntry): void {
array.emplace(index,
0, midPos.y, midPos.height, midPos.width,
0, minMaxPos.y, minMaxPos.height, minMaxPos.width,
);
}
}
/**
* @internal
* ProgramConfiguration contains the logic for binding style layer properties and tile
* layer feature data into GL program uniforms and vertex attributes.
*
* Non-data-driven property values are bound to shader uniforms. Data-driven property
* values are bound to vertex attributes. In order to support a uniform GLSL syntax over
* both, [Mapbox GL Shaders](https://github.com/mapbox/mapbox-gl-shaders) defines a `#pragma`
* abstraction, which ProgramConfiguration is responsible for implementing. At runtime,
* it examines the attributes of a particular layer, combines this with fixed knowledge
* about how layers of the particular type are implemented, and determines which uniforms
* and vertex attributes will be required. It can then substitute the appropriate text
* into the shader source code, create and link a program, and bind the uniforms and
* vertex attributes in preparation for drawing.
*
* When a vector tile is parsed, this same configuration information is used to
* populate the attribute buffers needed for data-driven styling using the zoom
* level and feature property data.
*/
export class ProgramConfiguration {
binders: {[_: string]: AttributeBinder | UniformBinder};
cacheKey: string;
_buffers: Array<VertexBuffer>;
constructor(layer: TypedStyleLayer, zoom: number, filterProperties: (_: string) => boolean) {
this.binders = {};
this._buffers = [];
const keys = [];
for (const property in layer.paint._values) {
if (!filterProperties(property)) continue;
const value = (layer.paint as any).get(property);
if (!(value instanceof PossiblyEvaluatedPropertyValue) || !supportsPropertyExpression(value.property.specification)) {
continue;
}
const names = paintAttributeNames(property, layer.type);
const expression = value.value;
const type = value.property.specification.type;
const useIntegerZoom = (value.property as any).useIntegerZoom;
const propType = value.property.specification['property-type'];
const isCrossFaded = propType === 'cross-faded' || propType === 'cross-faded-data-driven';
if (expression.kind === 'constant') {
this.binders[property] = isCrossFaded ?
new CrossFadedConstantBinder(expression.value, names) :
new ConstantBinder(expression.value, names, type);
keys.push(`/u_${property}`);
} else if (expression.kind === 'source' || isCrossFaded) {
const StructArrayLayout = layoutType(property, type, 'source');
this.binders[property] = isCrossFaded ?
property === 'line-dasharray' ?
new CrossFadedDasharrayBinder(expression as CompositeExpression, type, useIntegerZoom, zoom, StructArrayLayout, layer.id) :
new CrossFadedPatternBinder(expression as CompositeExpression, type, useIntegerZoom, zoom, StructArrayLayout, layer.id) :
new SourceExpressionBinder(expression as SourceExpression, names, type, StructArrayLayout);
keys.push(`/a_${property}`);
} else {
const StructArrayLayout = layoutType(property, type, 'composite');
this.binders[property] = new CompositeExpressionBinder(expression, names, type, useIntegerZoom, zoom, StructArrayLayout);
keys.push(`/z_${property}`);
}
}
this.cacheKey = keys.sort().join('');
}
getMaxValue(property: string): number {
const binder = this.binders[property];
return binder instanceof SourceExpressionBinder || binder instanceof CompositeExpressionBinder ? binder.maxValue : 0;
}
populatePaintArrays(newLength: number, feature: Feature, options: PaintOptions) {
for (const property in this.binders) {
const binder = this.binders[property];
if (binder instanceof SourceExpressionBinder || binder instanceof CompositeExpressionBinder || binder instanceof CrossFadedBinder)
binder.populatePaintArray(newLength, feature, options);
}
}
setConstantPatternPositions(posTo: ImagePosition, posFrom: ImagePosition) {
for (const property in this.binders) {
const binder = this.binders[property];
if (binder instanceof CrossFadedConstantBinder)
binder.setConstantPatternPositions(posTo, posFrom);
}
}
setConstantDashPositions(dashTo: DashEntry, dashFrom: DashEntry) {
for (const property in this.binders) {
const binder = this.binders[property];
if (binder instanceof CrossFadedConstantBinder)
binder.setConstantDashPositions(dashTo, dashFrom);
}
}
updatePaintArrays(
featureStates: FeatureStates,
featureMap: FeaturePositionMap,
vtLayer: VectorTileLayerLike,
layer: TypedStyleLayer,
options: PaintOptions
): boolean {
let dirty: boolean = false;
for (const id in featureStates) {
const positions = featureMap.getPositions(id);
for (const pos of positions) {
const feature = vtLayer.feature(pos.index);
for (const property in this.binders) {
const binder = this.binders[property];
if ((binder instanceof SourceExpressionBinder || binder instanceof CompositeExpressionBinder ||
binder instanceof CrossFadedBinder) && binder.expression.isStateDependent === true) {
//AHM: Remove after https://github.com/mapbox/mapbox-gl-js/issues/6255
const value = (layer.paint as any).get(property);
binder.expression = value.value;
binder.updatePaintArray(pos.start, pos.end, feature, featureStates[id], options);
dirty = true;
}
}
}
}
return dirty;
}
defines(): Array<string> {
const result = [];
for (const property in this.binders) {
const binder = this.binders[property];
if (binder instanceof ConstantBinder || binder instanceof CrossFadedConstantBinder) {
result.push(...binder.uniformNames.map(name => `#define HAS_UNIFORM_${name}`));
}
}
return result;
}
getBinderAttributes(): Array<string> {
const result = [];
for (const property in this.binders) {
const binder = this.binders[property];
if (binder instanceof SourceExpressionBinder || binder instanceof CompositeExpressionBinder) {
for (let i = 0; i < binder.paintVertexAttributes.length; i++) {
result.push(binder.paintVertexAttributes[i].name);
}
} else if (binder instanceof CrossFadedBinder) {
const attributes = binder.getVertexAttributes();
for (const attribute of attributes) {
result.push(attribute.name);
}
}
}
return result;
}
getBinderUniforms(): Array<string> {
const uniforms = [];
for (const property in this.binders) {
const binder = this.binders[property];
if (binder instanceof ConstantBinder || binder instanceof CrossFadedConstantBinder || binder instanceof CompositeExpressionBinder) {
for (const uniformName of binder.uniformNames) {
uniforms.push(uniformName);
}
}
}
return uniforms;
}
getPaintVertexBuffers(): Array<VertexBuffer> {
return this._buffers;
}
getUniforms(context: Context, locations: UniformLocations): Array<BinderUniform> {
const uniforms = [];
for (const property in this.binders) {
const binder = this.binders[property];
if (binder instanceof ConstantBinder || binder instanceof CrossFadedConstantBinder || binder instanceof CompositeExpressionBinder) {
for (const name of binder.uniformNames) {
if (locations[name]) {
const binding = binder.getBinding(context, locations[name], name);
uniforms.push({name, property, binding});
}
}
}
}
return uniforms;
}
setUniforms(
context: Context,
binderUniforms: Array<BinderUniform>,
properties: any,
globals: GlobalProperties
) {
// Uniform state bindings are owned by the Program, but we set them
// from within the ProgramConfiguration's binder members.
for (const {name, property, binding} of binderUniforms) {
(this.binders[property] as any).setUniform(binding, globals, properties.get(property), name);
}
}
updatePaintBuffers(crossfade?: CrossfadeParameters) {
this._buffers = [];
for (const property in this.binders) {
const binder = this.binders[property];
if (crossfade && binder instanceof CrossFadedBinder) {
const patternVertexBuffer = crossfade.fromScale === 2 ? binder.zoomInPaintVertexBuffer : binder.zoomOutPaintVertexBuffer;
if (patternVertexBuffer) this._buffers.push(patternVertexBuffer);
} else if ((binder instanceof SourceExpressionBinder || binder instanceof CompositeExpressionBinder) && binder.paintVertexBuffer) {
this._buffers.push(binder.paintVertexBuffer);
}
}
}
upload(context: Context) {
for (const property in this.binders) {
const binder = this.binders[property];
if (binder instanceof SourceExpressionBinder || binder instanceof CompositeExpressionBinder || binder instanceof CrossFadedBinder)
binder.upload(context);
}
this.updatePaintBuffers();
}
destroy() {
for (const property in this.binders) {
const binder = this.binders[property];
if (binder instanceof SourceExpressionBinder || binder instanceof CompositeExpressionBinder || binder instanceof CrossFadedBinder)
binder.destroy();
}
}
}
export class ProgramConfigurationSet<Layer extends TypedStyleLayer> {
programConfigurations: {[_: string]: ProgramConfiguration};
needsUpload: boolean;
_featureMap: FeaturePositionMap;
_bufferOffset: number;
constructor(layers: ReadonlyArray<Layer>, zoom: number, filterProperties: (_: string) => boolean = () => true) {
this.programConfigurations = {};
for (const layer of layers) {
this.programConfigurations[layer.id] = new ProgramConfiguration(layer, zoom, filterProperties);
}
this.needsUpload = false;
this._featureMap = new FeaturePositionMap();
this._bufferOffset = 0;
}
populatePaintArrays(length: number, feature: Feature, index: number, options: PaintOptions) {
for (const key in this.programConfigurations) {
this.programConfigurations[key].populatePaintArrays(length, feature, options);
}
if (feature.id !== undefined) {
this._featureMap.add(feature.id, index, this._bufferOffset, length);
}
this._bufferOffset = length;
this.needsUpload = true;
}
updatePaintArrays(featureStates: FeatureStates, vtLayer: VectorTileLayerLike, layers: ReadonlyArray<TypedStyleLayer>, options: PaintOptions) {
for (const layer of layers) {
this.needsUpload = this.programConfigurations[layer.id].updatePaintArrays(featureStates, this._featureMap, vtLayer, layer, options) || this.needsUpload;
}
}
get(layerId: string) {
return this.programConfigurations[layerId];
}
upload(context: Context) {
if (!this.needsUpload) return;
for (const layerId in this.programConfigurations) {
this.programConfigurations[layerId].upload(context);
}
this.needsUpload = false;
}
destroy() {
for (const layerId in this.programConfigurations) {
this.programConfigurations[layerId].destroy();
}
}
}
function paintAttributeNames(property: string, type: string) {
const attributeNameExceptions = {
'text-opacity': ['opacity'],
'icon-opacity': ['opacity'],
'text-color': ['fill_color'],
'icon-color': ['fill_color'],
'text-halo-color': ['halo_color'],
'icon-halo-color': ['halo_color'],
'text-halo-blur': ['halo_blur'],
'icon-halo-blur': ['halo_blur'],
'text-halo-width': ['halo_width'],
'icon-halo-width': ['halo_width'],
'line-gap-width': ['gapwidth'],
'line-dasharray': ['dasharray_to', 'dasharray_from'],
'line-pattern': ['pattern_to', 'pattern_from', 'pixel_ratio_to', 'pixel_ratio_from'],
'fill-pattern': ['pattern_to', 'pattern_from', 'pixel_ratio_to', 'pixel_ratio_from'],
'fill-extrusion-pattern': ['pattern_to', 'pattern_from', 'pixel_ratio_to', 'pixel_ratio_from'],
};
return attributeNameExceptions[property] || [property.replace(`${type}-`, '').replace(/-/g, '_')];
}
function getLayoutException(property: string) {
const propertyExceptions = {
'line-pattern': {
'source': PatternLayoutArray,
'composite': PatternLayoutArray
},
'fill-pattern': {
'source': PatternLayoutArray,
'composite': PatternLayoutArray
},
'fill-extrusion-pattern': {
'source': PatternLayoutArray,
'composite': PatternLayoutArray
},
'line-dasharray': {
'source': DashLayoutArray,
'composite': DashLayoutArray
},
};
return propertyExceptions[property];
}
function layoutType(property: string, type: string, binderType: string) {
const defaultLayouts = {
'color': {
'source': StructArrayLayout2f8,
'composite': StructArrayLayout4f16
},
'number': {
'source': StructArrayLayout1f4,
'composite': StructArrayLayout2f8
}
};
const layoutException = getLayoutException(property);
return layoutException && layoutException[binderType] || defaultLayouts[type][binderType];
}
register('ConstantBinder', ConstantBinder);
register('CrossFadedConstantBinder', CrossFadedConstantBinder);
register('SourceExpressionBinder', SourceExpressionBinder);
register('CrossFadedPatternBinder', CrossFadedPatternBinder);
register('CrossFadedDasharrayBinder', CrossFadedDasharrayBinder);
register('CompositeExpressionBinder', CompositeExpressionBinder);
register('ProgramConfiguration', ProgramConfiguration, {omit: ['_buffers']});
register('ProgramConfigurationSet', ProgramConfigurationSet);
+6
View File
@@ -0,0 +1,6 @@
import {createLayout} from '../util/struct_array';
export default createLayout([
{name: 'a_pos', type: 'Int16', components: 2},
{name: 'a_texture_pos', type: 'Int16', components: 2}
]);
+221
View File
@@ -0,0 +1,221 @@
import {describe, expect, test} from 'vitest';
import {FillLayoutArray, TriangleIndexArray} from './array_types.g';
import {SegmentVector} from './segment';
describe('SegmentVector', () => {
test('constructor', () => {
expect(new SegmentVector() instanceof SegmentVector).toBeTruthy();
});
test('simpleSegment', () => {
SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16;
const segmentVector = SegmentVector.simpleSegment(0, 0, 10, 0);
expect(segmentVector instanceof SegmentVector).toBeTruthy();
expect(segmentVector.segments).toHaveLength(1);
expect(segmentVector.segments[0].vertexLength).toBe(10);
});
test('prepareSegment returns a segment', () => {
SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16;
const vertexBuffer = new FillLayoutArray();
const indexBuffer = new TriangleIndexArray();
const segmentVector = new SegmentVector();
const result = segmentVector.prepareSegment(10, vertexBuffer, indexBuffer);
expect(result).toBeTruthy();
expect(result.vertexLength).toBe(0);
});
test('prepareSegment handles vertex overflow', () => {
SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16;
const vertexBuffer = new FillLayoutArray();
const indexBuffer = new TriangleIndexArray();
const segmentVector = new SegmentVector();
const first = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 10);
const second = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 10);
expect(first).toBeTruthy();
expect(second).toBeTruthy();
expect(first === second).toBe(false);
expect(first.vertexLength).toBe(10);
expect(second.vertexLength).toBe(10);
expect(segmentVector.segments).toHaveLength(2);
});
test('prepareSegment reuses segments', () => {
SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16;
const vertexBuffer = new FillLayoutArray();
const indexBuffer = new TriangleIndexArray();
const segmentVector = new SegmentVector();
const first = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5);
const second = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5);
expect(first).toBeTruthy();
expect(second).toBeTruthy();
expect(first === second).toBe(true);
expect(first.vertexLength).toBe(10);
});
test('createNewSegment returns a new segment', () => {
SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16;
const vertexBuffer = new FillLayoutArray();
const indexBuffer = new TriangleIndexArray();
const segmentVector = new SegmentVector();
const first = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5);
const second = segmentVector.createNewSegment(vertexBuffer, indexBuffer);
second.vertexLength += 5;
addVertices(vertexBuffer, 5);
const third = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5);
expect(first).toBeTruthy();
expect(second).toBeTruthy();
expect(third).toBeTruthy();
expect(first === second).toBe(false);
expect(second === third).toBe(true);
expect(first.vertexLength).toBe(5);
expect(third.vertexLength).toBe(10);
});
test('createNewSegment returns a new segment and resets invalidateLast', () => {
SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16;
const vertexBuffer = new FillLayoutArray();
const indexBuffer = new TriangleIndexArray();
const segmentVector = new SegmentVector();
const first = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5);
segmentVector.forceNewSegmentOnNextPrepare();
const second = segmentVector.createNewSegment(vertexBuffer, indexBuffer);
second.vertexLength += 5;
addVertices(vertexBuffer, 5);
const third = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5);
expect(first).toBeTruthy();
expect(second).toBeTruthy();
expect(third).toBeTruthy();
expect(first === second).toBe(false);
expect(second === third).toBe(true);
expect(first.vertexLength).toBe(5);
expect(third.vertexLength).toBe(10);
});
test('getOrCreateLatestSegment creates a new segment if SegmentVector was empty', () => {
SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16;
const vertexBuffer = new FillLayoutArray();
const indexBuffer = new TriangleIndexArray();
const segmentVector = new SegmentVector();
const first = segmentVector.getOrCreateLatestSegment(vertexBuffer, indexBuffer);
expect(first).toBeTruthy();
expect(segmentVector.segments).toHaveLength(1);
});
test('getOrCreateLatestSegment returns the last segment if invalidateLast=false', () => {
SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16;
const vertexBuffer = new FillLayoutArray();
const indexBuffer = new TriangleIndexArray();
const segmentVector = new SegmentVector();
const first = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5);
const second = segmentVector.getOrCreateLatestSegment(vertexBuffer, indexBuffer);
second.vertexLength += 5;
addVertices(vertexBuffer, 5);
const third = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5);
expect(first).toBeTruthy();
expect(second).toBeTruthy();
expect(third).toBeTruthy();
expect(first === second).toBe(true);
expect(second === third).toBe(true);
expect(first.vertexLength).toBe(15);
});
test('getOrCreateLatestSegment respects invalidateLast and returns a new segment', () => {
SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16;
const vertexBuffer = new FillLayoutArray();
const indexBuffer = new TriangleIndexArray();
const segmentVector = new SegmentVector();
const first = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5);
segmentVector.forceNewSegmentOnNextPrepare();
const second = segmentVector.getOrCreateLatestSegment(vertexBuffer, indexBuffer);
second.vertexLength += 5;
addVertices(vertexBuffer, 5);
const third = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5);
expect(first).toBeTruthy();
expect(second).toBeTruthy();
expect(third).toBeTruthy();
expect(first === second).toBe(false);
expect(second === third).toBe(true);
expect(first.vertexLength).toBe(5);
expect(third.vertexLength).toBe(10);
});
test('prepareSegment respects invalidateLast', () => {
SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16;
const vertexBuffer = new FillLayoutArray();
const indexBuffer = new TriangleIndexArray();
const segmentVector = new SegmentVector();
const first = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5);
segmentVector.forceNewSegmentOnNextPrepare();
const second = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5);
const third = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5);
expect(first).toBeTruthy();
expect(second).toBeTruthy();
expect(third).toBeTruthy();
expect(first === second).toBe(false);
expect(second === third).toBe(true);
expect(first.vertexLength).toBe(5);
expect(second.vertexLength).toBe(10);
expect(segmentVector.segments).toHaveLength(2);
});
test('invalidateLast called twice has no effect', () => {
SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16;
const vertexBuffer = new FillLayoutArray();
const indexBuffer = new TriangleIndexArray();
const segmentVector = new SegmentVector();
const first = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5);
segmentVector.forceNewSegmentOnNextPrepare();
segmentVector.forceNewSegmentOnNextPrepare();
const second = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5);
expect(first).toBeTruthy();
expect(second).toBeTruthy();
expect(first === second).toBe(false);
expect(first.vertexLength).toBe(5);
expect(second.vertexLength).toBe(5);
expect(segmentVector.segments).toHaveLength(2);
});
test('invalidateLast called on an empty SegmentVector has no effect', () => {
SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16;
const vertexBuffer = new FillLayoutArray();
const indexBuffer = new TriangleIndexArray();
const segmentVector = new SegmentVector();
segmentVector.forceNewSegmentOnNextPrepare();
const first = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5);
expect(first).toBeTruthy();
expect(first.vertexLength).toBe(5);
expect(segmentVector.segments).toHaveLength(1);
});
test('prepareSegment respects different sortKey', () => {
SegmentVector.MAX_VERTEX_ARRAY_LENGTH = 16;
const vertexBuffer = new FillLayoutArray();
const indexBuffer = new TriangleIndexArray();
const segmentVector = new SegmentVector();
const first = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5, 1);
const second = mockUseSegment(segmentVector, vertexBuffer, indexBuffer, 5, 2);
expect(first).toBeTruthy();
expect(second).toBeTruthy();
expect(first === second).toBe(false);
expect(first.vertexLength).toBe(5);
expect(second.vertexLength).toBe(5);
expect(segmentVector.segments).toHaveLength(2);
});
});
/**
* Mocks the usage of a segment from SegmentVector. Returns the used segment.
*/
function mockUseSegment(segmentVector: SegmentVector, vertexBuffer: FillLayoutArray, indexBuffer: TriangleIndexArray, numVertices: number, sortKey?: number) {
const seg = segmentVector.prepareSegment(numVertices, vertexBuffer, indexBuffer, sortKey);
seg.vertexLength += numVertices;
addVertices(vertexBuffer, numVertices);
return seg;
}
function addVertices(array: FillLayoutArray, count: number) {
for (let i = 0; i < count; i++) {
array.emplaceBack(0, 0);
}
}
+138
View File
@@ -0,0 +1,138 @@
import {warnOnce} from '../util/util';
import {register} from '../util/web_worker_transfer';
import type {VertexArrayObject} from '../render/vertex_array_object';
import type {StructArray} from '../util/struct_array';
/**
* @internal
* A single segment of a vector
*/
export type Segment = {
sortKey?: number;
vertexOffset: number;
primitiveOffset: number;
vertexLength: number;
primitiveLength: number;
vaos: {[_: string]: VertexArrayObject};
};
/**
* @internal
* Used for calculations on vector segments
*/
export class SegmentVector {
static MAX_VERTEX_ARRAY_LENGTH: number;
segments: Array<Segment>;
private _forceNewSegmentOnNextPrepare: boolean = false;
constructor(segments: Array<Segment> = []) {
this.segments = segments;
}
/**
* Returns the last segment if `numVertices` fits into it.
* If there are no segments yet or `numVertices` doesn't fit into the last one, creates a new empty segment and returns it.
*/
prepareSegment(
numVertices: number,
layoutVertexArray: StructArray,
indexArray: StructArray,
sortKey?: number
): Segment {
const lastSegment: Segment = this.segments[this.segments.length - 1];
if (numVertices > SegmentVector.MAX_VERTEX_ARRAY_LENGTH) {
warnOnce(`Max vertices per segment is ${SegmentVector.MAX_VERTEX_ARRAY_LENGTH}: bucket requested ${numVertices}. Consider using the \`fillLargeMeshArrays\` function if you require meshes with more than ${SegmentVector.MAX_VERTEX_ARRAY_LENGTH} vertices.`);
}
if (this._forceNewSegmentOnNextPrepare || !lastSegment || lastSegment.vertexLength + numVertices > SegmentVector.MAX_VERTEX_ARRAY_LENGTH || lastSegment.sortKey !== sortKey) {
return this.createNewSegment(layoutVertexArray, indexArray, sortKey);
} else {
return lastSegment;
}
}
/**
* Creates a new empty segment and returns it.
*/
createNewSegment(
layoutVertexArray: StructArray,
indexArray: StructArray,
sortKey?: number
): Segment {
const segment: Segment = {
vertexOffset: layoutVertexArray.length,
primitiveOffset: indexArray.length,
vertexLength: 0,
primitiveLength: 0,
vaos: {}
};
if (sortKey !== undefined) {
segment.sortKey = sortKey;
}
// If this was set, we have no need to create a new segment on next prepareSegment call,
// since this function already created a new, empty segment.
this._forceNewSegmentOnNextPrepare = false;
this.segments.push(segment);
return segment;
}
/**
* Returns the last segment, or creates a new segments if there are no segments yet.
*/
getOrCreateLatestSegment(
layoutVertexArray: StructArray,
indexArray: StructArray,
sortKey?: number
): Segment {
return this.prepareSegment(0, layoutVertexArray, indexArray, sortKey);
}
/**
* Causes the next call to {@link prepareSegment} to always return a new segment,
* not reusing the current segment even if the new geometry would fit it.
*/
forceNewSegmentOnNextPrepare() {
this._forceNewSegmentOnNextPrepare = true;
}
get() {
return this.segments;
}
destroy() {
for (const segment of this.segments) {
for (const k in segment.vaos) {
segment.vaos[k].destroy();
}
}
}
static simpleSegment(
vertexOffset: number,
primitiveOffset: number,
vertexLength: number,
primitiveLength: number
): SegmentVector {
return new SegmentVector([{
vertexOffset,
primitiveOffset,
vertexLength,
primitiveLength,
vaos: {},
sortKey: 0
}]);
}
}
/**
* The maximum size of a vertex array. This limit is imposed by WebGL's 16 bit
* addressing of vertex buffers.
*/
SegmentVector.MAX_VERTEX_ARRAY_LENGTH = Math.pow(2, 16) - 1;
register('SegmentVector', SegmentVector);
+159
View File
@@ -0,0 +1,159 @@
import {describe, test, expect} from 'vitest';
import {Bounds} from './bounds';
import Point from '@mapbox/point-geometry';
function bounds(minX: number, minY: number, maxX: number, maxY: number): Bounds {
return Bounds.fromPoints([
new Point(minX, minY),
new Point(maxX, maxY),
]);
}
describe('Bounds', () => {
test('empty bounding box', () => {
const empty = new Bounds();
expect(empty).toBeInstanceOf(Bounds);
expect(empty.contains(new Point(0, 0))).toBeFalsy();
expect(empty.empty()).toBeTruthy();
});
test('add single point', () => {
const bounds = new Bounds();
bounds.extend(new Point(1, 2));
expect(bounds.empty()).toBeFalsy();
expect(bounds.height()).toEqual(0);
expect(bounds.width()).toEqual(0);
expect(bounds.contains(new Point(1, 2))).toBeTruthy();
expect(bounds.contains(new Point(2, 2))).toBeFalsy();
expect(bounds.contains(new Point(-1, 2))).toBeFalsy();
expect(bounds.contains(new Point(1, 1))).toBeFalsy();
expect(bounds.contains(new Point(1, 3))).toBeFalsy();
});
test('add multiple points', () => {
const bounds = new Bounds();
bounds.extend(new Point(1, 2));
bounds.extend(new Point(3, 5));
expect(bounds.empty()).toBeFalsy();
expect(bounds.width()).toEqual(2);
expect(bounds.height()).toEqual(3);
expect(bounds.contains(new Point(1, 2))).toBeTruthy();
expect(bounds.contains(new Point(3, 2))).toBeTruthy();
expect(bounds.contains(new Point(3, 5))).toBeTruthy();
expect(bounds.contains(new Point(1, 5))).toBeTruthy();
expect(bounds.contains(new Point(0.9, 1.9))).toBeFalsy();
expect(bounds.contains(new Point(3.1, 1.9))).toBeFalsy();
expect(bounds.contains(new Point(3.1, 5.1))).toBeFalsy();
expect(bounds.contains(new Point(2, 5.1))).toBeFalsy();
});
test('fromPoints', () => {
const bounds = Bounds.fromPoints([new Point(1, 2), new Point(3, 4)]);
expect(bounds).toMatchObject({
minX: 1,
maxX: 3,
minY: 2,
maxY: 4,
});
});
test('expandBy positive', () => {
const bounds = new Bounds();
bounds.extend(new Point(0, 0));
bounds.expandBy(1);
expect(bounds).toMatchObject({
minX: -1,
maxX: 1,
minY: -1,
maxY: 1,
});
});
test('expandBy negative', () => {
const bounds = new Bounds();
bounds.extend(new Point(0, 0));
bounds.expandBy(2);
bounds.expandBy(-1);
expect(bounds.empty()).toBeFalsy();
expect(bounds).toMatchObject({
minX: -1,
maxX: 1,
minY: -1,
maxY: 1,
});
});
test('shrinkBy', () => {
const bounds = new Bounds();
bounds.extend(new Point(0, 0));
bounds.expandBy(2);
bounds.shrinkBy(1);
expect(bounds.empty()).toBeFalsy();
expect(bounds).toMatchObject({
minX: -1,
maxX: 1,
minY: -1,
maxY: 1,
});
});
test('expandBy collapse', () => {
const bounds = new Bounds();
bounds.extend(new Point(0, 0));
bounds.expandBy(2);
bounds.expandBy(-3);
expect(bounds.empty()).toBeTruthy();
});
test('map', () => {
const bounds = new Bounds();
bounds.extend(new Point(1, 2));
bounds.extend(new Point(3, 4));
expect(bounds.map(point => new Point(-point.y, -point.x))).toEqual({
minX: -4,
minY: -3,
maxX: -2,
maxY: -1,
});
});
test('covers', () => {
const e = 0.1;
const box = bounds(1, 2, 3, 4);
expect(box.covers(box)).toBeTruthy();
expect(box.covers(bounds(1-e, 2, 3, 4))).toBeFalsy();
expect(box.covers(bounds(1, 2-e, 3, 4))).toBeFalsy();
expect(box.covers(bounds(1, 2, 3+e, 4))).toBeFalsy();
expect(box.covers(bounds(1, 2, 3, 4+e))).toBeFalsy();
expect(box.covers(bounds(1+e, 2, 3, 4))).toBeTruthy();
expect(box.covers(bounds(1, 2+e, 3, 4))).toBeTruthy();
expect(box.covers(bounds(1, 2, 3-e, 4))).toBeTruthy();
expect(box.covers(bounds(1, 2, 3, 4-e))).toBeTruthy();
});
test('intersects', () => {
const e = 0.1;
const box = bounds(1, 2, 3, 4);
expect(box.intersects(box)).toBeTruthy();
// bottom-left corner
expect(box.intersects(bounds(0, 0, 1, 2))).toBeTruthy();
expect(box.intersects(bounds(0, 0, 1-e, 2))).toBeFalsy();
expect(box.intersects(bounds(0, 0, 1, 2-e))).toBeFalsy();
// bottom-right corner
expect(box.intersects(bounds(3, 0, 10, 2))).toBeTruthy();
expect(box.intersects(bounds(3+e, 0, 10, 2))).toBeFalsy();
expect(box.intersects(bounds(3, 0, 10, 2-e))).toBeFalsy();
// top-left corner
expect(box.intersects(bounds(0, 4, 1, 8))).toBeTruthy();
expect(box.intersects(bounds(0, 4+e, 1, 8))).toBeFalsy();
expect(box.intersects(bounds(0, 4, 1-e, 8))).toBeFalsy();
// top-right corner
expect(box.intersects(bounds(3, 4, 10, 10))).toBeTruthy();
expect(box.intersects(bounds(3+e, 4, 10, 10))).toBeFalsy();
expect(box.intersects(bounds(3, 4+e, 10, 10))).toBeFalsy();
});
});
+169
View File
@@ -0,0 +1,169 @@
import Point from '@mapbox/point-geometry';
import {type Point2D} from '@maplibre/maplibre-gl-style-spec';
export interface ReadOnlyBounds {
readonly minX: number;
readonly maxX: number;
readonly minY: number;
readonly maxY: number;
/**
* Returns whether this bounding box contains a point
*
* @param point - The point to check
* @returns True if this bounding box contains point, false otherwise.
*/
contains(point: Point2D): boolean;
/**
* Returns true if this bounding box contains no points
*
* @returns True if this bounding box contains no points.
*/
empty(): boolean;
/**
* Returns the width of this bounding box.
*
* @returns `maxX - minX`.
*/
width(): number;
/**
* Returns the height of this bounding box.
*
* @returns `maxY - minY`.
*/
height(): number;
/**
* Returns true if this bounding box completely covers `other`.
*
* @param other - The other bounding box
* @returns True if this bounding box completely encloses `other`
*/
covers(other: ReadOnlyBounds): boolean;
/**
* Returns true if this bounding box touches any part of `other`.
*
* @param other - The other bounding box
* @returns True if this bounding box touches any part of `other`.
*/
intersects(other: ReadOnlyBounds): boolean;
}
/** A 2-d bounding box covering an X and Y range. */
export class Bounds implements ReadOnlyBounds {
minX: number = Infinity;
maxX: number = -Infinity;
minY: number = Infinity;
maxY: number = -Infinity;
/**
* Expands this bounding box to include point.
*
* @param point - The point to include in this bounding box
* @returns This mutated bounding box
*/
extend(point: Point2D): this {
this.minX = Math.min(this.minX, point.x);
this.minY = Math.min(this.minY, point.y);
this.maxX = Math.max(this.maxX, point.x);
this.maxY = Math.max(this.maxY, point.y);
return this;
}
/**
* Expands this bounding box by a fixed amount in each direction.
*
* @param amount - The amount to expand the box by, or contract if negative
* @returns This mutated bounding box
*/
expandBy(amount: number): this {
this.minX -= amount;
this.minY -= amount;
this.maxX += amount;
this.maxY += amount;
// check if bounds collapsed in either dimension
if (this.minX > this.maxX || this.minY > this.maxY) {
this.minX = Infinity;
this.maxX = -Infinity;
this.minY = Infinity;
this.maxY = -Infinity;
}
return this;
}
/**
* Shrinks this bounding box by a fixed amount in each direction.
*
* @param amount - The amount to shrink the box by
* @returns This mutated bounding box
*/
shrinkBy(amount: number): this {
return this.expandBy(-amount);
}
/**
* Returns a new bounding box that contains all of the corners of this bounding
* box with a transform applied. Does not modify this bounding box.
*
* @param fn - The function to apply to each corner
* @returns A new bounding box containing all of the mapped points.
*/
map(fn: (point: Point2D) => Point2D) {
const result = new Bounds();
result.extend(fn(new Point(this.minX, this.minY)));
result.extend(fn(new Point(this.maxX, this.minY)));
result.extend(fn(new Point(this.minX, this.maxY)));
result.extend(fn(new Point(this.maxX, this.maxY)));
return result;
}
/**
* Creates a new bounding box that includes all points provided.
*
* @param points - The points to include inside the bounding box
* @returns The new bounding box
*/
static fromPoints(points: Point2D[]): Bounds {
const result = new Bounds();
for (const p of points) {
result.extend(p);
}
return result;
}
contains(point: Point2D): boolean {
return point.x >= this.minX && point.x <= this.maxX && point.y >= this.minY && point.y <= this.maxY;
}
empty(): boolean {
return this.minX > this.maxX;
}
width(): number {
return this.maxX - this.minX;
}
height(): number {
return this.maxY - this.minY;
}
covers(other: ReadOnlyBounds) {
return !this.empty() && !other.empty() &&
other.minX >= this.minX &&
other.maxX <= this.maxX &&
other.minY >= this.minY &&
other.maxY <= this.maxY;
}
intersects(other: ReadOnlyBounds) {
return !this.empty() && !other.empty() &&
other.minX <= this.maxX &&
other.maxX >= this.minX &&
other.minY <= this.maxY &&
other.maxY >= this.minY;
}
}
+83
View File
@@ -0,0 +1,83 @@
import {describe, test, expect} from 'vitest';
import {EdgeInsets} from '../geo/edge_insets';
describe('EdgeInsets', () => {
describe('constructor', () => {
test('creates an object with default values', () => {
expect(new EdgeInsets() instanceof EdgeInsets).toBeTruthy();
});
test('invalid initialization', () => {
expect(() => {
new EdgeInsets(NaN, 10);
}).toThrow('Invalid value for edge-insets, top, bottom, left and right must all be numbers');
expect(() => {
new EdgeInsets(-10, 10, 20, 10);
}).toThrow('Invalid value for edge-insets, top, bottom, left and right must all be numbers');
});
test('valid initialization', () => {
const top = 10;
const bottom = 15;
const left = 26;
const right = 19;
const inset = new EdgeInsets(top, bottom, left, right);
expect(inset.top).toBe(top);
expect(inset.bottom).toBe(bottom);
expect(inset.left).toBe(left);
expect(inset.right).toBe(right);
});
});
describe('getCenter', () => {
test('valid input', () => {
const inset = new EdgeInsets(10, 15, 50, 10);
const center = inset.getCenter(600, 400);
expect(center.x).toBe(320);
expect(center.y).toBe(197.5);
});
test('center clamping', () => {
const inset = new EdgeInsets(300, 200, 500, 200);
const center = inset.getCenter(600, 400);
// Midpoint of the overlap when padding overlaps
expect(center.x).toBe(450);
expect(center.y).toBe(250);
});
});
describe('interpolate', () => {
test('it works', () => {
const inset1 = new EdgeInsets(10, 15, 50, 10);
const inset2 = new EdgeInsets(20, 30, 100, 10);
const inset3 = inset1.interpolate(inset1, inset2, 0.5);
// inset1 is mutated in-place
expect(inset3).toBe(inset1);
expect(inset3.top).toBe(15);
expect(inset3.bottom).toBe(22.5);
expect(inset3.left).toBe(75);
expect(inset3.right).toBe(10);
});
});
test('equals', () => {
const inset1 = new EdgeInsets(10, 15, 50, 10);
const inset2 = new EdgeInsets(10, 15, 50, 10);
const inset3 = new EdgeInsets(10, 15, 50, 11);
expect(inset1.equals(inset2)).toBeTruthy();
expect(inset2.equals(inset3)).toBeFalsy();
});
test('clone', () => {
const inset1 = new EdgeInsets(10, 15, 50, 10);
const inset2 = inset1.clone();
expect(inset2 === inset1).toBeFalsy();
expect(inset1.equals(inset2)).toBeTruthy();
});
});
+146
View File
@@ -0,0 +1,146 @@
import {interpolates} from '@maplibre/maplibre-gl-style-spec';
import Point from '@mapbox/point-geometry';
import {clamp, type Complete, type RequireAtLeastOne} from '../util/util';
/**
* An `EdgeInset` object represents screen space padding applied to the edges of the viewport.
* This shifts the apparent center or the vanishing point of the map. This is useful for adding floating UI elements
* on top of the map and having the vanishing point shift as UI elements resize.
*
* @group Geography and Geometry
*/
export class EdgeInsets {
/**
* @defaultValue 0
*/
top: number;
/**
* @defaultValue 0
*/
bottom: number;
/**
* @defaultValue 0
*/
left: number;
/**
* @defaultValue 0
*/
right: number;
constructor(top: number = 0, bottom: number = 0, left: number = 0, right: number = 0) {
if (isNaN(top) || top < 0 ||
isNaN(bottom) || bottom < 0 ||
isNaN(left) || left < 0 ||
isNaN(right) || right < 0
) {
throw new Error('Invalid value for edge-insets, top, bottom, left and right must all be numbers');
}
this.top = top;
this.bottom = bottom;
this.left = left;
this.right = right;
}
/**
* Interpolates the inset in-place.
* This maintains the current inset value for any inset not present in `target`.
* @param start - interpolation start
* @param target - interpolation target
* @param t - interpolation step/weight
* @returns the insets
*/
interpolate(start: PaddingOptions | EdgeInsets, target: PaddingOptions, t: number): EdgeInsets {
if (target.top != null && start.top != null) this.top = interpolates.number(start.top, target.top, t);
if (target.bottom != null && start.bottom != null) this.bottom = interpolates.number(start.bottom, target.bottom, t);
if (target.left != null && start.left != null) this.left = interpolates.number(start.left, target.left, t);
if (target.right != null && start.right != null) this.right = interpolates.number(start.right, target.right, t);
return this;
}
/**
* Utility method that computes the new apparent center or vanishing point after applying insets.
* This is in pixels and with the top left being (0.0) and +y being downwards.
*
* @param width - the width
* @param height - the height
* @returns the point
*/
getCenter(width: number, height: number): Point {
// Clamp insets so they never overflow width/height and always calculate a valid center
const x = clamp((this.left + width - this.right) / 2, 0, width);
const y = clamp((this.top + height - this.bottom) / 2, 0, height);
return new Point(x, y);
}
equals(other: PaddingOptions): boolean {
return this.top === other.top &&
this.bottom === other.bottom &&
this.left === other.left &&
this.right === other.right;
}
clone(): EdgeInsets {
return new EdgeInsets(this.top, this.bottom, this.left, this.right);
}
/**
* Returns the current state as json, useful when you want to have a
* read-only representation of the inset.
*
* @returns state as json
*/
toJSON(): Complete<PaddingOptions> {
return {
top: this.top,
bottom: this.bottom,
left: this.left,
right: this.right
};
}
}
/**
* Options for setting padding on calls to methods such as {@link Map.fitBounds}, {@link Map.fitScreenCoordinates}, and {@link Map.setPadding}. Adjust these options to set the amount of padding in pixels added to the edges of the canvas. Set a uniform padding on all edges or individual values for each edge. All properties of this object must be
* non-negative integers.
*
* @group Geography and Geometry
*
* @example
* ```ts
* let bbox = [[-79, 43], [-73, 45]];
* map.fitBounds(bbox, {
* padding: {top: 10, bottom:25, left: 15, right: 5}
* });
* ```
*
* @example
* ```ts
* let bbox = [[-79, 43], [-73, 45]];
* map.fitBounds(bbox, {
* padding: 20
* });
* ```
* @see [Fit to the bounds of a LineString](https://maplibre.org/maplibre-gl-js/docs/examples/zoomto-linestring/)
* @see [Fit a map to a bounding box](https://maplibre.org/maplibre-gl-js/docs/examples/fitbounds/)
*/
export type PaddingOptions = RequireAtLeastOne<{
/**
* Padding in pixels from the top of the map canvas.
*/
top: number;
/**
* Padding in pixels from the bottom of the map canvas.
*/
bottom: number;
/**
* Padding in pixels from the left of the map canvas.
*/
right: number;
/**
* Padding in pixels from the right of the map canvas.
*/
left: number;
}>;
+64
View File
@@ -0,0 +1,64 @@
import {describe, test, expect} from 'vitest';
import {LngLat} from '../geo/lng_lat';
describe('LngLat', () => {
test('constructor', () => {
expect(new LngLat(0, 0) instanceof LngLat).toBeTruthy();
expect(() => {
new LngLat(0, -91);
}).toThrow('Invalid LngLat latitude value: must be between -90 and 90');
expect(() => {
new LngLat(0, 91);
}).toThrow('Invalid LngLat latitude value: must be between -90 and 90');
});
test('convert', () => {
expect(LngLat.convert([0, 10]) instanceof LngLat).toBeTruthy();
expect(LngLat.convert({lng: 0, lat: 10}) instanceof LngLat).toBeTruthy();
expect(LngLat.convert({lng: 0, lat: 0}) instanceof LngLat).toBeTruthy();
expect(LngLat.convert({lon: 0, lat: 10}) instanceof LngLat).toBeTruthy();
expect(LngLat.convert({lon: 0, lat: 0}) instanceof LngLat).toBeTruthy();
expect(LngLat.convert(new LngLat(0, 0)) instanceof LngLat).toBeTruthy();
});
test('wrap', () => {
expect(new LngLat(0, 0).wrap()).toEqual({lng: 0, lat: 0});
expect(new LngLat(10, 20).wrap()).toEqual({lng: 10, lat: 20});
expect(new LngLat(360, 0).wrap()).toEqual({lng: 0, lat: 0});
expect(new LngLat(190, 0).wrap()).toEqual({lng: -170, lat: 0});
});
test('toArray', () => {
expect(new LngLat(10, 20).toArray()).toEqual([10, 20]);
});
test('toString', () => {
expect(new LngLat(10, 20).toString()).toBe('LngLat(10, 20)');
});
test('distanceTo', () => {
const newYork = new LngLat(-74.0060, 40.7128);
const losAngeles = new LngLat(-118.2437, 34.0522);
const d = newYork.distanceTo(losAngeles); // 3935751.690893987, "true distance" is 3966km
expect(d > 3935750).toBeTruthy();
expect(d < 3935752).toBeTruthy();
});
test('distanceTo to pole', () => {
const newYork = new LngLat(-74.0060, 40.7128);
const northPole = new LngLat(-135, 90);
const d = newYork.distanceTo(northPole); // 5480494.158486183 , "true distance" is 5499km
expect(d > 5480493).toBeTruthy();
expect(d < 5480495).toBeTruthy();
});
test('distanceTo to Null Island', () => {
const newYork = new LngLat(-74.0060, 40.7128);
const nullIsland = new LngLat(0, 0);
const d = newYork.distanceTo(nullIsland); // 8667080.125666846 , "true distance" is 8661km
expect(d > 8667079).toBeTruthy();
expect(d < 8667081).toBeTruthy();
});
});
+176
View File
@@ -0,0 +1,176 @@
import {wrap} from '../util/util';
/*
* Approximate radius of the earth in meters.
* Uses the WGS-84 approximation. The radius at the equator is ~6378137 and at the poles is ~6356752. https://en.wikipedia.org/wiki/World_Geodetic_System#WGS84
* 6371008.8 is one published "average radius" see https://en.wikipedia.org/wiki/Earth_radius#Mean_radius, or ftp://athena.fsv.cvut.cz/ZFG/grs80-Moritz.pdf p.4
*/
export const earthRadius = 6371008.8;
/**
* A {@link LngLat} object, an array of two numbers representing longitude and latitude,
* or an object with `lng` and `lat` or `lon` and `lat` properties.
*
* @group Geography and Geometry
*
* @example
* ```ts
* let v1 = new LngLat(-122.420679, 37.772537);
* let v2 = [-122.420679, 37.772537];
* let v3 = {lon: -122.420679, lat: 37.772537};
* ```
*/
export type LngLatLike = LngLat | {
lng: number;
lat: number;
} | {
lon: number;
lat: number;
} | [number, number];
/**
* A `LngLat` object represents a given longitude and latitude coordinate, measured in degrees.
* These coordinates are based on the [WGS84 (EPSG:4326) standard](https://en.wikipedia.org/wiki/World_Geodetic_System#WGS84).
*
* MapLibre GL JS uses longitude, latitude coordinate order (as opposed to latitude, longitude) to match the
* [GeoJSON specification](https://tools.ietf.org/html/rfc7946).
*
* Note that any MapLibre GL JS method that accepts a `LngLat` object as an argument or option
* can also accept an `Array` of two numbers and will perform an implicit conversion.
* This flexible type is documented as {@link LngLatLike}.
*
* @group Geography and Geometry
*
* @example
* ```ts
* let ll = new LngLat(-123.9749, 40.7736);
* ll.lng; // = -123.9749
* ```
* @see [Get coordinates of the mouse pointer](https://maplibre.org/maplibre-gl-js/docs/examples/mouse-position/)
* @see [Display a popup](https://maplibre.org/maplibre-gl-js/docs/examples/popup/)
* @see [Create a timeline animation](https://maplibre.org/maplibre-gl-js/docs/examples/timeline-animation/)
*/
export class LngLat {
/**
* Longitude, measured in degrees.
*/
lng: number;
/**
* Latitude, measured in degrees.
*/
lat: number;
/**
* @param lng - Longitude, measured in degrees.
* @param lat - Latitude, measured in degrees.
*/
constructor(lng: number, lat: number) {
if (isNaN(lng) || isNaN(lat)) {
throw new Error(`Invalid LngLat object: (${lng}, ${lat})`);
}
this.lng = +lng;
this.lat = +lat;
if (this.lat > 90 || this.lat < -90) {
throw new Error('Invalid LngLat latitude value: must be between -90 and 90');
}
}
/**
* Returns a new `LngLat` object whose longitude is wrapped to the range (-180, 180).
*
* @returns The wrapped `LngLat` object.
* @example
* ```ts
* let ll = new LngLat(286.0251, 40.7736);
* let wrapped = ll.wrap();
* wrapped.lng; // = -73.9749
* ```
*/
wrap() {
return new LngLat(wrap(this.lng, -180, 180), this.lat);
}
/**
* Returns the coordinates represented as an array of two numbers.
*
* @returns The coordinates represented as an array of longitude and latitude.
* @example
* ```ts
* let ll = new LngLat(-73.9749, 40.7736);
* ll.toArray(); // = [-73.9749, 40.7736]
* ```
*/
toArray(): [number, number] {
return [this.lng, this.lat];
}
/**
* Returns the coordinates represent as a string.
*
* @returns The coordinates represented as a string of the format `'LngLat(lng, lat)'`.
* @example
* ```ts
* let ll = new LngLat(-73.9749, 40.7736);
* ll.toString(); // = "LngLat(-73.9749, 40.7736)"
* ```
*/
toString(): string {
return `LngLat(${this.lng}, ${this.lat})`;
}
/**
* Returns the approximate distance between a pair of coordinates in meters
* Uses the Haversine Formula (from R.W. Sinnott, "Virtues of the Haversine", Sky and Telescope, vol. 68, no. 2, 1984, p. 159)
*
* @param lngLat - coordinates to compute the distance to
* @returns Distance in meters between the two coordinates.
* @example
* ```ts
* let new_york = new LngLat(-74.0060, 40.7128);
* let los_angeles = new LngLat(-118.2437, 34.0522);
* new_york.distanceTo(los_angeles); // = 3935751.690893987, "true distance" using a non-spherical approximation is ~3966km
* ```
*/
distanceTo(lngLat: LngLat): number {
const rad = Math.PI / 180;
const lat1 = this.lat * rad;
const lat2 = lngLat.lat * rad;
const a = Math.sin(lat1) * Math.sin(lat2) + Math.cos(lat1) * Math.cos(lat2) * Math.cos((lngLat.lng - this.lng) * rad);
const maxMeters = earthRadius * Math.acos(Math.min(a, 1));
return maxMeters;
}
/**
* Converts an array of two numbers or an object with `lng` and `lat` or `lon` and `lat` properties
* to a `LngLat` object.
*
* If a `LngLat` object is passed in, the function returns it unchanged.
*
* @param input - An array of two numbers or object to convert, or a `LngLat` object to return.
* @returns A new `LngLat` object, if a conversion occurred, or the original `LngLat` object.
* @example
* ```ts
* let arr = [-73.9749, 40.7736];
* let ll = LngLat.convert(arr);
* ll; // = LngLat {lng: -73.9749, lat: 40.7736}
* ```
*/
static convert(input: LngLatLike): LngLat {
if (input instanceof LngLat) {
return input;
}
if (Array.isArray(input) && (input.length === 2 || input.length === 3)) {
return new LngLat(Number(input[0]), Number(input[1]));
}
if (!Array.isArray(input) && typeof input === 'object' && input !== null) {
return new LngLat(
// flow can't refine this to have one of lng or lat, so we have to cast to any
Number('lng' in input ? (input as any).lng : (input as any).lon),
Number(input.lat)
);
}
throw new Error('`LngLatLike` argument must be specified as a LngLat instance, an object {lng: <lng>, lat: <lat>}, an object {lon: <lng>, lat: <lat>}, or an array of [<lng>, <lat>]');
}
}
+444
View File
@@ -0,0 +1,444 @@
import {describe, test, expect} from 'vitest';
import {LngLat} from './lng_lat';
import {LngLatBounds} from './lng_lat_bounds';
import {tileIdToLngLatBounds} from '../tile/tile_id_to_lng_lat_bounds';
import {CanonicalTileID} from '../tile/tile_id';
import {EXTENT} from '../data/extent';
describe('LngLatBounds', () => {
test('constructor', () => {
const sw = new LngLat(0, 0);
const ne = new LngLat(-10, 10);
const bounds = new LngLatBounds(sw, ne);
expect(bounds.getSouth()).toBe(0);
expect(bounds.getWest()).toBe(0);
expect(bounds.getNorth()).toBe(10);
expect(bounds.getEast()).toBe(-10);
});
test('constructor across dateline', () => {
const sw = new LngLat(170, 0);
const ne = new LngLat(-170, 10);
const bounds = new LngLatBounds(sw, ne);
expect(bounds.getSouth()).toBe(0);
expect(bounds.getWest()).toBe(170);
expect(bounds.getNorth()).toBe(10);
expect(bounds.getEast()).toBe(-170);
});
test('constructor across pole', () => {
const sw = new LngLat(0, 85);
const ne = new LngLat(-10, -85);
const bounds = new LngLatBounds(sw, ne);
expect(bounds.getSouth()).toBe(85);
expect(bounds.getWest()).toBe(0);
expect(bounds.getNorth()).toBe(-85);
expect(bounds.getEast()).toBe(-10);
});
test('constructor no args', () => {
const bounds = new LngLatBounds();
const t1 = () => {
bounds.getCenter();
};
expect(t1).toThrow();
});
test('extend with coordinate', () => {
const bounds = new LngLatBounds([0, 0], [10, 10]);
bounds.extend([-10, -10]);
expect(bounds.getSouth()).toBe(-10);
expect(bounds.getWest()).toBe(-10);
expect(bounds.getNorth()).toBe(10);
expect(bounds.getEast()).toBe(10);
bounds.extend(new LngLat(-15, -15));
expect(bounds.getSouth()).toBe(-15);
expect(bounds.getWest()).toBe(-15);
expect(bounds.getNorth()).toBe(10);
expect(bounds.getEast()).toBe(10);
bounds.extend([-80, -80, 80, 80]);
expect(bounds.getSouth()).toBe(-80);
expect(bounds.getWest()).toBe(-80);
expect(bounds.getNorth()).toBe(80);
expect(bounds.getEast()).toBe(80);
bounds.extend({lng: -90, lat: -90});
expect(bounds.getSouth()).toBe(-90);
expect(bounds.getWest()).toBe(-90);
expect(bounds.getNorth()).toBe(80);
expect(bounds.getEast()).toBe(80);
bounds.extend({lon: 90, lat: 90});
expect(bounds.getSouth()).toBe(-90);
expect(bounds.getWest()).toBe(-90);
expect(bounds.getNorth()).toBe(90);
expect(bounds.getEast()).toBe(90);
});
test('extend with bounds', () => {
const bounds1 = new LngLatBounds([0, 0], [10, 10]);
const bounds2 = new LngLatBounds([-10, -10], [10, 10]);
bounds1.extend(bounds2);
expect(bounds1.getSouth()).toBe(-10);
expect(bounds1.getWest()).toBe(-10);
expect(bounds1.getNorth()).toBe(10);
expect(bounds1.getEast()).toBe(10);
const bounds4 = new LngLatBounds([-20, -20, 20, 20]);
bounds1.extend(bounds4);
expect(bounds1.getSouth()).toBe(-20);
expect(bounds1.getWest()).toBe(-20);
expect(bounds1.getNorth()).toBe(20);
expect(bounds1.getEast()).toBe(20);
const bounds5 = new LngLatBounds();
bounds1.extend(bounds5);
expect(bounds1.getSouth()).toBe(-20);
expect(bounds1.getWest()).toBe(-20);
expect(bounds1.getNorth()).toBe(20);
expect(bounds1.getEast()).toBe(20);
});
test('extend with null', () => {
const bounds = new LngLatBounds([0, 0], [10, 10]);
bounds.extend(null);
expect(bounds.getSouth()).toBe(0);
expect(bounds.getWest()).toBe(0);
expect(bounds.getNorth()).toBe(10);
expect(bounds.getEast()).toBe(10);
});
test('extend undefined bounding box', () => {
const bounds1 = new LngLatBounds(undefined, undefined);
const bounds2 = new LngLatBounds([-10, -10], [10, 10]);
bounds1.extend(bounds2);
expect(bounds1.getSouth()).toBe(-10);
expect(bounds1.getWest()).toBe(-10);
expect(bounds1.getNorth()).toBe(10);
expect(bounds1.getEast()).toBe(10);
});
test('extend same LngLat instance', () => {
const point = new LngLat(0, 0);
const bounds = new LngLatBounds(point, point);
bounds.extend(new LngLat(15, 15));
expect(bounds.getSouth()).toBe(0);
expect(bounds.getWest()).toBe(0);
expect(bounds.getNorth()).toBe(15);
expect(bounds.getEast()).toBe(15);
});
test('accessors', () => {
const sw = new LngLat(0, 0);
const ne = new LngLat(-10, -20);
const bounds = new LngLatBounds(sw, ne);
expect(bounds.getCenter()).toEqual(new LngLat(-5, -10));
expect(bounds.getSouth()).toBe(0);
expect(bounds.getWest()).toBe(0);
expect(bounds.getNorth()).toBe(-20);
expect(bounds.getEast()).toBe(-10);
expect(bounds.getSouthWest()).toEqual(new LngLat(0, 0));
expect(bounds.getSouthEast()).toEqual(new LngLat(-10, 0));
expect(bounds.getNorthEast()).toEqual(new LngLat(-10, -20));
expect(bounds.getNorthWest()).toEqual(new LngLat(0, -20));
});
test('convert', () => {
const sw = new LngLat(0, 0);
const ne = new LngLat(-10, 10);
const bounds = new LngLatBounds(sw, ne);
expect(LngLatBounds.convert(undefined)).toBeUndefined();
expect(LngLatBounds.convert(bounds)).toEqual(bounds);
expect(LngLatBounds.convert([sw, ne])).toEqual(bounds);
expect(
LngLatBounds.convert([bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()])
).toEqual(bounds);
});
test('toArray', () => {
const llb = new LngLatBounds([-73.9876, 40.7661], [-73.9397, 40.8002]);
expect(llb.toArray()).toEqual([[-73.9876, 40.7661], [-73.9397, 40.8002]]);
});
test('toString', () => {
const llb = new LngLatBounds([-73.9876, 40.7661], [-73.9397, 40.8002]);
expect(llb.toString()).toBe('LngLatBounds(LngLat(-73.9876, 40.7661), LngLat(-73.9397, 40.8002))');
});
test('isEmpty', () => {
const nullBounds = new LngLatBounds();
expect(nullBounds.isEmpty()).toBe(true);
const sw = new LngLat(0, 0);
const ne = new LngLat(-10, 10);
const bounds = new LngLatBounds(sw, ne);
expect(bounds.isEmpty()).toBe(false);
});
test('fromLngLat', () => {
const center0 = new LngLat(0, 0);
const center1 = new LngLat(-73.9749, 40.7736);
const center0Radius10 = LngLatBounds.fromLngLat(center0, 10);
const center1Radius10 = LngLatBounds.fromLngLat(center1, 10);
const center1Radius0 = LngLatBounds.fromLngLat(center1);
expect(center0Radius10.toArray()).toEqual(
[[-0.00008983152770714982, -0.00008983152770714982], [0.00008983152770714982, 0.00008983152770714982]]
);
expect(center1Radius10.toArray()).toEqual(
[[-73.97501862141328, 40.77351016847229], [-73.97478137858673, 40.77368983152771]]
);
expect(center1Radius0.toArray()).toEqual([[-73.9749, 40.7736], [-73.9749, 40.7736]]);
});
describe('LngLatBounds adjustAntiMeridian tests', () => {
test('kenya', () => {
const sw = new LngLat(32.958984, -5.353521);
const ne = new LngLat(43.50585, 5.615985);
const bounds = new LngLatBounds(sw, ne).adjustAntiMeridian();
expect(bounds.getSouth()).toBe(-5.353521);
expect(bounds.getWest()).toBe(32.958984);
expect(bounds.getNorth()).toBe(5.615985);
expect(bounds.getEast()).toBe(43.50585);
});
test('normal cross (crossing antimeridian)', () => {
const sw = new LngLat(170, 0);
const ne = new LngLat(-170, 10);
const bounds = new LngLatBounds(sw, ne).adjustAntiMeridian();
expect(bounds.getSouth()).toBe(0);
expect(bounds.getWest()).toBe(170);
expect(bounds.getNorth()).toBe(10);
expect(bounds.getEast()).toBe(190);
});
test('exactly meridian (crossing antimeridian)', () => {
const sw = new LngLat(180, -20);
const ne = new LngLat(-180, 20);
const bounds = new LngLatBounds(sw, ne).adjustAntiMeridian();
expect(bounds.getSouth()).toBe(-20);
expect(bounds.getWest()).toBe(180);
expect(bounds.getNorth()).toBe(20);
expect(bounds.getEast()).toBe(180);
});
test('small cross (crossing antimeridian)', () => {
const sw = new LngLat(179, -5);
const ne = new LngLat(-179, 5);
const bounds = new LngLatBounds(sw, ne).adjustAntiMeridian();
expect(bounds.getSouth()).toBe(-5);
expect(bounds.getWest()).toBe(179);
expect(bounds.getNorth()).toBe(5);
expect(bounds.getEast()).toBe(181);
});
test('large cross (crossing antimeridian)', () => {
const sw = new LngLat(100, -30);
const ne = new LngLat(-100, 30);
const bounds = new LngLatBounds(sw, ne).adjustAntiMeridian();
expect(bounds.getSouth()).toBe(-30);
expect(bounds.getWest()).toBe(100);
expect(bounds.getNorth()).toBe(30);
expect(bounds.getEast()).toBe(260);
});
test('reverse cross (crossing antimeridian)', () => {
const sw = new LngLat(-170, 0);
const ne = new LngLat(170, 10);
const bounds = new LngLatBounds(sw, ne).adjustAntiMeridian();
expect(bounds.getSouth()).toBe(0);
expect(bounds.getWest()).toBe(-170);
expect(bounds.getNorth()).toBe(10);
expect(bounds.getEast()).toBe(170);
});
test('reverse not cross', () => {
const sw = new LngLat(150, 0);
const ne = new LngLat(170, 10);
const bounds = new LngLatBounds(sw, ne).adjustAntiMeridian();
expect(bounds.getSouth()).toBe(0);
expect(bounds.getWest()).toBe(150);
expect(bounds.getNorth()).toBe(10);
expect(bounds.getEast()).toBe(170);
});
test('same longitude', () => {
const sw = new LngLat(175, -10);
const ne = new LngLat(175, 10);
const bounds = new LngLatBounds(sw, ne).adjustAntiMeridian();
expect(bounds.getSouth()).toBe(-10);
expect(bounds.getWest()).toBe(175);
expect(bounds.getNorth()).toBe(10);
expect(bounds.getEast()).toBe(175);
});
test('full world', () => {
const sw = new LngLat(-180, -90);
const ne = new LngLat(180, 90);
const bounds = new LngLatBounds(sw, ne).adjustAntiMeridian();
expect(bounds.getSouth()).toBe(-90);
expect(bounds.getWest()).toBe(-180);
expect(bounds.getNorth()).toBe(90);
expect(bounds.getEast()).toBe(180);
});
test('across pole', () => {
const sw = new LngLat(0, 85);
const ne = new LngLat(-10, -85);
const bounds = new LngLatBounds(sw, ne).adjustAntiMeridian();
expect(bounds.getSouth()).toBe(85);
expect(bounds.getWest()).toBe(0);
expect(bounds.getNorth()).toBe(-85);
expect(bounds.getEast()).toBe(350);
});
test('across pole reverse', () => {
const sw = new LngLat(-10, -85);
const ne = new LngLat(0, 85);
const bounds = new LngLatBounds(sw, ne).adjustAntiMeridian();
expect(bounds.getSouth()).toBe(-85);
expect(bounds.getWest()).toBe(-10);
expect(bounds.getNorth()).toBe(85);
expect(bounds.getEast()).toBe(0);
});
test('across dateline', () => {
const sw = new LngLat(170, 0);
const ne = new LngLat(-170, 10);
const bounds = new LngLatBounds(sw, ne).adjustAntiMeridian();
expect(bounds.getSouth()).toBe(0);
expect(bounds.getWest()).toBe(170);
expect(bounds.getNorth()).toBe(10);
expect(bounds.getEast()).toBe(190);
});
});
describe('contains', () => {
describe('point', () => {
test('point is in bounds', () => {
const llb = new LngLatBounds([-1, -1], [1, 1]);
const ll = {lng: 0, lat: 0};
expect(llb.contains(ll)).toBeTruthy();
});
test('point is not in bounds', () => {
const llb = new LngLatBounds([-1, -1], [1, 1]);
const ll = {lng: 3, lat: 3};
expect(llb.contains(ll)).toBeFalsy();
});
test('point is in bounds that spans dateline', () => {
const llb = new LngLatBounds([190, -10], [170, 10]);
const ll = {lng: 180, lat: 0};
expect(llb.contains(ll)).toBeTruthy();
});
test('point is not in bounds that spans dateline', () => {
const llb = new LngLatBounds([190, -10], [170, 10]);
const ll = {lng: 0, lat: 0};
expect(llb.contains(ll)).toBeFalsy();
});
});
});
describe('intersects', () => {
test('bounds intersect', () => {
const bounds1 = new LngLatBounds([0, 0], [10, 10]);
const bounds2 = new LngLatBounds([5, 5], [15, 15]);
expect(bounds1.intersects(bounds2)).toBe(true);
});
test('bounds do not intersect', () => {
const bounds1 = new LngLatBounds([0, 0], [10, 10]);
const bounds2 = new LngLatBounds([20, 20], [30, 30]);
expect(bounds1.intersects(bounds2)).toBe(false);
});
describe('dateline crossing', () => {
test('both bounds wrap around dateline - always intersect', () => {
const bounds1 = new LngLatBounds([170, 0], [-170, 10]);
const bounds2 = new LngLatBounds([160, 5], [-160, 15]);
expect(bounds1.intersects(bounds2)).toBe(true);
});
test('only first bounds wraps - intersects on east side', () => {
const bounds1 = new LngLatBounds([170, 0], [-170, 10]);
const bounds2 = new LngLatBounds([165, 0], [175, 10]);
expect(bounds1.intersects(bounds2)).toBe(true);
});
test('only first bounds wraps - intersects on west side', () => {
const bounds1 = new LngLatBounds([170, 0], [-170, 10]);
const bounds2 = new LngLatBounds([-175, 0], [-165, 10]);
expect(bounds1.intersects(bounds2)).toBe(true);
});
test('only first bounds wraps - does not intersect (in gap)', () => {
const bounds1 = new LngLatBounds([170, 0], [-170, 10]);
const bounds2 = new LngLatBounds([0, 0], [10, 10]);
expect(bounds1.intersects(bounds2)).toBe(false);
});
test('only second bounds wraps - intersects on east side', () => {
const bounds1 = new LngLatBounds([165, 0], [175, 10]);
const bounds2 = new LngLatBounds([170, 0], [-170, 10]);
expect(bounds1.intersects(bounds2)).toBe(true);
});
test('only second bounds wraps - intersects on west side', () => {
const bounds1 = new LngLatBounds([-175, 0], [-165, 10]);
const bounds2 = new LngLatBounds([170, 0], [-170, 10]);
expect(bounds1.intersects(bounds2)).toBe(true);
});
test('only second bounds wraps - does not intersect (in gap)', () => {
const bounds1 = new LngLatBounds([0, 0], [10, 10]);
const bounds2 = new LngLatBounds([170, 0], [-170, 10]);
expect(bounds1.intersects(bounds2)).toBe(false);
});
test('wrapping bounds with no latitude overlap', () => {
const bounds1 = new LngLatBounds([170, 0], [-170, 10]);
const bounds2 = new LngLatBounds([160, 20], [-160, 30]);
expect(bounds1.intersects(bounds2)).toBe(false);
});
test('wrapping tile bounds at dateline intersects with negative longitude bounds', () => {
const tileBounds = new LngLatBounds([170, 0], [-170, 10]);
const bounds = new LngLatBounds([-180, 5], [-175, 10]);
expect(tileBounds.intersects(bounds)).toBe(true);
});
test('entire worlds tile should return true', () => {
const tileBounds = tileIdToLngLatBounds(new CanonicalTileID(0, 0, 0), 2048 / EXTENT);
const bounds = new LngLatBounds([[-8.290589217651302, 44.47966524518165], [20.566067150212803, 50.98693819014929]]);
expect(tileBounds.intersects(bounds)).toBe(true);
});
test('point feature outside bounds does not intersect', () => {
const bounds = new LngLatBounds([0, 0], [10, 10]);
const point = new LngLatBounds([20, 5], [20, 5]);
expect(bounds.intersects(point)).toBe(false);
});
});
});
});
+414
View File
@@ -0,0 +1,414 @@
import {LngLat} from './lng_lat';
import type {LngLatLike} from './lng_lat';
import {wrap} from '../util/util';
/**
* A {@link LngLatBounds} object, an array of {@link LngLatLike} objects in [sw, ne] order,
* or an array of numbers in [west, south, east, north] order.
*
* @group Geography and Geometry
*
* @example
* ```ts
* let v1 = new LngLatBounds(
* new LngLat(-73.9876, 40.7661),
* new LngLat(-73.9397, 40.8002)
* );
* let v2 = new LngLatBounds([-73.9876, 40.7661], [-73.9397, 40.8002])
* let v3 = [[-73.9876, 40.7661], [-73.9397, 40.8002]];
* ```
*/
export type LngLatBoundsLike = LngLatBounds | [LngLatLike, LngLatLike] | [number, number, number, number];
/**
* A `LngLatBounds` object represents a geographical bounding box,
* defined by its southwest and northeast points in longitude and latitude.
*
* If no arguments are provided to the constructor, a `null` bounding box is created.
*
* Note that any Mapbox GL method that accepts a `LngLatBounds` object as an argument or option
* can also accept an `Array` of two {@link LngLatLike} constructs and will perform an implicit conversion.
* This flexible type is documented as {@link LngLatBoundsLike}.
*
* @group Geography and Geometry
*
* @example
* ```ts
* let sw = new LngLat(-73.9876, 40.7661);
* let ne = new LngLat(-73.9397, 40.8002);
* let llb = new LngLatBounds(sw, ne);
* ```
*/
export class LngLatBounds {
_ne: LngLat;
_sw: LngLat;
/**
* @param sw - The southwest corner of the bounding box.
* OR array of 4 numbers in the order of west, south, east, north
* OR array of 2 LngLatLike: [sw,ne]
* @param ne - The northeast corner of the bounding box.
* @example
* ```ts
* let sw = new LngLat(-73.9876, 40.7661);
* let ne = new LngLat(-73.9397, 40.8002);
* let llb = new LngLatBounds(sw, ne);
* ```
* OR
* ```ts
* let llb = new LngLatBounds([-73.9876, 40.7661, -73.9397, 40.8002]);
* ```
* OR
* ```ts
* let llb = new LngLatBounds([sw, ne]);
* ```
*/
constructor(sw?: LngLatLike | [number, number, number, number] | [LngLatLike, LngLatLike], ne?: LngLatLike) {
if (!sw) {
// noop
} else if (ne) {
this.setSouthWest(<LngLatLike>sw).setNorthEast(ne);
} else if (Array.isArray(sw)) {
if (sw.length === 4) {
// 4 element array: west, south, east, north
this.setSouthWest([sw[0], sw[1]]).setNorthEast([sw[2], sw[3]]);
} else {
this.setSouthWest(sw[0] as LngLatLike).setNorthEast(sw[1] as LngLatLike);
}
}
}
/**
* Set the northeast corner of the bounding box
*
* @param ne - a {@link LngLatLike} object describing the northeast corner of the bounding box.
*/
setNorthEast(ne: LngLatLike): this {
this._ne = ne instanceof LngLat ? new LngLat(ne.lng, ne.lat) : LngLat.convert(ne);
return this;
}
/**
* Set the southwest corner of the bounding box
*
* @param sw - a {@link LngLatLike} object describing the southwest corner of the bounding box.
*/
setSouthWest(sw: LngLatLike): this {
this._sw = sw instanceof LngLat ? new LngLat(sw.lng, sw.lat) : LngLat.convert(sw);
return this;
}
/**
* Extend the bounds to include a given LngLatLike or LngLatBoundsLike.
*
* @param obj - object to extend to
*/
extend(obj: LngLatLike | LngLatBoundsLike): this {
const sw = this._sw,
ne = this._ne;
let sw2, ne2;
if (obj instanceof LngLat) {
sw2 = obj;
ne2 = obj;
} else if (obj instanceof LngLatBounds) {
sw2 = obj._sw;
ne2 = obj._ne;
if (!sw2 || !ne2) return this;
} else {
if (Array.isArray(obj)) {
if (obj.length === 4 || (obj as any[]).every(Array.isArray)) {
const lngLatBoundsObj = (obj as any as LngLatBoundsLike);
return this.extend(LngLatBounds.convert(lngLatBoundsObj));
} else {
const lngLatObj = (obj as any as LngLatLike);
return this.extend(LngLat.convert(lngLatObj));
}
} else if (obj && ('lng' in obj || 'lon' in obj) && 'lat' in obj) {
return this.extend(LngLat.convert(obj));
}
return this;
}
if (!sw && !ne) {
this._sw = new LngLat(sw2.lng, sw2.lat);
this._ne = new LngLat(ne2.lng, ne2.lat);
} else {
sw.lng = Math.min(sw2.lng, sw.lng);
sw.lat = Math.min(sw2.lat, sw.lat);
ne.lng = Math.max(ne2.lng, ne.lng);
ne.lat = Math.max(ne2.lat, ne.lat);
}
return this;
}
/**
* Returns the geographical coordinate equidistant from the bounding box's corners.
*
* @returns The bounding box's center.
* @example
* ```ts
* let llb = new LngLatBounds([-73.9876, 40.7661], [-73.9397, 40.8002]);
* llb.getCenter(); // = LngLat {lng: -73.96365, lat: 40.78315}
* ```
*/
getCenter(): LngLat {
return new LngLat((this._sw.lng + this._ne.lng) / 2, (this._sw.lat + this._ne.lat) / 2);
}
/**
* Returns the southwest corner of the bounding box.
*
* @returns The southwest corner of the bounding box.
*/
getSouthWest(): LngLat { return this._sw; }
/**
* Returns the northeast corner of the bounding box.
*
* @returns The northeast corner of the bounding box.
*/
getNorthEast(): LngLat { return this._ne; }
/**
* Returns the northwest corner of the bounding box.
*
* @returns The northwest corner of the bounding box.
*/
getNorthWest(): LngLat { return new LngLat(this.getWest(), this.getNorth()); }
/**
* Returns the southeast corner of the bounding box.
*
* @returns The southeast corner of the bounding box.
*/
getSouthEast(): LngLat { return new LngLat(this.getEast(), this.getSouth()); }
/**
* Returns the west edge of the bounding box.
*
* @returns The west edge of the bounding box.
*/
getWest(): number { return this._sw.lng; }
/**
* Returns the south edge of the bounding box.
*
* @returns The south edge of the bounding box.
*/
getSouth(): number { return this._sw.lat; }
/**
* Returns the east edge of the bounding box.
*
* @returns The east edge of the bounding box.
*/
getEast(): number { return this._ne.lng; }
/**
* Returns the north edge of the bounding box.
*
* @returns The north edge of the bounding box.
*/
getNorth(): number { return this._ne.lat; }
/**
* Returns the bounding box represented as an array.
*
* @returns The bounding box represented as an array, consisting of the
* southwest and northeast coordinates of the bounding represented as arrays of numbers.
* @example
* ```ts
* let llb = new LngLatBounds([-73.9876, 40.7661], [-73.9397, 40.8002]);
* llb.toArray(); // = [[-73.9876, 40.7661], [-73.9397, 40.8002]]
* ```
*/
toArray(): [[number, number], [number, number]] {
return [this._sw.toArray(), this._ne.toArray()];
}
/**
* Return the bounding box represented as a string.
*
* @returns The bounding box represents as a string of the format
* `'LngLatBounds(LngLat(lng, lat), LngLat(lng, lat))'`.
* @example
* ```ts
* let llb = new LngLatBounds([-73.9876, 40.7661], [-73.9397, 40.8002]);
* llb.toString(); // = "LngLatBounds(LngLat(-73.9876, 40.7661), LngLat(-73.9397, 40.8002))"
* ```
*/
toString() {
return `LngLatBounds(${this._sw.toString()}, ${this._ne.toString()})`;
}
/**
* Check if the bounding box is an empty/`null`-type box.
*
* @returns True if bounds have been defined, otherwise false.
*/
isEmpty() {
return !(this._sw && this._ne);
}
/**
* Check if the point is within the bounding box.
*
* @param lnglat - geographic point to check against.
* @returns `true` if the point is within the bounding box.
* @example
* ```ts
* let llb = new LngLatBounds(
* new LngLat(-73.9876, 40.7661),
* new LngLat(-73.9397, 40.8002)
* );
*
* let ll = new LngLat(-73.9567, 40.7789);
*
* console.log(llb.contains(ll)); // = true
* ```
*/
contains(lnglat: LngLatLike) {
const {lng, lat} = LngLat.convert(lnglat);
const containsLatitude = this._sw.lat <= lat && lat <= this._ne.lat;
let containsLongitude = this._sw.lng <= lng && lng <= this._ne.lng;
if (this._sw.lng > this._ne.lng) { // wrapped coordinates
containsLongitude = this._sw.lng >= lng && lng >= this._ne.lng;
}
return containsLatitude && containsLongitude;
}
/**
* Checks if this bounding box intersects with another bounding box.
*
* Returns true if the bounding boxes share any area, including cases where
* they only touch along an edge or at a corner.
*
* This method properly handles cases where either or both bounding boxes cross
* the antimeridian (date line).
*/
intersects(other: LngLatBoundsLike): boolean {
other = LngLatBounds.convert(other);
const latIntersects =
other.getNorth() >= this.getSouth() &&
other.getSouth() <= this.getNorth();
if (!latIntersects) return false;
// Check if either bound covers the full world (|span| >= 360°)
// This must be done before wrapping to preserve the span information
const thisSpan = Math.abs(this.getEast() - this.getWest());
const otherSpan = Math.abs(other.getEast() - other.getWest());
if (thisSpan >= 360 || otherSpan >= 360) {
return true;
}
// Normalize longitudes to [-180, 180] range
const thisWest = wrap(this.getWest(), -180, 180);
const thisEast = wrap(this.getEast(), -180, 180);
const otherWest = wrap(other.getWest(), -180, 180);
const otherEast = wrap(other.getEast(), -180, 180);
// Check if either bounds wraps around the antimeridian
// Use strict inequality: equal values indicate zero-width bounds (e.g., a point), not wrapping
const thisWraps = thisWest > thisEast;
const otherWraps = otherWest > otherEast;
// Both wrap: they always intersect
if (thisWraps && otherWraps) {
return true;
}
// Only this wraps: intersects if other is outside the gap
if (thisWraps) {
return otherEast >= thisWest || otherWest <= thisEast;
}
if (otherWraps) {
// Only other wraps: intersects if this is outside the gap
return thisEast >= otherWest || thisWest <= otherEast;
}
// Neither wraps: standard intersection check
return otherWest <= thisEast && otherEast >= thisWest;
}
/**
* Converts an array to a `LngLatBounds` object.
*
* If a `LngLatBounds` object is passed in, the function returns it unchanged.
*
* Internally, the function calls {@link LngLat.convert} to convert arrays to `LngLat` values.
*
* @param input - An array of two coordinates to convert, or a `LngLatBounds` object to return.
* @returns A new `LngLatBounds` object, if a conversion occurred, or the original `LngLatBounds` object.
* @example
* ```ts
* let arr = [[-73.9876, 40.7661], [-73.9397, 40.8002]];
* let llb = LngLatBounds.convert(arr); // = LngLatBounds {_sw: LngLat {lng: -73.9876, lat: 40.7661}, _ne: LngLat {lng: -73.9397, lat: 40.8002}}
* ```
*/
static convert(input: LngLatBoundsLike | null): LngLatBounds {
if (input instanceof LngLatBounds) return input;
if (!input) return input as null;
return new LngLatBounds(input);
}
/**
* Returns a `LngLatBounds` from the coordinates extended by a given `radius`. The returned `LngLatBounds` completely contains the `radius`.
*
* @param center - center coordinates of the new bounds.
* @param radius - Distance in meters from the coordinates to extend the bounds.
* @returns A new `LngLatBounds` object representing the coordinates extended by the `radius`.
* @example
* ```ts
* let center = new LngLat(-73.9749, 40.7736);
* LngLatBounds.fromLngLat(100).toArray(); // = [[-73.97501862141328, 40.77351016847229], [-73.97478137858673, 40.77368983152771]]
* ```
*/
static fromLngLat(center: LngLat, radius:number = 0): LngLatBounds {
const earthCircumferenceInMetersAtEquator = 40075017;
const latAccuracy = 360 * radius / earthCircumferenceInMetersAtEquator,
lngAccuracy = latAccuracy / Math.cos((Math.PI / 180) * center.lat);
return new LngLatBounds(new LngLat(center.lng - lngAccuracy, center.lat - latAccuracy),
new LngLat(center.lng + lngAccuracy, center.lat + latAccuracy));
}
/**
* Adjusts the given bounds to handle the case where the bounds cross the 180th meridian (antimeridian).
*
* @returns The adjusted LngLatBounds
* @example
* ```ts
* let bounds = new LngLatBounds([175.813127, -20.157768], [-178. 340903, -15.449124]);
* let adjustedBounds = bounds.adjustAntiMeridian();
* // adjustedBounds will be: [[175.813127, -20.157768], [181.659097, -15.449124]]
* ```
*/
adjustAntiMeridian(): LngLatBounds {
const sw = new LngLat(this._sw.lng, this._sw.lat);
const ne = new LngLat(this._ne.lng, this._ne.lat);
if (sw.lng > ne.lng) {
return new LngLatBounds(
sw,
new LngLat(ne.lng + 360, ne.lat)
);
}
return new LngLatBounds(sw, ne);
}
}
+35
View File
@@ -0,0 +1,35 @@
import {describe, test, expect} from 'vitest';
import {LngLat} from './lng_lat';
import {MercatorCoordinate, mercatorScale} from './mercator_coordinate';
describe('LngLat', () => {
test('constructor', () => {
expect(new MercatorCoordinate(0, 0) instanceof MercatorCoordinate).toBeTruthy();
expect(new MercatorCoordinate(0, 0, 0) instanceof MercatorCoordinate).toBeTruthy();
});
test('fromLngLat', () => {
const nullIsland = new LngLat(0, 0);
expect(MercatorCoordinate.fromLngLat(nullIsland)).toEqual({x: 0.5, y: 0.5, z: 0});
});
test('toLngLat', () => {
const dc = new LngLat(-77, 39);
expect(MercatorCoordinate.fromLngLat(dc, 500).toLngLat()).toEqual({lng: -77, lat: 39});
});
test('toAltitude', () => {
const dc = new LngLat(-77, 39);
expect(MercatorCoordinate.fromLngLat(dc, 500).toAltitude()).toBe(500);
});
test('mercatorScale', () => {
expect(mercatorScale(0)).toBe(1);
expect(mercatorScale(45)).toBe(1.414213562373095);
});
test('meterInMercatorCoordinateUnits', () => {
const nullIsland = new LngLat(0, 0);
expect(MercatorCoordinate.fromLngLat(nullIsland).meterInMercatorCoordinateUnits()).toBe(2.4981121214570498e-8);
});
});
+157
View File
@@ -0,0 +1,157 @@
import {LngLat, earthRadius} from '../geo/lng_lat';
import type {LngLatLike} from '../geo/lng_lat';
import {type IMercatorCoordinate} from '@maplibre/maplibre-gl-style-spec';
/*
* The average circumference of the world in meters.
*/
const earthCircumference = 2 * Math.PI * earthRadius; // meters
/*
* The circumference at a line of latitude in meters.
*/
function circumferenceAtLatitude(latitude: number) {
return earthCircumference * Math.cos(latitude * Math.PI / 180);
}
export function mercatorXfromLng(lng: number) {
return (180 + lng) / 360;
}
export function mercatorYfromLat(lat: number) {
return (180 - (180 / Math.PI * Math.log(Math.tan(Math.PI / 4 + lat * Math.PI / 360)))) / 360;
}
export function mercatorZfromAltitude(altitude: number, lat: number) {
return altitude / circumferenceAtLatitude(lat);
}
export function lngFromMercatorX(x: number) {
return x * 360 - 180;
}
export function latFromMercatorY(y: number) {
const y2 = 180 - y * 360;
return 360 / Math.PI * Math.atan(Math.exp(y2 * Math.PI / 180)) - 90;
}
export function altitudeFromMercatorZ(z: number, y: number) {
return z * circumferenceAtLatitude(latFromMercatorY(y));
}
/**
* Determine the Mercator scale factor for a given latitude, see
* https://en.wikipedia.org/wiki/Mercator_projection#Scale_factor
*
* At the equator the scale factor will be 1, which increases at higher latitudes.
*
* @param lat - Latitude
* @returns scale factor
*/
export function mercatorScale(lat: number) {
return 1 / Math.cos(lat * Math.PI / 180);
}
/**
* A `MercatorCoordinate` object represents a projected three dimensional position.
*
* `MercatorCoordinate` uses the web mercator projection ([EPSG:3857](https://epsg.io/3857)) with slightly different units:
*
* - the size of 1 unit is the width of the projected world instead of the "mercator meter"
* - the origin of the coordinate space is at the north-west corner instead of the middle
*
* For example, `MercatorCoordinate(0, 0, 0)` is the north-west corner of the mercator world and
* `MercatorCoordinate(1, 1, 0)` is the south-east corner. If you are familiar with
* [vector tiles](https://github.com/mapbox/vector-tile-spec) it may be helpful to think
* of the coordinate space as the `0/0/0` tile with an extent of `1`.
*
* The `z` dimension of `MercatorCoordinate` is conformal. A cube in the mercator coordinate space would be rendered as a cube.
*
* @group Geography and Geometry
*
* @example
* ```ts
* let nullIsland = new MercatorCoordinate(0.5, 0.5, 0);
* ```
* @see [Add a custom style layer](https://maplibre.org/maplibre-gl-js/docs/examples/custom-style-layer/)
*/
export class MercatorCoordinate implements IMercatorCoordinate {
x: number;
y: number;
z: number;
/**
* @param x - The x component of the position.
* @param y - The y component of the position.
* @param z - The z component of the position.
*/
constructor(x: number, y: number, z: number = 0) {
this.x = +x;
this.y = +y;
this.z = +z;
}
/**
* Project a `LngLat` to a `MercatorCoordinate`.
*
* @param lngLatLike - The location to project.
* @param altitude - The altitude in meters of the position.
* @returns The projected mercator coordinate.
* @example
* ```ts
* let coord = MercatorCoordinate.fromLngLat({ lng: 0, lat: 0}, 0);
* coord; // MercatorCoordinate(0.5, 0.5, 0)
* ```
*/
static fromLngLat(lngLatLike: LngLatLike, altitude: number = 0): MercatorCoordinate {
const lngLat = LngLat.convert(lngLatLike);
return new MercatorCoordinate(
mercatorXfromLng(lngLat.lng),
mercatorYfromLat(lngLat.lat),
mercatorZfromAltitude(altitude, lngLat.lat));
}
/**
* Returns the `LngLat` for the coordinate.
*
* @returns The `LngLat` object.
* @example
* ```ts
* let coord = new MercatorCoordinate(0.5, 0.5, 0);
* let lngLat = coord.toLngLat(); // LngLat(0, 0)
* ```
*/
toLngLat() {
return new LngLat(
lngFromMercatorX(this.x),
latFromMercatorY(this.y));
}
/**
* Returns the altitude in meters of the coordinate.
*
* @returns The altitude in meters.
* @example
* ```ts
* let coord = new MercatorCoordinate(0, 0, 0.02);
* coord.toAltitude(); // 6914.281956295339
* ```
*/
toAltitude(): number {
return altitudeFromMercatorZ(this.z, this.y);
}
/**
* Returns the distance of 1 meter in `MercatorCoordinate` units at this latitude.
*
* For coordinates in real world units using meters, this naturally provides the scale
* to transform into `MercatorCoordinate`s.
*
* @returns Distance of 1 meter in `MercatorCoordinate` units.
*/
meterInMercatorCoordinateUnits(): number {
// 1 meter / circumference at equator in meters * Mercator projection scale factor at this latitude
return 1 / earthCircumference * mercatorScale(latFromMercatorY(this.y));
}
}
+221
View File
@@ -0,0 +1,221 @@
import Point from '@mapbox/point-geometry';
import {type IReadonlyTransform, type ITransform} from '../transform_interface';
import {type LngLat, type LngLatLike} from '../lng_lat';
import {type CameraForBoundsOptions, type PointLike} from '../../ui/camera';
import {type PaddingOptions} from '../edge_insets';
import {type LngLatBounds} from '../lng_lat_bounds';
import {degreesToRadians, getRollPitchBearing, type RollPitchBearing, rollPitchBearingToQuat, scaleZoom, warnOnce, zoomScale} from '../../util/util';
import {quat} from 'gl-matrix';
import {interpolates} from '@maplibre/maplibre-gl-style-spec';
import {projectToWorldCoordinates, unprojectFromWorldCoordinates} from './mercator_utils';
export type MapControlsDeltas = {
panDelta: Point;
zoomDelta: number;
bearingDelta: number;
pitchDelta: number;
rollDelta: number;
around: Point;
};
export type CameraForBoxAndBearingHandlerResult = {
center: LngLat;
zoom: number;
bearing: number;
};
export type EaseToHandlerOptions = {
bearing: number;
pitch: number;
roll: number;
padding: PaddingOptions;
offsetAsPoint: Point;
around?: LngLat;
aroundPoint?: Point;
center?: LngLatLike;
zoom?: number;
offset?: PointLike;
};
export type EaseToHandlerResult = {
easeFunc: (k: number) => void;
elevationCenter: LngLat;
isZooming: boolean;
};
export type FlyToHandlerOptions = {
bearing: number;
pitch: number;
roll: number;
padding: PaddingOptions;
offsetAsPoint: Point;
center?: LngLatLike;
locationAtOffset: LngLat;
zoom?: number;
minZoom?: number;
};
export type FlyToHandlerResult = {
easeFunc: (k: number, scale: number, centerFactor: number, pointAtOffset: Point) => void;
scaleOfZoom: number;
scaleOfMinZoom?: number;
targetCenter: LngLat;
pixelPathLength: number;
};
export type UpdateRotationArgs = {
/**
* The starting Euler angles.
*/
startEulerAngles: RollPitchBearing;
/**
* The end Euler angles.
*/
endEulerAngles: RollPitchBearing;
/**
* The transform to be updated
*/
tr: ITransform;
/**
* The interpolation fraction, between 0 and 1.
*/
k: number;
/**
* If true, use spherical linear interpolation. If false, use linear interpolation of Euler angles.
*/
useSlerp: boolean;
};
/**
* @internal
*/
export function cameraBoundsWarning() {
warnOnce(
'Map cannot fit within canvas with the given bounds, padding, and/or offset.'
);
}
/**
* @internal
* Contains projection-specific functions related to camera controls, easeTo, flyTo, inertia, etc.
*/
export interface ICameraHelper {
get useGlobeControls(): boolean;
handlePanInertia(pan: Point, transform: IReadonlyTransform): {
easingCenter: LngLat;
easingOffset: Point;
};
handleMapControlsRollPitchBearingZoom(deltas: MapControlsDeltas, tr: ITransform): void;
handleMapControlsPan(deltas: MapControlsDeltas, tr: ITransform, preZoomAroundLoc: LngLat): void;
cameraForBoxAndBearing(options: CameraForBoundsOptions, padding: PaddingOptions, bounds: LngLatBounds, bearing: number, tr: IReadonlyTransform): CameraForBoxAndBearingHandlerResult;
handleJumpToCenterZoom(tr: ITransform, options: { zoom?: number; center?: LngLatLike }): void;
handleEaseTo(tr: ITransform, options: EaseToHandlerOptions): EaseToHandlerResult;
handleFlyTo(tr: ITransform, options: FlyToHandlerOptions): FlyToHandlerResult;
}
/**
* @internal
* Set a transform's rotation to a value interpolated between startEulerAngles and endEulerAngles
*/
export function updateRotation(args: UpdateRotationArgs) {
if (args.useSlerp) {
// At pitch ==0, the Euler angle representation is ambiguous. In this case, set the Euler angles
// to the representation requested by the caller
if (args.k < 1) {
const startRotation = rollPitchBearingToQuat(args.startEulerAngles.roll, args.startEulerAngles.pitch, args.startEulerAngles.bearing);
const endRotation = rollPitchBearingToQuat(args.endEulerAngles.roll, args.endEulerAngles.pitch, args.endEulerAngles.bearing);
const rotation: quat = new Float64Array(4) as any;
quat.slerp(rotation, startRotation, endRotation, args.k);
const eulerAngles = getRollPitchBearing(rotation);
args.tr.setRoll(eulerAngles.roll);
args.tr.setPitch(eulerAngles.pitch);
args.tr.setBearing(eulerAngles.bearing);
} else {
args.tr.setRoll(args.endEulerAngles.roll);
args.tr.setPitch(args.endEulerAngles.pitch);
args.tr.setBearing(args.endEulerAngles.bearing);
}
} else {
args.tr.setRoll(interpolates.number(args.startEulerAngles.roll, args.endEulerAngles.roll, args.k));
args.tr.setPitch(interpolates.number(args.startEulerAngles.pitch, args.endEulerAngles.pitch, args.k));
args.tr.setBearing(interpolates.number(args.startEulerAngles.bearing, args.endEulerAngles.bearing, args.k));
}
}
export function cameraForBoxAndBearing(options: CameraForBoundsOptions, padding: PaddingOptions, bounds: LngLatBounds, bearing: number, tr: IReadonlyTransform): CameraForBoxAndBearingHandlerResult {
const edgePadding = tr.padding;
// Consider all corners of the rotated bounding box derived from the given points
// when find the camera position that fits the given points.
const nwWorld = projectToWorldCoordinates(tr.worldSize, bounds.getNorthWest());
const neWorld = projectToWorldCoordinates(tr.worldSize, bounds.getNorthEast());
const seWorld = projectToWorldCoordinates(tr.worldSize, bounds.getSouthEast());
const swWorld = projectToWorldCoordinates(tr.worldSize, bounds.getSouthWest());
const bearingRadians = degreesToRadians(-bearing);
const nwRotatedWorld = nwWorld.rotate(bearingRadians);
const neRotatedWorld = neWorld.rotate(bearingRadians);
const seRotatedWorld = seWorld.rotate(bearingRadians);
const swRotatedWorld = swWorld.rotate(bearingRadians);
const upperRight = new Point(
Math.max(nwRotatedWorld.x, neRotatedWorld.x, swRotatedWorld.x, seRotatedWorld.x),
Math.max(nwRotatedWorld.y, neRotatedWorld.y, swRotatedWorld.y, seRotatedWorld.y)
);
const lowerLeft = new Point(
Math.min(nwRotatedWorld.x, neRotatedWorld.x, swRotatedWorld.x, seRotatedWorld.x),
Math.min(nwRotatedWorld.y, neRotatedWorld.y, swRotatedWorld.y, seRotatedWorld.y)
);
// Calculate zoom: consider the original bbox and padding.
const size = upperRight.sub(lowerLeft);
const availableWidth = (tr.width - (edgePadding.left + edgePadding.right + padding.left + padding.right));
const availableHeight = (tr.height - (edgePadding.top + edgePadding.bottom + padding.top + padding.bottom));
const scaleX = availableWidth / size.x;
const scaleY = availableHeight / size.y;
if (scaleY < 0 || scaleX < 0) {
cameraBoundsWarning();
return undefined;
}
const zoom = Math.min(scaleZoom(tr.scale * Math.min(scaleX, scaleY)), options.maxZoom);
// Calculate center: apply the zoom, the configured offset, as well as offset that exists as a result of padding.
const offset = Point.convert(options.offset);
const paddingOffsetX = (padding.left - padding.right) / 2;
const paddingOffsetY = (padding.top - padding.bottom) / 2;
const paddingOffset = new Point(paddingOffsetX, paddingOffsetY);
const rotatedPaddingOffset = paddingOffset.rotate(degreesToRadians(bearing));
const offsetAtInitialZoom = offset.add(rotatedPaddingOffset);
const offsetAtFinalZoom = offsetAtInitialZoom.mult(tr.scale / zoomScale(zoom));
const center = unprojectFromWorldCoordinates(
tr.worldSize,
// either world diagonal can be used (NW-SE or NE-SW)
nwWorld.add(seWorld).div(2).sub(offsetAtFinalZoom)
);
const result = {
center,
zoom,
bearing
};
return result;
}
+837
View File
@@ -0,0 +1,837 @@
import {beforeEach, describe, expect, test} from 'vitest';
import {GlobeTransform} from './globe_transform';
import {LngLat} from '../lng_lat';
import {coveringTiles, coveringZoomLevel, createCalculateTileZoomFunction, type CoveringTilesOptions} from './covering_tiles';
import {OverscaledTileID} from '../../tile/tile_id';
import {MercatorTransform} from './mercator_transform';
import {globeConstants} from './vertical_perspective_projection';
describe('coveringTiles', () => {
describe('globe', () => {
beforeEach(() => {
// Force faster animations so we can use shorter sleeps when testing them
globeConstants.errorTransitionTimeSeconds = 0.1;
});
test('zoomed out', () => {
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setCenter(new LngLat(0.0, 0.0));
transform.setZoom(-1);
const tiles = coveringTiles(transform, {
tileSize: 512,
});
expect(tiles).toEqual([
new OverscaledTileID(0, 0, 0, 0, 0)
]);
});
test('zoomed in', () => {
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setCenter(new LngLat(-0.02, 0.01));
transform.setZoom(3);
const tiles = coveringTiles(transform, {
tileSize: 512,
});
expect(tiles).toEqual([
new OverscaledTileID(3, 0, 3, 3, 3),
new OverscaledTileID(3, 0, 3, 3, 4),
new OverscaledTileID(3, 0, 3, 4, 3),
new OverscaledTileID(3, 0, 3, 4, 4),
]);
});
test('zoomed in 512x512', () => {
const transform = new GlobeTransform();
transform.resize(512, 512);
transform.setCenter(new LngLat(-0.02, 0.01));
transform.setZoom(3);
const tiles = coveringTiles(transform, {
tileSize: 512,
});
expect(tiles).toEqual([
new OverscaledTileID(3, 0, 3, 3, 3),
new OverscaledTileID(3, 0, 3, 3, 4),
new OverscaledTileID(3, 0, 3, 4, 3),
new OverscaledTileID(3, 0, 3, 4, 4),
new OverscaledTileID(3, 0, 3, 2, 3),
new OverscaledTileID(3, 0, 3, 2, 4),
new OverscaledTileID(3, 0, 3, 5, 3),
new OverscaledTileID(3, 0, 3, 5, 4)
]);
});
test('pitched', () => {
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setCenter(new LngLat(-0.002, 0.001));
transform.setZoom(8);
transform.setMaxPitch(80);
transform.setPitch(80);
const tiles = coveringTiles(transform, {
tileSize: 512,
});
expect(tiles).toEqual([
new OverscaledTileID(6, 0, 6, 32, 31),
new OverscaledTileID(6, 0, 6, 31, 31),
new OverscaledTileID(10, 0, 10, 511, 512),
new OverscaledTileID(10, 0, 10, 512, 512),
]);
});
test('pitched+rotated', () => {
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setCenter(new LngLat(-0.002, 0.001));
transform.setZoom(8);
transform.setMaxPitch(80);
transform.setPitch(80);
transform.setBearing(45);
const tiles = coveringTiles(transform, {
tileSize: 512,
});
expect(tiles).toEqual([
new OverscaledTileID(7, 0, 7, 64, 64),
new OverscaledTileID(7, 0, 7, 64, 63),
new OverscaledTileID(7, 0, 7, 63, 63),
new OverscaledTileID(10, 0, 10, 510, 512),
new OverscaledTileID(10, 0, 10, 511, 512),
new OverscaledTileID(10, 0, 10, 511, 513)
]);
});
test('antimeridian1', () => {
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setCenter(new LngLat(179.99, -0.001));
transform.setZoom(5);
const tiles = coveringTiles(transform, {
tileSize: 512,
});
expect(tiles).toEqual([
new OverscaledTileID(5, 0, 5, 31, 16),
new OverscaledTileID(5, 0, 5, 31, 15),
new OverscaledTileID(5, 1, 5, 0, 16),
new OverscaledTileID(5, 1, 5, 0, 15),
]);
});
test('antimeridian2', () => {
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setCenter(new LngLat(-179.99, 0.001));
transform.setZoom(5);
const tiles = coveringTiles(transform, {
tileSize: 512,
});
expect(tiles).toEqual([
new OverscaledTileID(5, 0, 5, 0, 15),
new OverscaledTileID(5, 0, 5, 0, 16),
new OverscaledTileID(5, -1, 5, 31, 15),
new OverscaledTileID(5, -1, 5, 31, 16),
]);
});
test('zoom < 0', () => {
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setCenter(new LngLat(0.0, 80.0));
transform.setZoom(-0.5);
const tiles = coveringTiles(transform, {
tileSize: 512,
minzoom: 0,
maxzoom: 0,
reparseOverscaled: true
});
expect(tiles).toEqual([
new OverscaledTileID(0, 0, 0, 0, 0)
]);
});
test('zoom = 11', () => {
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setZoom(11);
transform.setPitch(0);
transform.setCenter(new LngLat(-179.73, -0.087));
const tiles = coveringTiles(transform, {
tileSize: 512,
minzoom: 0,
maxzoom: 15,
reparseOverscaled: true
});
expect(tiles).toEqual([
new OverscaledTileID(11, 0, 11, 1, 1024)
]);
});
test('zoom = 11, mid lat', () => {
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setZoom(11);
transform.setPitch(0);
transform.setCenter(new LngLat(-179.73, 60.02));
const tiles = coveringTiles(transform, {
tileSize: 512,
minzoom: 0,
maxzoom: 15,
reparseOverscaled: true
});
expect(tiles).toEqual([
new OverscaledTileID(11, 0, 11, 1, 594)
]);
});
test('zoom = 11, high lat', () => {
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setZoom(11);
transform.setPitch(0);
transform.setCenter(new LngLat(-179.73, 85.028));
const tiles = coveringTiles(transform, {
tileSize: 512,
minzoom: 0,
maxzoom: 15,
reparseOverscaled: true
});
expect(tiles).toEqual([
new OverscaledTileID(11, 0, 11, 1, 1)
]);
});
test('zoom = 11, mid lat, mid lng', () => {
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setZoom(11);
transform.setPitch(0);
transform.setCenter(new LngLat(-58.97, 60.02));
const tiles = coveringTiles(transform, {
tileSize: 512,
minzoom: 0,
maxzoom: 15,
reparseOverscaled: true
});
expect(tiles).toEqual([
new OverscaledTileID(11, 0, 11, 688, 594)
]);
});
test('zoom = 11, mid lng', () => {
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setZoom(11);
transform.setPitch(0);
transform.setCenter(new LngLat(-58.97, -0.087));
const tiles = coveringTiles(transform, {
tileSize: 512,
minzoom: 0,
maxzoom: 15,
reparseOverscaled: true
});
expect(tiles).toEqual([
new OverscaledTileID(11, 0, 11, 688, 1024)
]);
});
describe('nonzero center elevation', () => {
test('looking down', () => {
const options = {
minzoom: 1,
maxzoom: 15,
tileSize: 512,
reparseOverscaled: true
};
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setZoom(11);
transform.setCenter(new LngLat(0.021, 0.0915));
transform.setElevation(20000);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(11, 0, 11, 1024, 1023),
new OverscaledTileID(10, 0, 10, 511, 511),
]);
});
describe('high pitch', () => {
test('bearing 0', () => {
const options = {
minzoom: 1,
maxzoom: 15,
tileSize: 512,
reparseOverscaled: true
};
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setZoom(11);
transform.setPitch(70);
transform.setBearing(0);
transform.setCenter(new LngLat(0.021, 0.0915));
transform.setElevation(20000);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(11, 0, 11, 1023, 1023),
new OverscaledTileID(11, 0, 11, 1024, 1022),
new OverscaledTileID(11, 0, 11, 1023, 1022),
new OverscaledTileID(12, 0, 12, 2048, 2046),
new OverscaledTileID(12, 0, 12, 2048, 2047),
]);
});
test('bearing 90', () => {
const options = {
minzoom: 1,
maxzoom: 15,
tileSize: 512,
reparseOverscaled: true
};
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setZoom(11);
transform.setPitch(70);
transform.setBearing(90);
transform.setCenter(new LngLat(0.021, 0.0915));
transform.setElevation(20000);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(11, 0, 11, 1024, 1023),
new OverscaledTileID(9, 0, 9, 256, 256),
new OverscaledTileID(12, 0, 12, 2047, 2046),
new OverscaledTileID(12, 0, 12, 2047, 2047),
]);
});
test('bearing 180', () => {
const options = {
minzoom: 1,
maxzoom: 15,
tileSize: 512,
reparseOverscaled: true
};
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setZoom(11);
transform.setPitch(70);
transform.setBearing(180);
transform.setCenter(new LngLat(0.021, 0.0915));
transform.setElevation(20000);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(11, 0, 11, 1023, 1023),
new OverscaledTileID(8, 0, 8, 128, 128),
new OverscaledTileID(8, 0, 8, 127, 128),
new OverscaledTileID(12, 0, 12, 2048, 2046),
new OverscaledTileID(12, 0, 12, 2048, 2047),
]);
});
test('bearing 270', () => {
const options = {
minzoom: 1,
maxzoom: 15,
tileSize: 512,
reparseOverscaled: true
};
const transform = new GlobeTransform();
transform.resize(128, 128);
transform.setZoom(11);
transform.setPitch(70);
transform.setBearing(270);
transform.setCenter(new LngLat(0.021, 0.0915));
transform.setElevation(20000);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(10, 0, 10, 511, 511),
new OverscaledTileID(9, 0, 9, 255, 256),
new OverscaledTileID(12, 0, 12, 2048, 2046),
new OverscaledTileID(12, 0, 12, 2048, 2047),
]);
});
});
});
});
describe('mercator', () => {
const options = {
minzoom: 1,
maxzoom: 10,
tileSize: 512
};
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
transform.resize(200, 200);
test('general', () => {
// make slightly off center so that sort order is not subject to precision issues
transform.setCenter(new LngLat(-0.01, 0.01));
transform.setZoom(0);
expect(coveringTiles(transform, options)).toEqual([]);
transform.setZoom(1);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(1, 0, 1, 0, 0),
new OverscaledTileID(1, 0, 1, 1, 0),
new OverscaledTileID(1, 0, 1, 0, 1),
new OverscaledTileID(1, 0, 1, 1, 1)]);
transform.setZoom(2.4);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(2, 0, 2, 1, 1),
new OverscaledTileID(2, 0, 2, 2, 1),
new OverscaledTileID(2, 0, 2, 1, 2),
new OverscaledTileID(2, 0, 2, 2, 2)]);
transform.setZoom(10);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(10, 0, 10, 511, 511),
new OverscaledTileID(10, 0, 10, 512, 511),
new OverscaledTileID(10, 0, 10, 511, 512),
new OverscaledTileID(10, 0, 10, 512, 512)]);
transform.setZoom(11);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(10, 0, 10, 511, 511),
new OverscaledTileID(10, 0, 10, 512, 511),
new OverscaledTileID(10, 0, 10, 511, 512),
new OverscaledTileID(10, 0, 10, 512, 512)]);
transform.resize(2048, 128);
transform.setZoom(9);
transform.setPadding({top: 16});
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(9, 0, 9, 255, 255),
new OverscaledTileID(9, 0, 9, 256, 255),
new OverscaledTileID(9, 0, 9, 255, 256),
new OverscaledTileID(9, 0, 9, 256, 256),
new OverscaledTileID(9, 0, 9, 254, 255),
new OverscaledTileID(9, 0, 9, 254, 256),
new OverscaledTileID(9, 0, 9, 257, 255),
new OverscaledTileID(9, 0, 9, 257, 256),
new OverscaledTileID(9, 0, 9, 253, 255),
new OverscaledTileID(9, 0, 9, 253, 256)]);
transform.setPadding({top: 0});
transform.setZoom(5.1);
transform.setPitch(60.0);
transform.setBearing(32.0);
transform.setCenter(new LngLat(56.90, 48.20));
transform.resize(1024, 768);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(5, 0, 5, 21, 11),
new OverscaledTileID(5, 0, 5, 20, 11),
new OverscaledTileID(5, 0, 5, 21, 10),
new OverscaledTileID(5, 0, 5, 20, 10),
new OverscaledTileID(5, 0, 5, 21, 12),
new OverscaledTileID(5, 0, 5, 22, 11),
new OverscaledTileID(5, 0, 5, 20, 12),
new OverscaledTileID(5, 0, 5, 22, 10),
new OverscaledTileID(5, 0, 5, 21, 9),
new OverscaledTileID(5, 0, 5, 20, 9),
new OverscaledTileID(5, 0, 5, 22, 9),
new OverscaledTileID(5, 0, 5, 23, 10),
new OverscaledTileID(5, 0, 5, 21, 8),
new OverscaledTileID(5, 0, 5, 20, 8),
new OverscaledTileID(5, 0, 5, 23, 9),
new OverscaledTileID(5, 0, 5, 22, 8),
new OverscaledTileID(5, 0, 5, 23, 8),
new OverscaledTileID(5, 0, 5, 21, 7),
new OverscaledTileID(5, 0, 5, 20, 7),
new OverscaledTileID(5, 0, 5, 24, 9),
new OverscaledTileID(5, 0, 5, 22, 7)
]);
transform.setZoom(8);
transform.setPitch(85.0);
transform.setBearing(0.0);
transform.setCenter(new LngLat(20.918, 39.232));
transform.resize(50, 1000);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(8, 0, 8, 142, 98),
new OverscaledTileID(7, 0, 7, 71, 48),
new OverscaledTileID(5, 0, 5, 17, 11),
new OverscaledTileID(5, 0, 5, 17, 10),
new OverscaledTileID(9, 0, 9, 285, 198),
new OverscaledTileID(9, 0, 9, 285, 199)
]);
transform.setZoom(8);
transform.setPitch(60);
transform.setBearing(45.0);
transform.setCenter(new LngLat(25.02, 60.15));
transform.resize(300, 50);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(8, 0, 8, 145, 74),
new OverscaledTileID(8, 0, 8, 145, 73),
new OverscaledTileID(8, 0, 8, 146, 74)
]);
transform.resize(50, 300);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(8, 0, 8, 145, 74),
new OverscaledTileID(8, 0, 8, 145, 73),
new OverscaledTileID(8, 0, 8, 146, 74),
new OverscaledTileID(8, 0, 8, 146, 73)
]);
const optionsWithCustomTileLoading = {
minzoom: 1,
maxzoom: 10,
tileSize: 512,
calculateTileZoom: (_requestedCenterZoom: number,
_distanceToTile2D: number,
_distanceToTileZ: number,
_distanceToCenter3D: number,
_cameraVerticalFOV: number) => { return 7; }
};
transform.resize(50, 300);
transform.setPitch(70);
expect(coveringTiles(transform, optionsWithCustomTileLoading)).toEqual([
new OverscaledTileID(7, 0, 7, 74, 36),
new OverscaledTileID(7, 0, 7, 73, 37),
new OverscaledTileID(7, 0, 7, 74, 35),
new OverscaledTileID(7, 0, 7, 73, 36),
new OverscaledTileID(7, 0, 7, 72, 37),
new OverscaledTileID(7, 0, 7, 73, 35),
new OverscaledTileID(7, 0, 7, 72, 36)
]);
});
test('calculates tile coverage with low number of zoom levels and low tile count', () => {
const optionsWithTileLodParams = {
minzoom: 1,
maxzoom: 10,
tileSize: 512,
calculateTileZoom: createCalculateTileZoomFunction(1.0, 1.0)
};
expect(coveringTiles(transform, optionsWithTileLodParams)).toEqual([
new OverscaledTileID(5, 0, 5, 18, 9),
new OverscaledTileID(5, 0, 5, 18, 8)
]);
});
test('calculates tile coverage with low tile count', () => {
const optionsWithTileLodParams = {
minzoom: 1,
maxzoom: 10,
tileSize: 512,
calculateTileZoom: createCalculateTileZoomFunction(1.0, 10.0)
};
expect(coveringTiles(transform, optionsWithTileLodParams)).toEqual([
new OverscaledTileID(6, 0, 6, 37, 18),
new OverscaledTileID(6, 0, 6, 37, 17),
new OverscaledTileID(6, 0, 6, 36, 18),
new OverscaledTileID(6, 0, 6, 36, 17)
]);
});
test('calculates tile coverage with low number of zoom levels', () => {
const optionsWithTileLodParams = {
minzoom: 1,
maxzoom: 10,
tileSize: 512,
calculateTileZoom: createCalculateTileZoomFunction(10.0, 1.0)
};
expect(coveringTiles(transform, optionsWithTileLodParams)).toEqual([
new OverscaledTileID(7, 0, 7, 73, 37),
new OverscaledTileID(7, 0, 7, 73, 36),
new OverscaledTileID(7, 0, 7, 72, 36),
new OverscaledTileID(6, 0, 6, 37, 18),
new OverscaledTileID(5, 0, 5, 18, 8),
new OverscaledTileID(9, 0, 9, 290, 148),
new OverscaledTileID(9, 0, 9, 291, 148)
]);
});
test('calculates tile coverage at w > 0', () => {
transform.setZoom(2);
transform.setPitch(0);
transform.setBearing(0);
transform.resize(300, 300);
transform.setCenter(new LngLat(630.01, 0.01));
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(2, 2, 2, 1, 1),
new OverscaledTileID(2, 2, 2, 1, 2),
new OverscaledTileID(2, 2, 2, 0, 1),
new OverscaledTileID(2, 2, 2, 0, 2)
]);
});
test('calculates tile coverage at w = -1', () => {
transform.setCenter(new LngLat(-360.01, 0.01));
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(2, -1, 2, 1, 1),
new OverscaledTileID(2, -1, 2, 1, 2),
new OverscaledTileID(2, -1, 2, 2, 1),
new OverscaledTileID(2, -1, 2, 2, 2)
]);
});
test('calculates tile coverage across meridian', () => {
transform.setZoom(1);
transform.setCenter(new LngLat(-180.01, 0.01));
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(1, 0, 1, 0, 0),
new OverscaledTileID(1, 0, 1, 0, 1),
new OverscaledTileID(1, -1, 1, 1, 0),
new OverscaledTileID(1, -1, 1, 1, 1)
]);
});
test('only includes tiles for a single world, if renderWorldCopies is set to false', () => {
transform.setZoom(1);
transform.setCenter(new LngLat(-180.01, 0.01));
transform.setRenderWorldCopies(false);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(1, 0, 1, 0, 0),
new OverscaledTileID(1, 0, 1, 0, 1)
]);
});
test('overscaledZ', () => {
const options = {
minzoom: 1,
maxzoom: 10,
tileSize: 256,
reparseOverscaled: true
};
const transform = new MercatorTransform({minZoom: 0, maxZoom: 10, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
transform.resize(10, 400);
// make slightly off center so that sort order is not subject to precision issues
transform.setCenter(new LngLat(-0.01, 0.01));
transform.setPitch(85);
transform.setFov(10);
transform.setZoom(10);
const tiles = coveringTiles(transform, options);
for (const tile of tiles) {
expect(tile.overscaledZ).toBeGreaterThanOrEqual(tile.canonical.z);
}
});
test('maxzoom-0', () => {
const options = {
minzoom: 0,
maxzoom: 0,
tileSize: 512
};
const transform = new MercatorTransform({minZoom: 0, maxZoom: 0, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.resize(200, 200);
transform.setCenter(new LngLat(0.01, 0.01));
transform.setZoom(8);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(0, 0, 0, 0, 0)
]);
});
test('z11', () => {
const options = {
minzoom: 1,
maxzoom: 15,
tileSize: 512,
reparseOverscaled: true
};
const transform = new MercatorTransform({minZoom: 0, maxZoom: 15, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
transform.resize(128, 128);
transform.setZoom(11);
transform.setCenter(new LngLat(-179.73, -0.087));
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(11, 0, 11, 1, 1024)
]);
});
test('z11, mid lat', () => {
const options = {
minzoom: 1,
maxzoom: 15,
tileSize: 512,
reparseOverscaled: true
};
const transform = new MercatorTransform({minZoom: 0, maxZoom: 15, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
transform.resize(128, 128);
transform.setZoom(11);
transform.setCenter(new LngLat(-179.73, 60.02));
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(11, 0, 11, 1, 594)
]);
});
test('z11, high lat', () => {
const options = {
minzoom: 1,
maxzoom: 15,
tileSize: 512,
reparseOverscaled: true
};
const transform = new MercatorTransform({minZoom: 0, maxZoom: 15, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
transform.resize(128, 128);
transform.setZoom(11);
transform.setCenter(new LngLat(-179.73, 85.028));
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(11, 0, 11, 1, 1)
]);
});
test('z11, mid lat, mid lng', () => {
const options = {
minzoom: 1,
maxzoom: 15,
tileSize: 512,
reparseOverscaled: true
};
const transform = new MercatorTransform({minZoom: 0, maxZoom: 15, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
transform.resize(128, 128);
transform.setZoom(11);
transform.setCenter(new LngLat(-58.97, 60.02));
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(11, 0, 11, 688, 594)
]);
});
test('z11, low lat, mid lng', () => {
const options = {
minzoom: 1,
maxzoom: 15,
tileSize: 512,
reparseOverscaled: true
};
const transform = new MercatorTransform({minZoom: 0, maxZoom: 15, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
transform.resize(128, 128);
transform.setZoom(11);
transform.setCenter(new LngLat(-58.97, -0.087));
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(11, 0, 11, 688, 1024)
]);
});
test('nonzero center elevation', () => {
const options = {
minzoom: 1,
maxzoom: 15,
tileSize: 512,
reparseOverscaled: true
};
const transform = new MercatorTransform({minZoom: 0, maxZoom: 15, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
transform.resize(128, 128);
transform.setZoom(11);
transform.setCenter(new LngLat(0.03, 0.0915));
transform.setElevation(20000);
expect(coveringTiles(transform, options)).toEqual([
new OverscaledTileID(11, 0, 11, 1024, 1023),
new OverscaledTileID(11, 0, 11, 1023, 1023)
]);
});
});
});
describe('coveringZoomLevel', () => {
let transform: MercatorTransform;
let options: CoveringTilesOptions;
beforeEach(() => {
transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
options = {
tileSize: 512,
roundZoom: false,
};
});
test('zoom 0', () => {
transform.setZoom(0);
expect(coveringZoomLevel(transform, options)).toBe(0);
});
test('small zoom should be floored to 0', () => {
transform.setZoom(0.1);
expect(coveringZoomLevel(transform, options)).toBe(0);
});
test('zoom 2.7 should be floored to 2', () => {
transform.setZoom(2.7);
expect(coveringZoomLevel(transform, options)).toBe(2);
});
test('zoom 0 for small tile size', () => {
options.tileSize = 256;
transform.setZoom(0);
expect(coveringZoomLevel(transform, options)).toBe(1);
});
test('zoom 0.1 for small tile size', () => {
options.tileSize = 256;
transform.setZoom(0.1);
expect(coveringZoomLevel(transform, options)).toBe(1);
});
test('zoom 1 for small tile size', () => {
options.tileSize = 256;
transform.setZoom(1);
expect(coveringZoomLevel(transform, options)).toBe(2);
});
test('zoom 2.4 for small tile size', () => {
options.tileSize = 256;
transform.setZoom(2.4);
expect(coveringZoomLevel(transform, options)).toBe(3);
});
test('zoom 11.5 with rounded setting and small tile size', () => {
options.tileSize = 256;
options.roundZoom = true;
transform.setZoom(11.5);
expect(coveringZoomLevel(transform, options)).toBe(13);
});
});
+291
View File
@@ -0,0 +1,291 @@
import {OverscaledTileID} from '../../tile/tile_id';
import {vec2, type vec4} from 'gl-matrix';
import {MercatorCoordinate} from '../mercator_coordinate';
import {degreesToRadians, scaleZoom} from '../../util/util';
import type {IReadonlyTransform} from '../transform_interface';
import type {Terrain} from '../../render/terrain';
import type {Frustum} from '../../util/primitives/frustum';
import {maxMercatorHorizonAngle} from './mercator_utils';
import {type IBoundingVolume, IntersectionResult} from '../../util/primitives/bounding_volume';
type CoveringTilesResult = {
tileID: OverscaledTileID;
distanceSq: number;
tileDistanceToCamera: number;
};
type CoveringTilesStackEntry = {
zoom: number;
x: number;
y: number;
wrap: number;
fullyVisible: boolean;
};
export type CoveringTilesOptions = {
/**
* Smallest allowed tile zoom.
*/
minzoom?: number;
/**
* Largest allowed tile zoom.
*/
maxzoom?: number;
/**
* Whether to round or floor the target zoom level. If true, the value will be rounded to the closest integer. Otherwise the value will be floored.
*/
roundZoom?: boolean;
/**
* Tile size, expressed in screen pixels.
*/
tileSize: number;
};
export type CoveringTilesOptionsInternal = CoveringTilesOptions & {
/**
* `true` if tiles should be sent back to the worker for each overzoomed zoom level, `false` if not.
* Fill this option when computing covering tiles for a source.
* When true, any tile at `maxzoom` level that should be overscaled to a greater zoom will have
* its zoom set to the overscaled greater zoom. When false, such tiles will have zoom set to `maxzoom`.
*/
reparseOverscaled?: boolean;
/**
* When terrain is present, tile visibility will be computed in regards to the min and max elevations for each tile.
*/
terrain?: Terrain;
/**
* Optional function to redefine how tiles are loaded at high pitch angles.
*/
calculateTileZoom?: CalculateTileZoomFunction;
};
/**
* Function to define how tiles are loaded at high pitch angles
* @param requestedCenterZoom - the requested zoom level, valid at the center point.
* @param distanceToTile2D - 2D distance from the camera to the candidate tile, in mercator units.
* @param distanceToTileZ - vertical distance from the camera to the candidate tile, in mercator units.
* @param distanceToCenter3D - distance from camera to center point, in mercator units
* @param cameraVerticalFOV - camera vertical field of view, in degrees
* @return the desired zoom level for this tile. May not be an integer.
*/
export type CalculateTileZoomFunction = (requestedCenterZoom: number,
distanceToTile2D: number,
distanceToTileZ: number,
distanceToCenter3D: number,
cameraVerticalFOV: number) => number;
/**
* A simple/heuristic function that returns whether the tile is visible under the current transform.
* @returns an {@link IntersectionResult}.
*/
export function isTileVisible(frustum: Frustum, tileBoundingVolume: IBoundingVolume, plane?: vec4): IntersectionResult {
const frustumTest = tileBoundingVolume.intersectsFrustum(frustum);
if (!plane || frustumTest === IntersectionResult.None) {
return frustumTest;
}
const planeTest = tileBoundingVolume.intersectsPlane(plane);
if (planeTest === IntersectionResult.None) {
return IntersectionResult.None;
}
if (frustumTest === IntersectionResult.Full && planeTest === IntersectionResult.Full) {
return IntersectionResult.Full;
}
return IntersectionResult.Partial;
}
/**
* Definite integral of cos(x)^p. The analytical solution is described in `developer-guides/covering-tiles.md`,
* but here the integral is evaluated numerically.
* @param p - the power to raise cos(x) to inside the integral
* @param x1 - the starting point of the integral.
* @param x2 - the ending point of the integral.
* @return the integral of cos(x)^p from x=x1 to x=x2
*/
function integralOfCosXByP(p: number, x1: number, x2: number): number {
const numPoints = 10;
let sum = 0;
const dx = (x2 - x1 ) / numPoints;
// Midpoint integration
for( let i = 0; i < numPoints; i++)
{
const x = x1 + (i + 0.5)/numPoints * (x2 - x1);
sum += dx * Math.pow(Math.cos(x), p);
}
return sum;
}
export function createCalculateTileZoomFunction(maxZoomLevelsOnScreen: number, tileCountMaxMinRatio: number): CalculateTileZoomFunction {
return function (requestedCenterZoom: number,
distanceToTile2D: number,
distanceToTileZ: number,
distanceToCenter3D: number,
cameraVerticalFOV: number): number {
/**
* Controls how tiles are loaded at high pitch angles. Higher numbers cause fewer, lower resolution
* tiles to be loaded. Calculate the value that will result in the selected number of zoom levels in
* the worst-case condition (when the horizon is at the top of the screen). For more information, see
* `developer-guides/covering-tiles.md`
*/
const pitchTileLoadingBehavior = 2 * ((maxZoomLevelsOnScreen - 1) /
scaleZoom(Math.cos(degreesToRadians(maxMercatorHorizonAngle - cameraVerticalFOV)) /
Math.cos(degreesToRadians(maxMercatorHorizonAngle))) - 1);
const centerPitch = Math.acos(distanceToTileZ / distanceToCenter3D);
const tileCountPitch0 = 2 * integralOfCosXByP(pitchTileLoadingBehavior - 1, 0, degreesToRadians(cameraVerticalFOV / 2));
const highestPitch = Math.min(degreesToRadians(maxMercatorHorizonAngle), centerPitch + degreesToRadians(cameraVerticalFOV / 2));
const lowestPitch = Math.min(highestPitch, centerPitch - degreesToRadians(cameraVerticalFOV / 2));
const tileCount = integralOfCosXByP(pitchTileLoadingBehavior - 1, lowestPitch, highestPitch);
const thisTilePitch = Math.atan(distanceToTile2D / distanceToTileZ);
const distanceToTile3D = Math.hypot(distanceToTile2D, distanceToTileZ);
let thisTileDesiredZ = requestedCenterZoom;
// if distance to candidate tile is a tiny bit farther than distance to center,
// use the same zoom as the center. This is achieved by the scaling distance ratio by cos(fov/2)
thisTileDesiredZ = thisTileDesiredZ + scaleZoom(distanceToCenter3D / distanceToTile3D / Math.max(0.5, Math.cos(degreesToRadians(cameraVerticalFOV / 2))));
thisTileDesiredZ += pitchTileLoadingBehavior * scaleZoom(Math.cos(thisTilePitch)) / 2;
thisTileDesiredZ -= scaleZoom(Math.max(1, tileCount / tileCountPitch0 / tileCountMaxMinRatio)) / 2;
return thisTileDesiredZ;
};
}
const defaultMaxZoomLevelsOnScreen = 9.314;
const defaultTileCountMaxMinRatio = 3.0;
const defaultCalculateTileZoom = createCalculateTileZoomFunction(defaultMaxZoomLevelsOnScreen, defaultTileCountMaxMinRatio);
/**
* Return what zoom level of a tile source would most closely cover the tiles displayed by this transform.
* @param options - The options, most importantly the source's tile size.
* @returns An integer zoom level at which all tiles will be visible.
*/
export function coveringZoomLevel(transform: IReadonlyTransform, options: CoveringTilesOptions): number {
const z = (options.roundZoom ? Math.round : Math.floor)(
transform.zoom + scaleZoom(transform.tileSize / options.tileSize)
);
// At negative zoom levels load tiles from z0 because negative tile zoom levels don't exist.
return Math.max(0, z);
}
/**
* Returns a list of tiles that optimally covers the screen. Adapted for globe projection.
* Correctly handles LOD when moving over the antimeridian.
* @param transform - The transform instance.
* @param frustum - The covering frustum.
* @param plane - The clipping plane used by globe transform, or null.
* @param cameraCoord - The x, y, z position of the camera in MercatorCoordinates.
* @param centerCoord - The x, y, z position of the center point in MercatorCoordinates.
* @param options - Additional coveringTiles options.
* @param details - Interface to define required helper functions.
* @returns A list of tile coordinates, ordered by ascending distance from camera.
*/
export function coveringTiles(transform: IReadonlyTransform, options: CoveringTilesOptionsInternal): OverscaledTileID[] {
const frustum = transform.getCameraFrustum();
const plane = transform.getClippingPlane();
const cameraCoord = transform.screenPointToMercatorCoordinate(transform.getCameraPoint());
const centerCoord = MercatorCoordinate.fromLngLat(transform.center, transform.elevation);
cameraCoord.z = centerCoord.z + Math.cos(transform.pitchInRadians) * transform.cameraToCenterDistance / transform.worldSize;
const detailsProvider = transform.getCoveringTilesDetailsProvider();
const allowVariableZoom = detailsProvider.allowVariableZoom(transform, options);
const desiredZ = coveringZoomLevel(transform, options);
const minZoom = options.minzoom || 0;
const maxZoom = options.maxzoom !== undefined ? options.maxzoom : transform.maxZoom;
const nominalZ = Math.min(Math.max(0, desiredZ), maxZoom);
const numTiles = Math.pow(2, nominalZ);
const cameraPoint = [numTiles * cameraCoord.x, numTiles * cameraCoord.y, 0];
const centerPoint = [numTiles * centerCoord.x, numTiles * centerCoord.y, 0];
const distanceToCenter2d = Math.hypot(centerCoord.x - cameraCoord.x, centerCoord.y - cameraCoord.y);
const distanceZ = Math.abs(centerCoord.z - cameraCoord.z);
const distanceToCenter3d = Math.hypot(distanceToCenter2d, distanceZ);
const newRootTile = (wrap: number): CoveringTilesStackEntry => {
return {
zoom: 0,
x: 0,
y: 0,
wrap,
fullyVisible: false
};
};
// Do a depth-first traversal to find visible tiles and proper levels of detail
const stack: Array<CoveringTilesStackEntry> = [];
const result: Array<CoveringTilesResult> = [];
if (transform.renderWorldCopies && detailsProvider.allowWorldCopies()) {
// Render copy of the globe thrice on both sides
for (let i = 1; i <= 3; i++) {
stack.push(newRootTile(-i));
stack.push(newRootTile(i));
}
}
stack.push(newRootTile(0));
while (stack.length > 0) {
const it = stack.pop();
const x = it.x;
const y = it.y;
let fullyVisible = it.fullyVisible;
const tileID = {x, y, z: it.zoom};
const boundingVolume = detailsProvider.getTileBoundingVolume(tileID, it.wrap, transform.elevation, options);
// Visibility of a tile is not required if any of its ancestor is fully visible
if (!fullyVisible) {
const intersectResult = isTileVisible(frustum, boundingVolume, plane);
if (intersectResult === IntersectionResult.None)
continue;
fullyVisible = intersectResult === IntersectionResult.Full;
}
const distToTile2d = detailsProvider.distanceToTile2d(cameraCoord.x, cameraCoord.y, tileID, boundingVolume);
let thisTileDesiredZ = desiredZ;
if (allowVariableZoom) {
const tileZoomFunc = options.calculateTileZoom || defaultCalculateTileZoom;
thisTileDesiredZ = tileZoomFunc(transform.zoom + scaleZoom(transform.tileSize / options.tileSize),
distToTile2d,
distanceZ,
distanceToCenter3d,
transform.fov);
}
thisTileDesiredZ = (options.roundZoom ? Math.round : Math.floor)(thisTileDesiredZ);
thisTileDesiredZ = Math.max(0, thisTileDesiredZ);
const z = Math.min(thisTileDesiredZ, maxZoom);
// We need to compute a valid wrap value for the tile to keep globe compatibility with mercator
it.wrap = detailsProvider.getWrap(centerCoord, tileID, it.wrap);
// Have we reached the target depth?
if (it.zoom >= z) {
if (it.zoom < minZoom) {
continue;
}
const dz = nominalZ - it.zoom;
const dx = cameraPoint[0] - 0.5 - (x << dz);
const dy = cameraPoint[1] - 0.5 - (y << dz);
const overscaledZ = options.reparseOverscaled ? Math.max(it.zoom, thisTileDesiredZ) : it.zoom;
result.push({
tileID: new OverscaledTileID(it.zoom === maxZoom ? overscaledZ : it.zoom, it.wrap, it.zoom, x, y),
distanceSq: vec2.sqrLen([centerPoint[0] - 0.5 - x, centerPoint[1] - 0.5 - y]),
// this variable is currently not used, but may be important to reduce the amount of loaded tiles
tileDistanceToCamera: Math.sqrt(dx * dx + dy * dy)
});
continue;
}
for (let i = 0; i < 4; i++) {
const childX = (x << 1) + (i % 2);
const childY = (y << 1) + (i >> 1);
const childZ = it.zoom + 1;
stack.push({zoom: childZ, x: childX, y: childY, wrap: it.wrap, fullyVisible});
}
}
return result.sort((a, b) => a.distanceSq - b.distanceSq).map(a => a.tileID);
}
@@ -0,0 +1,44 @@
import {type IBoundingVolume} from '../../util/primitives/bounding_volume';
import {type MercatorCoordinate} from '../mercator_coordinate';
import {type IReadonlyTransform} from '../transform_interface';
import {type CoveringTilesOptionsInternal} from './covering_tiles';
export interface CoveringTilesDetailsProvider {
/**
* Returns the distance from the point to the tile
* @param pointX - point x.
* @param pointY - point y.
* @param tileID - Tile x, y and z for zoom.
* @param boundingVolume - tile bounding volume
*/
distanceToTile2d: (pointX: number, pointY: number, tileID: {x: number; y: number; z: number}, boundingVolume: IBoundingVolume) => number;
/**
* Returns the wrap value for a given tile.
*/
getWrap: (centerCoord: MercatorCoordinate, tileID: {x:number; y: number; z: number}, parentWrap: number) => number;
/**
* Returns the bounding volume of the specified tile.
* @param tileID - Tile x, y and z for zoom.
* @param wrap - wrap number of the tile.
* @param elevation - camera center point elevation.
* @param options - CoveringTilesOptions.
*/
getTileBoundingVolume: (tileID: {x: number; y: number; z: number}, wrap: number, elevation: number, options: CoveringTilesOptionsInternal) => IBoundingVolume;
/**
* Whether to allow variable zoom, which is used at high pitch angle to avoid loading an excessive amount of tiles.
*/
allowVariableZoom: (transform: IReadonlyTransform, options: CoveringTilesOptionsInternal) => boolean;
/**
* Whether to allow world copies to be rendered.
*/
allowWorldCopies: () => boolean;
/**
* Prepare cache for the next frame.
*/
prepareNextFrame(): void;
}
+66
View File
@@ -0,0 +1,66 @@
import {MercatorCameraHelper} from './mercator_camera_helper';
import {VerticalPerspectiveCameraHelper} from './vertical_perspective_camera_helper';
import type Point from '@mapbox/point-geometry';
import type {CameraForBoxAndBearingHandlerResult, EaseToHandlerResult, EaseToHandlerOptions, FlyToHandlerResult, FlyToHandlerOptions, ICameraHelper, MapControlsDeltas} from './camera_helper';
import type {LngLat, LngLatLike} from '../lng_lat';
import type {IReadonlyTransform, ITransform} from '../transform_interface';
import type {GlobeProjection} from './globe_projection';
import type {CameraForBoundsOptions} from '../../ui/camera';
import type {LngLatBounds} from '../lng_lat_bounds';
import type {PaddingOptions} from '../edge_insets';
/**
* @internal
*/
export class GlobeCameraHelper implements ICameraHelper {
private _globe: GlobeProjection;
private _mercatorCameraHelper: MercatorCameraHelper;
private _verticalPerspectiveCameraHelper: VerticalPerspectiveCameraHelper;
constructor(globe: GlobeProjection) {
this._globe = globe;
this._mercatorCameraHelper = new MercatorCameraHelper();
this._verticalPerspectiveCameraHelper = new VerticalPerspectiveCameraHelper();
}
get useGlobeControls(): boolean { return this._globe.useGlobeRendering; }
get currentHelper(): ICameraHelper {
return this.useGlobeControls ? this._verticalPerspectiveCameraHelper : this._mercatorCameraHelper;
}
handlePanInertia(pan: Point, transform: IReadonlyTransform): {
easingCenter: LngLat;
easingOffset: Point;
} {
return this.currentHelper.handlePanInertia(pan, transform);
}
handleMapControlsRollPitchBearingZoom(deltas: MapControlsDeltas, tr: ITransform): void {
return this.currentHelper.handleMapControlsRollPitchBearingZoom(deltas, tr);
}
handleMapControlsPan(deltas: MapControlsDeltas, tr: ITransform, preZoomAroundLoc: LngLat): void {
this.currentHelper.handleMapControlsPan(deltas, tr, preZoomAroundLoc);
}
cameraForBoxAndBearing(options: CameraForBoundsOptions, padding: PaddingOptions, bounds: LngLatBounds, bearing: number, tr: ITransform): CameraForBoxAndBearingHandlerResult {
return this.currentHelper.cameraForBoxAndBearing(options, padding, bounds, bearing, tr);
}
/**
* Handles the zoom and center change during camera jumpTo.
*/
handleJumpToCenterZoom(tr: ITransform, options: { zoom?: number; center?: LngLatLike }): void {
this.currentHelper.handleJumpToCenterZoom(tr, options);
}
handleEaseTo(tr: ITransform, options: EaseToHandlerOptions): EaseToHandlerResult {
return this.currentHelper.handleEaseTo(tr, options);
}
handleFlyTo(tr: ITransform, options: FlyToHandlerOptions): FlyToHandlerResult {
return this.currentHelper.handleFlyTo(tr, options);
}
}
@@ -0,0 +1,105 @@
import {describe, expect, test} from 'vitest';
import {expectToBeCloseToArray} from '../../util/test/util';
import {GlobeCoveringTilesDetailsProvider} from './globe_covering_tiles_details_provider';
import {ConvexVolume} from '../../util/primitives/convex_volume';
describe('bounding volume creation', () => {
test('z=0', () => {
const detailsProvider = new GlobeCoveringTilesDetailsProvider();
const convex = detailsProvider.getTileBoundingVolume({
x: 0,
y: 0,
z: 0,
}, null, null, null);
expect(convex).toEqual(ConvexVolume.fromAabb(
[-1, -1, -1],
[1, 1, 1],
));
});
test('z=1,x=0', () => {
const detailsProvider = new GlobeCoveringTilesDetailsProvider();
const convex = detailsProvider.getTileBoundingVolume({
x: 0,
y: 0,
z: 1,
}, null, null, null);
expect(convex).toEqual(ConvexVolume.fromAabb(
[-1, 0, -1],
[0, 1, 1],
));
});
test('z=1,x=1', () => {
const detailsProvider = new GlobeCoveringTilesDetailsProvider();
const convex = detailsProvider.getTileBoundingVolume({
x: 1,
y: 0,
z: 1,
}, null, null, null);
expect(convex).toEqual(ConvexVolume.fromAabb(
[0, 0, -1],
[1, 1, 1],
));
});
test('z=5,x=1,y=1', () => {
const detailsProvider = new GlobeCoveringTilesDetailsProvider();
const convex = detailsProvider.getTileBoundingVolume({
x: 1,
y: 1,
z: 5,
}, null, null, null);
const precision = 10;
const expectedMin = [-0.04878262717137475, 0.9918417649235776, -0.1250257487589308];
const expectedMax = [-0.020462724105427713, 0.9944839919477184, -0.09690430455523656];
const expectedPoints = [
[-0.040144275638466294, 0.9946001124628003, -0.09691685469802916],
[-0.04013795776704037, 0.9944589865528525, -0.09690160200714736],
[-0.02046537424682884, 0.9946001124628002, -0.10288638417221826],
[-0.020462153423906553, 0.9944589865528524, -0.10287019200194392],
[-0.04902182691658952, 0.9919123845540323, -0.11834915939433684],
[-0.049015509045163594, 0.9917712586440846, -0.11833390670345505],
[-0.02499111064168652, 0.9919123845540323, -0.1256387974810376],
[-0.02498788981876423, 0.9917712586440844, -0.12562260531076325]
];
const expectedPlanes = [
[0.033568258567807485, -0.9932912960221243, 0.11065971834147033, 1],
[
-0.033568258567807485,
0.9932912960221243,
-0.11065971834147033,
-0.999857920923587
],
[
-0.2883372432854479,
-0.11563909912606864,
-0.9505205062952928,
0.011318113428480242
],
[
0.2883372432854479,
0.11563909912606864,
0.9505205062952928,
0.011924266779254289
],
[
0.9238795325112867,
-3.8143839245115144e-17,
-0.38268343236509017,
0
],
[-0.9807852804032307, 0, 0.19509032201612764, 0]
];
expectToBeCloseToArray([...convex.min], expectedMin, precision);
expectToBeCloseToArray([...convex.max], expectedMax, precision);
expect(convex.points).toHaveLength(expectedPoints.length);
for (let i = 0; i < convex.points.length; i++) {
expectToBeCloseToArray([...convex.points[i]], expectedPoints[i], precision);
}
expect(convex.planes).toHaveLength(expectedPlanes.length);
for (let i = 0; i < convex.planes.length; i++) {
expectToBeCloseToArray([...convex.planes[i]], expectedPlanes[i], precision);
}
});
});
@@ -0,0 +1,311 @@
import {EXTENT} from '../../data/extent';
import {projectTileCoordinatesToSphere} from './globe_utils';
import {BoundingVolumeCache} from '../../util/primitives/bounding_volume_cache';
import {coveringZoomLevel, type CoveringTilesOptionsInternal} from './covering_tiles';
import {vec3, type vec4} from 'gl-matrix';
import type {IReadonlyTransform} from '../transform_interface';
import type {MercatorCoordinate} from '../mercator_coordinate';
import type {CoveringTilesDetailsProvider} from './covering_tiles_details_provider';
import {OverscaledTileID} from '../../tile/tile_id';
import {earthRadius} from '../lng_lat';
import {ConvexVolume} from '../../util/primitives/convex_volume';
import {threePlaneIntersection} from '../../util/util';
/**
* Computes distance of a point to a tile in an arbitrary axis.
* World is assumed to have size 1, distance returned is to the nearer tile edge.
* @param point - Point position.
* @param tile - Tile position.
* @param tileSize - Tile size.
*/
function distanceToTileSimple(point: number, tile: number, tileSize: number): number {
const delta = point - tile;
return (delta < 0) ? -delta : Math.max(0, delta - tileSize);
}
function distanceToTileWrapX(pointX: number, pointY: number, tileCornerX: number, tileCornerY: number, tileSize: number): number {
const tileCornerToPointX = pointX - tileCornerX;
let distanceX: number;
if (tileCornerToPointX < 0) {
// Point is left of tile
distanceX = Math.min(-tileCornerToPointX, 1.0 + tileCornerToPointX - tileSize);
} else if (tileCornerToPointX > tileSize) {
// Point is right of tile
distanceX = Math.min(Math.max(tileCornerToPointX - tileSize, 0), 1.0 - tileCornerToPointX);
} else {
// Point is inside tile in the X axis.
distanceX = 0;
}
return Math.max(distanceX, distanceToTileSimple(pointY, tileCornerY, tileSize));
}
export class GlobeCoveringTilesDetailsProvider implements CoveringTilesDetailsProvider {
private _boundingVolumeCache: BoundingVolumeCache<ConvexVolume> = new BoundingVolumeCache(this._computeTileBoundingVolume);
/**
* Prepares the internal bounding volume cache for the next frame.
*/
prepareNextFrame() {
this._boundingVolumeCache.swapBuffers();
}
/**
* Returns the distance of a point to a square tile. If the point is inside the tile, returns 0.
* Assumes the world to be of size 1.
* Handles distances on a sphere correctly: X is wrapped when crossing the antimeridian,
* when crossing the poles Y is mirrored and X is shifted by half world size.
*/
distanceToTile2d(pointX: number, pointY: number, tileID: {x: number; y: number; z: number}, _bv: ConvexVolume): number {
const scale = 1 << tileID.z;
const tileMercatorSize = 1.0 / scale;
const tileCornerX = tileID.x / scale; // In range 0..1
const tileCornerY = tileID.y / scale; // In range 0..1
const worldSize = 1.0;
const halfWorld = 0.5 * worldSize;
let smallestDistance = 2.0 * worldSize;
// Original tile
smallestDistance = Math.min(smallestDistance, distanceToTileWrapX(pointX, pointY, tileCornerX, tileCornerY, tileMercatorSize));
// Up
smallestDistance = Math.min(smallestDistance, distanceToTileWrapX(pointX, pointY, tileCornerX + halfWorld, -tileCornerY - tileMercatorSize, tileMercatorSize));
// Down
smallestDistance = Math.min(smallestDistance, distanceToTileWrapX(pointX, pointY, tileCornerX + halfWorld, worldSize + worldSize - tileCornerY - tileMercatorSize, tileMercatorSize));
return smallestDistance;
}
/**
* Returns the wrap value for a given tile, computed so that tiles will remain loaded when crossing the antimeridian.
*/
getWrap(centerCoord: MercatorCoordinate, tileID: {x: number; y: number; z: number}, _parentWrap: number): number {
const scale = 1 << tileID.z;
const tileMercatorSize = 1.0 / scale;
const tileX = tileID.x / scale; // In range 0..1
const distanceCurrent = distanceToTileSimple(centerCoord.x, tileX, tileMercatorSize);
const distanceLeft = distanceToTileSimple(centerCoord.x, tileX - 1.0, tileMercatorSize);
const distanceRight = distanceToTileSimple(centerCoord.x, tileX + 1.0, tileMercatorSize);
const distanceSmallest = Math.min(distanceCurrent, distanceLeft, distanceRight);
if (distanceSmallest === distanceRight) {
return 1;
}
if (distanceSmallest === distanceLeft) {
return -1;
}
return 0;
}
allowVariableZoom(transform: IReadonlyTransform, options: CoveringTilesOptionsInternal): boolean {
return coveringZoomLevel(transform, options) > 4;
}
allowWorldCopies(): boolean {
return false;
}
getTileBoundingVolume(tileID: { x: number; y: number; z: number }, wrap: number, elevation: number, options: CoveringTilesOptionsInternal) {
return this._boundingVolumeCache.getTileBoundingVolume(tileID, wrap, elevation, options);
}
private _computeTileBoundingVolume(tileID: {x: number; y: number; z: number}, wrap: number, elevation: number, options: CoveringTilesOptionsInternal): ConvexVolume {
let minElevation = 0;
let maxElevation = 0;
if (options?.terrain) {
const overscaledTileID = new OverscaledTileID(tileID.z, wrap, tileID.z, tileID.x, tileID.y);
const minMax = options.terrain.getMinMaxElevation(overscaledTileID);
minElevation = minMax.minElevation ?? Math.min(0, elevation);
maxElevation = minMax.maxElevation ?? Math.max(0, elevation);
}
// Convert elevation to distances from center of a unit sphere planet (so that 1 is surface)
minElevation /= earthRadius;
maxElevation /= earthRadius;
minElevation += 1;
maxElevation += 1;
if (tileID.z <= 0) {
// Tile covers the entire sphere.
return ConvexVolume.fromAabb( // We return an AABB in this case.
[-maxElevation, -maxElevation, -maxElevation],
[maxElevation, maxElevation, maxElevation]
);
} else if (tileID.z === 1) {
// Tile covers a quarter of the sphere.
// X is 1 at lng=E90°
// Y is 1 at **north** pole
// Z is 1 at null island
return ConvexVolume.fromAabb( // We also just use AABBs for this zoom level.
[tileID.x === 0 ? -maxElevation : 0, tileID.y === 0 ? 0 : -maxElevation, -maxElevation],
[tileID.x === 0 ? 0 : maxElevation, tileID.y === 0 ? maxElevation : 0, maxElevation]
);
} else {
const corners = [
projectTileCoordinatesToSphere(0, 0, tileID.x, tileID.y, tileID.z),
projectTileCoordinatesToSphere(EXTENT, 0, tileID.x, tileID.y, tileID.z),
projectTileCoordinatesToSphere(EXTENT, EXTENT, tileID.x, tileID.y, tileID.z),
projectTileCoordinatesToSphere(0, EXTENT, tileID.x, tileID.y, tileID.z),
];
const extremesPoints = [];
for (const c of corners) {
extremesPoints.push(vec3.scale([] as any, c, maxElevation));
}
if (maxElevation !== minElevation) {
// Only add additional points if terrain is enabled and is not flat.
for (const c of corners) {
extremesPoints.push(vec3.scale([] as any, c, minElevation));
}
}
// Special handling of poles - we need to extend the tile AABB
// to include the pole for tiles that border mercator north/south edge.
if (tileID.y === 0) {
extremesPoints.push([0, 1, 0]); // North pole
}
if (tileID.y === (1 << tileID.z) - 1) {
extremesPoints.push([0, -1, 0]); // South pole
}
// Compute a best-fit AABB for the frustum rejection test
const aabbMin: vec3 = [1, 1, 1];
const aabbMax: vec3 = [-1, -1, -1];
for (const c of extremesPoints) {
for (let i = 0; i < 3; i++) {
aabbMin[i] = Math.min(aabbMin[i], c[i]);
aabbMax[i] = Math.max(aabbMax[i], c[i]);
}
}
// Now we compute the actual bounding volume.
// The up/down plane will be normal to the tile's center.
// The north/south plane will be used for the tile's north and south edge and will be orthogonal to the up/down plane.
// The left and right planes will be determined by the tile's east/west edges and will differ slightly - we are not creating a box!
// We will find the min and max extents for the up/down and north/south planes using the set of points
// where the extremes are likely to lie.
// Vector "center" (from planet center to tile center) will be our up/down axis.
const center = projectTileCoordinatesToSphere(EXTENT / 2, EXTENT / 2, tileID.x, tileID.y, tileID.z);
// Vector to the east of "center".
const centerEast = vec3.cross([] as any, [0, 1, 0], center);
vec3.normalize(centerEast, centerEast);
// Vector to the north of "center" will be our north/south axis.
const north = vec3.cross([] as any, center, centerEast);
vec3.normalize(north, north);
// Axes for the east and west edge of our bounding volume.
// These axes are NOT opposites of each other, they differ!
// They are also not orthogonal to the up/down and north/south axes.
const axisEast = vec3.cross([] as any, corners[2], corners[1]);
vec3.normalize(axisEast, axisEast);
const axisWest = vec3.cross([] as any, corners[0], corners[3]);
vec3.normalize(axisWest, axisWest);
// Now we will expand the extremes point set for bounding volume creation.
// We will also include the tile center point, since it will always be an extreme for the "center" axis.
extremesPoints.push(vec3.scale([] as any, center, maxElevation));
// No need to include a minElevation-scaled center, since we already have minElevation corners in the set and these will always lie lower than the center.
// The extremes might also lie on the midpoint of the north or south edge.
// For tiles in the north hemisphere, only the south edge can contain an extreme,
// since when we imagine the tile's actual shape projected onto the plane normal to "center" vector,
// the tile's north edge will curve towards the tile center, thus its extremes are accounted for by the
// corners, however the south edge will curve away from the center point, extending beyond the tile's edges,
// thus it must be included.
// The poles are an exception - they must always be included in the extremes, if the tile touches the north/south mercator range edge.
//
// A tile's exaggerated shape on the northern hemisphere, projected onto the normal plane of "center".
// The "c" is the tile's center point. The "m" is the edge mid point we are looking for.
//
// /-- --\
// / ------- \
// / \
// / c \
// / \
// /-- --\
// ----- -----
// ---m---
if (tileID.y >= (1 << tileID.z) / 2) {
// South hemisphere - include the tile's north edge midpoint
extremesPoints.push(vec3.scale([] as any, projectTileCoordinatesToSphere(EXTENT / 2, 0, tileID.x, tileID.y, tileID.z), maxElevation));
// No need to include minElevation variant of this point, for the same reason why we don't include minElevation center.
}
if (tileID.y < (1 << tileID.z) / 2) {
// North hemisphere - include the tile's south edge midpoint
extremesPoints.push(vec3.scale([] as any, projectTileCoordinatesToSphere(EXTENT / 2, EXTENT, tileID.x, tileID.y, tileID.z), maxElevation));
// No need to include minElevation variant of this point, for the same reason why we don't include minElevation center.
}
// Find the min and max extends and the midpoints along each axis,
// using the set of extreme points.
const upDownMinMax = findAxisMinMax(center, extremesPoints);
const northSouthMinMax = findAxisMinMax(north, extremesPoints);
const planeUp = [-center[0], -center[1], -center[2], upDownMinMax.max] as vec4;
const planeDown = [center[0], center[1], center[2], -upDownMinMax.min] as vec4;
const planeNorth = [-north[0], -north[1], -north[2], northSouthMinMax.max] as vec4;
const planeSouth = [north[0], north[1], north[2], -northSouthMinMax.min] as vec4;
const planeEast = [...axisEast, 0] as vec4;
const planeWest = [...axisWest, 0] as vec4;
const points: vec3[] = [];
// North points
if (tileID.y === 0) {
// If the tile borders a pole, then
points.push(
threePlaneIntersection(planeWest, planeEast, planeUp),
threePlaneIntersection(planeWest, planeEast, planeDown),
);
} else {
points.push(
threePlaneIntersection(planeNorth, planeEast, planeUp),
threePlaneIntersection(planeNorth, planeEast, planeDown),
threePlaneIntersection(planeNorth, planeWest, planeUp),
threePlaneIntersection(planeNorth, planeWest, planeDown)
);
}
// South points
if (tileID.y === (1 << tileID.z) - 1) {
points.push(
threePlaneIntersection(planeWest, planeEast, planeUp),
threePlaneIntersection(planeWest, planeEast, planeDown),
);
} else {
points.push(
threePlaneIntersection(planeSouth, planeEast, planeUp),
threePlaneIntersection(planeSouth, planeEast, planeDown),
threePlaneIntersection(planeSouth, planeWest, planeUp),
threePlaneIntersection(planeSouth, planeWest, planeDown)
);
}
return new ConvexVolume(points, [
planeUp,
planeDown,
planeNorth,
planeSouth,
planeEast,
planeWest
], aabbMin, aabbMax);
}
}
}
function findAxisMinMax(axis: vec3, points: vec3[]) {
let min = +Infinity;
let max = -Infinity;
for (const c of points) {
const dot = vec3.dot(axis, c);
min = Math.min(min, dot);
max = Math.max(max, dot);
}
return {
min,
max
};
}
+139
View File
@@ -0,0 +1,139 @@
import {ProjectionDefinition, type ProjectionDefinitionSpecification, type ProjectionSpecification, type StylePropertySpecification, latest as styleSpec} from '@maplibre/maplibre-gl-style-spec';
import {DataConstantProperty, type PossiblyEvaluated, Properties, Transitionable, type Transitioning, type TransitionParameters} from '../../style/properties';
import {Evented} from '../../util/evented';
import {EvaluationParameters} from '../../style/evaluation_parameters';
import {MercatorProjection} from './mercator_projection';
import {VerticalPerspectiveProjection} from './vertical_perspective_projection';
import {type Projection, type ProjectionGPUContext, type TileMeshUsage} from './projection';
import {type PreparedShader} from '../../shaders/shaders';
import {type SubdivisionGranularitySetting} from '../../render/subdivision_granularity_settings';
import {type Context} from '../../gl/context';
import {type CanonicalTileID} from '../../tile/tile_id';
import {type Mesh} from '../../render/mesh';
type ProjectionProps = {
type: DataConstantProperty<ProjectionDefinition>;
};
type ProjectionPossiblyEvaluated = {
type: ProjectionDefinitionSpecification;
};
const properties: Properties<ProjectionProps> = new Properties({
'type': new DataConstantProperty(styleSpec.projection.type as StylePropertySpecification)
});
export class GlobeProjection extends Evented implements Projection {
properties: PossiblyEvaluated<ProjectionProps, ProjectionPossiblyEvaluated>;
_transitionable: Transitionable<ProjectionProps>;
_transitioning: Transitioning<ProjectionProps>;
_mercatorProjection: MercatorProjection;
_verticalPerspectiveProjection: VerticalPerspectiveProjection;
constructor(projection?: ProjectionSpecification) {
super();
this._transitionable = new Transitionable(properties, undefined);
this.setProjection(projection);
this._transitioning = this._transitionable.untransitioned();
this.recalculate(new EvaluationParameters(0));
this._mercatorProjection = new MercatorProjection();
this._verticalPerspectiveProjection = new VerticalPerspectiveProjection();
}
public get transitionState(): number {
const currentProjectionSpecValue = this.properties.get('type');
if (typeof currentProjectionSpecValue === 'string' && currentProjectionSpecValue === 'mercator') {
return 0;
}
if (typeof currentProjectionSpecValue === 'string' && currentProjectionSpecValue === 'vertical-perspective') {
return 1;
}
if (currentProjectionSpecValue instanceof ProjectionDefinition) {
if (currentProjectionSpecValue.from === 'vertical-perspective' && currentProjectionSpecValue.to === 'mercator') {
return 1 - currentProjectionSpecValue.transition;
}
if (currentProjectionSpecValue.from === 'mercator' && currentProjectionSpecValue.to === 'vertical-perspective') {
return currentProjectionSpecValue.transition;
}
};
return 1;
}
get useGlobeRendering(): boolean {
return this.transitionState > 0;
}
get latitudeErrorCorrectionRadians(): number { return this._verticalPerspectiveProjection.latitudeErrorCorrectionRadians; }
private get currentProjection(): Projection {
return this.useGlobeRendering ? this._verticalPerspectiveProjection : this._mercatorProjection;
}
get name(): ProjectionSpecification['type'] {
return 'globe';
}
get useSubdivision(): boolean {
return this.currentProjection.useSubdivision;
}
get shaderVariantName(): string {
return this.currentProjection.shaderVariantName;
}
get shaderDefine(): string {
return this.currentProjection.shaderDefine;
}
get shaderPreludeCode(): PreparedShader {
return this.currentProjection.shaderPreludeCode;
}
get vertexShaderPreludeCode(): string {
return this.currentProjection.vertexShaderPreludeCode;
}
get subdivisionGranularity(): SubdivisionGranularitySetting {
return this.currentProjection.subdivisionGranularity;
}
get useGlobeControls(): boolean {
return this.transitionState > 0;
}
public destroy(): void {
this._mercatorProjection.destroy();
this._verticalPerspectiveProjection.destroy();
}
public updateGPUdependent(context: ProjectionGPUContext): void {
this._mercatorProjection.updateGPUdependent(context);
this._verticalPerspectiveProjection.updateGPUdependent(context);
}
public getMeshFromTileID(context: Context, _tileID: CanonicalTileID, _hasBorder: boolean, _allowPoles: boolean, _usage: TileMeshUsage): Mesh {
return this.currentProjection.getMeshFromTileID(context, _tileID, _hasBorder, _allowPoles, _usage);
}
setProjection(projection?: ProjectionSpecification) {
this._transitionable.setValue('type', projection?.type || 'mercator');
}
updateTransitions(parameters: TransitionParameters) {
this._transitioning = this._transitionable.transitioned(parameters, this._transitioning);
}
hasTransition(): boolean {
return this._transitioning.hasTransition() || this.currentProjection.hasTransition();
}
recalculate(parameters: EvaluationParameters) {
this.properties = this._transitioning.possiblyEvaluate(parameters);
}
setErrorQueryLatitudeDegrees(value: number) {
this._verticalPerspectiveProjection.setErrorQueryLatitudeDegrees(value);
this._mercatorProjection.setErrorQueryLatitudeDegrees(value);
}
}
@@ -0,0 +1,243 @@
import {Color} from '@maplibre/maplibre-gl-style-spec';
import {ColorMode} from '../../gl/color_mode';
import {CullFaceMode} from '../../gl/cull_face_mode';
import {DepthMode} from '../../gl/depth_mode';
import {StencilMode} from '../../gl/stencil_mode';
import {warnOnce} from '../../util/util';
import {projectionErrorMeasurementUniformValues} from '../../render/program/projection_error_measurement_program';
import {Mesh} from '../../render/mesh';
import {SegmentVector} from '../../data/segment';
import {PosArray, TriangleIndexArray} from '../../data/array_types.g';
import posAttributes from '../../data/pos_attributes';
import {type Framebuffer} from '../../gl/framebuffer';
import {isWebGL2} from '../../gl/webgl2';
import {type ProjectionGPUContext} from './projection';
/**
* For vector globe the vertex shader projects mercator coordinates to angular coordinates on a sphere.
* This projection requires some inverse trigonometry `atan(exp(...))`, which is inaccurate on some GPUs (mainly on AMD and Nvidia).
* The inaccuracy is severe enough to require a workaround. The uncorrected map is shifted north-south by up to several hundred meters in some latitudes.
* Since the inaccuracy is hardware-dependant and may change in the future, we need to measure the error at runtime.
*
* Our approach relies on several assumptions:
*
* - the error is only present in the "latitude" component (longitude doesn't need any inverse trigonometry)
* - the error is continuous and changes slowly with latitude
* - at zoom levels where the error is noticeable, the error is more-or-less the same across the entire visible map area (and thus can be described with a single number)
*
* Solution:
*
* Every few frames, launch a GPU shader that measures the error for the current map center latitude, and writes it to a 1x1 texture.
* Read back that texture, and offset the globe projection matrix according to the error (interpolating smoothly from old error to new error if needed).
* The texture readback is done asynchronously using Pixel Pack Buffers (WebGL2) when possible, and has a few frames of latency, but that should not be a problem.
*
* General operation of this class each frame is:
*
* - render the error shader into a fbo, read that pixel into a PBO, place a fence
* - wait a few frames to allow the GPU (and driver) to actually execute the shader
* - wait for the fence to be signalled (guaranteeing the shader to actually be executed)
* - read back the PBO's contents
* - wait a few more frames
* - repeat
*/
export class ProjectionErrorMeasurement {
// We wait at least this many frames after measuring until we read back the value.
// After this period, we might wait more frames until a fence is signalled to make sure the rendering is completed.
private readonly _readbackWaitFrames = 4;
// We wait this many frames after *reading back* a measurement until we trigger measure again.
// We could in theory render the measurement pixel immediately, but we wait to make sure
// no pipeline stall happens.
private readonly _measureWaitFrames = 6;
private readonly _texWidth = 1;
private readonly _texHeight = 1;
private readonly _texFormat: number;
private readonly _texType: number;
private _fullscreenTriangle: Mesh;
private _fbo: Framebuffer;
private _resultBuffer: Uint8Array;
private _pbo: WebGLBuffer;
private _cachedRenderContext: ProjectionGPUContext;
private _measuredError: number = 0; // Result of last measurement
private _updateCount: number = 0;
private _lastReadbackFrame: number = -1000;
get awaitingQuery(): boolean {
return !!this._readbackQueue;
}
// There is never more than one readback waiting
private _readbackQueue: {
frameNumberIssued: number; // Frame number when the data was first computed
sync: WebGLSync;
} = null;
public constructor(renderContext: ProjectionGPUContext) {
this._cachedRenderContext = renderContext;
const context = renderContext.context;
const gl = context.gl;
this._texFormat = gl.RGBA;
this._texType = gl.UNSIGNED_BYTE;
const vertexArray = new PosArray();
vertexArray.emplaceBack(-1, -1);
vertexArray.emplaceBack(2, -1);
vertexArray.emplaceBack(-1, 2);
const indexArray = new TriangleIndexArray();
indexArray.emplaceBack(0, 1, 2);
this._fullscreenTriangle = new Mesh(
context.createVertexBuffer(vertexArray, posAttributes.members),
context.createIndexBuffer(indexArray),
SegmentVector.simpleSegment(0, 0, vertexArray.length, indexArray.length)
);
this._resultBuffer = new Uint8Array(4);
context.activeTexture.set(gl.TEXTURE1);
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texImage2D(gl.TEXTURE_2D, 0, this._texFormat, this._texWidth, this._texHeight, 0, this._texFormat, this._texType, null);
this._fbo = context.createFramebuffer(this._texWidth, this._texHeight, false, false);
this._fbo.colorAttachment.set(texture);
if (isWebGL2(gl)) {
this._pbo = gl.createBuffer();
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, this._pbo);
gl.bufferData(gl.PIXEL_PACK_BUFFER, 4, gl.STREAM_READ);
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
}
}
public destroy() {
const gl = this._cachedRenderContext.context.gl;
this._fullscreenTriangle.destroy();
this._fbo.destroy();
gl.deleteBuffer(this._pbo);
this._fullscreenTriangle = null;
this._fbo = null;
this._pbo = null;
this._resultBuffer = null;
}
public updateErrorLoop(normalizedMercatorY: number, expectedAngleY: number): number {
const currentFrame = this._updateCount;
if (this._readbackQueue) {
// Try to read back if enough frames elapsed. Otherwise do nothing, just wait another frame.
if (currentFrame >= this._readbackQueue.frameNumberIssued + this._readbackWaitFrames) {
// Try to read back - it is possible that this method does nothing, then
// the readback queue will not be cleared and we will retry next frame.
this._tryReadback();
}
} else {
if (currentFrame >= this._lastReadbackFrame + this._measureWaitFrames) {
this._renderErrorTexture(normalizedMercatorY, expectedAngleY);
}
}
this._updateCount++;
return this._measuredError;
}
private _bindFramebuffer() {
const context = this._cachedRenderContext.context;
const gl = context.gl;
context.activeTexture.set(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this._fbo.colorAttachment.get());
context.bindFramebuffer.set(this._fbo.framebuffer);
}
private _renderErrorTexture(input: number, outputExpected: number): void {
const context = this._cachedRenderContext.context;
const gl = context.gl;
// Update framebuffer contents
this._bindFramebuffer();
context.viewport.set([0, 0, this._texWidth, this._texHeight]);
context.clear({color: Color.transparent});
const program = this._cachedRenderContext.useProgram('projectionErrorMeasurement');
program.draw(context, gl.TRIANGLES,
DepthMode.disabled, StencilMode.disabled,
ColorMode.unblended, CullFaceMode.disabled,
projectionErrorMeasurementUniformValues(input, outputExpected), null, null,
'$clipping', this._fullscreenTriangle.vertexBuffer, this._fullscreenTriangle.indexBuffer,
this._fullscreenTriangle.segments);
if (this._pbo && isWebGL2(gl)) {
// Read back into PBO
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, this._pbo);
gl.readBuffer(gl.COLOR_ATTACHMENT0);
gl.readPixels(0, 0, this._texWidth, this._texHeight, this._texFormat, this._texType, 0);
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
const sync = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0);
gl.flush();
this._readbackQueue = {
frameNumberIssued: this._updateCount,
sync,
};
} else {
// Read it back later.
this._readbackQueue = {
frameNumberIssued: this._updateCount,
sync: null,
};
}
}
private _tryReadback(): void {
const gl = this._cachedRenderContext.context.gl;
if (this._pbo && this._readbackQueue && isWebGL2(gl)) {
// WebGL 2 path
const waitResult = gl.clientWaitSync(this._readbackQueue.sync, 0, 0);
if (waitResult === gl.WAIT_FAILED) {
warnOnce('WebGL2 clientWaitSync failed.');
this._readbackQueue = null;
this._lastReadbackFrame = this._updateCount;
return;
}
if (waitResult === gl.TIMEOUT_EXPIRED) {
return; // Wait one more frame
}
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, this._pbo);
gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, this._resultBuffer, 0, 4);
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
} else {
// WebGL1 compatible
this._bindFramebuffer();
gl.readPixels(0, 0, this._texWidth, this._texHeight, this._texFormat, this._texType, this._resultBuffer);
}
// If we made it here, _resultBuffer contains the new measurement
this._readbackQueue = null;
this._measuredError = ProjectionErrorMeasurement._parseRGBA8float(this._resultBuffer);
this._lastReadbackFrame = this._updateCount;
}
private static _parseRGBA8float(buffer: Uint8Array): number {
let result = 0;
result += buffer[0] / 256.0;
result += buffer[1] / 65536.0;
result += buffer[2] / 16777216.0;
if (buffer[3] < 127.0) {
result = -result;
}
return result / 128.0;
}
}
+606
View File
@@ -0,0 +1,606 @@
import {describe, expect, test} from 'vitest';
import {EXTENT} from '../../data/extent';
import Point from '@mapbox/point-geometry';
import {LngLat} from '../lng_lat';
import {GlobeTransform} from './globe_transform';
import {CanonicalTileID, OverscaledTileID, UnwrappedTileID} from '../../tile/tile_id';
import {angularCoordinatesRadiansToVector, mercatorCoordinatesToAngularCoordinatesRadians, sphereSurfacePointToCoordinates} from './globe_utils';
import {expectToBeCloseToArray} from '../../util/test/util';
import {MercatorCoordinate} from '../mercator_coordinate';
import {tileCoordinatesToLocation} from './mercator_utils';
import {MercatorTransform} from './mercator_transform';
import {globeConstants} from './vertical_perspective_projection';
function testPlaneAgainstLngLat(lngDegrees: number, latDegrees: number, plane: Array<number>) {
const lat = latDegrees / 180.0 * Math.PI;
const lng = lngDegrees / 180.0 * Math.PI;
const len = Math.cos(lat);
const pointOnSphere = [
Math.sin(lng) * len,
Math.sin(lat),
Math.cos(lng) * len
];
return planeDistance(pointOnSphere, plane);
}
function planeDistance(point: Array<number>, plane: Array<number>) {
return point[0] * plane[0] + point[1] * plane[1] + point[2] * plane[2] + plane[3];
}
function createGlobeTransform() {
const globeTransform = new GlobeTransform();
globeTransform.resize(640, 480);
globeTransform.setFov(45);
return globeTransform;
}
describe('GlobeTransform', () => {
// Force faster animations so we can use shorter sleeps when testing them
globeConstants.errorTransitionTimeSeconds = 0.1;
describe('getProjectionData', () => {
const globeTransform = createGlobeTransform();
test('mercator tile extents are set', () => {
const projectionData = globeTransform.getProjectionData({overscaledTileID: new OverscaledTileID(1, 0, 1, 1, 0)});
expectToBeCloseToArray(projectionData.tileMercatorCoords, [0.5, 0, 0.5 / EXTENT, 0.5 / EXTENT]);
});
test('Globe transition is 0 when not applying the globe matrix', () => {
const projectionData = globeTransform.getProjectionData({overscaledTileID: new OverscaledTileID(1, 0, 1, 1, 0)});
expect(projectionData.projectionTransition).toBe(0);
});
test('Applying the globe matrix sets transition to something different than 0', () => {
const projectionData = globeTransform.getProjectionData({overscaledTileID: new OverscaledTileID(1, 0, 1, 1, 0), applyGlobeMatrix: true});
expect(projectionData.projectionTransition).not.toBe(0);
});
});
describe('clipping plane', () => {
const globeTransform = createGlobeTransform();
describe('general plane properties', () => {
const projectionData = globeTransform.getProjectionData({overscaledTileID: new OverscaledTileID(0, 0, 0, 0, 0)});
test('plane vector length <= 1 so they are not clipped by the near plane.', () => {
const len = Math.sqrt(
projectionData.clippingPlane[0] * projectionData.clippingPlane[0] +
projectionData.clippingPlane[1] * projectionData.clippingPlane[1] +
projectionData.clippingPlane[2] * projectionData.clippingPlane[2]
);
expect(len).toBeLessThanOrEqual(1);
});
test('camera is in positive halfspace', () => {
expect(planeDistance(globeTransform.cameraPosition as [number, number, number], projectionData.clippingPlane)).toBeGreaterThan(0);
});
test('coordinates 0E,0N are in positive halfspace', () => {
expect(testPlaneAgainstLngLat(0, 0, projectionData.clippingPlane)).toBeGreaterThan(0);
});
test('coordinates 40E,0N are in positive halfspace', () => {
expect(testPlaneAgainstLngLat(40, 0, projectionData.clippingPlane)).toBeGreaterThan(0);
});
test('coordinates 0E,90N are in negative halfspace', () => {
expect(testPlaneAgainstLngLat(0, 90, projectionData.clippingPlane)).toBeLessThan(0);
});
test('coordinates 90E,0N are in negative halfspace', () => {
expect(testPlaneAgainstLngLat(90, 0, projectionData.clippingPlane)).toBeLessThan(0);
});
test('coordinates 180E,0N are in negative halfspace', () => {
expect(testPlaneAgainstLngLat(180, 0, projectionData.clippingPlane)).toBeLessThan(0);
});
});
});
describe('projection', () => {
test('mercator coordinate to sphere point', () => {
const precisionDigits = 10;
let projectedAngles;
let projected;
projectedAngles = mercatorCoordinatesToAngularCoordinatesRadians(0.5, 0.5);
expectToBeCloseToArray(projectedAngles, [0, 0], precisionDigits);
projected = angularCoordinatesRadiansToVector(projectedAngles[0], projectedAngles[1]) as [number, number, number];
expectToBeCloseToArray(projected, [0, 0, 1], precisionDigits);
projectedAngles = mercatorCoordinatesToAngularCoordinatesRadians(0, 0.5);
expectToBeCloseToArray(projectedAngles, [Math.PI, 0], precisionDigits);
projected = angularCoordinatesRadiansToVector(projectedAngles[0], projectedAngles[1]) as [number, number, number];
expectToBeCloseToArray(projected, [0, 0, -1], precisionDigits);
projectedAngles = mercatorCoordinatesToAngularCoordinatesRadians(0.75, 0.5);
expectToBeCloseToArray(projectedAngles, [Math.PI / 2.0, 0], precisionDigits);
projected = angularCoordinatesRadiansToVector(projectedAngles[0], projectedAngles[1]) as [number, number, number];
expectToBeCloseToArray(projected, [1, 0, 0], precisionDigits);
projectedAngles = mercatorCoordinatesToAngularCoordinatesRadians(0.5, 0);
expectToBeCloseToArray(projectedAngles, [0, 1.4844222297453324], precisionDigits); // ~0.47pi
projected = angularCoordinatesRadiansToVector(projectedAngles[0], projectedAngles[1]) as [number, number, number];
expectToBeCloseToArray(projected, [0, 0.99627207622075, 0.08626673833405434], precisionDigits);
});
test('camera position', () => {
const precisionDigits = 10;
const globeTransform = createGlobeTransform();
expectToBeCloseToArray(globeTransform.cameraPosition as Array<number>, [0, 0, 8.110445867263898], precisionDigits);
globeTransform.resize(512, 512);
globeTransform.setZoom(-0.5);
globeTransform.setCenter(new LngLat(0, 80));
expectToBeCloseToArray(globeTransform.cameraPosition as Array<number>, [0, 2.2818294674820794, 0.40234810049271963], precisionDigits);
globeTransform.setPitch(35);
globeTransform.setBearing(70);
expectToBeCloseToArray(globeTransform.cameraPosition as Array<number>, [-0.7098603286961542, 2.002400604307631, 0.6154310261827212], precisionDigits);
globeTransform.setPitch(35);
globeTransform.setBearing(70);
globeTransform.setRoll(40);
expectToBeCloseToArray(globeTransform.cameraPosition as Array<number>, [-0.7098603286961542, 2.002400604307631, 0.6154310261827212], precisionDigits);
globeTransform.setPitch(35);
globeTransform.setBearing(70);
globeTransform.setRoll(180);
expectToBeCloseToArray(globeTransform.cameraPosition as Array<number>, [-0.7098603286961542, 2.002400604307631, 0.6154310261827212], precisionDigits);
globeTransform.setCenter(new LngLat(-10, 42));
expectToBeCloseToArray(globeTransform.cameraPosition as Array<number>, [-3.8450970996236364, 2.9368285470351516, 4.311953269048194], precisionDigits);
});
test('sphere point to coordinate', () => {
const precisionDigits = 10;
let unprojected = sphereSurfacePointToCoordinates([0, 0, 1]) as LngLat;
expect(unprojected.lng).toBeCloseTo(0, precisionDigits);
expect(unprojected.lat).toBeCloseTo(0, precisionDigits);
unprojected = sphereSurfacePointToCoordinates([0, 1, 0]) as LngLat;
expect(unprojected.lng).toBeCloseTo(0, precisionDigits);
expect(unprojected.lat).toBeCloseTo(90, precisionDigits);
unprojected = sphereSurfacePointToCoordinates([1, 0, 0]) as LngLat;
expect(unprojected.lng).toBeCloseTo(90, precisionDigits);
expect(unprojected.lat).toBeCloseTo(0, precisionDigits);
});
const screenCenter = new Point(640 / 2, 480 / 2); // We need the exact screen center
const screenTopEdgeCenter = new Point(640 / 2, 0);
describe('project location to coordinates', () => {
const precisionDigits = 10;
const globeTransform = createGlobeTransform();
test('basic test', () => {
globeTransform.setCenter(new LngLat(0, 0));
let projected = globeTransform.locationToScreenPoint(globeTransform.center);
expect(projected.x).toBeCloseTo(screenCenter.x, precisionDigits);
expect(projected.y).toBeCloseTo(screenCenter.y, precisionDigits);
globeTransform.setCenter(new LngLat(70, 50));
projected = globeTransform.locationToScreenPoint(globeTransform.center);
expect(projected.x).toBeCloseTo(screenCenter.x, precisionDigits);
expect(projected.y).toBeCloseTo(screenCenter.y, precisionDigits);
globeTransform.setCenter(new LngLat(0, 84));
projected = globeTransform.locationToScreenPoint(globeTransform.center);
expect(projected.x).toBeCloseTo(screenCenter.x, precisionDigits);
expect(projected.y).toBeCloseTo(screenCenter.y, precisionDigits);
});
test('project a location that is slightly above and below map\'s center point', () => {
globeTransform.setCenter(new LngLat(0, 0));
let projected = globeTransform.locationToScreenPoint(new LngLat(0, 1));
expect(projected.x).toBeCloseTo(screenCenter.x, precisionDigits);
expect(projected.y).toBeLessThan(screenCenter.y);
projected = globeTransform.locationToScreenPoint(new LngLat(0, -1));
expect(projected.x).toBeCloseTo(screenCenter.x, precisionDigits);
expect(projected.y).toBeGreaterThan(screenCenter.y);
});
});
describe('unproject', () => {
test('unproject screen center', () => {
const precisionDigits = 10;
const globeTransform = createGlobeTransform();
let unprojected = globeTransform.screenPointToLocation(screenCenter);
expect(unprojected.lng).toBeCloseTo(globeTransform.center.lng, precisionDigits);
expect(unprojected.lat).toBeCloseTo(globeTransform.center.lat, precisionDigits);
globeTransform.setCenter(new LngLat(90.0, 0.0));
unprojected = globeTransform.screenPointToLocation(screenCenter);
expect(unprojected.lng).toBeCloseTo(globeTransform.center.lng, precisionDigits);
expect(unprojected.lat).toBeCloseTo(globeTransform.center.lat, precisionDigits);
globeTransform.setCenter(new LngLat(0.0, 60.0));
unprojected = globeTransform.screenPointToLocation(screenCenter);
expect(unprojected.lng).toBeCloseTo(globeTransform.center.lng, precisionDigits);
expect(unprojected.lat).toBeCloseTo(globeTransform.center.lat, precisionDigits);
});
test('unproject point to the side', () => {
const precisionDigits = 10;
const globeTransform = createGlobeTransform();
let coords: LngLat;
let projected: Point;
let unprojected: LngLat;
coords = new LngLat(0, 0);
projected = globeTransform.locationToScreenPoint(coords);
unprojected = globeTransform.screenPointToLocation(projected);
expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
coords = new LngLat(10, 20);
projected = globeTransform.locationToScreenPoint(coords);
unprojected = globeTransform.screenPointToLocation(projected);
expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
coords = new LngLat(15, -2);
projected = globeTransform.locationToScreenPoint(coords);
unprojected = globeTransform.screenPointToLocation(projected);
expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
});
test('unproject behind the pole', () => {
// This test tries to unproject a point that is beyond the north pole
// from the camera's point of view.
// This particular case turned out to be problematic, hence this test.
const precisionDigits = 10;
const globeTransform = createGlobeTransform();
// Transform settings from the render test projection/globe/fill-planet-pole
// See the expected result for how the globe should look with this transform.
globeTransform.resize(512, 512);
globeTransform.setZoom(-0.5);
globeTransform.setCenter(new LngLat(0, 80));
let coords: LngLat;
let projected: Point;
let unprojected: LngLat;
coords = new LngLat(179.9, 71);
projected = globeTransform.locationToScreenPoint(coords);
unprojected = globeTransform.screenPointToLocation(projected);
expect(projected.x).toBeCloseTo(256.2434702034287, precisionDigits);
expect(projected.y).toBeCloseTo(48.27080146399297, precisionDigits);
expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
// Near the pole
coords = new LngLat(179.9, 89.0);
projected = globeTransform.locationToScreenPoint(coords);
unprojected = globeTransform.screenPointToLocation(projected);
expect(projected.x).toBeCloseTo(256.0140972925064, precisionDigits);
expect(projected.y).toBeCloseTo(167.69159699932908, precisionDigits);
expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
});
test('unproject outside of sphere', () => {
const precisionDigits = 10;
const globeTransform = createGlobeTransform();
// Try unprojection a point somewhere above the western horizon
globeTransform.setPitch(60);
globeTransform.setBearing(-90);
const unprojected = globeTransform.screenPointToLocation(screenTopEdgeCenter);
expect(unprojected.lng).toBeCloseTo(-28.990298145461963, precisionDigits);
expect(unprojected.lat).toBeCloseTo(0.0, precisionDigits);
});
test('unproject further outside of sphere clamps to horizon', () => {
const globeTransform = createGlobeTransform();
globeTransform.setPitch(60);
globeTransform.setBearing(-90);
const screenPointAboveWesternHorizon = screenTopEdgeCenter;
const screenPointFurtherAboveWesternHorizon = screenTopEdgeCenter.sub(new Point(0, -100));
const unprojected = globeTransform.screenPointToLocation(screenPointAboveWesternHorizon);
const unprojected2 = globeTransform.screenPointToLocation(screenPointFurtherAboveWesternHorizon);
expect(unprojected.lat).toBeCloseTo(unprojected2.lat, 10);
expect(unprojected.lng).toBeCloseTo(unprojected2.lng, 10);
});
});
describe('setLocationAtPoint', () => {
const precisionDigits = 10;
const globeTransform = createGlobeTransform();
globeTransform.setZoom(1);
let coords: LngLat;
let point: Point;
let projected: Point;
let unprojected: LngLat;
test('identity', () => {
// Should do nothing
coords = new LngLat(0, 0);
point = new Point(320, 240);
globeTransform.setLocationAtPoint(coords, point);
unprojected = globeTransform.screenPointToLocation(point);
projected = globeTransform.locationToScreenPoint(coords);
expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
expect(projected.x).toBeCloseTo(point.x, precisionDigits);
expect(projected.y).toBeCloseTo(point.y, precisionDigits);
});
test('offset lnglat', () => {
coords = new LngLat(5, 10);
point = new Point(320, 240);
globeTransform.setLocationAtPoint(coords, point);
unprojected = globeTransform.screenPointToLocation(point);
projected = globeTransform.locationToScreenPoint(coords);
expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
expect(projected.x).toBeCloseTo(point.x, precisionDigits);
expect(projected.y).toBeCloseTo(point.y, precisionDigits);
});
test('offset pixel + lnglat', () => {
coords = new LngLat(5, 10);
point = new Point(330, 240); // 10 pixels to the right
globeTransform.setLocationAtPoint(coords, point);
unprojected = globeTransform.screenPointToLocation(point);
projected = globeTransform.locationToScreenPoint(coords);
expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
expect(projected.x).toBeCloseTo(point.x, precisionDigits);
expect(projected.y).toBeCloseTo(point.y, precisionDigits);
});
test('larger offset', () => {
coords = new LngLat(30, -2);
point = new Point(250, 180);
globeTransform.setLocationAtPoint(coords, point);
unprojected = globeTransform.screenPointToLocation(point);
projected = globeTransform.locationToScreenPoint(coords);
expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
expect(projected.x).toBeCloseTo(point.x, precisionDigits);
expect(projected.y).toBeCloseTo(point.y, precisionDigits);
});
describe('rotated', () => {
globeTransform.setBearing(90);
test('identity', () => {
// Should do nothing
coords = new LngLat(0, 0);
point = new Point(320, 240);
globeTransform.setLocationAtPoint(coords, point);
unprojected = globeTransform.screenPointToLocation(point);
projected = globeTransform.locationToScreenPoint(coords);
expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
expect(projected.x).toBeCloseTo(point.x, precisionDigits);
expect(projected.y).toBeCloseTo(point.y, precisionDigits);
});
test('offset lnglat', () => {
coords = new LngLat(5, 0);
point = new Point(320, 240);
globeTransform.setLocationAtPoint(coords, point);
unprojected = globeTransform.screenPointToLocation(point);
projected = globeTransform.locationToScreenPoint(coords);
expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
expect(projected.x).toBeCloseTo(point.x, precisionDigits);
expect(projected.y).toBeCloseTo(point.y, precisionDigits);
});
test('offset pixel + lnglat', () => {
coords = new LngLat(0, 10);
point = new Point(350, 240); // 30 pixels to the right
globeTransform.setLocationAtPoint(coords, point);
unprojected = globeTransform.screenPointToLocation(point);
projected = globeTransform.locationToScreenPoint(coords);
expect(unprojected.lng).toBeCloseTo(coords.lng, precisionDigits);
expect(unprojected.lat).toBeCloseTo(coords.lat, precisionDigits);
expect(projected.x).toBeCloseTo(point.x, precisionDigits);
expect(projected.y).toBeCloseTo(point.y, precisionDigits);
expect(globeTransform.center.lat).toBeCloseTo(20.659450722109348, precisionDigits);
});
});
});
});
describe('isPointOnMapSurface', () => {
const globeTransform = new GlobeTransform();
globeTransform.resize(640, 480);
globeTransform.setZoom(1);
test('Top screen edge', () => {
expect(globeTransform.isPointOnMapSurface(new Point(320, 0))).toBe(false);
});
test('Screen center', () => {
expect(globeTransform.isPointOnMapSurface(new Point(320, 240))).toBe(true);
});
test('Top', () => {
expect(globeTransform.isPointOnMapSurface(new Point(320, 104))).toBe(false);
expect(globeTransform.isPointOnMapSurface(new Point(320, 105))).toBe(true);
});
test('Bottom', () => {
expect(globeTransform.isPointOnMapSurface(new Point(320, 480 - 105))).toBe(true);
expect(globeTransform.isPointOnMapSurface(new Point(320, 480 - 104))).toBe(false);
});
test('Left', () => {
expect(globeTransform.isPointOnMapSurface(new Point(184, 240))).toBe(false);
expect(globeTransform.isPointOnMapSurface(new Point(185, 240))).toBe(true);
});
test('Right', () => {
expect(globeTransform.isPointOnMapSurface(new Point(640 - 185, 240))).toBe(true);
expect(globeTransform.isPointOnMapSurface(new Point(640 - 184, 240))).toBe(false);
});
test('Diagonal', () => {
expect(globeTransform.isPointOnMapSurface(new Point(223, 147))).toBe(true);
expect(globeTransform.isPointOnMapSurface(new Point(221, 144))).toBe(false);
});
});
test('pointCoordinate', () => {
const precisionDigits = 10;
const globeTransform = createGlobeTransform();
let coords: LngLat;
let coordsMercator: MercatorCoordinate;
let projected: Point;
let unprojectedCoordinates: MercatorCoordinate;
coords = new LngLat(0, 0);
coordsMercator = MercatorCoordinate.fromLngLat(coords);
projected = globeTransform.locationToScreenPoint(coords);
unprojectedCoordinates = globeTransform.screenPointToMercatorCoordinate(projected);
expect(unprojectedCoordinates.x).toBeCloseTo(coordsMercator.x, precisionDigits);
expect(unprojectedCoordinates.y).toBeCloseTo(coordsMercator.y, precisionDigits);
coords = new LngLat(10, 20);
coordsMercator = MercatorCoordinate.fromLngLat(coords);
projected = globeTransform.locationToScreenPoint(coords);
unprojectedCoordinates = globeTransform.screenPointToMercatorCoordinate(projected);
expect(unprojectedCoordinates.x).toBeCloseTo(coordsMercator.x, precisionDigits);
expect(unprojectedCoordinates.y).toBeCloseTo(coordsMercator.y, precisionDigits);
});
describe('getBounds', () => {
const precisionDigits = 10;
const globeTransform = new GlobeTransform();
globeTransform.resize(640, 480);
test('basic', () => {
globeTransform.setCenter(new LngLat(0, 0));
globeTransform.setZoom(1);
const bounds = globeTransform.getBounds();
expect(bounds._ne.lat).toBeCloseTo(79.3636705287052, precisionDigits);
expect(bounds._ne.lng).toBeCloseTo(79.36367052870514, precisionDigits);
expect(bounds._sw.lat).toBeCloseTo(-79.3636705287052, precisionDigits);
expect(bounds._sw.lng).toBeCloseTo(-79.3636705287052, precisionDigits);
});
test('zoomed in', () => {
globeTransform.setCenter(new LngLat(0, 0));
globeTransform.setZoom(4);
const bounds = globeTransform.getBounds();
expect(bounds._ne.lat).toBeCloseTo(11.76627084591695, precisionDigits);
expect(bounds._ne.lng).toBeCloseTo(16.124697669965144, precisionDigits);
expect(bounds._sw.lat).toBeCloseTo(-11.76627084591695, precisionDigits);
expect(bounds._sw.lng).toBeCloseTo(-16.124697669965144, precisionDigits);
});
test('looking at south pole', () => {
globeTransform.setCenter(new LngLat(0, -84));
globeTransform.setZoom(-2);
const bounds = globeTransform.getBounds();
expect(bounds._ne.lat).toBeCloseTo(-6.299534770946991, precisionDigits);
expect(bounds._ne.lng).toBeCloseTo(180, precisionDigits);
expect(bounds._sw.lat).toBeCloseTo(-90, precisionDigits);
expect(bounds._sw.lng).toBeCloseTo(-180, precisionDigits);
});
test('looking at south edge of mercator', () => {
globeTransform.setCenter(new LngLat(-163, -83));
globeTransform.setZoom(3);
const bounds = globeTransform.getBounds();
expect(bounds._ne.lat).toBeCloseTo(-79.75570418234764, precisionDigits);
expect(bounds._ne.lng).toBeCloseTo(-124.19771985801174, precisionDigits);
expect(bounds._sw.lat).toBeCloseTo(-85.59109073899032, precisionDigits);
expect(bounds._sw.lng).toBeCloseTo(-201.80228014198985, precisionDigits);
});
});
describe('projectTileCoordinates', () => {
const precisionDigits = 10;
const transform = new GlobeTransform();
transform.resize(512, 512);
transform.setCenter(new LngLat(10.0, 50.0));
transform.setZoom(-1);
test('basic', () => {
const projection = transform.projectTileCoordinates(1024, 1024, new UnwrappedTileID(0, new CanonicalTileID(1, 1, 0)), (_x, _y) => 0);
expect(projection.point.x).toBeCloseTo(0.008635590705360347, precisionDigits);
expect(projection.point.y).toBeCloseTo(0.16970500709841846, precisionDigits);
expect(projection.signedDistanceFromCamera).toBeCloseTo(781.0549201758624, precisionDigits);
expect(projection.isOccluded).toBe(false);
});
test('rotated', () => {
transform.setBearing(12);
transform.setPitch(10);
const projection = transform.projectTileCoordinates(1024, 1024, new UnwrappedTileID(0, new CanonicalTileID(1, 1, 0)), (_x, _y) => 0);
expect(projection.point.x).toBeCloseTo(-0.026585319983152694, precisionDigits);
expect(projection.point.y).toBeCloseTo(0.15506884411121183, precisionDigits);
expect(projection.signedDistanceFromCamera).toBeCloseTo(788.4423931260653, precisionDigits);
expect(projection.isOccluded).toBe(false);
});
test('occluded by planet', () => {
transform.setBearing(-90);
transform.setPitch(60);
const projection = transform.projectTileCoordinates(8192, 8192, new UnwrappedTileID(0, new CanonicalTileID(1, 1, 0)), (_x, _y) => 0);
expect(projection.point.x).toBeCloseTo(0.22428309892086878, precisionDigits);
expect(projection.point.y).toBeCloseTo(-0.4462620847133465, precisionDigits);
expect(projection.signedDistanceFromCamera).toBeCloseTo(822.280942015371, precisionDigits);
expect(projection.isOccluded).toBe(true);
});
});
describe('isLocationOccluded', () => {
const transform = new GlobeTransform();
transform.resize(512, 512);
transform.setCenter(new LngLat(0.0, 0.0));
transform.setZoom(-1);
test('center', () => {
expect(transform.isLocationOccluded(new LngLat(0, 0))).toBe(false);
});
test('center from tile', () => {
expect(transform.isLocationOccluded(tileCoordinatesToLocation(0, 0, new CanonicalTileID(1, 1, 1)))).toBe(false);
});
test('backside', () => {
expect(transform.isLocationOccluded(new LngLat(179.9, 0))).toBe(true);
});
test('backside from tile', () => {
expect(transform.isLocationOccluded(tileCoordinatesToLocation(0, 0, new CanonicalTileID(1, 0, 1)))).toBe(true);
});
test('barely visible', () => {
expect(transform.isLocationOccluded(new LngLat(84.49, 0))).toBe(false);
});
test('barely hidden', () => {
expect(transform.isLocationOccluded(new LngLat(84.50, 0))).toBe(true);
});
});
describe('render world copies', () => {
test('change projection and make sure render world copies is kept', () => {
const globeTransform = createGlobeTransform();
globeTransform.setRenderWorldCopies(true);
expect(globeTransform.renderWorldCopies).toBeTruthy();
});
test('change transform and make sure render world copies is kept', () => {
const globeTransform = createGlobeTransform();
globeTransform.setRenderWorldCopies(true);
const mercator = new MercatorTransform({minZoom: 0, maxZoom: 1, minPitch: 2, maxPitch: 3, renderWorldCopies: false});
mercator.apply(globeTransform, false);
expect(mercator.renderWorldCopies).toBeTruthy();
});
});
});
+471
View File
@@ -0,0 +1,471 @@
import type {mat2, mat4, vec3, vec4} from 'gl-matrix';
import {TransformHelper} from '../transform_helper';
import {MercatorTransform} from './mercator_transform';
import {VerticalPerspectiveTransform} from './vertical_perspective_transform';
import {type LngLat, type LngLatLike,} from '../lng_lat';
import {lerp} from '../../util/util';
import type {OverscaledTileID, UnwrappedTileID, CanonicalTileID} from '../../tile/tile_id';
import type Point from '@mapbox/point-geometry';
import type {MercatorCoordinate} from '../mercator_coordinate';
import type {LngLatBounds} from '../lng_lat_bounds';
import type {Frustum} from '../../util/primitives/frustum';
import type {Terrain} from '../../render/terrain';
import type {PointProjection} from '../../symbol/projection';
import type {IReadonlyTransform, ITransform, TransformConstrainFunction} from '../transform_interface';
import type {TransformOptions} from '../transform_helper';
import type {PaddingOptions} from '../edge_insets';
import type {ProjectionData, ProjectionDataParams} from './projection_data';
import type {CoveringTilesDetailsProvider} from './covering_tiles_details_provider';
/**
* Globe transform is a transform that moves between vertical perspective and mercator projections.
*/
export class GlobeTransform implements ITransform {
private _helper: TransformHelper;
//
// Implementation of transform getters and setters
//
get pixelsToClipSpaceMatrix(): mat4 {
return this._helper.pixelsToClipSpaceMatrix;
}
get clipSpaceToPixelsMatrix(): mat4 {
return this._helper.clipSpaceToPixelsMatrix;
}
get pixelsToGLUnits(): [number, number] {
return this._helper.pixelsToGLUnits;
}
get centerOffset(): Point {
return this._helper.centerOffset;
}
get size(): Point {
return this._helper.size;
}
get rotationMatrix(): mat2 {
return this._helper.rotationMatrix;
}
get centerPoint(): Point {
return this._helper.centerPoint;
}
get pixelsPerMeter(): number {
return this._helper.pixelsPerMeter;
}
setMinZoom(zoom: number): void {
this._helper.setMinZoom(zoom);
}
setMaxZoom(zoom: number): void {
this._helper.setMaxZoom(zoom);
}
setMinPitch(pitch: number): void {
this._helper.setMinPitch(pitch);
}
setMaxPitch(pitch: number): void {
this._helper.setMaxPitch(pitch);
}
setRenderWorldCopies(renderWorldCopies: boolean): void {
this._helper.setRenderWorldCopies(renderWorldCopies);
}
setBearing(bearing: number): void {
this._helper.setBearing(bearing);
}
setPitch(pitch: number): void {
this._helper.setPitch(pitch);
}
setRoll(roll: number): void {
this._helper.setRoll(roll);
}
setFov(fov: number): void {
this._helper.setFov(fov);
}
setZoom(zoom: number): void {
this._helper.setZoom(zoom);
}
setCenter(center: LngLat): void {
this._helper.setCenter(center);
}
setElevation(elevation: number): void {
this._helper.setElevation(elevation);
}
setMinElevationForCurrentTile(elevation: number): void {
this._helper.setMinElevationForCurrentTile(elevation);
}
setPadding(padding: PaddingOptions): void {
this._helper.setPadding(padding);
}
interpolatePadding(start: PaddingOptions, target: PaddingOptions, t: number): void {
return this._helper.interpolatePadding(start, target, t);
}
isPaddingEqual(padding: PaddingOptions): boolean {
return this._helper.isPaddingEqual(padding);
}
resize(width: number, height: number, constrainTransform: boolean = true): void {
this._helper.resize(width, height, constrainTransform);
}
getMaxBounds(): LngLatBounds {
return this._helper.getMaxBounds();
}
setMaxBounds(bounds?: LngLatBounds): void {
this._helper.setMaxBounds(bounds);
}
setConstrainOverride(constrain?: TransformConstrainFunction | null): void {
this._helper.setConstrainOverride(constrain);
}
overrideNearFarZ(nearZ: number, farZ: number): void {
this._helper.overrideNearFarZ(nearZ, farZ);
}
clearNearFarZOverride(): void {
this._helper.clearNearFarZOverride();
}
getCameraQueryGeometry(queryGeometry: Point[]): Point[] {
return this._helper.getCameraQueryGeometry(this.getCameraPoint(), queryGeometry);
}
get tileSize(): number {
return this._helper.tileSize;
}
get tileZoom(): number {
return this._helper.tileZoom;
}
get scale(): number {
return this._helper.scale;
}
get worldSize(): number {
return this._helper.worldSize;
}
get width(): number {
return this._helper.width;
}
get height(): number {
return this._helper.height;
}
get lngRange(): [number, number] {
return this._helper.lngRange;
}
get latRange(): [number, number] {
return this._helper.latRange;
}
get minZoom(): number {
return this._helper.minZoom;
}
get maxZoom(): number {
return this._helper.maxZoom;
}
get zoom(): number {
return this._helper.zoom;
}
get center(): LngLat {
return this._helper.center;
}
get minPitch(): number {
return this._helper.minPitch;
}
get maxPitch(): number {
return this._helper.maxPitch;
}
get pitch(): number {
return this._helper.pitch;
}
get pitchInRadians(): number {
return this._helper.pitchInRadians;
}
get roll(): number {
return this._helper.roll;
}
get rollInRadians(): number {
return this._helper.rollInRadians;
}
get bearing(): number {
return this._helper.bearing;
}
get bearingInRadians(): number {
return this._helper.bearingInRadians;
}
get fov(): number {
return this._helper.fov;
}
get fovInRadians(): number {
return this._helper.fovInRadians;
}
get elevation(): number {
return this._helper.elevation;
}
get minElevationForCurrentTile(): number {
return this._helper.minElevationForCurrentTile;
}
get padding(): PaddingOptions {
return this._helper.padding;
}
get unmodified(): boolean {
return this._helper.unmodified;
}
get renderWorldCopies(): boolean {
return this._helper.renderWorldCopies;
}
get cameraToCenterDistance(): number {
return this._helper.cameraToCenterDistance;
}
get constrainOverride(): TransformConstrainFunction {
return this._helper.constrainOverride;
}
public get nearZ(): number {
return this._helper.nearZ;
}
public get farZ(): number {
return this._helper.farZ;
}
public get autoCalculateNearFarZ(): boolean {
return this._helper.autoCalculateNearFarZ;
}
//
// Implementation of globe transform
//
private _globeLatitudeErrorCorrectionRadians: number = 0;
/**
* True when globe render path should be used instead of the old but simpler mercator rendering.
* Globe automatically transitions to mercator at high zoom levels, which causes a switch from
* globe to mercator render path.
*/
get isGlobeRendering(): boolean {
return this._globeness > 0;
}
setTransitionState(globeness: number, errorCorrectionValue: number): void {
this._globeness = globeness;
this._globeLatitudeErrorCorrectionRadians = errorCorrectionValue;
this._calcMatrices();
this._verticalPerspectiveTransform.getCoveringTilesDetailsProvider().prepareNextFrame();
this._mercatorTransform.getCoveringTilesDetailsProvider().prepareNextFrame();
}
private get currentTransform(): ITransform {
return this.isGlobeRendering ? this._verticalPerspectiveTransform : this._mercatorTransform;
}
/**
* Globe projection can smoothly interpolate between globe view and mercator. This variable controls this interpolation.
* Value 0 is mercator, value 1 is globe, anything between is an interpolation between the two projections.
*/
private _globeness: number = 1.0;
private _mercatorTransform: MercatorTransform;
private _verticalPerspectiveTransform: VerticalPerspectiveTransform;
public constructor(options?: TransformOptions) {
this._helper = new TransformHelper({
calcMatrices: () => { this._calcMatrices(); },
defaultConstrain: (center, zoom) => { return this.defaultConstrain(center, zoom); }
}, options);
this._globeness = 1; // When transform is cloned for use in symbols, `_updateAnimation` function which usually sets this value never gets called.
this._mercatorTransform = new MercatorTransform();
this._verticalPerspectiveTransform = new VerticalPerspectiveTransform();
}
clone(): ITransform {
const clone = new GlobeTransform();
clone._globeness = this._globeness;
clone._globeLatitudeErrorCorrectionRadians = this._globeLatitudeErrorCorrectionRadians;
clone.apply(this, false);
return clone;
}
public apply(that: IReadonlyTransform, constrain: boolean): void {
this._helper.apply(that, constrain);
this._mercatorTransform.apply(this, false);
this._verticalPerspectiveTransform.apply(this, false, this._globeLatitudeErrorCorrectionRadians);
}
public get projectionMatrix(): mat4 { return this.currentTransform.projectionMatrix; }
public get modelViewProjectionMatrix(): mat4 { return this.currentTransform.modelViewProjectionMatrix; }
public get inverseProjectionMatrix(): mat4 { return this.currentTransform.inverseProjectionMatrix; }
public get cameraPosition(): vec3 { return this.currentTransform.cameraPosition; }
getProjectionData(params: ProjectionDataParams): ProjectionData {
const mercatorProjectionData = this._mercatorTransform.getProjectionData(params);
const verticalPerspectiveProjectionData = this._verticalPerspectiveTransform.getProjectionData(params);
return {
mainMatrix: this.isGlobeRendering ? verticalPerspectiveProjectionData.mainMatrix : mercatorProjectionData.mainMatrix,
clippingPlane: verticalPerspectiveProjectionData.clippingPlane,
tileMercatorCoords: verticalPerspectiveProjectionData.tileMercatorCoords,
projectionTransition: params.applyGlobeMatrix ? this._globeness : 0,
fallbackMatrix: mercatorProjectionData.fallbackMatrix,
};
}
public isLocationOccluded(location: LngLat): boolean {
return this.currentTransform.isLocationOccluded(location);
}
public transformLightDirection(dir: vec3): vec3 {
return this.currentTransform.transformLightDirection(dir);
}
public getPixelScale(): number {
return lerp(this._mercatorTransform.getPixelScale(), this._verticalPerspectiveTransform.getPixelScale(), this._globeness);
}
public getCircleRadiusCorrection(): number {
return lerp(this._mercatorTransform.getCircleRadiusCorrection(), this._verticalPerspectiveTransform.getCircleRadiusCorrection(), this._globeness);
}
public getPitchedTextCorrection(textAnchorX: number, textAnchorY: number, tileID: UnwrappedTileID): number {
const mercatorCorrection = this._mercatorTransform.getPitchedTextCorrection(textAnchorX, textAnchorY, tileID);
const verticalCorrection = this._verticalPerspectiveTransform.getPitchedTextCorrection(textAnchorX, textAnchorY, tileID);
return lerp(mercatorCorrection, verticalCorrection, this._globeness);
}
public projectTileCoordinates(x: number, y: number, unwrappedTileID: UnwrappedTileID, getElevation: (x: number, y: number) => number): PointProjection {
return this.currentTransform.projectTileCoordinates(x, y, unwrappedTileID, getElevation);
}
private _calcMatrices(): void {
if (!this._helper._width || !this._helper._height) {
return;
}
// VerticalPerspective reads our near/farZ values and autoCalculateNearFarZ:
// - if autoCalculateNearFarZ is true then it computes globe Z values
// - if autoCalculateNearFarZ is false then it inherits our Z values
// In either case, its Z values are consistent with out settings and we want to copy its Z values to our helper.
this._verticalPerspectiveTransform.apply(this, false, this._globeLatitudeErrorCorrectionRadians);
this._helper._nearZ = this._verticalPerspectiveTransform.nearZ;
this._helper._farZ = this._verticalPerspectiveTransform.farZ;
// When transitioning between globe and mercator, we need to synchronize the depth values in both transforms.
// For this reason we first update vertical perspective and then sync our Z values to its result.
// Now if globe rendering, we always want to force mercator transform to adapt our Z values.
// If not, it will either compute its own (autoCalculateNearFarZ=false) or adapt our (autoCalculateNearFarZ=true).
// In either case we want to (again) sync our Z values, this time with
this._mercatorTransform.apply(this, true, this.isGlobeRendering);
this._helper._nearZ = this._mercatorTransform.nearZ;
this._helper._farZ = this._mercatorTransform.farZ;
}
calculateFogMatrix(unwrappedTileID: UnwrappedTileID): mat4 {
return this.currentTransform.calculateFogMatrix(unwrappedTileID);
}
getVisibleUnwrappedCoordinates(tileID: CanonicalTileID): UnwrappedTileID[] {
return this.currentTransform.getVisibleUnwrappedCoordinates(tileID);
}
getCameraFrustum(): Frustum {
return this.currentTransform.getCameraFrustum();
}
getClippingPlane(): vec4 | null {
return this.currentTransform.getClippingPlane();
}
getCoveringTilesDetailsProvider(): CoveringTilesDetailsProvider {
return this.currentTransform.getCoveringTilesDetailsProvider();
}
recalculateZoomAndCenter(terrain?: Terrain): void {
this._mercatorTransform.recalculateZoomAndCenter(terrain);
this._verticalPerspectiveTransform.recalculateZoomAndCenter(terrain);
}
maxPitchScaleFactor(): number {
// Using mercator version of this should be good enough approximation for globe.
return this._mercatorTransform.maxPitchScaleFactor();
}
getCameraPoint(): Point {
return this._helper.getCameraPoint();
}
getCameraAltitude(): number {
return this._helper.getCameraAltitude();
}
getCameraLngLat(): LngLat {
return this._helper.getCameraLngLat();
}
lngLatToCameraDepth(lngLat: LngLat, elevation: number): number {
return this.currentTransform.lngLatToCameraDepth(lngLat, elevation);
}
populateCache(coords: OverscaledTileID[]): void {
this._mercatorTransform.populateCache(coords);
this._verticalPerspectiveTransform.populateCache(coords);
}
getBounds(): LngLatBounds {
return this.currentTransform.getBounds();
}
defaultConstrain: TransformConstrainFunction = (lngLat, zoom) => {
return this.currentTransform.defaultConstrain(lngLat, zoom);
};
applyConstrain: TransformConstrainFunction = (lngLat, zoom) => {
return this._helper.applyConstrain(lngLat, zoom);
};
calculateCenterFromCameraLngLatAlt(lngLat: LngLatLike, alt: number, bearing?: number, pitch?: number): {center: LngLat; elevation: number; zoom: number} {
return this._helper.calculateCenterFromCameraLngLatAlt(lngLat, alt, bearing, pitch);
}
/**
* Note: automatically adjusts zoom to keep planet size consistent
* (same size before and after a {@link setLocationAtPoint} call).
*/
setLocationAtPoint(lnglat: LngLat, point: Point): void {
if (!this.isGlobeRendering) {
this._mercatorTransform.setLocationAtPoint(lnglat, point);
this.apply(this._mercatorTransform, false);
return;
}
this._verticalPerspectiveTransform.setLocationAtPoint(lnglat, point);
this.apply(this._verticalPerspectiveTransform, false);
return;
}
locationToScreenPoint(lnglat: LngLat, terrain?: Terrain): Point {
return this.currentTransform.locationToScreenPoint(lnglat, terrain);
}
screenPointToMercatorCoordinate(p: Point, terrain?: Terrain): MercatorCoordinate {
return this.currentTransform.screenPointToMercatorCoordinate(p, terrain);
}
screenPointToLocation(p: Point, terrain?: Terrain): LngLat {
return this.currentTransform.screenPointToLocation(p, terrain);
}
isPointOnMapSurface(p: Point, terrain?: Terrain): boolean {
return this.currentTransform.isPointOnMapSurface(p, terrain);
}
/**
* Computes normalized direction of a ray from the camera to the given screen pixel.
*/
getRayDirectionFromPixel(p: Point): vec3 {
return this._verticalPerspectiveTransform.getRayDirectionFromPixel(p);
}
getMatrixForModel(location: LngLatLike, altitude?: number): mat4 {
return this.currentTransform.getMatrixForModel(location, altitude);
}
getProjectionDataForCustomLayer(applyGlobeMatrix: boolean = true): ProjectionData {
const mercatorData = this._mercatorTransform.getProjectionDataForCustomLayer(applyGlobeMatrix);
if (!this.isGlobeRendering) {
return mercatorData;
}
const globeData = this._verticalPerspectiveTransform.getProjectionDataForCustomLayer(applyGlobeMatrix);
globeData.fallbackMatrix = mercatorData.mainMatrix;
return globeData;
}
getFastPathSimpleProjectionMatrix(tileID: OverscaledTileID): mat4 {
return this.currentTransform.getFastPathSimpleProjectionMatrix(tileID);
}
}
+50
View File
@@ -0,0 +1,50 @@
import {describe, expect, test} from 'vitest';
import {LngLat} from '../lng_lat';
import {getGlobeCircumferencePixels, getZoomAdjustment, globeDistanceOfLocationsPixels} from './globe_utils';
describe('globe utils', () => {
const digitsPrecision = 10;
test('getGlobeCircumferencePixels', () => {
expect(getGlobeCircumferencePixels({
worldSize: 1,
center: {
lat: 0
}
})).toBeCloseTo(1, digitsPrecision);
expect(getGlobeCircumferencePixels({
worldSize: 1,
center: {
lat: 60
}
})).toBeCloseTo(2, digitsPrecision);
});
test('globeDistanceOfLocationsPixels', () => {
expect(globeDistanceOfLocationsPixels({
worldSize: 1,
center: {
lat: 0
}
}, new LngLat(0, 0), new LngLat(90, 0))).toBeCloseTo(0.25, digitsPrecision);
expect(globeDistanceOfLocationsPixels({
worldSize: 1,
center: {
lat: 0
}
}, new LngLat(0, -45), new LngLat(0, 45))).toBeCloseTo(0.25, digitsPrecision);
expect(globeDistanceOfLocationsPixels({
worldSize: 1,
center: {
lat: 0
}
}, new LngLat(0, 0), new LngLat(45, 45))).toBeCloseTo(0.16666666666666666, digitsPrecision);
});
test('getZoomAdjustment', () => {
expect(getZoomAdjustment(0, 60)).toBeCloseTo(-1, digitsPrecision);
expect(getZoomAdjustment(60, 0)).toBeCloseTo(1, digitsPrecision);
});
});
+253
View File
@@ -0,0 +1,253 @@
import {type ReadonlyVec4, vec3} from 'gl-matrix';
import {clamp, createVec3f64, lerp, MAX_VALID_LATITUDE, mod, remapSaturate, scaleZoom, wrap} from '../../util/util';
import {LngLat} from '../lng_lat';
import {EXTENT} from '../../data/extent';
import type Point from '@mapbox/point-geometry';
export function getGlobeCircumferencePixels(transform: {worldSize: number; center: {lat: number}}): number {
const radius = getGlobeRadiusPixels(transform.worldSize, transform.center.lat);
const circumference = 2.0 * Math.PI * radius;
return circumference;
}
export function globeDistanceOfLocationsPixels(transform: {worldSize: number; center: {lat: number}}, a: LngLat, b: LngLat): number {
const vecA = angularCoordinatesToSurfaceVector(a);
const vecB = angularCoordinatesToSurfaceVector(b);
const dot = vec3.dot(vecA, vecB);
const radians = Math.acos(dot);
const circumference = getGlobeCircumferencePixels(transform);
return radians / (2.0 * Math.PI) * circumference;
}
/**
* For given mercator coordinates in range 0..1, returns the angular coordinates on the sphere's surface, in radians.
*/
export function mercatorCoordinatesToAngularCoordinatesRadians(mercatorX: number, mercatorY: number): [number, number] {
const sphericalX = mod(mercatorX * Math.PI * 2.0 + Math.PI, Math.PI * 2);
const sphericalY = 2.0 * Math.atan(Math.exp(Math.PI - (mercatorY * Math.PI * 2.0))) - Math.PI * 0.5;
return [sphericalX, sphericalY];
}
/**
* For a given longitude and latitude (note: in radians) returns the normalized vector from the planet center to the specified place on the surface.
* @param lngRadians - Longitude in radians.
* @param latRadians - Latitude in radians.
*/
export function angularCoordinatesRadiansToVector(lngRadians: number, latRadians: number): vec3 {
const len = Math.cos(latRadians);
const vec = new Float64Array(3) as any;
vec[0] = Math.sin(lngRadians) * len;
vec[1] = Math.sin(latRadians);
vec[2] = Math.cos(lngRadians) * len;
return vec;
}
/**
* Projects a point within a tile to the surface of the unit sphere globe.
* @param inTileX - X coordinate inside the tile in range [0 .. 8192].
* @param inTileY - Y coordinate inside the tile in range [0 .. 8192].
* @param tileIdX - Tile's X coordinate in range [0 .. 2^zoom - 1].
* @param tileIdY - Tile's Y coordinate in range [0 .. 2^zoom - 1].
* @param tileIdZ - Tile's zoom.
* @returns A 3D vector - coordinates of the projected point on a unit sphere.
*/
export function projectTileCoordinatesToSphere(inTileX: number, inTileY: number, tileIdX: number, tileIdY: number, tileIdZ: number): vec3 {
// This code could be assembled from 3 functions, but this is a hot path for symbol placement,
// so for optimization purposes everything is inlined by hand.
//
// Non-inlined variant of this function would be this:
// const mercator = tileCoordinatesToMercatorCoordinates(inTileX, inTileY, tileID);
// const angular = mercatorCoordinatesToAngularCoordinatesRadians(mercator.x, mercator.y);
// const sphere = angularCoordinatesRadiansToVector(angular[0], angular[1]);
// return sphere;
const scale = 1.0 / (1 << tileIdZ);
const mercatorX = inTileX / EXTENT * scale + tileIdX * scale;
const mercatorY = inTileY / EXTENT * scale + tileIdY * scale;
const sphericalX = mod(mercatorX * Math.PI * 2.0 + Math.PI, Math.PI * 2);
const sphericalY = 2.0 * Math.atan(Math.exp(Math.PI - (mercatorY * Math.PI * 2.0))) - Math.PI * 0.5;
const len = Math.cos(sphericalY);
const vec = new Float64Array(3) as any;
vec[0] = Math.sin(sphericalX) * len;
vec[1] = Math.sin(sphericalY);
vec[2] = Math.cos(sphericalX) * len;
return vec;
}
/**
* For a given longitude and latitude (note: in degrees) returns the normalized vector from the planet center to the specified place on the surface.
*/
export function angularCoordinatesToSurfaceVector(lngLat: LngLat): vec3 {
return angularCoordinatesRadiansToVector(lngLat.lng * Math.PI / 180, lngLat.lat * Math.PI / 180);
}
export function getGlobeRadiusPixels(worldSize: number, latitudeDegrees: number) {
// We want zoom levels to be consistent between globe and flat views.
// This means that the pixel size of features at the map center point
// should be the same for both globe and flat view.
// For this reason we scale the globe up when map center is nearer to the poles.
return worldSize / (2.0 * Math.PI) / Math.cos(latitudeDegrees * Math.PI / 180);
}
/**
* Given a 3D point on the surface of a unit sphere, returns its angular coordinates in degrees.
* The input vector must be normalized.
*/
export function sphereSurfacePointToCoordinates(surface: vec3): LngLat {
const latRadians = Math.asin(surface[1]);
const latDegrees = latRadians / Math.PI * 180.0;
const lengthXZ = Math.sqrt(surface[0] * surface[0] + surface[2] * surface[2]);
if (lengthXZ > 1e-6) {
const projX = surface[0] / lengthXZ;
const projZ = surface[2] / lengthXZ;
const acosZ = Math.acos(projZ);
const lngRadians = (projX > 0) ? acosZ : -acosZ;
const lngDegrees = lngRadians / Math.PI * 180.0;
return new LngLat(wrap(lngDegrees, -180, 180), latDegrees);
} else {
return new LngLat(0.0, latDegrees);
}
}
/**
* Given a normalized horizon plane in Ax+By+Cz+D=0 format, compute the center and radius of
* the circle in that plain that contains the entire visible portion of the unit sphere from horizon
* to horizon.
* @param horizonPlane - The plane that passes through visible horizon in Ax + By + Cz + D = 0 format where mag(A,B,C)=1
* @returns the center point and radius of the disc that passes through the entire visible horizon
*/
export function horizonPlaneToCenterAndRadius(horizonPlane: ReadonlyVec4): { center: vec3; radius: number } {
const center = createVec3f64();
center[0] = horizonPlane[0] * -horizonPlane[3];
center[1] = horizonPlane[1] * -horizonPlane[3];
center[2] = horizonPlane[2] * -horizonPlane[3];
/*
.*******
****|\
** | \
** | 1
* radius | \
* | \
* center +--D--+(0,0,0)
*/
const radius = Math.sqrt(1 - horizonPlane[3] * horizonPlane[3]);
return {center, radius};
}
/**
* Computes the closest point on a sphere to `point`.
* @param center - Center of the sphere
* @param radius - Radius of the sphere
* @param point - Point inside or outside the sphere
* @returns A 3d vector of the point on the sphere closest to `point`
*/
export function clampToSphere(center: vec3, radius: number, point: vec3) {
const relativeToCenter = createVec3f64();
vec3.sub(relativeToCenter, point, center);
const clamped = createVec3f64();
vec3.scaleAndAdd(clamped, center, relativeToCenter, radius / vec3.len(relativeToCenter));
return clamped;
}
function planetScaleAtLatitude(latitudeDegrees: number): number {
return Math.cos(latitudeDegrees * Math.PI / 180);
}
/**
* Computes how much to modify zoom to keep the globe size constant when changing latitude.
* @param transform - An instance of any transform. Does not have any relation on the computed values.
* @param oldLat - Latitude before change, in degrees.
* @param newLat - Latitude after change, in degrees.
* @returns A value to add to zoom level used for old latitude to keep same planet radius at new latitude.
*/
export function getZoomAdjustment(oldLat: number, newLat: number): number {
const oldCircumference = planetScaleAtLatitude(oldLat);
const newCircumference = planetScaleAtLatitude(newLat);
return scaleZoom(newCircumference / oldCircumference);
}
export function getDegreesPerPixel(worldSize: number, lat: number): number {
return 360.0 / getGlobeCircumferencePixels({worldSize, center: {lat}});
}
/**
* Returns transform's new center rotation after applying panning.
* @param panDelta - Panning delta, in same units as what is supplied to {@link HandlerManager}.
* @param tr - Current transform. This object is not modified by the function.
* @returns New center location to set to the map's transform to apply the specified panning.
*/
export function computeGlobePanCenter(panDelta: Point, tr: {
readonly bearingInRadians: number;
readonly worldSize: number;
readonly center: LngLat;
readonly zoom: number;
}): LngLat {
// Apply map bearing to the panning vector
const rotatedPanDelta = panDelta.rotate(tr.bearingInRadians);
// Compute what the current zoom would be if the transform center would be moved to latitude 0.
const normalizedGlobeZoom = tr.zoom + getZoomAdjustment(tr.center.lat, 0);
// Note: we divide longitude speed by planet width at the given latitude. But we diminish this effect when the globe is zoomed out a lot.
const lngSpeed = lerp(
1.0 / planetScaleAtLatitude(tr.center.lat), // speed adjusted by latitude
1.0 / planetScaleAtLatitude(Math.min(Math.abs(tr.center.lat), 60)), // also adjusted, but latitude is clamped to 60° to avoid too large speeds near poles
remapSaturate(normalizedGlobeZoom, 7, 3, 0, 1.0) // Values chosen so that globe interactions feel good. Not scientific by any means.
);
const panningDegreesPerPixel = getDegreesPerPixel(tr.worldSize, tr.center.lat);
return new LngLat(
tr.center.lng - rotatedPanDelta.x * panningDegreesPerPixel * lngSpeed,
clamp(tr.center.lat + rotatedPanDelta.y * panningDegreesPerPixel, -MAX_VALID_LATITUDE, MAX_VALID_LATITUDE)
);
}
/**
* Integration of `1 / cos(x)`.
*/
function integrateSecX(x: number): number {
const xHalf = 0.5 * x;
const sin = Math.sin(xHalf);
const cos = Math.cos(xHalf);
return Math.log(sin + cos) - Math.log(cos - sin);
}
/**
* Interpolates globe center between two locations while preserving apparent rotation speed during interpolation.
* @param start - The starting location of the interpolation.
* @param deltaLng - Longitude delta to the end of the interpolation.
* @param deltaLat - Latitude delta to the end of the interpolation.
* @param t - The interpolation point in [0..1], where 0 is starting location, 1 is end location and other values are in between.
* @returns The interpolated location.
*/
export function interpolateLngLatForGlobe(start: LngLat, deltaLng: number, deltaLat: number, t: number): LngLat {
// Rate of change of longitude when moving the globe should be roughly 1/cos(latitude)
// We want to use this rate of change, even for interpolation during easing.
// Thus we know the derivative of our interpolation function: 1/cos(x)
// To get our interpolation function, we need to integrate that.
const interpolatedLat = start.lat + deltaLat * t;
if (Math.abs(deltaLat) > 1) {
const endLat = start.lat + deltaLat;
const onDifferentHemispheres = Math.sign(endLat) !== Math.sign(start.lat);
// Where do we sample the integrated speed curve?
const samplePointStart = (onDifferentHemispheres ? -Math.abs(start.lat) : Math.abs(start.lat)) * Math.PI / 180;
const samplePointEnd = Math.abs(start.lat + deltaLat) * Math.PI / 180;
// Read the integrated speed curve at those points, and at the interpolation value "t".
const valueT = integrateSecX(samplePointStart + t * (samplePointEnd - samplePointStart));
const valueStart = integrateSecX(samplePointStart);
const valueEnd = integrateSecX(samplePointEnd);
// Compute new interpolation factor based on the speed curve
const newT = (valueT - valueStart) / (valueEnd - valueStart);
// Interpolate using that factor
const interpolatedLng = start.lng + deltaLng * newT;
return new LngLat(
interpolatedLng,
interpolatedLat
);
} else {
// Fall back to simple interpolation when latitude doesn't change much.
const interpolatedLng = start.lng + deltaLng * t;
return new LngLat(
interpolatedLng,
interpolatedLat
);
}
}
+187
View File
@@ -0,0 +1,187 @@
import type Point from '@mapbox/point-geometry';
import {LngLat, type LngLatLike} from '../lng_lat';
import {cameraForBoxAndBearing, type CameraForBoxAndBearingHandlerResult, type EaseToHandlerResult, type EaseToHandlerOptions, type FlyToHandlerResult, type FlyToHandlerOptions, type ICameraHelper, type MapControlsDeltas, updateRotation, type UpdateRotationArgs} from './camera_helper';
import {normalizeCenter} from '../transform_helper';
import {rollPitchBearingEqual, scaleZoom, zoomScale} from '../../util/util';
import {getMercatorHorizon, projectToWorldCoordinates, unprojectFromWorldCoordinates} from './mercator_utils';
import {interpolates} from '@maplibre/maplibre-gl-style-spec';
import type {IReadonlyTransform, ITransform} from '../transform_interface';
import type {CameraForBoundsOptions} from '../../ui/camera';
import type {PaddingOptions} from '../edge_insets';
import type {LngLatBounds} from '../lng_lat_bounds';
/**
* @internal
*/
export class MercatorCameraHelper implements ICameraHelper {
get useGlobeControls(): boolean { return false; }
handlePanInertia(pan: Point, transform: IReadonlyTransform): {
easingCenter: LngLat;
easingOffset: Point;
} {
// Reduce the offset so that it never goes past the horizon. If it goes past
// the horizon, the pan direction is opposite of the intended direction.
const offsetLength = pan.mag();
const pixelsToHorizon = Math.abs(getMercatorHorizon(transform));
const horizonFactor = 0.75; // Must be < 1 to prevent the offset from crossing the horizon
const offsetAsPoint = pan.mult(Math.min(pixelsToHorizon * horizonFactor / offsetLength, 1.0));
return {
easingOffset: offsetAsPoint,
easingCenter: transform.center,
};
}
handleMapControlsRollPitchBearingZoom(deltas: MapControlsDeltas, tr: ITransform): void {
if (deltas.bearingDelta) tr.setBearing(tr.bearing + deltas.bearingDelta);
if (deltas.pitchDelta) tr.setPitch(tr.pitch + deltas.pitchDelta);
if (deltas.rollDelta) tr.setRoll(tr.roll + deltas.rollDelta);
if (deltas.zoomDelta) tr.setZoom(tr.zoom + deltas.zoomDelta);
}
handleMapControlsPan(deltas: MapControlsDeltas, tr: ITransform, preZoomAroundLoc: LngLat): void {
// If we are rotating about the center point, there is no need to update the transform center. Doing so causes
// a small amount of drift of the center point, especially when pitch is close to 90 degrees.
// In this case, return early.
if (deltas.around.distSqr(tr.centerPoint) < 1.0e-2) {
return;
}
tr.setLocationAtPoint(preZoomAroundLoc, deltas.around);
}
cameraForBoxAndBearing(options: CameraForBoundsOptions, padding: PaddingOptions, bounds: LngLatBounds, bearing: number, tr: IReadonlyTransform): CameraForBoxAndBearingHandlerResult {
return cameraForBoxAndBearing(options, padding, bounds, bearing, tr);
}
handleJumpToCenterZoom(tr: ITransform, options: { zoom?: number; center?: LngLatLike }): void {
// Mercator zoom & center handling.
const optionsZoom = typeof options.zoom !== 'undefined';
const zoom = optionsZoom ? +options.zoom : tr.zoom;
if (tr.zoom !== zoom) {
tr.setZoom(+options.zoom);
}
if (options.center !== undefined) {
tr.setCenter(LngLat.convert(options.center));
}
}
handleEaseTo(tr: ITransform, options: EaseToHandlerOptions): EaseToHandlerResult {
const startZoom = tr.zoom;
const startPadding = tr.padding;
const startEulerAngles = {roll: tr.roll, pitch: tr.pitch, bearing: tr.bearing};
const endRoll = options.roll === undefined ? tr.roll : options.roll;
const endPitch = options.pitch === undefined ? tr.pitch : options.pitch;
const endBearing = options.bearing === undefined ? tr.bearing : options.bearing;
const endEulerAngles = {roll: endRoll, pitch: endPitch, bearing: endBearing};
const optionsZoom = typeof options.zoom !== 'undefined';
const doPadding = !tr.isPaddingEqual(options.padding);
let isZooming = false;
const zoom = optionsZoom ? +options.zoom : tr.zoom;
let pointAtOffset = tr.centerPoint.add(options.offsetAsPoint);
const locationAtOffset = tr.screenPointToLocation(pointAtOffset);
const {center, zoom: endZoom} = tr.applyConstrain(
LngLat.convert(options.center || locationAtOffset),
zoom ?? startZoom
);
normalizeCenter(tr, center);
const from = projectToWorldCoordinates(tr.worldSize, locationAtOffset);
const delta = projectToWorldCoordinates(tr.worldSize, center).sub(from);
const finalScale = zoomScale(endZoom - startZoom);
isZooming = (endZoom !== startZoom);
const easeFunc = (k: number) => {
if (isZooming) {
tr.setZoom(interpolates.number(startZoom, endZoom, k));
}
if (!rollPitchBearingEqual(startEulerAngles, endEulerAngles)) {
updateRotation({
startEulerAngles,
endEulerAngles,
tr,
k,
useSlerp: startEulerAngles.roll != endEulerAngles.roll} as UpdateRotationArgs);
}
if (doPadding) {
tr.interpolatePadding(startPadding, options.padding, k);
// When padding is being applied, Transform.centerPoint is changing continuously,
// thus we need to recalculate offsetPoint every frame
pointAtOffset = tr.centerPoint.add(options.offsetAsPoint);
}
if (options.around) {
tr.setLocationAtPoint(options.around, options.aroundPoint);
} else {
const scale = zoomScale(tr.zoom - startZoom);
const base = endZoom > startZoom ?
Math.min(2, finalScale) :
Math.max(0.5, finalScale);
const speedup = Math.pow(base, 1 - k);
const newCenter = unprojectFromWorldCoordinates(tr.worldSize, from.add(delta.mult(k * speedup)).mult(scale));
tr.setLocationAtPoint(tr.renderWorldCopies ? newCenter.wrap() : newCenter, pointAtOffset);
}
};
return {
easeFunc,
isZooming,
elevationCenter: center,
};
}
handleFlyTo(tr: ITransform, options: FlyToHandlerOptions): FlyToHandlerResult {
const optionsZoom = typeof options.zoom !== 'undefined';
const startZoom = tr.zoom;
// Obtain target center and zoom
const constrained = tr.applyConstrain(
LngLat.convert(options.center || options.locationAtOffset),
optionsZoom ? +options.zoom : startZoom
);
const targetCenter = constrained.center;
const targetZoom = constrained.zoom;
normalizeCenter(tr, targetCenter);
const from = projectToWorldCoordinates(tr.worldSize, options.locationAtOffset);
const delta = projectToWorldCoordinates(tr.worldSize, targetCenter).sub(from);
const pixelPathLength = delta.mag();
const scaleOfZoom = zoomScale(targetZoom - startZoom);
const optionsMinZoom = typeof options.minZoom !== 'undefined';
let scaleOfMinZoom: number;
if (optionsMinZoom) {
const minZoomPreConstrain = Math.min(+options.minZoom, startZoom, targetZoom);
const minZoom = tr.applyConstrain(targetCenter, minZoomPreConstrain).zoom;
scaleOfMinZoom = zoomScale(minZoom - startZoom);
}
const easeFunc = (k: number, scale: number, centerFactor: number, pointAtOffset: Point) => {
tr.setZoom(k === 1 ? targetZoom : startZoom + scaleZoom(scale));
const newCenter = k === 1 ? targetCenter : unprojectFromWorldCoordinates(tr.worldSize, from.add(delta.mult(centerFactor)).mult(scale));
tr.setLocationAtPoint(tr.renderWorldCopies ? newCenter.wrap() : newCenter, pointAtOffset);
};
return {
easeFunc,
scaleOfZoom,
targetCenter,
scaleOfMinZoom,
pixelPathLength,
};
}
}
@@ -0,0 +1,55 @@
import {OverscaledTileID} from '../../tile/tile_id';
import {Aabb} from '../../util/primitives/aabb';
import {clamp} from '../../util/util';
import {type MercatorCoordinate} from '../mercator_coordinate';
import {type IReadonlyTransform} from '../transform_interface';
import {type CoveringTilesOptionsInternal} from './covering_tiles';
import {type CoveringTilesDetailsProvider} from './covering_tiles_details_provider';
export class MercatorCoveringTilesDetailsProvider implements CoveringTilesDetailsProvider {
distanceToTile2d(pointX: number, pointY: number, _tileID: {x: number; y: number; z: number}, aabb: Aabb): number {
const distanceX = aabb.distanceX([pointX, pointY]);
const distanceY = aabb.distanceY([pointX, pointY]);
return Math.hypot(distanceX, distanceY);
}
/**
* Returns the wrap value for a given tile, computed so that tiles will remain loaded when crossing the antimeridian.
*/
getWrap(centerCoord: MercatorCoordinate, tileID: {x:number; y: number; z: number}, parentWrap: number): number {
return parentWrap;
}
/**
* Returns the AABB of the specified tile.
* @param tileID - Tile x, y and z for zoom.
*/
getTileBoundingVolume(tileID: {x: number; y: number; z: number}, wrap: number, elevation: number, options: CoveringTilesOptionsInternal): Aabb {
let minElevation = 0;
let maxElevation = 0;
if (options?.terrain) {
const overscaledTileID = new OverscaledTileID(tileID.z, wrap, tileID.z, tileID.x, tileID.y);
const minMax = options.terrain.getMinMaxElevation(overscaledTileID);
minElevation = minMax.minElevation ?? Math.min(0, elevation);
maxElevation = minMax.maxElevation ?? Math.max(0, elevation);
}
const numTiles = 1 << tileID.z;
return new Aabb([wrap + tileID.x / numTiles, tileID.y / numTiles, minElevation],
[wrap + (tileID.x + 1) / numTiles, (tileID.y + 1) / numTiles, maxElevation]);
}
allowVariableZoom(transform: IReadonlyTransform, options: CoveringTilesOptionsInternal): boolean {
const zfov = transform.fov * (Math.abs(Math.cos(transform.rollInRadians)) * transform.height + Math.abs(Math.sin(transform.rollInRadians)) * transform.width) / transform.height;
const maxConstantZoomPitch = clamp(78.5 - zfov / 2, 0.0, 60.0);
return (!!options.terrain || transform.pitch > maxConstantZoomPitch);
}
allowWorldCopies(): boolean {
return true;
}
prepareNextFrame(): void {
// Do nothing
}
}
+102
View File
@@ -0,0 +1,102 @@
import type {Projection, ProjectionGPUContext, TileMeshUsage} from './projection';
import type {CanonicalTileID} from '../../tile/tile_id';
import {EXTENT} from '../../data/extent';
import {type PreparedShader, shaders} from '../../shaders/shaders';
import type {Context} from '../../gl/context';
import {Mesh} from '../../render/mesh';
import {PosArray, TriangleIndexArray} from '../../data/array_types.g';
import {SegmentVector} from '../../data/segment';
import posAttributes from '../../data/pos_attributes';
import {SubdivisionGranularitySetting} from '../../render/subdivision_granularity_settings';
export const MercatorShaderDefine = '#define PROJECTION_MERCATOR';
export const MercatorShaderVariantKey = 'mercator';
export class MercatorProjection implements Projection {
private _cachedMesh: Mesh = null;
get name(): 'mercator' {
return 'mercator';
}
get useSubdivision(): boolean {
// Mercator never uses subdivision.
return false;
}
get shaderVariantName(): string {
return MercatorShaderVariantKey;
}
get shaderDefine(): string {
return MercatorShaderDefine;
}
get shaderPreludeCode(): PreparedShader {
return shaders.projectionMercator;
}
get vertexShaderPreludeCode(): string {
return shaders.projectionMercator.vertexSource;
}
get subdivisionGranularity(): SubdivisionGranularitySetting {
return SubdivisionGranularitySetting.noSubdivision;
}
get useGlobeControls(): boolean {
return false;
}
get transitionState(): number {
return 0;
}
get latitudeErrorCorrectionRadians(): number {
return 0;
}
public destroy(): void {
// Do nothing.
}
public updateGPUdependent(_: ProjectionGPUContext): void {
// Do nothing.
}
public getMeshFromTileID(context: Context, _tileID: CanonicalTileID, _hasBorder: boolean, _allowPoles: boolean, _usage: TileMeshUsage): Mesh {
if (this._cachedMesh) {
return this._cachedMesh;
}
// The parameters tileID, hasBorder and allowPoles are all ignored on purpose for mercator meshes.
const tileExtentArray = new PosArray();
tileExtentArray.emplaceBack(0, 0);
tileExtentArray.emplaceBack(EXTENT, 0);
tileExtentArray.emplaceBack(0, EXTENT);
tileExtentArray.emplaceBack(EXTENT, EXTENT);
const tileExtentBuffer = context.createVertexBuffer(tileExtentArray, posAttributes.members);
const tileExtentSegments = SegmentVector.simpleSegment(0, 0, 4, 2);
const quadTriangleIndices = new TriangleIndexArray();
quadTriangleIndices.emplaceBack(1, 0, 2);
quadTriangleIndices.emplaceBack(1, 2, 3);
const quadTriangleIndexBuffer = context.createIndexBuffer(quadTriangleIndices);
this._cachedMesh = new Mesh(tileExtentBuffer, quadTriangleIndexBuffer, tileExtentSegments);
return this._cachedMesh;
}
public recalculate(): void {
// Do nothing.
}
public hasTransition(): boolean {
return false;
}
setErrorQueryLatitudeDegrees(_value: number) {
// Do nothing.
}
}
+619
View File
@@ -0,0 +1,619 @@
import {describe, test, expect} from 'vitest';
import Point from '@mapbox/point-geometry';
import {LngLat} from '../lng_lat';
import {CanonicalTileID, UnwrappedTileID} from '../../tile/tile_id';
import {fixedLngLat, fixedCoord} from '../../../test/unit/lib/fixed';
import type {Terrain} from '../../render/terrain';
import {MercatorTransform} from './mercator_transform';
import {LngLatBounds} from '../lng_lat_bounds';
import {getMercatorHorizon} from './mercator_utils';
import {mat4} from 'gl-matrix';
import {expectToBeCloseToArray} from '../../util/test/util';
describe('transform', () => {
test('creates a transform', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.resize(500, 500);
expect(transform.unmodified).toBe(true);
expect(transform.tileSize).toBe(512);
expect(transform.worldSize).toBe(512);
expect(transform.width).toBe(500);
expect(transform.minZoom).toBe(0);
expect(transform.minPitch).toBe(0);
// Support signed zero
expect(transform.bearing === 0 ? 0 : transform.bearing).toBe(0);
transform.setBearing(1);
expect(transform.bearing).toBe(1);
expect([...transform.rotationMatrix]).toEqual([0.9998477101325989, -0.017452405765652657, 0.017452405765652657, 0.9998477101325989]);
transform.setBearing(0);
expect(transform.bearing).toBe(0);
expect(transform.unmodified).toBe(false);
transform.setMinZoom(10);
expect(transform.minZoom).toBe(10);
transform.setMaxZoom(10);
expect(transform.maxZoom).toBe(10);
expect(transform.minZoom).toBe(10);
expect(transform.center).toEqual({lng: 0, lat: 0});
expect(transform.maxZoom).toBe(10);
transform.setMinPitch(10);
expect(transform.minPitch).toBe(10);
transform.setMaxPitch(10);
expect(transform.maxPitch).toBe(10);
expect(transform.size.equals(new Point(500, 500))).toBe(true);
expect(transform.centerPoint.equals(new Point(250, 250))).toBe(true);
expect(transform.height).toBe(500);
expect(transform.nearZ).toBe(10);
expect(transform.farZ).toBe(804.8028169246645);
expect([...transform.projectionMatrix]).toEqual([3, 0, 0, 0, 0, 3, 0, 0, -0, 0, -1.0251635313034058, -1, 0, 0, -20.25163459777832, 0]);
expectToBeCloseToArray([...transform.inverseProjectionMatrix], [0.3333333333333333, 0, 0, 0, 0, 0.3333333333333333, 0, 0, 0, 0, 0, -0.04937872980873673, 0, 0, -1, 0.05062127019126326], 10);
expectToBeCloseToArray([...mat4.multiply(new Float64Array(16) as any, transform.projectionMatrix, transform.inverseProjectionMatrix)], [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1], 6);
expect([...transform.modelViewProjectionMatrix]).toEqual([3, 0, 0, 0, 0, -2.954423259036624, -0.1780177690666898, -0.17364817766693033, -0, 0.006822967915294533, -0.013222891287479163, -0.012898324631281611, -786432, 774484.3308168967, 47414.91102496082, 46270.827886319785]);
expect(fixedLngLat(transform.screenPointToLocation(new Point(250, 250)))).toEqual({lng: 0, lat: 0});
expect(fixedCoord(transform.screenPointToMercatorCoordinate(new Point(250, 250)))).toEqual({x: 0.5, y: 0.5, z: 0});
expect(fixedCoord(transform.screenPointToMercatorCoordinateAtZ(new Point(250, 250), 1))).toEqual({x: 0.5, y: 0.5000000044, z: 1});
expect(transform.locationToScreenPoint(new LngLat(0, 0))).toEqual({x: 250, y: 250});
});
test('does not throw on bad center', () => {
expect(() => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.resize(500, 500);
transform.setCenter(new LngLat(50, -90));
}).not.toThrow();
});
test('setLocationAt', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.resize(500, 500);
transform.setZoom(4);
expect(transform.center).toEqual({lng: 0, lat: 0});
transform.setLocationAtPoint(new LngLat(13, 10), new Point(15, 45));
expect(fixedLngLat(transform.screenPointToLocation(new Point(15, 45)))).toEqual({lng: 13, lat: 10});
});
test('setLocationAt tilted', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.resize(500, 500);
transform.setZoom(4);
transform.setPitch(50);
expect(transform.center).toEqual({lng: 0, lat: 0});
transform.setLocationAtPoint(new LngLat(13, 10), new Point(15, 45));
expect(fixedLngLat(transform.screenPointToLocation(new Point(15, 45)))).toEqual({lng: 13, lat: 10});
});
test('setLocationAt tilted rolled', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.resize(500, 500);
transform.setZoom(4);
transform.setPitch(50);
transform.setRoll(50);
expect(transform.center).toEqual({lng: 0, lat: 0});
transform.setLocationAtPoint(new LngLat(13, 10), new Point(15, 45));
expect(fixedLngLat(transform.screenPointToLocation(new Point(15, 45)))).toEqual({lng: 13, lat: 10});
});
test('has a default zoom', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.resize(500, 500);
expect(transform.tileZoom).toBe(0);
expect(transform.tileZoom).toBe(transform.zoom);
});
test('set zoom inits tileZoom with zoom value', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60});
transform.setZoom(5);
expect(transform.tileZoom).toBe(5);
});
test('set zoom clamps tileZoom to non negative value ', () => {
const transform = new MercatorTransform({minZoom: -2, maxZoom: 22, minPitch: 0, maxPitch: 60});
transform.setZoom(-2);
expect(transform.tileZoom).toBe(0);
});
test('set fov', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.setFov(10);
expect(transform.fov).toBe(10);
transform.setFov(10);
expect(transform.fov).toBe(10);
});
test('lngRange & latRange constrain zoom and center', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.setCenter(new LngLat(0, 0));
transform.setZoom(10);
transform.resize(500, 500);
transform.setMaxBounds(new LngLatBounds([-5, -5, 5, 5]));
transform.setZoom(0);
expect(transform.zoom).toBe(5.1357092861044045);
transform.setCenter(new LngLat(-50, -30));
expect(transform.center).toEqual(new LngLat(0, -0.0063583052861417855));
transform.setZoom(10);
transform.setCenter(new LngLat(-50, -30));
expect(transform.center).toEqual(new LngLat(-4.828338623046875, -4.828969771321582));
});
test('lngRange & latRange constrain zoom and center after cloning', () => {
const old = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
old.setCenter(new LngLat(0, 0));
old.setZoom(10);
old.resize(500, 500);
old.setMaxBounds(new LngLatBounds([-5, -5, 5, 5]));
const transform = old.clone();
transform.setZoom(0);
expect(transform.zoom).toBe(5.1357092861044045);
transform.setCenter(new LngLat(-50, -30));
expect(transform.center).toEqual(new LngLat(0, -0.0063583052861417855));
transform.setZoom(10);
transform.setCenter(new LngLat(-50, -30));
expect(transform.center).toEqual(new LngLat(-4.828338623046875, -4.828969771321582));
});
test('lngRange can constrain zoom and center across meridian', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.setCenter(new LngLat(180, 0));
transform.setZoom(10);
transform.resize(500, 500);
// equivalent ranges
const lngRanges: [number, number][] = [
[175, -175], [175, 185], [-185, -175], [-185, 185]
];
for (const lngRange of lngRanges) {
transform.setMaxBounds(new LngLatBounds([lngRange[0], -5, lngRange[1], 5]));
transform.setZoom(0);
expect(transform.zoom).toBe(5.1357092861044045);
transform.setCenter(new LngLat(-50, -30));
expect(transform.center).toEqual(new LngLat(180, -0.0063583052861417855));
transform.setZoom(10);
transform.setCenter(new LngLat(-50, -30));
expect(transform.center).toEqual(new LngLat(-175.171661376953125, -4.828969771321582));
transform.setCenter(new LngLat(230, 0));
expect(transform.center).toEqual(new LngLat(-175.171661376953125, 0));
transform.setCenter(new LngLat(130, 0));
expect(transform.center).toEqual(new LngLat(175.171661376953125, 0));
}
});
test('clamps pitch', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.setPitch(45);
expect(transform.pitch).toBe(45);
transform.setPitch(-10);
expect(transform.pitch).toBe(0);
transform.setPitch(90);
expect(transform.pitch).toBe(60);
});
test('visibleUnwrappedCoordinates', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.resize(200, 200);
transform.setZoom(0);
transform.setCenter(new LngLat(-170.01, 0.01));
let unwrappedCoords = transform.getVisibleUnwrappedCoordinates(new CanonicalTileID(0, 0, 0));
expect(unwrappedCoords).toHaveLength(4);
//getVisibleUnwrappedCoordinates should honor _renderWorldCopies
transform.setRenderWorldCopies(false);
unwrappedCoords = transform.getVisibleUnwrappedCoordinates(new CanonicalTileID(0, 0, 0));
expect(unwrappedCoords).toHaveLength(1);
});
test('maintains high float precision when calculating matrices', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.resize(200.25, 200.25);
transform.setZoom(20.25);
transform.setPitch(67.25);
transform.setCenter(new LngLat(0.0, 0.0));
const customLayerMatrix = transform.getProjectionDataForCustomLayer().mainMatrix;
expect(customLayerMatrix[0].toString().length).toBeGreaterThan(9);
expect(transform.pixelsToClipSpaceMatrix[0].toString().length).toBeGreaterThan(9);
expect(transform.maxPitchScaleFactor()).toBeCloseTo(2.366025418080343, 5);
});
test('recalculateZoomAndCenter: no change', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.setElevation(200);
transform.setCenter(new LngLat(10.0, 50.0));
transform.setZoom(14);
transform.setPitch(45);
transform.resize(512, 512);
// This should be an invariant throughout - the zoom is greater when the camera is
// closer to the terrain (and therefore also when the terrain is closer to the camera),
// but that shouldn't change the camera's position in world space if that wasn't requested.
const expectedAltitude = 1865.7579397718;
expect(transform.getCameraAltitude()).toBeCloseTo(expectedAltitude, 10);
const expectedCamLngLat = transform.getCameraLngLat();
expect(expectedCamLngLat.lng).toBeCloseTo(10, 10);
expect(expectedCamLngLat.lat).toBeCloseTo(49.9850171656428, 10);
// expect same values because of no elevation change
const terrain = {
getElevationForLngLatZoom: () => 200,
pointCoordinate: () => null
};
transform.recalculateZoomAndCenter(terrain as any);
expect(transform.getCameraAltitude()).toBeCloseTo(expectedAltitude, 10);
expect(transform.zoom).toBe(14);
});
test('recalculateZoomAndCenter: small elevation change at extreme latitude does not drastically shift center', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.setElevation(200);
transform.setPitch(60);
transform.setZoom(3);
transform.setCenter(new LngLat(0, 82));
transform.resize(512, 512);
expect(transform.center.lat).toBeCloseTo(82, 10);
const terrain = {
getElevationForLngLatZoom: () => 200 + 1,
pointCoordinate: () => null
};
transform.recalculateZoomAndCenter(terrain as any);
expect(transform.center.lat).toBeCloseTo(82, 4);
});
test('recalculateZoomAndCenter: elevation increase', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.setElevation(200);
transform.setCenter(new LngLat(10.0, 50.0));
transform.setZoom(14);
transform.setPitch(45);
transform.resize(512, 512);
// This should be an invariant throughout - the zoom is greater when the camera is
// closer to the terrain (and therefore also when the terrain is closer to the camera),
// but that shouldn't change the camera's position in world space if that wasn't requested.
const expectedAltitude = 1865.7579397718;
expect(transform.getCameraAltitude()).toBeCloseTo(expectedAltitude, 10);
const expectedCamLngLat = transform.getCameraLngLat();
expect(expectedCamLngLat.lng).toBeCloseTo(10, 10);
expect(expectedCamLngLat.lat).toBeCloseTo(49.9850171656428, 10);
// expect new zoom and center because of elevation change
const terrain = {
getElevationForLngLatZoom: () => 400,
pointCoordinate: () => null
};
transform.recalculateZoomAndCenter(terrain as any);
expect(transform.elevation).toBe(400);
expect(transform.center.lng).toBeCloseTo(10, 10);
expect(transform.center.lat).toBeCloseTo(49.998201325627264, 10);
expect(transform.getCameraLngLat().lng).toBeCloseTo(expectedCamLngLat.lng, 10);
// Latitude precision is lower as a compromise to a stable recalculateZoomAndCenter at extreme latitudes
expect(transform.getCameraLngLat().lat).toBeCloseTo(expectedCamLngLat.lat, 5);
expect(transform.getCameraAltitude()).toBeCloseTo(expectedAltitude, 10);
expect(transform.zoom).toBeCloseTo(14.184585871638795, 10);
});
test('recalculateZoomAndCenter: elevation decrease', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.setElevation(200);
transform.setCenter(new LngLat(10.0, 50.0));
transform.setZoom(14);
transform.setPitch(45);
transform.resize(512, 512);
// This should be an invariant throughout - the zoom is greater when the camera is
// closer to the terrain (and therefore also when the terrain is closer to the camera),
// but that shouldn't change the camera's position in world space if that wasn't requested.
const expectedAltitude = 1865.7579397718;
expect(transform.getCameraAltitude()).toBeCloseTo(expectedAltitude, 10);
const expectedCamLngLat = transform.getCameraLngLat();
expect(expectedCamLngLat.lng).toBeCloseTo(10, 10);
expect(expectedCamLngLat.lat).toBeCloseTo(49.9850171656428, 10);
// expect new zoom because of elevation change to point below sea level
const terrain = {
getElevationForLngLatZoom: () => -200,
pointCoordinate: () => null
};
transform.recalculateZoomAndCenter(terrain as any);
expect(transform.elevation).toBe(-200);
expect(transform.getCameraLngLat().lng).toBeCloseTo(expectedCamLngLat.lng, 10);
// Latitude precision is lower as a compromise to a stable recalculateZoomAndCenter at extreme latitudes
expect(transform.getCameraLngLat().lat).toBeCloseTo(expectedCamLngLat.lat, 5);
expect(transform.getCameraAltitude()).toBeCloseTo(expectedAltitude, 10);
expect(transform.zoom).toBeCloseTo(13.68939960698451, 10);
});
test('recalculateZoomAndCenterNoTerrain', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.setElevation(200);
transform.setCenter(new LngLat(10.0, 50.0));
transform.setZoom(14);
transform.setPitch(45);
transform.resize(512, 512);
// This should be an invariant throughout - the zoom is greater when the camera is
// closer to the terrain (and therefore also when the terrain is closer to the camera),
// but that shouldn't change the camera's position in world space if that wasn't requested.
const expectedAltitude = 1865.7579397718;
expect(transform.getCameraAltitude()).toBeCloseTo(expectedAltitude, 10);
const expectedCamLngLat = transform.getCameraLngLat();
expect(expectedCamLngLat.lng).toBeCloseTo(10, 10);
expect(expectedCamLngLat.lat).toBeCloseTo(49.9850171656428, 10);
// expect same values because of no elevation change
transform.recalculateZoomAndCenter();
expect(transform.elevation).toBeCloseTo(0, 10);
expect(transform.center.lng).toBeCloseTo(10, 10);
expect(transform.center.lat).toBeCloseTo(50.00179860708241, 10);
expect(transform.getCameraLngLat().lng).toBeCloseTo(expectedCamLngLat.lng, 10);
// Latitude precision is lower as a compromise to a stable recalculateZoomAndCenter at extreme latitudes
expect(transform.getCameraLngLat().lat).toBeCloseTo(expectedCamLngLat.lat, 5);
expect(transform.getCameraAltitude()).toBeCloseTo(expectedAltitude, 10);
expect(transform.zoom).toBeCloseTo(13.836362970131438, 10);
});
test('pointCoordinate with terrain when returning null should fall back to 2D', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.resize(500, 500);
const terrain = {
pointCoordinate: () => null
} as any as Terrain;
const coordinate = transform.screenPointToMercatorCoordinate(new Point(0, 0), terrain);
expect(coordinate).toBeDefined();
});
test('getBounds with horizon', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
transform.resize(500, 500);
transform.setPitch(60);
expect(transform.getBounds().getNorthWest().toArray()).toStrictEqual(transform.screenPointToLocation(new Point(0, 0)).toArray());
transform.setPitch(75);
const top = Math.max(0, transform.height / 2 - getMercatorHorizon(transform));
expect(top).toBeCloseTo(79.1823898251593, 10);
expect(transform.getBounds().getNorthWest().toArray()).toStrictEqual(transform.screenPointToLocation(new Point(0, top)).toArray());
});
test('lngLatToCameraDepth', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
transform.resize(500, 500);
transform.setCenter(new LngLat(10.0, 50.0));
expect(transform.lngLatToCameraDepth(new LngLat(10, 50), 4)).toBeCloseTo(0.9997324396231673);
transform.setPitch(60);
expect(transform.lngLatToCameraDepth(new LngLat(10, 50), 4)).toBeCloseTo(0.9865782165762236);
});
test('projectTileCoordinates', () => {
const precisionDigits = 10;
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
transform.resize(500, 500);
transform.setCenter(new LngLat(10.0, 50.0));
let projection = transform.projectTileCoordinates(1024, 1024, new UnwrappedTileID(0, new CanonicalTileID(1, 1, 0)), (_x, _y) => 0);
expect(projection.point.x).toBeCloseTo(0.07111111111111101, precisionDigits);
expect(projection.point.y).toBeCloseTo(0.8719999854792714, precisionDigits);
expect(projection.signedDistanceFromCamera).toBeCloseTo(750, precisionDigits);
expect(projection.isOccluded).toBe(false);
transform.setBearing(12);
transform.setPitch(10);
projection = transform.projectTileCoordinates(1024, 1024, new UnwrappedTileID(0, new CanonicalTileID(1, 1, 0)), (_x, _y) => 0);
expect(projection.point.x).toBeCloseTo(-0.10639783257205901, precisionDigits);
expect(projection.point.y).toBeCloseTo(0.8136784996777623, precisionDigits);
expect(projection.signedDistanceFromCamera).toBeCloseTo(787.6699126802941, precisionDigits);
expect(projection.isOccluded).toBe(false);
});
test('getCameraLngLat', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 180, renderWorldCopies: true});
transform.setElevation(200);
transform.setCenter(new LngLat(15.0, 55.0));
transform.setZoom(14);
transform.setPitch(55);
transform.setBearing(75);
transform.resize(512, 512);
expect(transform.getCameraAltitude()).toBeCloseTo(1405.7075926414002, 10);
expect(transform.getCameraLngLat().lng).toBeCloseTo(14.973921529405033, 10);
expect(transform.getCameraLngLat().lat).toBeCloseTo(54.99599181678275, 10);
transform.setRoll(31);
expect(transform.getCameraAltitude()).toBeCloseTo(1405.7075926414002, 10);
expect(transform.getCameraLngLat().lng).toBeCloseTo(14.973921529405033, 10);
expect(transform.getCameraLngLat().lat).toBeCloseTo(54.99599181678275, 10);
});
test('calculateCenterFromCameraLngLatAlt no pitch no bearing', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 180, renderWorldCopies: true});
transform.setPitch(55);
transform.setBearing(75);
transform.resize(512, 512);
const camLngLat = new LngLat(15, 55);
const camAlt = 400;
const centerInfo = transform.calculateCenterFromCameraLngLatAlt(camLngLat, camAlt);
transform.setZoom(centerInfo.zoom);
transform.setCenter(centerInfo.center);
transform.setElevation(centerInfo.elevation);
expect(transform.zoom).toBeGreaterThan(0);
expect(transform.getCameraAltitude()).toBeCloseTo(camAlt, 10);
expect(transform.getCameraLngLat().lng).toBeCloseTo(camLngLat.lng, 10);
expect(transform.getCameraLngLat().lat).toBeCloseTo(camLngLat.lat, 10);
});
test('calculateCenterFromCameraLngLatAlt no pitch', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 180, renderWorldCopies: true});
transform.setPitch(55);
transform.setBearing(75);
transform.resize(512, 512);
const camLngLat = new LngLat(15, 55);
const camAlt = 400;
const bearing = 20;
const centerInfo = transform.calculateCenterFromCameraLngLatAlt(camLngLat, camAlt, bearing);
transform.setZoom(centerInfo.zoom);
transform.setCenter(centerInfo.center);
transform.setElevation(centerInfo.elevation);
transform.setBearing(bearing);
expect(transform.zoom).toBeGreaterThan(0);
expect(transform.getCameraAltitude()).toBeCloseTo(camAlt, 10);
expect(transform.getCameraLngLat().lng).toBeCloseTo(camLngLat.lng, 10);
expect(transform.getCameraLngLat().lat).toBeCloseTo(camLngLat.lat, 10);
});
test('calculateCenterFromCameraLngLatAlt', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 180, renderWorldCopies: true});
transform.setPitch(55);
transform.setBearing(75);
transform.resize(512, 512);
const camLngLat = new LngLat(15, 55);
const camAlt = 400;
const bearing = 40;
const pitch = 30;
const centerInfo = transform.calculateCenterFromCameraLngLatAlt(camLngLat, camAlt, bearing, pitch);
transform.setZoom(centerInfo.zoom);
transform.setCenter(centerInfo.center);
transform.setElevation(centerInfo.elevation);
transform.setBearing(bearing);
transform.setPitch(pitch);
expect(transform.zoom).toBeGreaterThan(0);
expect(transform.getCameraAltitude()).toBeCloseTo(camAlt, 10);
expect(transform.getCameraLngLat().lng).toBeCloseTo(camLngLat.lng, 10);
expect(transform.getCameraLngLat().lat).toBeCloseTo(camLngLat.lat, 10);
});
test('calculateCenterFromCameraLngLatAlt 89 degrees pitch', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 180, renderWorldCopies: true});
transform.setPitch(55);
transform.setBearing(75);
transform.resize(512, 512);
const camLngLat = new LngLat(15, 55);
const camAlt = 400;
const bearing = 40;
const pitch = 88;
const centerInfo = transform.calculateCenterFromCameraLngLatAlt(camLngLat, camAlt, bearing, pitch);
transform.setZoom(centerInfo.zoom);
transform.setCenter(centerInfo.center);
transform.setElevation(centerInfo.elevation);
transform.setBearing(bearing);
transform.setPitch(pitch);
expect(transform.zoom).toBeGreaterThan(0);
expect(transform.getCameraAltitude()).toBeCloseTo(camAlt, 10);
expect(transform.getCameraLngLat().lng).toBeCloseTo(camLngLat.lng, 10);
expect(transform.getCameraLngLat().lat).toBeCloseTo(camLngLat.lat, 10);
});
test('calculateCenterFromCameraLngLatAlt 89.99 degrees pitch', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 180, renderWorldCopies: true});
transform.setPitch(55);
transform.setBearing(75);
transform.resize(512, 512);
const camLngLat = new LngLat(15, 55);
const camAlt = 400;
const bearing = 40;
const pitch = 89.99;
const centerInfo = transform.calculateCenterFromCameraLngLatAlt(camLngLat, camAlt, bearing, pitch);
transform.setZoom(centerInfo.zoom);
transform.setCenter(centerInfo.center);
transform.setElevation(centerInfo.elevation);
transform.setBearing(bearing);
transform.setPitch(pitch);
expect(transform.zoom).toBeGreaterThan(0);
expect(transform.getCameraAltitude()).toBeCloseTo(camAlt, 10);
expect(transform.getCameraLngLat().lng).toBeCloseTo(camLngLat.lng, 10);
expect(transform.getCameraLngLat().lat).toBeCloseTo(camLngLat.lat, 10);
});
test('calculateCenterFromCameraLngLatAlt 90 degrees pitch', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 180, renderWorldCopies: true});
transform.setPitch(55);
transform.setBearing(75);
transform.resize(512, 512);
const camLngLat = new LngLat(15, 55);
const camAlt = 400;
const bearing = 40;
const pitch = 90;
const centerInfo = transform.calculateCenterFromCameraLngLatAlt(camLngLat, camAlt, bearing, pitch);
transform.setZoom(centerInfo.zoom);
transform.setCenter(centerInfo.center);
transform.setElevation(centerInfo.elevation);
transform.setBearing(bearing);
transform.setPitch(pitch);
expect(transform.zoom).toBeGreaterThan(0);
expect(transform.getCameraAltitude()).toBeCloseTo(camAlt, 10);
expect(transform.getCameraLngLat().lng).toBeCloseTo(camLngLat.lng, 10);
expect(transform.getCameraLngLat().lat).toBeCloseTo(camLngLat.lat, 10);
});
test('calculateCenterFromCameraLngLatAlt 95 degrees pitch', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 180, renderWorldCopies: true});
transform.setPitch(55);
transform.setBearing(75);
transform.resize(512, 512);
const camLngLat = new LngLat(15, 55);
const camAlt = 400;
const bearing = 40;
const pitch = 95;
const centerInfo = transform.calculateCenterFromCameraLngLatAlt(camLngLat, camAlt, bearing, pitch);
transform.setZoom(centerInfo.zoom);
transform.setCenter(centerInfo.center);
transform.setElevation(centerInfo.elevation);
transform.setBearing(bearing);
transform.setPitch(pitch);
expect(transform.zoom).toBeGreaterThan(0);
expect(transform.getCameraAltitude()).toBeCloseTo(camAlt, 10);
expect(transform.getCameraLngLat().lng).toBeCloseTo(camLngLat.lng, 10);
expect(transform.getCameraLngLat().lat).toBeCloseTo(camLngLat.lat, 10);
});
test('calculateCenterFromCameraLngLatAlt 180 degrees pitch', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 180, renderWorldCopies: true});
transform.setPitch(55);
transform.setBearing(75);
transform.resize(512, 512);
const camLngLat = new LngLat(15, 55);
const camAlt = 400;
const bearing = 40;
const pitch = 180;
const centerInfo = transform.calculateCenterFromCameraLngLatAlt(camLngLat, camAlt, bearing, pitch);
transform.setZoom(centerInfo.zoom);
transform.setCenter(centerInfo.center);
transform.setElevation(centerInfo.elevation);
transform.setBearing(bearing);
transform.setPitch(pitch);
expect(transform.zoom).toBeGreaterThan(0);
expect(transform.getCameraAltitude()).toBeCloseTo(camAlt, 10);
expect(transform.getCameraLngLat().lng).toBeCloseTo(camLngLat.lng, 10);
expect(transform.getCameraLngLat().lat).toBeCloseTo(camLngLat.lat, 10);
});
});
+845
View File
@@ -0,0 +1,845 @@
import {LngLat, type LngLatLike} from '../lng_lat';
import {MercatorCoordinate, mercatorXfromLng, mercatorYfromLat, mercatorZfromAltitude} from '../mercator_coordinate';
import Point from '@mapbox/point-geometry';
import {wrap, clamp, createIdentityMat4f64, createMat4f64, degreesToRadians, createIdentityMat4f32, zoomScale, scaleZoom} from '../../util/util';
import {type mat2, mat4, vec3, vec4} from 'gl-matrix';
import {UnwrappedTileID, OverscaledTileID, type CanonicalTileID, calculateTileKey} from '../../tile/tile_id';
import {interpolates} from '@maplibre/maplibre-gl-style-spec';
import {type PointProjection, xyTransformMat4} from '../../symbol/projection';
import {LngLatBounds} from '../lng_lat_bounds';
import {getMercatorHorizon, projectToWorldCoordinates, unprojectFromWorldCoordinates, calculateTileMatrix, maxMercatorHorizonAngle, cameraMercatorCoordinateFromCenterAndRotation} from './mercator_utils';
import {EXTENT} from '../../data/extent';
import {TransformHelper} from '../transform_helper';
import {MercatorCoveringTilesDetailsProvider} from './mercator_covering_tiles_details_provider';
import {Frustum} from '../../util/primitives/frustum';
import type {Terrain} from '../../render/terrain';
import type {IReadonlyTransform, ITransform, TransformConstrainFunction} from '../transform_interface';
import type {TransformOptions} from '../transform_helper';
import type {PaddingOptions} from '../edge_insets';
import type {ProjectionData, ProjectionDataParams} from './projection_data';
import type {CoveringTilesDetailsProvider} from './covering_tiles_details_provider';
export class MercatorTransform implements ITransform {
private _helper: TransformHelper;
//
// Implementation of transform getters and setters
//
get pixelsToClipSpaceMatrix(): mat4 {
return this._helper.pixelsToClipSpaceMatrix;
}
get clipSpaceToPixelsMatrix(): mat4 {
return this._helper.clipSpaceToPixelsMatrix;
}
get pixelsToGLUnits(): [number, number] {
return this._helper.pixelsToGLUnits;
}
get centerOffset(): Point {
return this._helper.centerOffset;
}
get size(): Point {
return this._helper.size;
}
get rotationMatrix(): mat2 {
return this._helper.rotationMatrix;
}
get centerPoint(): Point {
return this._helper.centerPoint;
}
get pixelsPerMeter(): number {
return this._helper.pixelsPerMeter;
}
setMinZoom(zoom: number): void {
this._helper.setMinZoom(zoom);
}
setMaxZoom(zoom: number): void {
this._helper.setMaxZoom(zoom);
}
setMinPitch(pitch: number): void {
this._helper.setMinPitch(pitch);
}
setMaxPitch(pitch: number): void {
this._helper.setMaxPitch(pitch);
}
setRenderWorldCopies(renderWorldCopies: boolean): void {
this._helper.setRenderWorldCopies(renderWorldCopies);
}
setBearing(bearing: number): void {
this._helper.setBearing(bearing);
}
setPitch(pitch: number): void {
this._helper.setPitch(pitch);
}
setRoll(roll: number): void {
this._helper.setRoll(roll);
}
setFov(fov: number): void {
this._helper.setFov(fov);
}
setZoom(zoom: number): void {
this._helper.setZoom(zoom);
}
setCenter(center: LngLat): void {
this._helper.setCenter(center);
}
setElevation(elevation: number): void {
this._helper.setElevation(elevation);
}
setMinElevationForCurrentTile(elevation: number): void {
this._helper.setMinElevationForCurrentTile(elevation);
}
setPadding(padding: PaddingOptions): void {
this._helper.setPadding(padding);
}
interpolatePadding(start: PaddingOptions, target: PaddingOptions, t: number): void {
return this._helper.interpolatePadding(start, target, t);
}
isPaddingEqual(padding: PaddingOptions): boolean {
return this._helper.isPaddingEqual(padding);
}
resize(width: number, height: number, constrain: boolean = true): void {
this._helper.resize(width, height, constrain);
}
getMaxBounds(): LngLatBounds {
return this._helper.getMaxBounds();
}
setMaxBounds(bounds?: LngLatBounds): void {
this._helper.setMaxBounds(bounds);
}
setConstrainOverride(constrain?: TransformConstrainFunction | null): void {
this._helper.setConstrainOverride(constrain);
}
overrideNearFarZ(nearZ: number, farZ: number): void {
this._helper.overrideNearFarZ(nearZ, farZ);
}
clearNearFarZOverride(): void {
this._helper.clearNearFarZOverride();
}
getCameraQueryGeometry(queryGeometry: Point[]): Point[] {
return this._helper.getCameraQueryGeometry(this.getCameraPoint(), queryGeometry);
}
get tileSize(): number {
return this._helper.tileSize;
}
get tileZoom(): number {
return this._helper.tileZoom;
}
get scale(): number {
return this._helper.scale;
}
get worldSize(): number {
return this._helper.worldSize;
}
get width(): number {
return this._helper.width;
}
get height(): number {
return this._helper.height;
}
get lngRange(): [number, number] {
return this._helper.lngRange;
}
get latRange(): [number, number] {
return this._helper.latRange;
}
get minZoom(): number {
return this._helper.minZoom;
}
get maxZoom(): number {
return this._helper.maxZoom;
}
get zoom(): number {
return this._helper.zoom;
}
get center(): LngLat {
return this._helper.center;
}
get minPitch(): number {
return this._helper.minPitch;
}
get maxPitch(): number {
return this._helper.maxPitch;
}
get pitch(): number {
return this._helper.pitch;
}
get pitchInRadians(): number {
return this._helper.pitchInRadians;
}
get roll(): number {
return this._helper.roll;
}
get rollInRadians(): number {
return this._helper.rollInRadians;
}
get bearing(): number {
return this._helper.bearing;
}
get bearingInRadians(): number {
return this._helper.bearingInRadians;
}
get fov(): number {
return this._helper.fov;
}
get fovInRadians(): number {
return this._helper.fovInRadians;
}
get elevation(): number {
return this._helper.elevation;
}
get minElevationForCurrentTile(): number {
return this._helper.minElevationForCurrentTile;
}
get padding(): PaddingOptions {
return this._helper.padding;
}
get unmodified(): boolean {
return this._helper.unmodified;
}
get renderWorldCopies(): boolean {
return this._helper.renderWorldCopies;
}
get cameraToCenterDistance(): number {
return this._helper.cameraToCenterDistance;
}
get constrainOverride(): TransformConstrainFunction {
return this._helper.constrainOverride;
}
public get nearZ(): number {
return this._helper.nearZ;
}
public get farZ(): number {
return this._helper.farZ;
}
public get autoCalculateNearFarZ(): boolean {
return this._helper.autoCalculateNearFarZ;
}
setTransitionState(_value: number, _error: number): void {
// Do nothing
}
//
// Implementation of mercator transform
//
private _cameraPosition: vec3;
private _mercatorMatrix: mat4;
private _projectionMatrix: mat4;
private _viewProjMatrix: mat4;
private _invViewProjMatrix: mat4;
private _invProjMatrix: mat4;
private _alignedProjMatrix: mat4;
private _pixelMatrix: mat4;
private _pixelMatrix3D: mat4;
private _pixelMatrixInverse: mat4;
private _fogMatrix: mat4;
private _posMatrixCache: Map<string, {f64: mat4; f32: mat4}> = new Map();
private _alignedPosMatrixCache: Map<string, {f64: mat4; f32: mat4}> = new Map();
private _fogMatrixCacheF32: Map<string, mat4> = new Map();
private _coveringTilesDetailsProvider;
constructor(options?: TransformOptions) {
this._helper = new TransformHelper({
calcMatrices: () => { this._calcMatrices(); },
defaultConstrain: (center, zoom) => { return this.defaultConstrain(center, zoom); }
}, options);
this._coveringTilesDetailsProvider = new MercatorCoveringTilesDetailsProvider();
}
public clone(): ITransform {
const clone = new MercatorTransform();
clone.apply(this, false);
return clone;
}
public apply(that: IReadonlyTransform, constrain: boolean, forceOverrideZ?: boolean): void {
this._helper.apply(that, constrain, forceOverrideZ);
}
public get cameraPosition(): vec3 { return this._cameraPosition; }
public get projectionMatrix(): mat4 { return this._projectionMatrix; }
public get modelViewProjectionMatrix(): mat4 { return this._viewProjMatrix; }
public get inverseProjectionMatrix(): mat4 { return this._invProjMatrix; }
public get mercatorMatrix(): mat4 { return this._mercatorMatrix; } // Not part of ITransform interface
getVisibleUnwrappedCoordinates(tileID: CanonicalTileID): Array<UnwrappedTileID> {
const result = [new UnwrappedTileID(0, tileID)];
if (this._helper._renderWorldCopies) {
const utl = this.screenPointToMercatorCoordinate(new Point(0, 0));
const utr = this.screenPointToMercatorCoordinate(new Point(this._helper._width, 0));
const ubl = this.screenPointToMercatorCoordinate(new Point(this._helper._width, this._helper._height));
const ubr = this.screenPointToMercatorCoordinate(new Point(0, this._helper._height));
const w0 = Math.floor(Math.min(utl.x, utr.x, ubl.x, ubr.x));
const w1 = Math.floor(Math.max(utl.x, utr.x, ubl.x, ubr.x));
// Add an extra copy of the world on each side to properly render ImageSources and CanvasSources.
// Both sources draw outside the tile boundaries of the tile that "contains them" so we need
// to add extra copies on both sides in case offscreen tiles need to draw into on-screen ones.
const extraWorldCopy = 1;
for (let w = w0 - extraWorldCopy; w <= w1 + extraWorldCopy; w++) {
if (w === 0) continue;
result.push(new UnwrappedTileID(w, tileID));
}
}
return result;
}
getCameraFrustum(): Frustum {
return Frustum.fromInvProjectionMatrix(this._invViewProjMatrix, this.worldSize);
}
getClippingPlane(): vec4 | null {
return null;
}
getCoveringTilesDetailsProvider(): CoveringTilesDetailsProvider {
return this._coveringTilesDetailsProvider;
}
recalculateZoomAndCenter(terrain?: Terrain): void {
// find position the camera is looking on
const center = this.screenPointToLocation(this.centerPoint, terrain);
const elevation = terrain ? terrain.getElevationForLngLatZoom(center, this._helper._tileZoom) : 0;
this._helper.recalculateZoomAndCenter(elevation);
}
setLocationAtPoint(lnglat: LngLat, point: Point) {
const z = mercatorZfromAltitude(this.elevation, this.center.lat);
const a = this.screenPointToMercatorCoordinateAtZ(point, z);
const b = this.screenPointToMercatorCoordinateAtZ(this.centerPoint, z);
const loc = MercatorCoordinate.fromLngLat(lnglat);
const newCenter = new MercatorCoordinate(
loc.x - (a.x - b.x),
loc.y - (a.y - b.y));
this.setCenter(newCenter?.toLngLat());
if (this._helper._renderWorldCopies) {
this.setCenter(this.center.wrap());
}
}
locationToScreenPoint(lnglat: LngLat, terrain?: Terrain): Point {
return terrain ?
this.coordinatePoint(MercatorCoordinate.fromLngLat(lnglat), terrain.getElevationForLngLat(lnglat, this), this._pixelMatrix3D) :
this.coordinatePoint(MercatorCoordinate.fromLngLat(lnglat));
}
screenPointToLocation(p: Point, terrain?: Terrain): LngLat {
return this.screenPointToMercatorCoordinate(p, terrain)?.toLngLat();
}
screenPointToMercatorCoordinate(p: Point, terrain?: Terrain): MercatorCoordinate {
// get point-coordinate from terrain coordinates framebuffer
if (terrain) {
const coordinate = terrain.pointCoordinate(p);
if (coordinate != null) {
return coordinate;
}
}
return this.screenPointToMercatorCoordinateAtZ(p);
}
screenPointToMercatorCoordinateAtZ(p: Point, mercatorZ?: number): MercatorCoordinate {
// calculate point-coordinate on flat earth
const targetZ = mercatorZ ? mercatorZ : 0;
// since we don't know the correct projected z value for the point,
// unproject two points to get a line and then find the point on that
// line with z=0
const coord0 = [p.x, p.y, 0, 1] as vec4;
const coord1 = [p.x, p.y, 1, 1] as vec4;
vec4.transformMat4(coord0, coord0, this._pixelMatrixInverse);
vec4.transformMat4(coord1, coord1, this._pixelMatrixInverse);
const w0 = coord0[3];
const w1 = coord1[3];
const x0 = coord0[0] / w0;
const x1 = coord1[0] / w1;
const y0 = coord0[1] / w0;
const y1 = coord1[1] / w1;
const z0 = coord0[2] / w0;
const z1 = coord1[2] / w1;
const t = z0 === z1 ? 0 : (targetZ - z0) / (z1 - z0);
return new MercatorCoordinate(
interpolates.number(x0, x1, t) / this.worldSize,
interpolates.number(y0, y1, t) / this.worldSize,
targetZ);
}
/**
* Given a coordinate, return the screen point that corresponds to it
* @param coord - the coordinates
* @param elevation - the elevation
* @param pixelMatrix - the pixel matrix
* @returns screen point
*/
coordinatePoint(coord: MercatorCoordinate, elevation: number = 0, pixelMatrix: mat4 = this._pixelMatrix): Point {
const p = [coord.x * this.worldSize, coord.y * this.worldSize, elevation, 1] as vec4;
vec4.transformMat4(p, p, pixelMatrix);
return new Point(p[0] / p[3], p[1] / p[3]);
}
getBounds(): LngLatBounds {
const top = Math.max(0, this._helper._height / 2 - getMercatorHorizon(this));
return new LngLatBounds()
.extend(this.screenPointToLocation(new Point(0, top)))
.extend(this.screenPointToLocation(new Point(this._helper._width, top)))
.extend(this.screenPointToLocation(new Point(this._helper._width, this._helper._height)))
.extend(this.screenPointToLocation(new Point(0, this._helper._height)));
}
isPointOnMapSurface(p: Point, terrain?: Terrain): boolean {
if (terrain) {
const coordinate = terrain.pointCoordinate(p);
return coordinate != null;
}
return (p.y > this.height / 2 - getMercatorHorizon(this));
}
/**
* Calculate the posMatrix that, given a tile coordinate, would be used to display the tile on a map.
* This function is specific to the mercator projection.
* @param tileID - the tile ID
* @param aligned - whether to use a pixel-aligned matrix variant, intended for rendering raster tiles
* @param useFloat32 - when true, returns a float32 matrix instead of float64. Use float32 for matrices that are passed to shaders, use float64 for everything else.
*/
calculatePosMatrix(tileID: UnwrappedTileID | OverscaledTileID, aligned: boolean = false, useFloat32?: boolean): mat4 {
const posMatrixKey = tileID.key ?? calculateTileKey(tileID.wrap, tileID.canonical.z, tileID.canonical.z, tileID.canonical.x, tileID.canonical.y);
const cache = aligned ? this._alignedPosMatrixCache : this._posMatrixCache;
if (cache.has(posMatrixKey)) {
const matrices = cache.get(posMatrixKey);
return useFloat32 ? matrices.f32 : matrices.f64;
}
const tileMatrix = calculateTileMatrix(tileID, this.worldSize);
mat4.multiply(tileMatrix, aligned ? this._alignedProjMatrix : this._viewProjMatrix, tileMatrix);
const matrices = {
f64: tileMatrix,
f32: new Float32Array(tileMatrix), // Must have a 32 bit float version for WebGL, otherwise WebGL calls in Chrome get very slow.
};
cache.set(posMatrixKey, matrices);
// Make sure to return the correct precision
return useFloat32 ? matrices.f32 : matrices.f64;
}
calculateFogMatrix(unwrappedTileID: UnwrappedTileID): mat4 {
const posMatrixKey = unwrappedTileID.key;
const cache = this._fogMatrixCacheF32;
if (cache.has(posMatrixKey)) {
return cache.get(posMatrixKey);
}
const fogMatrix = calculateTileMatrix(unwrappedTileID, this.worldSize);
mat4.multiply(fogMatrix, this._fogMatrix, fogMatrix);
cache.set(posMatrixKey, new Float32Array(fogMatrix)); // Must be 32 bit floats, otherwise WebGL calls in Chrome get very slow.
return cache.get(posMatrixKey);
}
/**
* This mercator implementation returns center lngLat and zoom to ensure that:
*
* 1) everything beyond the bounds is excluded
* 2) a given lngLat is as near the center as possible
*
* Bounds are those set by maxBounds or North & South "Poles" and, if only 1 globe is displayed, antimeridian.
*/
defaultConstrain: TransformConstrainFunction = (lngLat, zoom) => {
zoom = clamp(+zoom, this.minZoom, this.maxZoom);
const result = {
center: new LngLat(lngLat.lng, lngLat.lat),
zoom
};
let lngRange = this._helper._lngRange;
if (!this._helper._renderWorldCopies && lngRange === null) {
const almost180 = 180 - 1e-10;
lngRange = [-almost180, almost180];
}
const worldSize = this.tileSize * zoomScale(result.zoom); // A world size for the requested zoom level, not the current world size
let minY = 0;
let maxY = worldSize;
let minX = 0;
let maxX = worldSize;
let scaleY = 0;
let scaleX = 0;
const {x: screenWidth, y: screenHeight} = this.size;
if (this._helper._latRange) {
const latRange = this._helper._latRange;
minY = mercatorYfromLat(latRange[1]) * worldSize;
maxY = mercatorYfromLat(latRange[0]) * worldSize;
const shouldZoomIn = maxY - minY < screenHeight;
if (shouldZoomIn) scaleY = screenHeight / (maxY - minY);
}
if (lngRange) {
minX = wrap(
mercatorXfromLng(lngRange[0]) * worldSize,
0,
worldSize
);
maxX = wrap(
mercatorXfromLng(lngRange[1]) * worldSize,
0,
worldSize
);
if (maxX < minX) maxX += worldSize;
const shouldZoomIn = maxX - minX < screenWidth;
if (shouldZoomIn) scaleX = screenWidth / (maxX - minX);
}
const {x: originalX, y: originalY} = projectToWorldCoordinates(worldSize, lngLat);
let modifiedX, modifiedY;
const scale = Math.max(scaleX || 0, scaleY || 0);
if (scale) {
// zoom in to exclude all beyond the given lng/lat ranges
const newPoint = new Point(
scaleX ? (maxX + minX) / 2 : originalX,
scaleY ? (maxY + minY) / 2 : originalY);
result.center = unprojectFromWorldCoordinates(worldSize, newPoint).wrap();
result.zoom += scaleZoom(scale);
return result;
}
if (this._helper._latRange) {
const h2 = screenHeight / 2;
if (originalY - h2 < minY) modifiedY = minY + h2;
if (originalY + h2 > maxY) modifiedY = maxY - h2;
}
if (lngRange) {
const centerX = (minX + maxX) / 2;
let wrappedX = originalX;
if (this._helper._renderWorldCopies) {
wrappedX = wrap(originalX, centerX - worldSize / 2, centerX + worldSize / 2);
}
const w2 = screenWidth / 2;
if (wrappedX - w2 < minX) modifiedX = minX + w2;
if (wrappedX + w2 > maxX) modifiedX = maxX - w2;
}
// pan the map if the screen goes off the range
if (modifiedX !== undefined || modifiedY !== undefined) {
const newPoint = new Point(modifiedX ?? originalX, modifiedY ?? originalY);
result.center = unprojectFromWorldCoordinates(worldSize, newPoint).wrap();
}
return result;
};
applyConstrain: TransformConstrainFunction = (lngLat, zoom) => {
return this._helper.applyConstrain(lngLat, zoom);
};
calculateCenterFromCameraLngLatAlt(lnglat: LngLatLike, alt: number, bearing?: number, pitch?: number): {center: LngLat; elevation: number; zoom: number} {
return this._helper.calculateCenterFromCameraLngLatAlt(lnglat, alt, bearing, pitch);
}
_calculateNearFarZIfNeeded(cameraToSeaLevelDistance: number, limitedPitchRadians: number, offset: Point): void {
if (!this._helper.autoCalculateNearFarZ) {
return;
}
// In case of negative minimum elevation (e.g. the dead see, under the sea maps) use a lower plane for calculation
const minRenderDistanceBelowCameraInMeters = 100;
const minElevation = Math.min(this.elevation, this.minElevationForCurrentTile, this.getCameraAltitude() - minRenderDistanceBelowCameraInMeters);
const cameraToLowestPointDistance = cameraToSeaLevelDistance - minElevation * this._helper._pixelPerMeter / Math.cos(limitedPitchRadians);
const lowestPlane = minElevation < 0 ? cameraToLowestPointDistance : cameraToSeaLevelDistance;
// Find the distance from the center point [width/2 + offset.x, height/2 + offset.y] to the
// center top point [width/2 + offset.x, 0] in Z units, using the law of sines.
// 1 Z unit is equivalent to 1 horizontal px at the center of the map
// (the distance between[width/2, height/2] and [width/2 + 1, height/2])
const groundAngle = Math.PI / 2 + this.pitchInRadians;
const zfov = degreesToRadians(this.fov) * (Math.abs(Math.cos(degreesToRadians(this.roll))) * this.height + Math.abs(Math.sin(degreesToRadians(this.roll))) * this.width) / this.height;
const fovAboveCenter = zfov * (0.5 + offset.y / this.height);
const topHalfSurfaceDistance = Math.sin(fovAboveCenter) * lowestPlane / Math.sin(clamp(Math.PI - groundAngle - fovAboveCenter, 0.01, Math.PI - 0.01));
// Find the distance from the center point to the horizon
const horizon = getMercatorHorizon(this);
const horizonAngle = Math.atan(horizon / this._helper.cameraToCenterDistance);
const minFovCenterToHorizonRadians = degreesToRadians(90 - maxMercatorHorizonAngle);
const fovCenterToHorizon = horizonAngle > minFovCenterToHorizonRadians ? 2 * horizonAngle * (0.5 + offset.y / (horizon * 2)) : minFovCenterToHorizonRadians;
const topHalfSurfaceDistanceHorizon = Math.sin(fovCenterToHorizon) * lowestPlane / Math.sin(clamp(Math.PI - groundAngle - fovCenterToHorizon, 0.01, Math.PI - 0.01));
// Calculate z distance of the farthest fragment that should be rendered.
// Add a bit extra to avoid precision problems when a fragment's distance is exactly `furthestDistance`
const topHalfMinDistance = Math.min(topHalfSurfaceDistance, topHalfSurfaceDistanceHorizon);
this._helper._farZ = (Math.cos(Math.PI / 2 - limitedPitchRadians) * topHalfMinDistance + lowestPlane) * 1.01;
// The larger the value of nearZ is
// - the more depth precision is available for features (good)
// - clipping starts appearing sooner when the camera is close to 3d features (bad)
//
// Other values work for mapbox-gl-js but deck.gl was encountering precision issues
// when rendering custom layers. This value was experimentally chosen and
// seems to solve z-fighting issues in deck.gl while not clipping buildings too close to the camera.
this._helper._nearZ = this._helper._height / 50;
}
_calcMatrices(): void {
if (!this._helper._height) return;
const offset = this.centerOffset;
const point = projectToWorldCoordinates(this.worldSize, this.center);
const x = point.x, y = point.y;
this._helper._pixelPerMeter = mercatorZfromAltitude(1, this.center.lat) * this.worldSize;
// Calculate the camera to sea-level distance in pixel in respect of terrain
const limitedPitchRadians = degreesToRadians(Math.min(this.pitch, maxMercatorHorizonAngle));
const cameraToSeaLevelDistance = Math.max(this._helper.cameraToCenterDistance / 2, this._helper.cameraToCenterDistance + this._helper._elevation * this._helper._pixelPerMeter / Math.cos(limitedPitchRadians));
this._calculateNearFarZIfNeeded(cameraToSeaLevelDistance, limitedPitchRadians, offset);
// matrix for conversion from location to clip space(-1 .. 1)
let m: mat4;
m = new Float64Array(16) as any;
mat4.perspective(m, this.fovInRadians, this._helper._width / this._helper._height, this._helper._nearZ, this._helper._farZ);
this._invProjMatrix = new Float64Array(16) as any as mat4;
mat4.invert(this._invProjMatrix, m);
// Apply center of perspective offset
m[8] = -offset.x * 2 / this._helper._width;
m[9] = offset.y * 2 / this._helper._height;
this._projectionMatrix = mat4.clone(m);
mat4.scale(m, m, [1, -1, 1]);
mat4.translate(m, m, [0, 0, -this._helper.cameraToCenterDistance]);
mat4.rotateZ(m, m, -this.rollInRadians);
mat4.rotateX(m, m, this.pitchInRadians);
mat4.rotateZ(m, m, -this.bearingInRadians);
mat4.translate(m, m, [-x, -y, 0]);
// The mercatorMatrix can be used to transform points from mercator coordinates
// ([0, 0] nw, [1, 1] se) to clip space.
this._mercatorMatrix = mat4.scale([] as any, m, [this.worldSize, this.worldSize, this.worldSize]);
// scale vertically to meters per pixel (inverse of ground resolution):
mat4.scale(m, m, [1, 1, this._helper._pixelPerMeter]);
// matrix for conversion from world space to screen coordinates in 2D
this._pixelMatrix = mat4.multiply(new Float64Array(16) as any, this.clipSpaceToPixelsMatrix, m);
// matrix for conversion from world space to clip space (-1 .. 1)
mat4.translate(m, m, [0, 0, -this.elevation]); // elevate camera over terrain
this._viewProjMatrix = m;
this._invViewProjMatrix = mat4.invert([] as any, m);
const cameraPos: vec4 = [0, 0, -1, 1];
vec4.transformMat4(cameraPos, cameraPos, this._invViewProjMatrix);
this._cameraPosition = [
cameraPos[0] / cameraPos[3],
cameraPos[1] / cameraPos[3],
cameraPos[2] / cameraPos[3]
];
// create a fog matrix, same es proj-matrix but with near clipping-plane in mapcenter
// needed to calculate a correct z-value for fog calculation, because projMatrix z value is not
this._fogMatrix = new Float64Array(16) as any;
mat4.perspective(this._fogMatrix, this.fovInRadians, this.width / this.height, cameraToSeaLevelDistance, this._helper._farZ);
this._fogMatrix[8] = -offset.x * 2 / this.width;
this._fogMatrix[9] = offset.y * 2 / this.height;
mat4.scale(this._fogMatrix, this._fogMatrix, [1, -1, 1]);
mat4.translate(this._fogMatrix, this._fogMatrix, [0, 0, -this.cameraToCenterDistance]);
mat4.rotateZ(this._fogMatrix, this._fogMatrix, -this.rollInRadians);
mat4.rotateX(this._fogMatrix, this._fogMatrix, this.pitchInRadians);
mat4.rotateZ(this._fogMatrix, this._fogMatrix, -this.bearingInRadians);
mat4.translate(this._fogMatrix, this._fogMatrix, [-x, -y, 0]);
mat4.scale(this._fogMatrix, this._fogMatrix, [1, 1, this._helper._pixelPerMeter]);
mat4.translate(this._fogMatrix, this._fogMatrix, [0, 0, -this.elevation]); // elevate camera over terrain
// matrix for conversion from world space to screen coordinates in 3D
this._pixelMatrix3D = mat4.multiply(new Float64Array(16) as any, this.clipSpaceToPixelsMatrix, m);
// Make a second projection matrix that is aligned to a pixel grid for rendering raster tiles.
// We're rounding the (floating point) x/y values to achieve to avoid rendering raster images to fractional
// coordinates. Additionally, we adjust by half a pixel in either direction in case that viewport dimension
// is an odd integer to preserve rendering to the pixel grid. We're rotating this shift based on the angle
// of the transformation so that 0°, 90°, 180°, and 270° rasters are crisp, and adjust the shift so that
// it is always <= 0.5 pixels.
const xShift = (this._helper._width % 2) / 2, yShift = (this._helper._height % 2) / 2,
angleCos = Math.cos(this.bearingInRadians), angleSin = Math.sin(-this.bearingInRadians),
dx = x - Math.round(x) + angleCos * xShift + angleSin * yShift,
dy = y - Math.round(y) + angleCos * yShift + angleSin * xShift;
const alignedM = new Float64Array(m) as any as mat4;
mat4.translate(alignedM, alignedM, [dx > 0.5 ? dx - 1 : dx, dy > 0.5 ? dy - 1 : dy, 0]);
this._alignedProjMatrix = alignedM;
// inverse matrix for conversion from screen coordinates to location
m = mat4.invert(new Float64Array(16) as any, this._pixelMatrix);
if (!m) throw new Error('failed to invert matrix');
this._pixelMatrixInverse = m;
this._clearMatrixCaches();
}
private _clearMatrixCaches(): void {
this._posMatrixCache.clear();
this._alignedPosMatrixCache.clear();
this._fogMatrixCacheF32.clear();
}
maxPitchScaleFactor(): number {
// calcMatrices hasn't run yet
if (!this._pixelMatrixInverse) return 1;
const coord = this.screenPointToMercatorCoordinate(new Point(0, 0));
const p = [coord.x * this.worldSize, coord.y * this.worldSize, 0, 1] as vec4;
const topPoint = vec4.transformMat4(p, p, this._pixelMatrix);
return topPoint[3] / this._helper.cameraToCenterDistance;
}
getCameraPoint(): Point {
return this._helper.getCameraPoint();
}
getCameraAltitude(): number {
return this._helper.getCameraAltitude();
}
getCameraLngLat(): LngLat {
const pixelPerMeter = mercatorZfromAltitude(1, this.center.lat) * this.worldSize;
const cameraToCenterDistanceMeters = this._helper.cameraToCenterDistance / pixelPerMeter;
const camMercator = cameraMercatorCoordinateFromCenterAndRotation(this.center, this.elevation, this.pitch, this.bearing, cameraToCenterDistanceMeters);
return camMercator.toLngLat();
}
lngLatToCameraDepth(lngLat: LngLat, elevation: number) {
const coord = MercatorCoordinate.fromLngLat(lngLat);
const p = [coord.x * this.worldSize, coord.y * this.worldSize, elevation, 1] as vec4;
vec4.transformMat4(p, p, this._viewProjMatrix);
return (p[2] / p[3]);
}
getProjectionData(params: ProjectionDataParams): ProjectionData {
const {overscaledTileID, aligned, applyTerrainMatrix} = params;
const mercatorTileCoordinates = this._helper.getMercatorTileCoordinates(overscaledTileID);
const tilePosMatrix = overscaledTileID ? this.calculatePosMatrix(overscaledTileID, aligned, true) : null;
let mainMatrix: mat4;
if (overscaledTileID && overscaledTileID.terrainRttPosMatrix32f && applyTerrainMatrix) {
mainMatrix = overscaledTileID.terrainRttPosMatrix32f;
} else if (tilePosMatrix) {
mainMatrix = tilePosMatrix; // This matrix should be float32
} else {
mainMatrix = createIdentityMat4f32();
}
return {
mainMatrix, // Might be set to a custom matrix by different projections.
tileMercatorCoords: mercatorTileCoordinates,
clippingPlane: [0, 0, 0, 0],
projectionTransition: 0.0, // Range 0..1, where 0 is mercator, 1 is another projection, mostly globe.
fallbackMatrix: mainMatrix,
};
}
isLocationOccluded(_: LngLat): boolean {
return false;
}
getPixelScale(): number {
return 1.0;
}
getCircleRadiusCorrection(): number {
return 1.0;
}
getPitchedTextCorrection(_textAnchorX: number, _textAnchorY: number, _tileID: UnwrappedTileID): number {
return 1.0;
}
transformLightDirection(dir: vec3): vec3 {
return vec3.clone(dir);
}
getRayDirectionFromPixel(_p: Point): vec3 {
throw new Error('Not implemented.'); // No need for this in mercator transform
}
projectTileCoordinates(x: number, y: number, unwrappedTileID: UnwrappedTileID, getElevation: (x: number, y: number) => number): PointProjection {
const matrix = this.calculatePosMatrix(unwrappedTileID);
let pos;
if (getElevation) { // slow because of handle z-index
pos = [x, y, getElevation(x, y), 1] as vec4;
vec4.transformMat4(pos, pos, matrix);
} else { // fast because of ignore z-index
pos = [x, y, 0, 1] as vec4;
xyTransformMat4(pos, pos, matrix);
}
const w = pos[3];
return {
point: new Point(pos[0] / w, pos[1] / w),
signedDistanceFromCamera: w,
isOccluded: false
};
}
populateCache(coords: Array<OverscaledTileID>): void {
for (const coord of coords) {
// Return value is thrown away, but this function will still
// place the pos matrix into the transform's internal cache.
this.calculatePosMatrix(coord);
}
}
getMatrixForModel(location: LngLatLike, altitude?: number): mat4 {
const modelAsMercatorCoordinate = MercatorCoordinate.fromLngLat(
location,
altitude
);
const scale = modelAsMercatorCoordinate.meterInMercatorCoordinateUnits();
const m = createIdentityMat4f64();
mat4.translate(m, m, [modelAsMercatorCoordinate.x, modelAsMercatorCoordinate.y, modelAsMercatorCoordinate.z]);
mat4.rotateZ(m, m, Math.PI);
mat4.rotateX(m, m, Math.PI / 2);
mat4.scale(m, m, [-scale, scale, scale]);
return m;
}
getProjectionDataForCustomLayer(applyGlobeMatrix: boolean = true): ProjectionData {
const tileID = new OverscaledTileID(0, 0, 0, 0, 0);
const projectionData = this.getProjectionData({overscaledTileID: tileID, applyGlobeMatrix});
const tileMatrix = calculateTileMatrix(tileID, this.worldSize);
mat4.multiply(tileMatrix, this._viewProjMatrix, tileMatrix);
projectionData.tileMercatorCoords = [0, 0, 1, 1];
// Even though we requested projection data for the mercator base tile which covers the entire mercator range,
// the shader projection machinery still expects inputs to be in tile units range [0..EXTENT].
// Since custom layers are expected to supply mercator coordinates [0..1], we need to rescale
// both matrices by EXTENT. We also need to rescale Z.
const scale: vec3 = [EXTENT, EXTENT, this.worldSize / this._helper.pixelsPerMeter];
// We pass full-precision 64bit float matrices to custom layers to prevent precision loss in case the user wants to do further transformations.
// Otherwise we get very visible precision-artifacts and twitching for objects that are bulding-scale.
const projectionMatrixScaled = createMat4f64();
mat4.scale(projectionMatrixScaled, tileMatrix, scale);
projectionData.fallbackMatrix = projectionMatrixScaled;
projectionData.mainMatrix = projectionMatrixScaled;
return projectionData;
}
getFastPathSimpleProjectionMatrix(tileID: OverscaledTileID): mat4 {
return this.calculatePosMatrix(tileID);
}
}
+113
View File
@@ -0,0 +1,113 @@
import {describe, expect, test} from 'vitest';
import Point from '@mapbox/point-geometry';
import {LngLat} from '../lng_lat';
import {getMercatorHorizon, projectToWorldCoordinates, tileCoordinatesToLocation, tileCoordinatesToMercatorCoordinates} from './mercator_utils';
import {MercatorTransform} from './mercator_transform';
import {CanonicalTileID} from '../../tile/tile_id';
import {EXTENT} from '../../data/extent';
import {createIdentityMat4f32, MAX_VALID_LATITUDE} from '../../util/util';
describe('mercator utils', () => {
test('projectToWorldCoordinates basic', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.setZoom(10);
expect(projectToWorldCoordinates(transform.worldSize, transform.center)).toEqual(new Point(262144, 262144));
});
test('projectToWorldCoordinates clamps latitude', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
expect(projectToWorldCoordinates(transform.worldSize, new LngLat(0, -90))).toEqual(projectToWorldCoordinates(transform.worldSize, new LngLat(0, -MAX_VALID_LATITUDE)));
expect(projectToWorldCoordinates(transform.worldSize, new LngLat(0, 90))).toEqual(projectToWorldCoordinates(transform.worldSize, new LngLat(0, MAX_VALID_LATITUDE)));
});
test('getMercatorHorizon', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 85, renderWorldCopies: true});
transform.resize(500, 500);
transform.setPitch(75);
const horizon = getMercatorHorizon(transform);
expect(horizon).toBeCloseTo(170.8176101748407, 10);
});
test('getMercatorHorizon90', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 180, renderWorldCopies: true});
transform.resize(500, 500);
transform.setPitch(90);
const horizon = getMercatorHorizon(transform);
expect(horizon).toBeCloseTo(-9.818037813626313, 10);
});
test('getMercatorHorizon95', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 180, renderWorldCopies: true});
transform.resize(500, 500);
transform.setPitch(95);
const horizon = getMercatorHorizon(transform);
expect(horizon).toBeCloseTo(-75.52102888757743, 10);
});
describe('getProjectionData', () => {
test('return identity matrix when not passing overscaledTileID', () => {
const transform = new MercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 180, renderWorldCopies: true});
const projectionData = transform.getProjectionData({overscaledTileID: null});
expect(projectionData.fallbackMatrix).toEqual(createIdentityMat4f32());
});
});
describe('tileCoordinatesToMercatorCoordinates', () => {
const precisionDigits = 10;
test('Test 0,0', () => {
const result = tileCoordinatesToMercatorCoordinates(0, 0, new CanonicalTileID(0, 0, 0));
expect(result.x).toBe(0);
expect(result.y).toBe(0);
});
test('Test tile center', () => {
const result = tileCoordinatesToMercatorCoordinates(EXTENT / 2, EXTENT / 2, new CanonicalTileID(0, 0, 0));
expect(result.x).toBeCloseTo(0.5, precisionDigits);
expect(result.y).toBeCloseTo(0.5, precisionDigits);
});
test('Test higher zoom 0,0', () => {
const result = tileCoordinatesToMercatorCoordinates(0, 0, new CanonicalTileID(3, 0, 0));
expect(result.x).toBe(0);
expect(result.y).toBe(0);
});
test('Test higher zoom tile center', () => {
const result = tileCoordinatesToMercatorCoordinates(EXTENT / 2, EXTENT / 2, new CanonicalTileID(3, 0, 0));
expect(result.x).toBeCloseTo(1 / 16, precisionDigits);
expect(result.y).toBeCloseTo(1 / 16, precisionDigits);
});
});
describe('tileCoordinatesToLocation', () => {
const precisionDigits = 5;
test('Test 0,0', () => {
const result = tileCoordinatesToLocation(0, 0, new CanonicalTileID(0, 0, 0));
expect(result.lng).toBeCloseTo(-180, precisionDigits);
expect(result.lat).toBeCloseTo(MAX_VALID_LATITUDE, precisionDigits);
});
test('Test tile center', () => {
const result = tileCoordinatesToLocation(EXTENT / 2, EXTENT / 2, new CanonicalTileID(0, 0, 0));
expect(result.lng).toBeCloseTo(0, precisionDigits);
expect(result.lat).toBeCloseTo(0, precisionDigits);
});
test('Test higher zoom 0,0', () => {
const result = tileCoordinatesToLocation(0, 0, new CanonicalTileID(3, 0, 0));
expect(result.lng).toBeCloseTo(-180, precisionDigits);
expect(result.lat).toBeCloseTo(MAX_VALID_LATITUDE, precisionDigits);
});
test('Test higher zoom mercator center', () => {
const result = tileCoordinatesToLocation(EXTENT, EXTENT, new CanonicalTileID(3, 3, 3));
expect(result.lng).toBeCloseTo(0, precisionDigits);
expect(result.lat).toBeCloseTo(0, precisionDigits);
});
});
});
+106
View File
@@ -0,0 +1,106 @@
import {mat4} from 'gl-matrix';
import {EXTENT} from '../../data/extent';
import {clamp, degreesToRadians, MAX_VALID_LATITUDE, zoomScale} from '../../util/util';
import {MercatorCoordinate, mercatorXfromLng, mercatorYfromLat, mercatorZfromAltitude} from '../mercator_coordinate';
import Point from '@mapbox/point-geometry';
import type {UnwrappedTileIDType} from '../transform_helper';
import type {LngLat} from '../lng_lat';
/*
* The maximum angle to use for the Mercator horizon. This must be less than 90
* to prevent errors in `MercatorTransform::_calcMatrices()`. It shouldn't be too close
* to 90, or the distance to the horizon will become very large, unnecessarily increasing
* the number of tiles needed to render the map.
*/
export const maxMercatorHorizonAngle = 89.25;
/**
* Returns mercator coordinates in range 0..1 for given coordinates inside a specified tile.
* @param inTileX - X coordinate in tile units - range [0..EXTENT].
* @param inTileY - Y coordinate in tile units - range [0..EXTENT].
* @param canonicalTileID - Tile canonical ID - mercator X, Y and zoom.
* @returns Mercator coordinates of the specified point in range [0..1].
*/
export function tileCoordinatesToMercatorCoordinates(inTileX: number, inTileY: number, canonicalTileID: {x: number; y: number; z: number}): MercatorCoordinate {
const scale = 1.0 / (1 << canonicalTileID.z);
return new MercatorCoordinate(
inTileX / EXTENT * scale + canonicalTileID.x * scale,
inTileY / EXTENT * scale + canonicalTileID.y * scale
);
}
/**
* Returns LngLat for given in-tile coordinates and tile ID.
* @param inTileX - X coordinate in tile units - range [0..EXTENT].
* @param inTileY - Y coordinate in tile units - range [0..EXTENT].
* @param canonicalTileID - Tile canonical ID - mercator X, Y and zoom.
*/
export function tileCoordinatesToLocation(inTileX: number, inTileY: number, canonicalTileID: {x: number; y: number; z: number}): LngLat {
return tileCoordinatesToMercatorCoordinates(inTileX, inTileY, canonicalTileID).toLngLat();
}
/**
* Convert from LngLat to world coordinates (Mercator coordinates scaled by world size).
* @param worldSize - Mercator world size computed from zoom level and tile size.
* @param lnglat - The location to convert.
* @returns Point
*/
export function projectToWorldCoordinates(worldSize: number, lnglat: LngLat): Point {
const lat = clamp(lnglat.lat, -MAX_VALID_LATITUDE, MAX_VALID_LATITUDE);
return new Point(
mercatorXfromLng(lnglat.lng) * worldSize,
mercatorYfromLat(lat) * worldSize);
}
/**
* Convert from world coordinates (mercator coordinates scaled by world size) to LngLat.
* @param worldSize - Mercator world size computed from zoom level and tile size.
* @param point - World coordinate.
* @returns LngLat
*/
export function unprojectFromWorldCoordinates(worldSize: number, point: Point): LngLat {
return new MercatorCoordinate(point.x / worldSize, point.y / worldSize).toLngLat();
}
/**
* Calculate pixel height of the visible horizon in relation to map-center (e.g. height/2),
* multiplied by a static factor to simulate the earth-radius.
* The calculated value is the horizontal line from the camera-height to sea-level.
* @returns Horizon above center in pixels.
*/
export function getMercatorHorizon(transform: {pitch: number; cameraToCenterDistance: number}): number {
return transform.cameraToCenterDistance * Math.min(Math.tan(degreesToRadians(90 - transform.pitch)) * 0.85,
Math.tan(degreesToRadians(maxMercatorHorizonAngle - transform.pitch)));
}
export function calculateTileMatrix(unwrappedTileID: UnwrappedTileIDType, worldSize: number): mat4 {
const canonical = unwrappedTileID.canonical;
const scale = worldSize / zoomScale(canonical.z);
const unwrappedX = canonical.x + Math.pow(2, canonical.z) * unwrappedTileID.wrap;
const worldMatrix = mat4.identity(new Float64Array(16) as any);
mat4.translate(worldMatrix, worldMatrix, [unwrappedX * scale, canonical.y * scale, 0]);
mat4.scale(worldMatrix, worldMatrix, [scale / EXTENT, scale / EXTENT, 1]);
return worldMatrix;
}
export function cameraMercatorCoordinateFromCenterAndRotation(center: LngLat, elevation: number, pitch: number, bearing: number, distance: number): MercatorCoordinate {
const centerMercator = MercatorCoordinate.fromLngLat(center, elevation);
const mercUnitsPerMeter = mercatorZfromAltitude(1, center.lat);
const dMercator = distance * mercUnitsPerMeter;
const {x, y, z} = cameraDirectionFromPitchBearing(pitch, bearing);
const dxMercator = dMercator * -x;
const dyMercator = dMercator * -y;
const dzMercator = dMercator * -z;
return new MercatorCoordinate(centerMercator.x + dxMercator, centerMercator.y + dyMercator, centerMercator.z + dzMercator);
}
export function cameraDirectionFromPitchBearing(pitch: number, bearing: number): {x: number; y: number; z: number} {
const pitchRadians = degreesToRadians(pitch);
const bearingRadians = degreesToRadians(bearing);
const z = Math.cos(-pitchRadians);
const h = Math.sin(pitchRadians);
const x = h * Math.sin(bearingRadians);
const y = -h * Math.cos(bearingRadians);
return {x, y, z};
}
+149
View File
@@ -0,0 +1,149 @@
import type {CanonicalTileID} from '../../tile/tile_id';
import type {PreparedShader} from '../../shaders/shaders';
import type {Context} from '../../gl/context';
import type {Mesh} from '../../render/mesh';
import type {Program} from '../../render/program';
import type {SubdivisionGranularitySetting} from '../../render/subdivision_granularity_settings';
import type {ProjectionSpecification} from '@maplibre/maplibre-gl-style-spec';
import type {EvaluationParameters} from '../../style/evaluation_parameters';
/**
* Custom projections are handled both by a class which implements this `Projection` interface,
* and a class that is derived from the `Transform` base class. What is the difference?
*
* The transform-derived class:
* - should do all the heavy lifting for the projection - implement all the `project*` and `unproject*` functions, etc.
* - must store the map's state - center, pitch, etc. - this is handled in the `Transform` base class
* - must be cloneable - it should not create any heavy resources
*
* The projection-implementing class:
* - must provide basic information and data about the projection, which is *independent of the map's state* - name, shader functions, subdivision settings, etc.
* - must be a "singleton" - no matter how many copies of the matching Transform class exist, the Projection should always exist as a single instance (per Map)
* - may create heavy resources that should not exist in multiple copies (projection is never cloned) - for example, see the GPU inaccuracy mitigation for globe projection
* - must be explicitly disposed of after usage using the `destroy` function - this allows the implementing class to free any allocated resources
*/
/**
* @internal
*/
export type ProjectionGPUContext = {
context: Context;
useProgram: (name: string) => Program<any>;
};
/**
* @internal
* Specifies the usage for a square tile mesh:
* - 'stencil' for drawing stencil masks
* - 'raster' for drawing raster tiles, hillshade, etc.
*/
export type TileMeshUsage = 'stencil' | 'raster';
/**
* An interface the implementations of which are used internally by MapLibre to handle different projections.
*/
export interface Projection {
/**
* @internal
* A short, descriptive name of this projection, such as 'mercator' or 'globe'.
*/
get name(): ProjectionSpecification['type'];
/**
* @internal
* True if this projection needs to render subdivided geometry.
* Optimized rendering paths for non-subdivided geometry might be used throughout MapLibre.
* The value of this property may change during runtime, for example in globe projection depending on zoom.
*/
get useSubdivision(): boolean;
/**
* Name of the shader projection variant that should be used for this projection.
* Note that this value may change dynamically, for example when globe projection internally transitions to mercator.
* Then globe projection might start reporting the mercator shader variant name to make MapLibre use faster mercator shaders.
*/
get shaderVariantName(): string;
/**
* A `#define` macro that is injected into every MapLibre shader that uses this projection.
* @example
* `const define = projection.shaderDefine; // '#define GLOBE'`
*/
get shaderDefine(): string;
/**
* @internal
* A preprocessed prelude code for both vertex and fragment shaders.
*/
get shaderPreludeCode(): PreparedShader;
/**
* Vertex shader code that is injected into every MapLibre vertex shader that uses this projection.
*/
get vertexShaderPreludeCode(): string;
/**
* @internal
* An object describing how much subdivision should be applied to rendered geometry.
* The subdivision settings should be a constant for a given projection.
* Projections that do not require subdivision should return {@link SubdivisionGranularitySetting.noSubdivision}.
*/
get subdivisionGranularity(): SubdivisionGranularitySetting;
/**
* @internal
* A number representing the current transition state of the projection.
* The return value should be a number between 0 and 1,
* where 0 means the projection is fully in the initial state,
* and 1 means the projection is fully in the final state.
*/
get transitionState(): number;
/**
* @internal
* Gets the error correction latitude in radians.
*/
get latitudeErrorCorrectionRadians(): number;
/**
* @internal
* Cleans up any resources the projection created, especially GPU buffers.
*/
destroy(): void;
/**
* @internal
* Runs any GPU-side tasks this projection required. Called at the beginning of every frame.
*/
updateGPUdependent(renderContext: ProjectionGPUContext): void;
/**
* @internal
* Returns a subdivided mesh for a given tile ID, covering 0..EXTENT range.
* @param context - WebGL context.
* @param tileID - The tile coordinates for which to return a mesh. Meshes for tiles that border the top/bottom mercator edge might include extra geometry for the north/south pole.
* @param hasBorder - When true, the mesh will also include a small border beyond the 0..EXTENT range.
* @param allowPoles - When true, the mesh will also include geometry to cover the north (south) pole, if the given tileID borders the mercator range's top (bottom) edge.
* @param usage - Specify the usage of the tile mesh, as different usages might use different levels of subdivision.
*/
getMeshFromTileID(context: Context, tileID: CanonicalTileID, hasBorder: boolean, allowPoles: boolean, usage: TileMeshUsage): Mesh;
/**
* @internal
* Recalculates the projection state based on the current evaluation parameters.
* @param params - Evaluation parameters.
*/
recalculate(params: EvaluationParameters): void;
/**
* @internal
* Returns true if the projection is currently transitioning between two states.
*/
hasTransition(): boolean;
/**
* @internal
* Sets the error query latidude in degrees
*/
setErrorQueryLatitudeDegrees(value: number);
}
+70
View File
@@ -0,0 +1,70 @@
import type {mat4} from 'gl-matrix';
import type {OverscaledTileID} from '../../tile/tile_id';
/**
* This type contains all data necessary to project a tile to screen in MapLibre's shader system.
* Contains data used for both mercator and globe projection.
*/
export type ProjectionData = {
/**
* The main projection matrix. For mercator projection, it usually projects in-tile coordinates 0..EXTENT to screen,
* for globe projection, it projects a unit sphere planet to screen.
* Uniform name: `u_projection_matrix`.
*/
mainMatrix: mat4;
/**
* The extent of current tile in the mercator square.
* Used by globe projection.
* First two components are X and Y offset, last two are X and Y scale.
* Uniform name: `u_projection_tile_mercator_coords`.
*
* Conversion from in-tile coordinates in range 0..EXTENT is done as follows:
* @example
* ```
* vec2 mercator_coords = u_projection_tile_mercator_coords.xy + in_tile.xy * u_projection_tile_mercator_coords.zw;
* ```
*/
tileMercatorCoords: [number, number, number, number];
/**
* The plane equation for a plane that intersects the planet's horizon.
* Assumes the planet to be a unit sphere.
* Used by globe projection for clipping.
* Uniform name: `u_projection_clipping_plane`.
*/
clippingPlane: [number, number, number, number];
/**
* A value in range 0..1 indicating interpolation between mercator (0) and globe (1) projections.
* Used by globe projection to hide projection transition at high zooms.
* Uniform name: `u_projection_transition`.
*/
projectionTransition: number;
/**
* Fallback matrix that projects the current tile according to mercator projection.
* Used by globe projection to fall back to mercator projection in an animated way.
* Uniform name: `u_projection_fallback_matrix`.
*/
fallbackMatrix: mat4;
};
/**
* Parameters object for the transform's `getProjectionData` function.
* Contains the requested tile ID and more.
*/
export type ProjectionDataParams = {
/**
* The ID of the current tile
*/
overscaledTileID: OverscaledTileID | null;
/**
* Set to true if a pixel-aligned matrix should be used, if possible (mostly used for raster tiles under mercator projection)
*/
aligned?: boolean;
/**
* Set to true if the terrain matrix should be applied (i.e. when rendering terrain)
*/
applyTerrainMatrix?: boolean;
/**
* Set to true if the globe matrix should be applied (i.e. when rendering globe)
*/
applyGlobeMatrix?: boolean;
};
+75
View File
@@ -0,0 +1,75 @@
import {warnOnce} from '../../util/util';
import {MercatorProjection} from './mercator_projection';
import {MercatorTransform} from './mercator_transform';
import {MercatorCameraHelper} from './mercator_camera_helper';
import {GlobeProjection} from './globe_projection';
import {GlobeTransform} from './globe_transform';
import {GlobeCameraHelper} from './globe_camera_helper';
import {VerticalPerspectiveCameraHelper} from './vertical_perspective_camera_helper';
import {VerticalPerspectiveTransform} from './vertical_perspective_transform';
import {VerticalPerspectiveProjection} from './vertical_perspective_projection';
import type {ProjectionSpecification} from '@maplibre/maplibre-gl-style-spec';
import type {Projection} from './projection';
import type {ITransform, TransformConstrainFunction} from '../transform_interface';
import type {ICameraHelper} from './camera_helper';
export function createProjectionFromName(name: ProjectionSpecification['type'], transformConstrain?: TransformConstrainFunction): {
projection: Projection;
transform: ITransform;
cameraHelper: ICameraHelper;
} {
const transformOptions = {constrainOverride: transformConstrain};
if (Array.isArray(name)) {
const globeProjection = new GlobeProjection({type: name});
return {
projection: globeProjection,
transform: new GlobeTransform(transformOptions),
cameraHelper: new GlobeCameraHelper(globeProjection),
};
}
switch (name) {
case 'mercator':
{
return {
projection: new MercatorProjection(),
transform: new MercatorTransform(transformOptions),
cameraHelper: new MercatorCameraHelper(),
};
}
case 'globe':
{
const globeProjection = new GlobeProjection({type: [
'interpolate',
['linear'],
['zoom'],
11,
'vertical-perspective',
12,
'mercator'
]});
return {
projection: globeProjection,
transform: new GlobeTransform(transformOptions),
cameraHelper: new GlobeCameraHelper(globeProjection),
};
}
case 'vertical-perspective':
{
return {
projection: new VerticalPerspectiveProjection(),
transform: new VerticalPerspectiveTransform(transformOptions),
cameraHelper: new VerticalPerspectiveCameraHelper(),
};
}
default:
{
warnOnce(`Unknown projection name: ${name}. Falling back to mercator projection.`);
return {
projection: new MercatorProjection(),
transform: new MercatorTransform(transformOptions),
cameraHelper: new MercatorCameraHelper(),
};
}
}
}
@@ -0,0 +1,458 @@
import Point from '@mapbox/point-geometry';
import {cameraBoundsWarning, type CameraForBoxAndBearingHandlerResult, type EaseToHandlerResult, type EaseToHandlerOptions, type FlyToHandlerResult, type FlyToHandlerOptions, type ICameraHelper, type MapControlsDeltas, updateRotation, type UpdateRotationArgs, cameraForBoxAndBearing} from './camera_helper';
import {LngLat, type LngLatLike} from '../lng_lat';
import {angularCoordinatesToSurfaceVector, computeGlobePanCenter, getGlobeRadiusPixels, getZoomAdjustment, globeDistanceOfLocationsPixels, interpolateLngLatForGlobe} from './globe_utils';
import {clamp, createVec3f64, differenceOfAnglesDegrees, MAX_VALID_LATITUDE, remapSaturate, rollPitchBearingEqual, scaleZoom, warnOnce, zoomScale} from '../../util/util';
import {type mat4, vec3} from 'gl-matrix';
import {normalizeCenter} from '../transform_helper';
import {interpolates} from '@maplibre/maplibre-gl-style-spec';
import type {IReadonlyTransform, ITransform} from '../transform_interface';
import type {CameraForBoundsOptions} from '../../ui/camera';
import type {LngLatBounds} from '../lng_lat_bounds';
import type {PaddingOptions} from '../edge_insets';
/**
* @internal
*/
export class VerticalPerspectiveCameraHelper implements ICameraHelper {
get useGlobeControls(): boolean { return true; }
handlePanInertia(pan: Point, transform: IReadonlyTransform): {
easingCenter: LngLat;
easingOffset: Point;
} {
const panCenter = computeGlobePanCenter(pan, transform);
if (Math.abs(panCenter.lng - transform.center.lng) > 180) {
// If easeTo target would be over 180° distant, the animation would move
// in the opposite direction that what the user intended.
// Thus we clamp the movement to 179.5°.
panCenter.lng = transform.center.lng + 179.5 * Math.sign(panCenter.lng - transform.center.lng);
}
return {
easingCenter: panCenter,
easingOffset: new Point(0, 0),
};
}
handleMapControlsRollPitchBearingZoom(deltas: MapControlsDeltas, tr: ITransform): void {
const zoomPixel = deltas.around;
const zoomLoc = tr.screenPointToLocation(zoomPixel);
if (deltas.bearingDelta) tr.setBearing(tr.bearing + deltas.bearingDelta);
if (deltas.pitchDelta) tr.setPitch(tr.pitch + deltas.pitchDelta);
if (deltas.rollDelta) tr.setRoll(tr.roll + deltas.rollDelta);
const oldZoomPreZoomDelta = tr.zoom;
if (deltas.zoomDelta) tr.setZoom(tr.zoom + deltas.zoomDelta);
const actualZoomDelta = tr.zoom - oldZoomPreZoomDelta;
if (actualZoomDelta === 0) {
return;
}
// Problem: `setLocationAtPoint` for globe works when it is called a single time, but is a little glitchy in practice when used repeatedly for zooming.
// - `setLocationAtPoint` repeatedly called at a location behind a pole will eventually glitch out
// - `setLocationAtPoint` at location the longitude of which is more than 90° different from current center will eventually glitch out
// But otherwise works fine at higher zooms, or when the target is somewhat near the current map center.
// Solution: use a heuristic zooming in the problematic cases and interpolate to `setLocationAtPoint` when possible.
// Magic numbers that control:
// - when zoom movement slowing starts for cursor not on globe (avoid unnatural map movements)
// - when we interpolate from exact zooming to heuristic zooming based on longitude difference of target location to current center
// - when we interpolate from exact zooming to heuristic zooming based on globe being too small on screen
// - when zoom movement slowing starts for globe being too small on viewport (avoids unnatural/unwanted map movements when map is zoomed out a lot)
const raySurfaceDistanceForSlowingStart = 0.3; // Zoom movement slowing will start when the planet surface to ray distance is greater than this number (globe radius is 1, so 0.3 is ~2000km form the surface).
const slowingMultiplier = 0.5; // The lower this value, the slower will the "zoom movement slowing" occur.
const interpolateToHeuristicStartLng = 45; // When zoom location longitude is this many degrees away from map center, we start interpolating from exact zooming to heuristic zooming.
const interpolateToHeuristicEndLng = 85; // Longitude difference at which interpolation to heuristic zooming ends.
const interpolateToHeuristicExponent = 0.25; // Makes interpolation smoother.
const interpolateToHeuristicStartRadius = 0.75; // When globe is this many times larger than the smaller viewport dimension, we start interpolating from exact zooming to heuristic zooming.
const interpolateToHeuristicEndRadius = 0.35; // Globe size at which interpolation to heuristic zooming ends.
const slowingRadiusStart = 0.9; // If globe is this many times larger than the smaller viewport dimension, start inhibiting map movement while zooming
const slowingRadiusStop = 0.5;
const slowingRadiusSlowFactor = 0.25; // How much is movement slowed when globe is too small
const dLngRaw = differenceOfAnglesDegrees(tr.center.lng, zoomLoc.lng);
const dLng = dLngRaw / (Math.abs(dLngRaw / 180) + 1.0); // This gradually reduces the amount of longitude change if the zoom location is very far, eg. on the other side of the pole (possible when looking at a pole).
const dLat = differenceOfAnglesDegrees(tr.center.lat, zoomLoc.lat);
// Slow zoom movement down if the mouse ray is far from the planet.
const rayDirection = tr.getRayDirectionFromPixel(zoomPixel);
const rayOrigin = tr.cameraPosition;
const distanceToClosestPoint = vec3.dot(rayOrigin, rayDirection) * -1; // Globe center relative to ray origin is equal to -rayOrigin and rayDirection is normalized, thus we want to compute dot(-rayOrigin, rayDirection).
const closestPoint = createVec3f64();
vec3.add(closestPoint, rayOrigin, [
rayDirection[0] * distanceToClosestPoint,
rayDirection[1] * distanceToClosestPoint,
rayDirection[2] * distanceToClosestPoint
]);
const distanceFromSurface = vec3.length(closestPoint) - 1;
const distanceFactor = Math.exp(-Math.max(distanceFromSurface - raySurfaceDistanceForSlowingStart, 0) * slowingMultiplier);
// Slow zoom movement down if the globe is too small on viewport
const radius = getGlobeRadiusPixels(tr.worldSize, tr.center.lat) / Math.min(tr.width, tr.height); // Radius relative to larger viewport dimension
const radiusFactor = remapSaturate(radius, slowingRadiusStart, slowingRadiusStop, 1.0, slowingRadiusSlowFactor);
// Compute how much to move towards the zoom location
const factor = (1.0 - zoomScale(-actualZoomDelta)) * Math.min(distanceFactor, radiusFactor);
const oldCenterLat = tr.center.lat;
const oldZoom = tr.zoom;
const heuristicCenter = new LngLat(
tr.center.lng + dLng * factor,
clamp(tr.center.lat + dLat * factor, -MAX_VALID_LATITUDE, MAX_VALID_LATITUDE)
);
// Now compute the map center exact zoom
tr.setLocationAtPoint(zoomLoc, zoomPixel);
const exactCenter = tr.center;
// Interpolate between exact zooming and heuristic zooming depending on the longitude difference between current center and zoom location.
const interpolationFactorLongitude = remapSaturate(Math.abs(dLngRaw), interpolateToHeuristicStartLng, interpolateToHeuristicEndLng, 0, 1);
const interpolationFactorRadius = remapSaturate(radius, interpolateToHeuristicStartRadius, interpolateToHeuristicEndRadius, 0, 1);
const heuristicFactor = Math.pow(Math.max(interpolationFactorLongitude, interpolationFactorRadius), interpolateToHeuristicExponent);
const lngExactToHeuristic = differenceOfAnglesDegrees(exactCenter.lng, heuristicCenter.lng);
const latExactToHeuristic = differenceOfAnglesDegrees(exactCenter.lat, heuristicCenter.lat);
tr.setCenter(new LngLat(
exactCenter.lng + lngExactToHeuristic * heuristicFactor,
exactCenter.lat + latExactToHeuristic * heuristicFactor
).wrap());
tr.setZoom(oldZoom + getZoomAdjustment(oldCenterLat, tr.center.lat));
}
handleMapControlsPan(deltas: MapControlsDeltas, tr: ITransform, _preZoomAroundLoc: LngLat): void {
if (!deltas.panDelta) {
return;
}
// These are actually very similar to mercator controls, and should converge to them at high zooms.
// We avoid using the "grab a place and move it around" approach from mercator here,
// since it is not a very pleasant way to pan a globe.
const oldLat = tr.center.lat;
const oldZoom = tr.zoom;
tr.setCenter(computeGlobePanCenter(deltas.panDelta, tr).wrap());
// Setting the center might adjust zoom to keep globe size constant, we need to avoid adding this adjustment a second time
tr.setZoom(oldZoom + getZoomAdjustment(oldLat, tr.center.lat));
}
cameraForBoxAndBearing(options: CameraForBoundsOptions, padding: PaddingOptions, bounds: LngLatBounds, bearing: number, tr: ITransform): CameraForBoxAndBearingHandlerResult {
const result = cameraForBoxAndBearing(options, padding, bounds, bearing, tr);
// If globe is enabled, we use the parameters computed for mercator, and just update the zoom to fit the bounds.
// Get clip space bounds including padding
const xLeft = (padding.left) / tr.width * 2.0 - 1.0;
const xRight = (tr.width - padding.right) / tr.width * 2.0 - 1.0;
const yTop = (padding.top) / tr.height * -2.0 + 1.0;
const yBottom = (tr.height - padding.bottom) / tr.height * -2.0 + 1.0;
// Get camera bounds
const flipEastWest = differenceOfAnglesDegrees(bounds.getWest(), bounds.getEast()) < 0;
const lngWest = flipEastWest ? bounds.getEast() : bounds.getWest();
const lngEast = flipEastWest ? bounds.getWest() : bounds.getEast();
const latNorth = Math.max(bounds.getNorth(), bounds.getSouth()); // "getNorth" doesn't always return north...
const latSouth = Math.min(bounds.getNorth(), bounds.getSouth());
// Additional vectors will be tested for the rectangle midpoints
const lngMid = lngWest + differenceOfAnglesDegrees(lngWest, lngEast) * 0.5;
const latMid = latNorth + differenceOfAnglesDegrees(latNorth, latSouth) * 0.5;
// Obtain a globe projection matrix that does not include pitch (unsupported)
const clonedTr = tr.clone();
clonedTr.setCenter(result.center);
clonedTr.setBearing(result.bearing);
clonedTr.setPitch(0);
clonedTr.setRoll(0);
clonedTr.setZoom(result.zoom);
const matrix = clonedTr.modelViewProjectionMatrix;
// Vectors to test - the bounds' corners and edge midpoints
const testVectors = [
angularCoordinatesToSurfaceVector(bounds.getNorthWest()),
angularCoordinatesToSurfaceVector(bounds.getNorthEast()),
angularCoordinatesToSurfaceVector(bounds.getSouthWest()),
angularCoordinatesToSurfaceVector(bounds.getSouthEast()),
// Also test edge midpoints
angularCoordinatesToSurfaceVector(new LngLat(lngEast, latMid)),
angularCoordinatesToSurfaceVector(new LngLat(lngWest, latMid)),
angularCoordinatesToSurfaceVector(new LngLat(lngMid, latNorth)),
angularCoordinatesToSurfaceVector(new LngLat(lngMid, latSouth))
];
const vecToCenter = angularCoordinatesToSurfaceVector(result.center);
// Test each vector, measure how much to scale down the globe to satisfy all tested points that they are inside clip space.
let smallestNeededScale = Number.POSITIVE_INFINITY;
for (const vec of testVectors) {
if (xLeft < 0)
smallestNeededScale = VerticalPerspectiveCameraHelper.getLesserNonNegativeNonNull(smallestNeededScale, VerticalPerspectiveCameraHelper.solveVectorScale(vec, vecToCenter, matrix, 'x', xLeft));
if (xRight > 0)
smallestNeededScale = VerticalPerspectiveCameraHelper.getLesserNonNegativeNonNull(smallestNeededScale, VerticalPerspectiveCameraHelper.solveVectorScale(vec, vecToCenter, matrix, 'x', xRight));
if (yTop > 0)
smallestNeededScale = VerticalPerspectiveCameraHelper.getLesserNonNegativeNonNull(smallestNeededScale, VerticalPerspectiveCameraHelper.solveVectorScale(vec, vecToCenter, matrix, 'y', yTop));
if (yBottom < 0)
smallestNeededScale = VerticalPerspectiveCameraHelper.getLesserNonNegativeNonNull(smallestNeededScale, VerticalPerspectiveCameraHelper.solveVectorScale(vec, vecToCenter, matrix, 'y', yBottom));
}
if (!Number.isFinite(smallestNeededScale) || smallestNeededScale === 0) {
cameraBoundsWarning();
return undefined;
}
// Compute target zoom from the obtained scale.
result.zoom = Math.min(clonedTr.zoom + scaleZoom(smallestNeededScale), options.maxZoom);
return result;
}
/**
* Handles the zoom and center change during camera jumpTo.
*/
handleJumpToCenterZoom(tr: ITransform, options: { zoom?: number; center?: LngLatLike }): void {
// Special zoom & center handling for globe:
// Globe constrained center isn't dependent on zoom level
const startingLat = tr.center.lat;
const constrainedCenter = tr.applyConstrain(options.center ? LngLat.convert(options.center) : tr.center, tr.zoom).center;
tr.setCenter(constrainedCenter.wrap());
// Make sure to compute correct target zoom level if no zoom is specified
const targetZoom = (typeof options.zoom !== 'undefined') ? +options.zoom : (tr.zoom + getZoomAdjustment(startingLat, constrainedCenter.lat));
if (tr.zoom !== targetZoom) {
tr.setZoom(targetZoom);
}
}
handleEaseTo(tr: ITransform, options: EaseToHandlerOptions): EaseToHandlerResult {
const startZoom = tr.zoom;
const startCenter = tr.center;
const startPadding = tr.padding;
const startEulerAngles = {roll: tr.roll, pitch: tr.pitch, bearing: tr.bearing};
const endRoll = options.roll === undefined ? tr.roll : options.roll;
const endPitch = options.pitch === undefined ? tr.pitch : options.pitch;
const endBearing = options.bearing === undefined ? tr.bearing : options.bearing;
const endEulerAngles = {roll: endRoll, pitch: endPitch, bearing: endBearing};
const optionsZoom = typeof options.zoom !== 'undefined';
const doPadding = !tr.isPaddingEqual(options.padding);
let isZooming = false;
// Globe needs special handling for how zoom should be animated.
// 1) if zoom is set, ease to the given mercator zoom
// 2) if neither is set, assume constant apparent zoom (constant planet size) is to be kept
const preConstrainCenter = options.center ?
LngLat.convert(options.center) :
startCenter;
const constrainedCenter = tr.applyConstrain(
preConstrainCenter,
startZoom // zoom can be whatever at this stage, it should not affect anything if globe is enabled
).center;
normalizeCenter(tr, constrainedCenter);
const clonedTr = tr.clone();
clonedTr.setCenter(constrainedCenter);
clonedTr.setZoom(optionsZoom ?
+options.zoom :
startZoom + getZoomAdjustment(startCenter.lat, preConstrainCenter.lat));
clonedTr.setBearing(options.bearing);
const clampedPoint = new Point(
clamp(tr.centerPoint.x + options.offsetAsPoint.x, 0, tr.width),
clamp(tr.centerPoint.y + options.offsetAsPoint.y, 0, tr.height)
);
clonedTr.setLocationAtPoint(constrainedCenter, clampedPoint);
// Find final animation targets
const endCenterWithShift = (options.offset && options.offsetAsPoint.mag()) > 0 ? clonedTr.center : constrainedCenter;
const endZoomWithShift = optionsZoom ?
+options.zoom :
startZoom + getZoomAdjustment(startCenter.lat, endCenterWithShift.lat);
// Planet radius for a given zoom level differs according to latitude
// Convert zooms to what they would be at equator for the given planet radius
const normalizedStartZoom = startZoom + getZoomAdjustment(startCenter.lat, 0);
const normalizedEndZoom = endZoomWithShift + getZoomAdjustment(endCenterWithShift.lat, 0);
const deltaLng = differenceOfAnglesDegrees(startCenter.lng, endCenterWithShift.lng);
const deltaLat = differenceOfAnglesDegrees(startCenter.lat, endCenterWithShift.lat);
const finalScale = zoomScale(normalizedEndZoom - normalizedStartZoom);
isZooming = (endZoomWithShift !== startZoom);
const easeFunc = (k: number) => {
if (!rollPitchBearingEqual(startEulerAngles, endEulerAngles)) {
updateRotation({
startEulerAngles,
endEulerAngles,
tr,
k,
useSlerp: startEulerAngles.roll != endEulerAngles.roll} as UpdateRotationArgs);
}
if (doPadding) {
tr.interpolatePadding(startPadding, options.padding,k);
}
if (options.around) {
warnOnce('Easing around a point is not supported under globe projection.');
tr.setLocationAtPoint(options.around, options.aroundPoint);
} else {
const base = normalizedEndZoom > normalizedStartZoom ?
Math.min(2, finalScale) :
Math.max(0.5, finalScale);
const speedup = Math.pow(base, 1 - k);
const factor = k * speedup;
// Spherical lerp might be used here instead, but that was tested and it leads to very weird paths when the interpolated arc gets near the poles.
// Instead we interpolate LngLat almost directly, but taking into account that
// one degree of longitude gets progressively smaller relative to latitude towards the poles.
const newCenter = interpolateLngLatForGlobe(startCenter, deltaLng, deltaLat, factor);
tr.setCenter(newCenter.wrap());
}
if (isZooming) {
const normalizedInterpolatedZoom = interpolates.number(normalizedStartZoom, normalizedEndZoom, k);
const interpolatedZoom = normalizedInterpolatedZoom + getZoomAdjustment(0, tr.center.lat);
tr.setZoom(interpolatedZoom);
}
};
return {
easeFunc,
isZooming,
elevationCenter: endCenterWithShift,
};
}
handleFlyTo(tr: ITransform, options: FlyToHandlerOptions): FlyToHandlerResult {
const optionsZoom = typeof options.zoom !== 'undefined';
const startCenter = tr.center;
const startZoom = tr.zoom;
const startPadding = tr.padding;
const doPadding = !tr.isPaddingEqual(options.padding);
// Obtain target center and zoom
const constrainedCenter = tr.applyConstrain(
LngLat.convert(options.center || options.locationAtOffset),
startZoom
).center;
const targetZoom = optionsZoom ? +options.zoom : tr.zoom + getZoomAdjustment(tr.center.lat, constrainedCenter.lat);
// Compute target center that respects offset by creating a temporary transform and calling its `setLocationAtPoint`.
const clonedTr = tr.clone();
clonedTr.setCenter(constrainedCenter);
clonedTr.setZoom(targetZoom);
clonedTr.setBearing(options.bearing);
const clampedPoint = new Point(
clamp(tr.centerPoint.x + options.offsetAsPoint.x, 0, tr.width),
clamp(tr.centerPoint.y + options.offsetAsPoint.y, 0, tr.height)
);
clonedTr.setLocationAtPoint(constrainedCenter, clampedPoint);
const targetCenter = clonedTr.center;
normalizeCenter(tr, targetCenter);
const pixelPathLength = globeDistanceOfLocationsPixels(tr, startCenter, targetCenter);
const normalizedStartZoom = startZoom + getZoomAdjustment(startCenter.lat, 0);
const normalizedTargetZoom = targetZoom + getZoomAdjustment(targetCenter.lat, 0);
const scaleOfZoom = zoomScale(normalizedTargetZoom - normalizedStartZoom);
const optionsMinZoom = typeof options.minZoom === 'number';
let scaleOfMinZoom: number;
if (optionsMinZoom) {
const normalizedOptionsMinZoom = +options.minZoom + getZoomAdjustment(targetCenter.lat, 0);
const normalizedMinZoomPreConstrain = Math.min(normalizedOptionsMinZoom, normalizedStartZoom, normalizedTargetZoom);
const minZoomPreConstrain = normalizedMinZoomPreConstrain + getZoomAdjustment(0, targetCenter.lat);
const minZoom = tr.applyConstrain(targetCenter, minZoomPreConstrain).zoom;
const normalizedMinZoom = minZoom + getZoomAdjustment(targetCenter.lat, 0);
scaleOfMinZoom = zoomScale(normalizedMinZoom - normalizedStartZoom);
}
const deltaLng = differenceOfAnglesDegrees(startCenter.lng, targetCenter.lng);
const deltaLat = differenceOfAnglesDegrees(startCenter.lat, targetCenter.lat);
const easeFunc = (k: number, scale: number, centerFactor: number, _pointAtOffset: Point) => {
const interpolatedCenter = interpolateLngLatForGlobe(startCenter, deltaLng, deltaLat, centerFactor);
if (doPadding) {
tr.interpolatePadding(startPadding, options.padding,k);
}
const newCenter = k === 1 ? targetCenter : interpolatedCenter;
tr.setCenter(newCenter.wrap());
const interpolatedZoom = normalizedStartZoom + scaleZoom(scale);
tr.setZoom(k === 1 ? targetZoom : (interpolatedZoom + getZoomAdjustment(0, newCenter.lat)));
};
return {
easeFunc,
scaleOfZoom,
targetCenter,
scaleOfMinZoom,
pixelPathLength,
};
}
/**
* Computes how much to scale the globe in order for a given point on its surface (a location) to project to a given clip space coordinate in either the X or the Y axis.
* @param vector - Position of the queried location on the surface of the unit sphere globe.
* @param toCenter - Position of current transform center on the surface of the unit sphere globe.
* This is needed because zooming the globe not only changes its scale,
* but also moves the camera closer or further away along this vector (pitch is disregarded).
* @param projection - The globe projection matrix.
* @param targetDimension - The dimension in which the scaled vector must match the target value in clip space.
* @param targetValue - The target clip space value in the specified dimension to which the queried vector must project.
* @returns How much to scale the globe.
*/
private static solveVectorScale(vector: vec3, toCenter: vec3, projection: mat4, targetDimension: 'x' | 'y', targetValue: number): number | null {
// We want to compute how much to scale the sphere in order for the input `vector` to project to `targetValue` in the given `targetDimension` (X or Y).
const k = targetValue;
const columnXorY = targetDimension === 'x' ?
[projection[0], projection[4], projection[8], projection[12]] : // X
[projection[1], projection[5], projection[9], projection[13]]; // Y
const columnZ = [projection[3], projection[7], projection[11], projection[15]];
const vecDotXY = vector[0] * columnXorY[0] + vector[1] * columnXorY[1] + vector[2] * columnXorY[2];
const vecDotZ = vector[0] * columnZ[0] + vector[1] * columnZ[1] + vector[2] * columnZ[2];
const toCenterDotXY = toCenter[0] * columnXorY[0] + toCenter[1] * columnXorY[1] + toCenter[2] * columnXorY[2];
const toCenterDotZ = toCenter[0] * columnZ[0] + toCenter[1] * columnZ[1] + toCenter[2] * columnZ[2];
// The following can be derived from writing down what happens to a vector scaled by a parameter ("V * t") when it is multiplied by a projection matrix, then solving for "t".
// Or rather, we derive it for a vector "V * t + (1-t) * C". Where V is `vector` and C is `toCenter`. The extra addition is needed because zooming out also moves the camera along "C".
const t = (toCenterDotXY + columnXorY[3] - k * toCenterDotZ - k * columnZ[3]) / (toCenterDotXY - vecDotXY - k * toCenterDotZ + k * vecDotZ);
if (
toCenterDotXY + k * vecDotZ === vecDotXY + k * toCenterDotZ ||
columnZ[3] * (vecDotXY - toCenterDotXY) + columnXorY[3] * (toCenterDotZ - vecDotZ) + vecDotXY * toCenterDotZ === toCenterDotXY * vecDotZ
) {
// The computed result is invalid.
return null;
}
return t;
}
/**
* Returns `newValue` if it is:
*
* - not null AND
* - not negative AND
* - smaller than `newValue`,
*
* ...otherwise returns `oldValue`.
*/
private static getLesserNonNegativeNonNull(oldValue: number, newValue: number): number {
if (newValue !== null && newValue >= 0 && newValue < oldValue) {
return newValue;
} else {
return oldValue;
}
}
}
@@ -0,0 +1,167 @@
import type {Context} from '../../gl/context';
import type {CanonicalTileID} from '../../tile/tile_id';
import {type Mesh} from '../../render/mesh';
import {now} from '../../util/time_control';
import {easeCubicInOut, lerp} from '../../util/util';
import {mercatorYfromLat} from '../mercator_coordinate';
import {SubdivisionGranularityExpression, SubdivisionGranularitySetting} from '../../render/subdivision_granularity_settings';
import type {Projection, ProjectionGPUContext, TileMeshUsage} from './projection';
import {type PreparedShader, shaders} from '../../shaders/shaders';
import {ProjectionErrorMeasurement} from './globe_projection_error_measurement';
import {createTileMeshWithBuffers, type CreateTileMeshOptions} from '../../util/create_tile_mesh';
import {type EvaluationParameters} from '../../style/evaluation_parameters';
export const VerticalPerspectiveShaderDefine = '#define GLOBE';
export const VerticalPerspectiveShaderVariantKey = 'globe';
export const globeConstants = {
errorTransitionTimeSeconds: 0.5
};
const granularitySettingsGlobe: SubdivisionGranularitySetting = new SubdivisionGranularitySetting({
fill: new SubdivisionGranularityExpression(128, 2),
line: new SubdivisionGranularityExpression(512, 0),
// Always keep at least some subdivision on raster tiles, etc,
// otherwise they will be visibly warped at high zooms (before mercator transition).
// This si not needed on fill, because fill geometry tends to already be
// highly tessellated and granular at high zooms.
tile: new SubdivisionGranularityExpression(128, 32),
// Stencil granularity must never be higher than fill granularity,
// otherwise we would get seams in the oceans at zoom levels where
// stencil has higher granularity than fill.
stencil: new SubdivisionGranularityExpression(128, 1),
circle: 3
});
export class VerticalPerspectiveProjection implements Projection {
private _tileMeshCache: {[_: string]: Mesh} = {};
// GPU atan() error correction
private _errorMeasurement: ProjectionErrorMeasurement;
private _errorQueryLatitudeDegrees: number;
private _errorCorrectionUsable: number = 0.0;
private _errorMeasurementLastValue: number = 0.0;
private _errorCorrectionPreviousValue: number = 0.0;
private _errorMeasurementLastChangeTime: number = -1000.0;
get name(): 'vertical-perspective' {
return 'vertical-perspective';
}
get transitionState(): number {
return 1;
}
get useSubdivision(): boolean {
return true;
}
get shaderVariantName(): string {
return VerticalPerspectiveShaderVariantKey;
}
get shaderDefine(): string {
return VerticalPerspectiveShaderDefine;
}
get shaderPreludeCode(): PreparedShader {
return shaders.projectionGlobe;
}
get vertexShaderPreludeCode(): string {
return shaders.projectionMercator.vertexSource;
}
get subdivisionGranularity(): SubdivisionGranularitySetting {
return granularitySettingsGlobe;
}
get useGlobeControls(): boolean {
return true;
}
/**
* @internal
* Globe projection periodically measures the error of the GPU's
* projection from mercator to globe and computes how much to correct
* the globe's latitude alignment.
* This stores the correction that should be applied to the projection matrix.
*/
get latitudeErrorCorrectionRadians(): number { return this._errorCorrectionUsable; }
public destroy() {
if (this._errorMeasurement) {
this._errorMeasurement.destroy();
}
}
public updateGPUdependent(renderContext: ProjectionGPUContext): void {
if (!this._errorMeasurement) {
this._errorMeasurement = new ProjectionErrorMeasurement(renderContext);
}
const mercatorY = mercatorYfromLat(this._errorQueryLatitudeDegrees);
const expectedResult = 2.0 * Math.atan(Math.exp(Math.PI - (mercatorY * Math.PI * 2.0))) - Math.PI * 0.5;
const newValue = this._errorMeasurement.updateErrorLoop(mercatorY, expectedResult);
const currentTime = now();
if (newValue !== this._errorMeasurementLastValue) {
this._errorCorrectionPreviousValue = this._errorCorrectionUsable; // store the interpolated value
this._errorMeasurementLastValue = newValue;
this._errorMeasurementLastChangeTime = currentTime;
}
const sinceUpdateSeconds = (currentTime - this._errorMeasurementLastChangeTime) / 1000.0;
const mix = Math.min(Math.max(sinceUpdateSeconds / globeConstants.errorTransitionTimeSeconds, 0.0), 1.0);
const newCorrection = -this._errorMeasurementLastValue; // Note the negation
this._errorCorrectionUsable = lerp(this._errorCorrectionPreviousValue, newCorrection, easeCubicInOut(mix));
}
private _getMeshKey(options: CreateTileMeshOptions): string {
return `${options.granularity.toString(36)}_${options.generateBorders ? 'b' : ''}${options.extendToNorthPole ? 'n' : ''}${options.extendToSouthPole ? 's' : ''}`;
}
public getMeshFromTileID(context: Context, canonical: CanonicalTileID, hasBorder: boolean, allowPoles: boolean, usage: TileMeshUsage): Mesh {
// Stencil granularity must match fill granularity
const granularityConfig = usage === 'stencil' ? granularitySettingsGlobe.stencil : granularitySettingsGlobe.tile;
const granularity = granularityConfig.getGranularityForZoomLevel(canonical.z);
const north = (canonical.y === 0) && allowPoles;
const south = (canonical.y === (1 << canonical.z) - 1) && allowPoles;
return this._getMesh(context, {
granularity,
generateBorders: hasBorder,
extendToNorthPole: north,
extendToSouthPole: south,
});
}
private _getMesh(context: Context, options: CreateTileMeshOptions): Mesh {
const key = this._getMeshKey(options);
if (key in this._tileMeshCache) {
return this._tileMeshCache[key];
}
const mesh = createTileMeshWithBuffers(context, options);
this._tileMeshCache[key] = mesh;
return mesh;
}
recalculate(_params: EvaluationParameters): void {
// Do nothing.
}
hasTransition(): boolean {
const currentTime = now();
let dirty = false;
// Error correction transition
dirty = dirty || (currentTime - this._errorMeasurementLastChangeTime) / 1000.0 < (globeConstants.errorTransitionTimeSeconds + 0.2);
// Error correction query in flight
dirty = dirty || (this._errorMeasurement && this._errorMeasurement.awaitingQuery);
return dirty;
}
setErrorQueryLatitudeDegrees(value: number) {
this._errorQueryLatitudeDegrees = value;
}
}
File diff suppressed because it is too large Load Diff
+86
View File
@@ -0,0 +1,86 @@
import {describe, expect, test} from 'vitest';
import {LngLat} from './lng_lat';
import {LngLatBounds} from './lng_lat_bounds';
import {TransformHelper} from './transform_helper';
import {OverscaledTileID} from '../tile/tile_id';
import {expectToBeCloseToArray} from '../util/test/util';
import {EXTENT} from '../data/extent';
const emptyCallbacks = {
calcMatrices: () => {},
defaultConstrain: (center, zoom) => { return {center, zoom}; },
};
describe('TransformHelper', () => {
test('apply', () => {
const original = new TransformHelper(emptyCallbacks);
original.setConstrainOverride((lngLat, zoom) => {
return {center: lngLat, zoom: zoom ?? 0};
});
original.setBearing(12);
original.setCenter(new LngLat(3, 4));
original.setElevation(5);
original.setFov(1);
original.setMaxBounds(new LngLatBounds([-160, -80, 160, 80]));
original.setMaxPitch(50);
original.setMaxZoom(10);
original.setMinElevationForCurrentTile(0.1);
original.setMinPitch(0.1);
original.setMinZoom(0.1);
original.setPadding({
top: 1,
right: 4,
bottom: 2,
left: 3,
});
original.setPitch(3);
original.setRoll(7);
original.setRenderWorldCopies(false);
original.setZoom(2.3);
const cloned = new TransformHelper(emptyCallbacks);
cloned.apply(original, false);
// Check all getters from the ITransformGetters interface
expect(cloned.constrainOverride).toEqual(original.constrainOverride);
expect(cloned.tileSize).toEqual(original.tileSize);
expect(cloned.tileZoom).toEqual(original.tileZoom);
expect(cloned.scale).toEqual(original.scale);
expect(cloned.worldSize).toEqual(original.worldSize);
expect(cloned.width).toEqual(original.width);
expect(cloned.height).toEqual(original.height);
expect(cloned.bearingInRadians).toEqual(original.bearingInRadians);
expect(cloned.lngRange).toEqual(original.lngRange);
expect(cloned.latRange).toEqual(original.latRange);
expect(cloned.minZoom).toEqual(original.minZoom);
expect(cloned.maxZoom).toEqual(original.maxZoom);
expect(cloned.zoom).toEqual(original.zoom);
expect(cloned.center).toEqual(original.center);
expect(cloned.minPitch).toEqual(original.minPitch);
expect(cloned.maxPitch).toEqual(original.maxPitch);
expect(cloned.pitch).toEqual(original.pitch);
expect(cloned.roll).toEqual(original.roll);
expect(cloned.bearing).toEqual(original.bearing);
expect(cloned.fov).toEqual(original.fov);
expect(cloned.elevation).toEqual(original.elevation);
expect(cloned.minElevationForCurrentTile).toEqual(original.minElevationForCurrentTile);
expect(cloned.padding).toEqual(original.padding);
expect(cloned.unmodified).toEqual(original.unmodified);
expect(cloned.renderWorldCopies).toEqual(original.renderWorldCopies);
});
describe('getMercatorTilesCoordinates', () => {
test('mercator tile extents are set', () => {
const helper = new TransformHelper(emptyCallbacks);
let tileMercatorCoords = helper.getMercatorTileCoordinates(new OverscaledTileID(0, 0, 0, 0, 0));
expectToBeCloseToArray(tileMercatorCoords, [0, 0, 1 / EXTENT, 1 / EXTENT]);
tileMercatorCoords = helper.getMercatorTileCoordinates(new OverscaledTileID(1, 0, 1, 0, 0));
expectToBeCloseToArray(tileMercatorCoords, [0, 0, 0.5 / EXTENT, 0.5 / EXTENT]);
tileMercatorCoords = helper.getMercatorTileCoordinates(new OverscaledTileID(1, 0, 1, 1, 0));
expectToBeCloseToArray(tileMercatorCoords, [0.5, 0, 0.5 / EXTENT, 0.5 / EXTENT]);
});
});
});
+694
View File
@@ -0,0 +1,694 @@
import {LngLat, type LngLatLike} from './lng_lat';
import {LngLatBounds} from './lng_lat_bounds';
import Point from '@mapbox/point-geometry';
import {wrap, clamp, degreesToRadians, radiansToDegrees, zoomScale, MAX_VALID_LATITUDE, scaleZoom} from '../util/util';
import {mat4, mat2} from 'gl-matrix';
import {EdgeInsets} from './edge_insets';
import {altitudeFromMercatorZ, MercatorCoordinate, mercatorZfromAltitude} from './mercator_coordinate';
import {cameraMercatorCoordinateFromCenterAndRotation, cameraDirectionFromPitchBearing} from './projection/mercator_utils';
import {EXTENT} from '../data/extent';
import type {PaddingOptions} from './edge_insets';
import type {IReadonlyTransform, ITransformGetters, TransformConstrainFunction} from './transform_interface';
import type {OverscaledTileID} from '../tile/tile_id';
import {Bounds} from './bounds';
/**
* If a path crossing the antimeridian would be shorter, extend the final coordinate so that
* interpolating between the two endpoints will cross it.
* @param center - The LngLat object of the desired center. This object will be mutated.
*/
export function normalizeCenter(tr: IReadonlyTransform, center: LngLat): void {
if (!tr.renderWorldCopies || tr.lngRange) return;
const delta = center.lng - tr.center.lng;
center.lng +=
delta > 180 ? -360 :
delta < -180 ? 360 : 0;
}
export type UnwrappedTileIDType = {
/**
* Tile wrap: 0 for the "main" world,
* negative values for worlds left of the main,
* positive values for worlds right of the main.
*/
wrap?: number;
canonical: {
/**
* Tile X coordinate, in range 0..(z^2)-1
*/
x: number;
/**
* Tile Y coordinate, in range 0..(z^2)-1
*/
y: number;
/**
* Tile zoom level.
*/
z: number;
};
};
export type TransformHelperCallbacks = {
/**
* The transform's default getter of center lngLat and zoom to ensure that
* 1) everything beyond the bounds is excluded
* 2) a given lngLat is as near the center as possible
* Bounds are those set by maxBounds or North & South "Poles" and, if only 1 globe is displayed, antimeridian.
*/
defaultConstrain: TransformConstrainFunction;
/**
* Updates the underlying transform's internal matrices.
*/
calcMatrices: () => void;
};
export type TransformOptions = {
/**
* The minimum zoom level of the map.
*/
minZoom?: number;
/**
* The maximum zoom level of the map.
*/
maxZoom?: number;
/**
* The minimum pitch of the map.
*/
minPitch?: number;
/**
* The maximum pitch of the map.
*/
maxPitch?: number;
/**
* Whether to render multiple copies of the world side by side in the map.
*/
renderWorldCopies?: boolean;
/**
* An override of the transform's default constraining function for respecting its longitude and latitude bounds.
*/
constrainOverride?: TransformConstrainFunction | null;
};
function getTileZoom(zoom: number): number {
return Math.max(0, Math.floor(zoom));
}
/**
* @internal
* This class stores all values that define a transform's state,
* such as center, zoom, minZoom, etc.
* This can be used as a helper for implementing the ITransform interface.
*/
export class TransformHelper implements ITransformGetters {
private _callbacks: TransformHelperCallbacks;
_tileSize: number; // constant
_tileZoom: number; // integer zoom level for tiles
_lngRange: [number, number];
_latRange: [number, number];
_scale: number; // computed based on zoom
_width: number;
_height: number;
/**
* Vertical field of view in radians.
*/
_fovInRadians: number;
/**
* This transform's bearing in radians.
*/
_bearingInRadians: number;
/**
* Pitch in radians.
*/
_pitchInRadians: number;
/**
* Roll in radians.
*/
_rollInRadians: number;
_zoom: number;
_renderWorldCopies: boolean;
_minZoom: number;
_maxZoom: number;
_minPitch: number;
_maxPitch: number;
_center: LngLat;
_elevation: number;
_minElevationForCurrentTile: number;
_pixelPerMeter: number;
_edgeInsets: EdgeInsets;
_unmodified: boolean;
_constraining: boolean;
_rotationMatrix: mat2;
_pixelsToGLUnits: [number, number];
_pixelsToClipSpaceMatrix: mat4;
_clipSpaceToPixelsMatrix: mat4;
_cameraToCenterDistance: number;
_nearZ: number;
_farZ: number;
_autoCalculateNearFarZ: boolean;
_constrainOverride: TransformConstrainFunction;
constructor(callbacks: TransformHelperCallbacks, options?: TransformOptions) {
this._callbacks = callbacks;
this._tileSize = 512; // constant
this._renderWorldCopies = options?.renderWorldCopies === undefined ? true : !!options?.renderWorldCopies;
this._minZoom = options?.minZoom || 0;
this._maxZoom = options?.maxZoom || 22;
this._minPitch = (options?.minPitch === undefined || options?.minPitch === null) ? 0 : options?.minPitch;
this._maxPitch = (options?.maxPitch === undefined || options?.maxPitch === null) ? 60 : options?.maxPitch;
this._constrainOverride = options?.constrainOverride ?? null;
this.setMaxBounds();
this._width = 0;
this._height = 0;
this._center = new LngLat(0, 0);
this._elevation = 0;
this._zoom = 0;
this._tileZoom = getTileZoom(this._zoom);
this._scale = zoomScale(this._zoom);
this._bearingInRadians = 0;
this._fovInRadians = 0.6435011087932844;
this._pitchInRadians = 0;
this._rollInRadians = 0;
this._unmodified = true;
this._edgeInsets = new EdgeInsets();
this._minElevationForCurrentTile = 0;
this._autoCalculateNearFarZ = true;
}
public apply(thatI: ITransformGetters, constrain: boolean, forceOverrideZ?: boolean): void {
this._constrainOverride = thatI.constrainOverride;
this._latRange = thatI.latRange;
this._lngRange = thatI.lngRange;
this._width = thatI.width;
this._height = thatI.height;
this._center = thatI.center;
this._elevation = thatI.elevation;
this._minElevationForCurrentTile = thatI.minElevationForCurrentTile;
this._zoom = thatI.zoom;
this._tileZoom = getTileZoom(this._zoom);
this._scale = zoomScale(this._zoom);
this._bearingInRadians = thatI.bearingInRadians;
this._fovInRadians = thatI.fovInRadians;
this._pitchInRadians = thatI.pitchInRadians;
this._rollInRadians = thatI.rollInRadians;
this._unmodified = thatI.unmodified;
this._edgeInsets = new EdgeInsets(thatI.padding.top, thatI.padding.bottom, thatI.padding.left, thatI.padding.right);
this._minZoom = thatI.minZoom;
this._maxZoom = thatI.maxZoom;
this._minPitch = thatI.minPitch;
this._maxPitch = thatI.maxPitch;
this._renderWorldCopies = thatI.renderWorldCopies;
this._cameraToCenterDistance = thatI.cameraToCenterDistance;
this._nearZ = thatI.nearZ;
this._farZ = thatI.farZ;
this._autoCalculateNearFarZ = !forceOverrideZ && thatI.autoCalculateNearFarZ;
if (constrain) {
this.constrainInternal();
}
this._calcMatrices();
}
get pixelsToClipSpaceMatrix(): mat4 { return this._pixelsToClipSpaceMatrix; }
get clipSpaceToPixelsMatrix(): mat4 { return this._clipSpaceToPixelsMatrix; }
get minElevationForCurrentTile(): number { return this._minElevationForCurrentTile; }
setMinElevationForCurrentTile(ele: number) {
this._minElevationForCurrentTile = ele;
}
get tileSize(): number { return this._tileSize; }
get tileZoom(): number { return this._tileZoom; }
get scale(): number { return this._scale; }
/**
* Gets the transform's width in pixels. Use {@link resize} to set the transform's size.
*/
get width(): number { return this._width; }
/**
* Gets the transform's height in pixels. Use {@link resize} to set the transform's size.
*/
get height(): number { return this._height; }
/**
* Gets the transform's bearing in radians.
*/
get bearingInRadians(): number { return this._bearingInRadians; }
get lngRange(): [number, number] { return this._lngRange; }
get latRange(): [number, number] { return this._latRange; }
get pixelsToGLUnits(): [number, number] { return this._pixelsToGLUnits; }
get minZoom(): number { return this._minZoom; }
setMinZoom(zoom: number) {
if (this._minZoom === zoom) return;
this._minZoom = zoom;
this.setZoom(this.applyConstrain(this._center, this.zoom).zoom);
}
get maxZoom(): number { return this._maxZoom; }
setMaxZoom(zoom: number) {
if (this._maxZoom === zoom) return;
this._maxZoom = zoom;
this.setZoom(this.applyConstrain(this._center, this.zoom).zoom);
}
get minPitch(): number { return this._minPitch; }
setMinPitch(pitch: number) {
if (this._minPitch === pitch) return;
this._minPitch = pitch;
this.setPitch(Math.max(this.pitch, pitch));
}
get maxPitch(): number { return this._maxPitch; }
setMaxPitch(pitch: number) {
if (this._maxPitch === pitch) return;
this._maxPitch = pitch;
this.setPitch(Math.min(this.pitch, pitch));
}
get renderWorldCopies(): boolean { return this._renderWorldCopies; }
setRenderWorldCopies(renderWorldCopies: boolean) {
if (renderWorldCopies === undefined) {
renderWorldCopies = true;
} else if (renderWorldCopies === null) {
renderWorldCopies = false;
}
this._renderWorldCopies = renderWorldCopies;
}
get constrainOverride(): TransformConstrainFunction { return this._constrainOverride; }
setConstrainOverride(constrain?: TransformConstrainFunction | null) {
if (constrain === undefined) constrain = null;
if (this._constrainOverride === constrain) return;
this._constrainOverride = constrain;
this.constrainInternal();
this._calcMatrices();
}
get worldSize(): number {
return this._tileSize * this._scale;
}
get centerOffset(): Point {
return this.centerPoint._sub(this.size._div(2));
}
/**
* Gets the transform's dimensions packed into a Point object.
*/
get size(): Point {
return new Point(this._width, this._height);
}
get bearing(): number {
return this._bearingInRadians / Math.PI * 180;
}
setBearing(bearing: number) {
const b = wrap(bearing, -180, 180) * Math.PI / 180;
if (this._bearingInRadians === b) return;
this._unmodified = false;
this._bearingInRadians = b;
this._calcMatrices();
// 2x2 matrix for rotating points
this._rotationMatrix = mat2.create();
mat2.rotate(this._rotationMatrix, this._rotationMatrix, -this._bearingInRadians);
}
get rotationMatrix(): mat2 { return this._rotationMatrix; }
get pitchInRadians(): number {
return this._pitchInRadians;
}
get pitch(): number {
return this._pitchInRadians / Math.PI * 180;
}
setPitch(pitch: number) {
const p = clamp(pitch, this.minPitch, this.maxPitch) / 180 * Math.PI;
if (this._pitchInRadians === p) return;
this._unmodified = false;
this._pitchInRadians = p;
this._calcMatrices();
}
get rollInRadians(): number {
return this._rollInRadians;
}
get roll(): number {
return this._rollInRadians / Math.PI * 180;
}
setRoll(roll: number) {
const r = roll / 180 * Math.PI;
if (this._rollInRadians === r) return;
this._unmodified = false;
this._rollInRadians = r;
this._calcMatrices();
}
get fovInRadians(): number {
return this._fovInRadians;
}
get fov(): number {
return radiansToDegrees(this._fovInRadians);
}
setFov(fov: number) {
fov = clamp(fov, 0.1, 150);
if (this.fov === fov) return;
this._unmodified = false;
this._fovInRadians = degreesToRadians(fov);
this._calcMatrices();
}
get zoom(): number { return this._zoom; }
setZoom(zoom: number) {
const constrainedZoom = this.applyConstrain(this._center, zoom).zoom;
if (this._zoom === constrainedZoom) return;
this._unmodified = false;
this._zoom = constrainedZoom;
this._tileZoom = Math.max(0, Math.floor(constrainedZoom));
this._scale = zoomScale(constrainedZoom);
this.constrainInternal();
this._calcMatrices();
}
get center(): LngLat { return this._center; }
setCenter(center: LngLat) {
if (center.lat === this._center.lat && center.lng === this._center.lng) return;
this._unmodified = false;
this._center = center;
this.constrainInternal();
this._calcMatrices();
}
/**
* Elevation at current center point, meters above sea level
*/
get elevation(): number { return this._elevation; }
setElevation(elevation: number) {
if (elevation === this._elevation) return;
this._elevation = elevation;
this.constrainInternal();
this._calcMatrices();
}
get padding(): PaddingOptions { return this._edgeInsets.toJSON(); }
setPadding(padding: PaddingOptions) {
if (this._edgeInsets.equals(padding)) return;
this._unmodified = false;
// Update edge-insets in-place
this._edgeInsets.interpolate(this._edgeInsets, padding, 1);
this._calcMatrices();
}
/**
* The center of the screen in pixels with the top-left corner being (0,0)
* and +y axis pointing downwards. This accounts for padding.
*/
get centerPoint(): Point {
return this._edgeInsets.getCenter(this._width, this._height);
}
/**
* @internal
*/
get pixelsPerMeter(): number { return this._pixelPerMeter; }
get unmodified(): boolean { return this._unmodified; }
get cameraToCenterDistance(): number { return this._cameraToCenterDistance; }
get nearZ(): number { return this._nearZ; }
get farZ(): number { return this._farZ; }
get autoCalculateNearFarZ(): boolean { return this._autoCalculateNearFarZ; }
overrideNearFarZ(nearZ: number, farZ: number): void {
this._autoCalculateNearFarZ = false;
this._nearZ = nearZ;
this._farZ = farZ;
this._calcMatrices();
}
clearNearFarZOverride(): void {
this._autoCalculateNearFarZ = true;
this._calcMatrices();
}
/**
* Returns if the padding params match
*
* @param padding - the padding to check against
* @returns true if they are equal, false otherwise
*/
isPaddingEqual(padding: PaddingOptions): boolean {
return this._edgeInsets.equals(padding);
}
/**
* Helper method to update edge-insets in place
*
* @param start - the starting padding
* @param target - the target padding
* @param t - the step/weight
*/
interpolatePadding(start: PaddingOptions, target: PaddingOptions, t: number): void {
this._unmodified = false;
this._edgeInsets.interpolate(start, target, t);
this.constrainInternal();
this._calcMatrices();
}
resize(width: number, height: number, constrain: boolean = true): void {
this._width = width;
this._height = height;
if (constrain) this.constrainInternal();
this._calcMatrices();
}
/**
* Returns the maximum geographical bounds the map is constrained to, or `null` if none set.
* @returns max bounds
*/
getMaxBounds(): LngLatBounds | null {
if (!this._latRange || this._latRange.length !== 2 ||
!this._lngRange || this._lngRange.length !== 2) return null;
return new LngLatBounds([this._lngRange[0], this._latRange[0]], [this._lngRange[1], this._latRange[1]]);
}
/**
* Sets or clears the map's geographical constraints.
* @param bounds - A {@link LngLatBounds} object describing the new geographic boundaries of the map.
*/
setMaxBounds(bounds?: LngLatBounds | null): void {
if (bounds) {
this._lngRange = [bounds.getWest(), bounds.getEast()];
this._latRange = [bounds.getSouth(), bounds.getNorth()];
this.constrainInternal();
} else {
this._lngRange = null;
this._latRange = [-MAX_VALID_LATITUDE, MAX_VALID_LATITUDE];
}
}
/**
* When the map is pitched, some of the 3D features that intersect a query will not intersect
* the query at the surface of the earth. Instead the feature may be closer and only intersect
* the query because it extrudes into the air.
* @param queryGeometry - For point queries, the line from the query point to the "camera point",
* for other geometries, the envelope of the query geometry and the "camera point"
* @returns a geometry that includes all of the original query as well as all possible ares of the
* screen where the *base* of a visible extrusion could be.
*
*/
getCameraQueryGeometry(cameraPoint: Point, queryGeometry: Array<Point>): Array<Point> {
if (queryGeometry.length === 1) {
return [queryGeometry[0], cameraPoint];
} else {
const {minX, minY, maxX, maxY} = Bounds.fromPoints(queryGeometry).extend(cameraPoint);
return [
new Point(minX, minY),
new Point(maxX, minY),
new Point(maxX, maxY),
new Point(minX, maxY),
new Point(minX, minY)
];
}
}
applyConstrain: TransformConstrainFunction = (lngLat, zoom) => {
if (this._constrainOverride !== null) {
return this._constrainOverride(lngLat, zoom);
} else {
return this._callbacks.defaultConstrain(lngLat, zoom);
}
};
/**
* @internal
* Snaps the transform's center, zoom, etc. into the valid range.
*/
private constrainInternal(): void {
if (!this.center || !this._width || !this._height || this._constraining) return;
this._constraining = true;
const unmodified = this._unmodified;
const {center, zoom} = this.applyConstrain(this.center, this.zoom);
this.setCenter(center);
this.setZoom(zoom);
this._unmodified = unmodified;
this._constraining = false;
}
/**
* This function is called every time one of the transform's defining properties (center, pitch, etc.) changes.
* This function should update the transform's internal data, such as matrices.
* Any derived `_calcMatrices` function should also call the base function first. The base function only depends on the `_width` and `_height` fields.
*/
private _calcMatrices(): void {
if (this._width && this._height) {
this._pixelsToGLUnits = [2 / this._width, -2 / this._height];
let m = mat4.identity(new Float64Array(16) as any);
mat4.scale(m, m, [this._width / 2, -this._height / 2, 1]);
mat4.translate(m, m, [1, -1, 0]);
this._clipSpaceToPixelsMatrix = m;
m = mat4.identity(new Float64Array(16) as any);
mat4.scale(m, m, [1, -1, 1]);
mat4.translate(m, m, [-1, -1, 0]);
mat4.scale(m, m, [2 / this._width, 2 / this._height, 1]);
this._pixelsToClipSpaceMatrix = m;
const halfFov = this.fovInRadians / 2;
this._cameraToCenterDistance = 0.5 / Math.tan(halfFov) * this._height;
}
this._callbacks.calcMatrices();
}
calculateCenterFromCameraLngLatAlt(lnglat: LngLatLike, alt: number, bearing?: number, pitch?: number): {center: LngLat; elevation: number; zoom: number} {
const cameraBearing = bearing !== undefined ? bearing : this.bearing;
const cameraPitch = pitch = pitch !== undefined ? pitch : this.pitch;
const {distanceToCenter, clampedElevation} = this._distanceToCenterFromAltElevationPitch(alt, this.elevation, cameraPitch);
const {x, y} = cameraDirectionFromPitchBearing(cameraPitch, cameraBearing);
// The mercator transform scale changes with latitude. At high latitudes, there are more "Merc units" per meter
// than at the equator. We treat the center point as our fundamental quantity. This means we want to convert
// elevation to Mercator Z using the scale factor at the center point (not the camera point). Since the center point is
// initially unknown, we compute it using the scale factor at the camera point. This gives us a better estimate of the
// center point scale factor, which we use to recompute the center point. We repeat until the error is very small.
// This typically takes about 5 iterations.
const camMercator = MercatorCoordinate.fromLngLat(lnglat, alt);
let metersPerMercUnit = altitudeFromMercatorZ(1, camMercator.y);
let centerMercator: MercatorCoordinate;
let dMercator: number;
let iter = 0;
const maxIter = 10;
do {
iter += 1;
if (iter > maxIter) {
break;
}
dMercator = distanceToCenter / metersPerMercUnit;
const dx = x * dMercator;
const dy = y * dMercator;
centerMercator = new MercatorCoordinate(camMercator.x + dx, camMercator.y + dy);
metersPerMercUnit = 1 / centerMercator.meterInMercatorCoordinateUnits();
} while (Math.abs(distanceToCenter - dMercator * metersPerMercUnit) > 1.0e-12);
const center = centerMercator.toLngLat();
const zoom = scaleZoom(this.height / 2 / Math.tan(this.fovInRadians / 2) / dMercator / this.tileSize);
return {center, elevation: clampedElevation, zoom};
}
recalculateZoomAndCenter(elevation: number): void {
if (this.elevation - elevation === 0) return;
// Critical: Stay in pixels and use original center to avoid instability at extreme latitudes when using Mercator-LngLat
const mercUnitsPerPixel = 1 / this.worldSize;
const originalMercUnitsPerMeter = mercatorZfromAltitude(1, this.center.lat);
const originalPixelsPerMeter = originalMercUnitsPerMeter * this.worldSize;
// Determine camera
const originalCenterMercator = MercatorCoordinate.fromLngLat(this.center, this.elevation);
const originalCenterPixelX = originalCenterMercator.x / mercUnitsPerPixel;
const originalCenterPixelY = originalCenterMercator.y / mercUnitsPerPixel;
const originalCenterPixelZ = originalCenterMercator.z / mercUnitsPerPixel;
const cameraPitch = this.pitch;
const cameraBearing = this.bearing;
const {x, y, z} = cameraDirectionFromPitchBearing(cameraPitch, cameraBearing);
const dCamPixel = this.cameraToCenterDistance;
const camPixelX = originalCenterPixelX + dCamPixel * -x;
const camPixelY = originalCenterPixelY + dCamPixel * -y;
const camPixelZ = originalCenterPixelZ + dCamPixel * z;
// Determine corresponding center
const {distanceToCenter, clampedElevation} = this._distanceToCenterFromAltElevationPitch(camPixelZ / originalPixelsPerMeter, elevation, cameraPitch);
const distanceToCenterPixels = distanceToCenter * originalPixelsPerMeter;
const centerPixelX = camPixelX + x * distanceToCenterPixels;
const centerPixelY = camPixelY + y * distanceToCenterPixels;
const center = new MercatorCoordinate(centerPixelX * mercUnitsPerPixel, centerPixelY * mercUnitsPerPixel, 0).toLngLat();
const mercUnitsPerMeter = mercatorZfromAltitude(1, center.lat);
const zoom = scaleZoom(this.height / 2 / Math.tan(this.fovInRadians / 2) / distanceToCenter / mercUnitsPerMeter / this.tileSize);
// Update matrices
this._elevation = clampedElevation;
this._center = center;
this.setZoom(zoom);
}
_distanceToCenterFromAltElevationPitch(alt: number, elevation: number, pitch: number): {distanceToCenter: number; clampedElevation: number} {
const dzNormalized = -Math.cos(degreesToRadians(pitch));
const altitudeAGL = alt - elevation;
let distanceToCenter: number;
let clampedElevation = elevation;
if (dzNormalized * altitudeAGL >= 0.0 || Math.abs(dzNormalized) < 0.1) {
distanceToCenter = 10000;
clampedElevation = alt + distanceToCenter * dzNormalized;
} else {
distanceToCenter = -altitudeAGL / dzNormalized;
}
return {distanceToCenter, clampedElevation};
}
getCameraPoint(): Point {
const pitch = this.pitchInRadians;
const offset = Math.tan(pitch) * (this.cameraToCenterDistance || 1);
return this.centerPoint.add(new Point(offset * Math.sin(this.rollInRadians), offset * Math.cos(this.rollInRadians)));
}
getCameraAltitude(): number {
const altitude = Math.cos(this.pitchInRadians) * this._cameraToCenterDistance / this._pixelPerMeter;
return altitude + this.elevation;
}
getCameraLngLat(): LngLat {
const pixelPerMeter = mercatorZfromAltitude(1, this.center.lat) * this.worldSize;
const cameraToCenterDistanceMeters = this.cameraToCenterDistance / pixelPerMeter;
const camMercator = cameraMercatorCoordinateFromCenterAndRotation(this.center, this.elevation, this.pitch, this.bearing, cameraToCenterDistanceMeters);
return camMercator.toLngLat();
}
getMercatorTileCoordinates(overscaledTileID: OverscaledTileID): [number, number, number, number] {
if (!overscaledTileID) {
return [0, 0, 1, 1];
}
const scale = (overscaledTileID.canonical.z >= 0) ? (1 << overscaledTileID.canonical.z) : Math.pow(2.0, overscaledTileID.canonical.z);
return [
overscaledTileID.canonical.x / scale,
overscaledTileID.canonical.y / scale,
1.0 / scale / EXTENT,
1.0 / scale / EXTENT
];
}
}
+523
View File
@@ -0,0 +1,523 @@
import type {LngLat, LngLatLike} from './lng_lat';
import type {LngLatBounds} from './lng_lat_bounds';
import type {MercatorCoordinate} from './mercator_coordinate';
import type Point from '@mapbox/point-geometry';
import type {mat4, mat2, vec3, vec4} from 'gl-matrix';
import type {UnwrappedTileID, OverscaledTileID, CanonicalTileID} from '../tile/tile_id';
import type {PaddingOptions} from './edge_insets';
import type {Terrain} from '../render/terrain';
import type {PointProjection} from '../symbol/projection';
import type {ProjectionData, ProjectionDataParams} from './projection/projection_data';
import type {CoveringTilesDetailsProvider} from './projection/covering_tiles_details_provider';
import type {Frustum} from '../util/primitives/frustum';
/**
* The callback defining how the transform constrains the viewport's lnglat and zoom to respect the longitude and latitude bounds.
* @see [Customize the map transform constrain](https://maplibre.org/maplibre-gl-js/docs/examples/customize-the-map-transform-constrain/)
*/
export type TransformConstrainFunction = (
lngLat: LngLat,
zoom: number
) => {
center: LngLat;
zoom: number;
};
export interface ITransformGetters {
get tileSize(): number;
get tileZoom(): number;
/**
* How many times "larger" the world is compared to zoom 0. Usually computed as `pow(2, zoom)`.
* Relevant mostly for mercator projection.
*/
get scale(): number;
/**
* How many units the current world has. Computed by multiplying {@link worldSize} by {@link tileSize}.
* Relevant mostly for mercator projection.
*/
get worldSize(): number;
/**
* Gets the transform's width in pixels. Use {@link ITransform.resize} to set the transform's size.
*/
get width(): number;
/**
* Gets the transform's height in pixels. Use {@link ITransform.resize} to set the transform's size.
*/
get height(): number;
get lngRange(): [number, number];
get latRange(): [number, number];
get minZoom(): number;
get maxZoom(): number;
get zoom(): number;
get center(): LngLat;
get minPitch(): number;
get maxPitch(): number;
/**
* Roll in degrees.
*/
get roll(): number;
get rollInRadians(): number;
/**
* Pitch in degrees.
*/
get pitch(): number;
get pitchInRadians(): number;
/**
* Bearing in degrees.
*/
get bearing(): number;
get bearingInRadians(): number;
/**
* Vertical field of view in degrees.
*/
get fov(): number;
get fovInRadians(): number;
get elevation(): number;
get minElevationForCurrentTile(): number;
get padding(): PaddingOptions;
get unmodified(): boolean;
get renderWorldCopies(): boolean;
/**
* The distance from the camera to the center of the map in pixels space.
*/
get cameraToCenterDistance(): number;
get nearZ(): number;
get farZ(): number;
get autoCalculateNearFarZ(): boolean;
get constrainOverride(): TransformConstrainFunction;
}
/**
* @internal
* All the functions that may mutate a transform.
*/
interface ITransformMutators {
clone(): ITransform;
/**
* Applies a transform to the current transform.
* @param that - The transform to apply to the current transform.
* @param constrain - Whether to constrain the transform's center and zoom and recompute internal matrices once applied.
*/
apply(that: IReadonlyTransform, constrain: boolean): void;
/**
* Sets the transform's minimal allowed zoom level.
* Automatically constrains the transform's zoom to the new range and recomputes internal matrices if needed.
*/
setMinZoom(zoom: number): void;
/**
* Sets the transform's maximal allowed zoom level.
* Automatically constrains the transform's zoom to the new range and recomputes internal matrices if needed.
*/
setMaxZoom(zoom: number): void;
/**
* Sets the transform's minimal allowed pitch, in degrees.
* Automatically constrains the transform's pitch to the new range and recomputes internal matrices if needed.
*/
setMinPitch(pitch: number): void;
/**
* Sets the transform's maximal allowed pitch, in degrees.
* Automatically constrains the transform's pitch to the new range and recomputes internal matrices if needed.
*/
setMaxPitch(pitch: number): void;
setRenderWorldCopies(renderWorldCopies: boolean): void;
/**
* Sets the transform's bearing, in degrees.
* Recomputes internal matrices if needed.
*/
setBearing(bearing: number): void;
/**
* Sets the transform's pitch, in degrees.
* Recomputes internal matrices if needed.
*/
setPitch(pitch: number): void;
/**
* Sets the transform's roll, in degrees.
* Recomputes internal matrices if needed.
*/
setRoll(roll: number): void;
/**
* Sets the transform's vertical field of view, in degrees.
* Recomputes internal matrices if needed.
*/
setFov(fov: number): void;
/**
* Sets the transform's zoom.
* Automatically constrains the transform's center and zoom and recomputes internal matrices if needed.
*/
setZoom(zoom: number): void;
/**
* Sets the transform's center.
* Automatically constrains the transform's center and zoom and recomputes internal matrices if needed.
*/
setCenter(center: LngLat): void;
setElevation(elevation: number): void;
setMinElevationForCurrentTile(elevation: number): void;
setPadding(padding: PaddingOptions): void;
/**
* Sets the overriding values to use for near and far Z instead of what the transform would normally compute.
* If set to undefined, the transform will compute its ideal values.
* Calling this will set `autoCalculateNearFarZ` to false.
*/
overrideNearFarZ(nearZ: number, farZ: number): void;
/**
* Resets near and far Z plane override. Sets `autoCalculateNearFarZ` to true.
*/
clearNearFarZOverride(): void;
/**
* Sets the transform's width and height and recomputes internal matrices.
*/
resize(width: number, height: number, constrainTransform: boolean): void;
/**
* Helper method to update edge-insets in place
*
* @param start - the starting padding
* @param target - the target padding
* @param t - the step/weight
*/
interpolatePadding(start: PaddingOptions, target: PaddingOptions, t: number): void;
/**
* This method works in combination with freezeElevation activated.
* freezeElevation is enabled during map-panning because during this the camera should sit in constant height.
* After panning finished, call this method to recalculate the zoom level and center point for the current camera-height in current terrain.
* @param terrain - the terrain
*/
recalculateZoomAndCenter(terrain?: Terrain): void;
/**
* Set's the transform's center so that the given point on screen is at the given world coordinates.
* @param lnglat - Desired world coordinates of the point.
* @param point - The screen point that should lie at the given coordinates.
*/
setLocationAtPoint(lnglat: LngLat, point: Point): void;
/**
* Sets or clears the map's geographical constraints.
* @param bounds - A {@link LngLatBounds} object describing the new geographic boundaries of the map.
*/
setMaxBounds(bounds?: LngLatBounds | null): void;
/** Sets or clears the custom callback overriding the transform's default constrain,
* whose responsibility is to respect the longitude and latitude bounds by constraining the viewport's lnglat and zoom.
* @param constrain - A {@link TransformConstrainFunction} callback defining how the viewport should respect the bounds.
*/
setConstrainOverride(constrain?: TransformConstrainFunction | null): void;
/**
* @internal
* Called before rendering to allow the transform implementation
* to precompute data needed to render the given tiles.
* Used in mercator transform to precompute tile matrices (posMatrix).
* @param coords - Array of tile IDs that will be rendered.
*/
populateCache(coords: Array<OverscaledTileID>): void;
/**
* @internal
* Sets the transform's transition state from one projection to another.
* @param value - The transition state value.
* @param error - The error value.
*/
setTransitionState(value: number, error: number): void;
}
/**
* @internal
* A variant of {@link ITransform} without any mutating functions.
* Note that an instance of {@link IReadonlyTransform} may still be mutated
* by code that has a reference to in under the {@link ITransform} type.
*/
export interface IReadonlyTransform extends ITransformGetters {
/**
* Distance from camera origin to view plane, in pixels.
* Calculated using vertical fov and viewport height.
* Center is considered to be in the middle of the viewport.
*/
get cameraToCenterDistance(): number;
get modelViewProjectionMatrix(): mat4;
get projectionMatrix(): mat4;
/**
* Inverse of matrix from camera space to clip space.
*/
get inverseProjectionMatrix(): mat4;
get pixelsToClipSpaceMatrix(): mat4;
get clipSpaceToPixelsMatrix(): mat4;
get pixelsToGLUnits(): [number, number];
get centerOffset(): Point;
/**
* Gets the transform's width and height in pixels (viewport size). Use {@link resize} to set the transform's size.
*/
get size(): Point;
get rotationMatrix(): mat2;
/**
* The center of the screen in pixels with the top-left corner being (0,0)
* and +y axis pointing downwards. This accounts for padding.
*/
get centerPoint(): Point;
/**
* @internal
*/
get pixelsPerMeter(): number;
/**
* @internal
* Returns the camera's position transformed to be in the same space as 3D features under this transform's projection. Mostly used for globe + fill-extrusion.
*/
get cameraPosition(): vec3;
/**
* Returns if the padding params match
*
* @param padding - the padding to check against
* @returns true if they are equal, false otherwise
*/
isPaddingEqual(padding: PaddingOptions): boolean;
/**
* @internal
* Return any "wrapped" copies of a given tile coordinate that are visible
* in the current view.
*/
getVisibleUnwrappedCoordinates(tileID: CanonicalTileID): Array<UnwrappedTileID>;
/**
* @internal
* Return the camera frustum for the current view.
*/
getCameraFrustum(): Frustum;
/**
* @internal
* Return the clipping plane, behind which nothing should be rendered. If the camera frustum is sufficient
* to describe the render geometry (additional clipping is not required), this may be null.
*/
getClippingPlane(): vec4 | null;
/**
* @internal
* Returns this transform's CoveringTilesDetailsProvider.
*/
getCoveringTilesDetailsProvider(): CoveringTilesDetailsProvider;
/**
* @internal
* Given a LngLat location, return the screen point that corresponds to it.
* @param lnglat - location
* @param terrain - optional terrain
* @returns screen point
*/
locationToScreenPoint(lnglat: LngLat, terrain?: Terrain): Point;
/**
* @internal
* Given a point on screen, return its LngLat location.
* @param p - screen point
* @param terrain - optional terrain
* @returns lnglat location
*/
screenPointToLocation(p: Point, terrain?: Terrain): LngLat;
/**
* @internal
* Given a point on screen, return its mercator coordinate.
* @param p - the point
* @param terrain - optional terrain
* @returns lnglat
*/
screenPointToMercatorCoordinate(p: Point, terrain?: Terrain): MercatorCoordinate;
/**
* @internal
* Returns the map's geographical bounds. When the bearing or pitch is non-zero, the visible region is not
* an axis-aligned rectangle, and the result is the smallest bounds that encompasses the visible region.
* @returns Returns a {@link LngLatBounds} object describing the map's geographical bounds.
*/
getBounds(): LngLatBounds;
/**
* Returns the maximum geographical bounds the map is constrained to, or `null` if none set.
* @returns max bounds
*/
getMaxBounds(): LngLatBounds | null;
/**
* @internal
* Returns whether the specified screen point lies on the map.
* May return false if, for example, the point is above the map's horizon, or if doesn't lie on the planet's surface if globe is enabled.
* @param p - The point's coordinates.
* @param terrain - Optional terrain.
*/
isPointOnMapSurface(p: Point, terrain?: Terrain): boolean;
/**
* @internal
* The tranform's default callback that ensures that longitude and latitude bounds are respected by the viewport.
*/
defaultConstrain: TransformConstrainFunction;
/**
* Constrain the center lngLat and zoom to ensure that longitude and latitude bounds are respected and regions beyond the map bounds are not displayed.
*/
applyConstrain: TransformConstrainFunction;
maxPitchScaleFactor(): number;
/**
* The camera looks at the map from a 3D (lng, lat, altitude) location. Let's use `cameraLocation`
* as the name for the location under the camera and on the surface of the earth (lng, lat, 0).
* `cameraPoint` is the projected position of the `cameraLocation`.
*
* This point is useful to us because only fill-extrusions that are between `cameraPoint` and
* the query point on the surface of the earth can extend and intersect the query.
*
* When the map is not pitched the `cameraPoint` is equivalent to the center of the map because
* the camera is right above the center of the map.
*/
getCameraPoint(): Point;
/**
* The altitude of the camera above the sea level in meters.
*/
getCameraAltitude(): number;
/**
* The longitude and latitude of the camera.
*/
getCameraLngLat(): LngLat;
/**
* Given the camera position (lng, lat, alt), calculate the center point and zoom level
* @param lngLat - lng, lat of the camera
* @param alt - altitude of the camera above sea level, in meters
* @param bearing - bearing of the camera, in degrees
* @param pitch - pitch angle of the camera, in degrees
*/
calculateCenterFromCameraLngLatAlt(lngLat: LngLatLike, alt: number, bearing?: number, pitch?: number): {center: LngLat; elevation: number; zoom: number};
getRayDirectionFromPixel(p: Point): vec3;
/**
* When the map is pitched, some of the 3D features that intersect a query will not intersect
* the query at the surface of the earth. Instead the feature may be closer and only intersect
* the query because it extrudes into the air.
* @param queryGeometry - For point queries, the line from the query point to the "camera point",
* for other geometries, the envelope of the query geometry and the "camera point"
* @returns a geometry that includes all of the original query as well as all possible ares of the
* screen where the *base* of a visible extrusion could be.
*
*/
getCameraQueryGeometry(queryGeometry: Array<Point>): Array<Point>;
/**
* Return the distance to the camera in clip space from a LngLat.
* This can be compared to the value from the depth buffer (terrain.depthAtPoint)
* to determine whether a point is occluded.
* @param lngLat - the point
* @param elevation - the point's elevation
* @returns depth value in clip space (between 0 and 1)
*/
lngLatToCameraDepth(lngLat: LngLat, elevation: number): number;
/**
* @internal
* Calculate the fogMatrix that, given a tile coordinate, would be used to calculate fog on the map.
* Currently only supported in mercator projection.
* @param unwrappedTileID - the tile ID
*/
calculateFogMatrix(unwrappedTileID: UnwrappedTileID): mat4;
/**
* @internal
* Generates a `ProjectionData` instance to be used while rendering the supplied tile.
* @param params - Parameters for the projection data generation.
*/
getProjectionData(params: ProjectionDataParams): ProjectionData;
/**
* @internal
* Returns whether the supplied location is occluded in this projection.
* For example during globe rendering a location on the backfacing side of the globe is occluded.
*/
isLocationOccluded(lngLat: LngLat): boolean;
/**
* @internal
*/
getPixelScale(): number;
/**
* @internal
* Allows the projection to adjust the radius of `circle-pitch-alignment: 'map'` circles and heatmap kernels based on the map's latitude.
* Circle radius and heatmap kernel radius is multiplied by this value.
*/
getCircleRadiusCorrection(): number;
/**
* @internal
* Allows the projection to adjust the scale of `text-pitch-alignment: 'map'` symbols's collision boxes based on the map's center and the text anchor.
* Only affects the collision boxes (and click areas), scaling of the rendered text is mostly handled in shaders.
* @param transform - The map's transform, with only the `center` property, describing the map's longitude and latitude.
* @param textAnchorX - Text anchor position inside the tile, X axis.
* @param textAnchorY - Text anchor position inside the tile, Y axis.
* @param tileID - The tile coordinates.
*/
getPitchedTextCorrection(textAnchorX: number, textAnchorY: number, tileID: UnwrappedTileID): number;
/**
* @internal
* Returns light direction transformed to be in the same space as 3D features under this projection. Mostly used for globe + fill-extrusion.
* @param transform - Current map transform.
* @param dir - The light direction.
* @returns A new vector with the transformed light direction.
*/
transformLightDirection(dir: vec3): vec3;
/**
* @internal
* Projects a point in tile coordinates to clip space. Used in symbol rendering.
*/
projectTileCoordinates(x: number, y: number, unwrappedTileID: UnwrappedTileID, getElevation: (x: number, y: number) => number): PointProjection;
/**
* Returns a matrix that will place, rotate and scale a model to display at the given location and altitude
* while also being projected by the custom layer matrix.
* This function is intended to be called from custom layers.
* @param location - Location of the model.
* @param altitude - Altitude of the model. May be undefined.
*/
getMatrixForModel(location: LngLatLike, altitude?: number): mat4;
/**
* Return projection data such that coordinates in mercator projection in range 0..1 will get projected to the map correctly.
*/
getProjectionDataForCustomLayer(applyGlobeMatrix: boolean): ProjectionData;
/**
* Returns a tile-specific projection matrix. Used for symbol placement fast-path for mercator transform.
*/
getFastPathSimpleProjectionMatrix(tileID: OverscaledTileID): mat4 | undefined;
}
/**
* @internal
* The transform stores everything needed to project or otherwise transform points on a map,
* including most of the map's view state - center, zoom, pitch, etc.
* A transform is cloneable, which is used when a given map state must be retained for multiple frames, mostly during symbol placement.
*/
export interface ITransform extends IReadonlyTransform, ITransformMutators {}
+31
View File
@@ -0,0 +1,31 @@
import {Color} from '@maplibre/maplibre-gl-style-spec';
import type {BlendFuncType, ColorMaskType} from './types';
const ZERO = 0x0000;
const ONE = 0x0001;
const ONE_MINUS_SRC_ALPHA = 0x0303;
export class ColorMode {
blendFunction: BlendFuncType;
blendColor: Color;
mask: ColorMaskType;
constructor(blendFunction: BlendFuncType, blendColor: Color, mask: ColorMaskType) {
this.blendFunction = blendFunction;
this.blendColor = blendColor;
this.mask = mask;
}
static Replace: BlendFuncType;
static disabled: Readonly<ColorMode>;
static unblended: Readonly<ColorMode>;
static alphaBlended: Readonly<ColorMode>;
}
ColorMode.Replace = [ONE, ZERO];
ColorMode.disabled = new ColorMode(ColorMode.Replace, Color.transparent, [false, false, false, false]);
ColorMode.unblended = new ColorMode(ColorMode.Replace, Color.transparent, [true, true, true, true]);
ColorMode.alphaBlended = new ColorMode([ONE, ONE_MINUS_SRC_ALPHA], Color.transparent, [true, true, true, true]);
+316
View File
@@ -0,0 +1,316 @@
import {IndexBuffer} from './index_buffer';
import {VertexBuffer} from './vertex_buffer';
import {Framebuffer} from './framebuffer';
import {type DepthMode} from './depth_mode';
import {type StencilMode} from './stencil_mode';
import {ColorMode} from './color_mode';
import {type CullFaceMode} from './cull_face_mode';
import {deepEqual} from '../util/util';
import {ClearColor, ClearDepth, ClearStencil, ColorMask, DepthMask, StencilMask, StencilFunc, StencilOp, StencilTest, DepthRange, DepthTest, DepthFunc, Blend, BlendFunc, BlendColor, BlendEquation, CullFace, CullFaceSide, FrontFace, ProgramValue, ActiveTextureUnit, Viewport, BindFramebuffer, BindRenderbuffer, BindTexture, BindVertexBuffer, BindElementBuffer, BindVertexArray, PixelStoreUnpack, PixelStoreUnpackPremultiplyAlpha, PixelStoreUnpackFlipY} from './value';
import type {TriangleIndexArray, LineIndexArray, LineStripIndexArray} from '../data/index_array_type';
import type {
StructArray,
StructArrayMember
} from '../util/struct_array';
import type {Color} from '@maplibre/maplibre-gl-style-spec';
import {isWebGL2} from './webgl2';
type ClearArgs = {
color?: Color;
depth?: number;
stencil?: number;
};
/**
* @internal
* A webgl wrapper class to allow injection, mocking and abstraction
*/
export class Context {
gl: WebGLRenderingContext | WebGL2RenderingContext;
currentNumAttributes: number;
maxTextureSize: number;
clearColor: ClearColor;
clearDepth: ClearDepth;
clearStencil: ClearStencil;
colorMask: ColorMask;
depthMask: DepthMask;
stencilMask: StencilMask;
stencilFunc: StencilFunc;
stencilOp: StencilOp;
stencilTest: StencilTest;
depthRange: DepthRange;
depthTest: DepthTest;
depthFunc: DepthFunc;
blend: Blend;
blendFunc: BlendFunc;
blendColor: BlendColor;
blendEquation: BlendEquation;
cullFace: CullFace;
cullFaceSide: CullFaceSide;
frontFace: FrontFace;
program: ProgramValue;
activeTexture: ActiveTextureUnit;
viewport: Viewport;
bindFramebuffer: BindFramebuffer;
bindRenderbuffer: BindRenderbuffer;
bindTexture: BindTexture;
bindVertexBuffer: BindVertexBuffer;
bindElementBuffer: BindElementBuffer;
bindVertexArray: BindVertexArray;
pixelStoreUnpack: PixelStoreUnpack;
pixelStoreUnpackPremultiplyAlpha: PixelStoreUnpackPremultiplyAlpha;
pixelStoreUnpackFlipY: PixelStoreUnpackFlipY;
extTextureFilterAnisotropic: EXT_texture_filter_anisotropic | null;
extTextureFilterAnisotropicMax?: GLfloat;
HALF_FLOAT?: GLenum;
RGBA16F?: GLenum;
RGB16F?: GLenum;
constructor(gl: WebGLRenderingContext | WebGL2RenderingContext) {
this.gl = gl;
this.clearColor = new ClearColor(this);
this.clearDepth = new ClearDepth(this);
this.clearStencil = new ClearStencil(this);
this.colorMask = new ColorMask(this);
this.depthMask = new DepthMask(this);
this.stencilMask = new StencilMask(this);
this.stencilFunc = new StencilFunc(this);
this.stencilOp = new StencilOp(this);
this.stencilTest = new StencilTest(this);
this.depthRange = new DepthRange(this);
this.depthTest = new DepthTest(this);
this.depthFunc = new DepthFunc(this);
this.blend = new Blend(this);
this.blendFunc = new BlendFunc(this);
this.blendColor = new BlendColor(this);
this.blendEquation = new BlendEquation(this);
this.cullFace = new CullFace(this);
this.cullFaceSide = new CullFaceSide(this);
this.frontFace = new FrontFace(this);
this.program = new ProgramValue(this);
this.activeTexture = new ActiveTextureUnit(this);
this.viewport = new Viewport(this);
this.bindFramebuffer = new BindFramebuffer(this);
this.bindRenderbuffer = new BindRenderbuffer(this);
this.bindTexture = new BindTexture(this);
this.bindVertexBuffer = new BindVertexBuffer(this);
this.bindElementBuffer = new BindElementBuffer(this);
this.bindVertexArray = new BindVertexArray(this);
this.pixelStoreUnpack = new PixelStoreUnpack(this);
this.pixelStoreUnpackPremultiplyAlpha = new PixelStoreUnpackPremultiplyAlpha(this);
this.pixelStoreUnpackFlipY = new PixelStoreUnpackFlipY(this);
this.extTextureFilterAnisotropic = gl.getExtension('EXT_texture_filter_anisotropic');
if (this.extTextureFilterAnisotropic) {
this.extTextureFilterAnisotropicMax = gl.getParameter(this.extTextureFilterAnisotropic.MAX_TEXTURE_MAX_ANISOTROPY_EXT);
}
this.maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE);
if (isWebGL2(gl)) {
this.HALF_FLOAT = gl.HALF_FLOAT;
const extColorBufferHalfFloat = gl.getExtension('EXT_color_buffer_half_float');
this.RGBA16F = gl.RGBA16F ?? extColorBufferHalfFloat?.RGBA16F_EXT;
this.RGB16F = gl.RGB16F ?? extColorBufferHalfFloat?.RGB16F_EXT;
gl.getExtension('EXT_color_buffer_float');
} else {
gl.getExtension('EXT_color_buffer_half_float');
gl.getExtension('OES_texture_half_float_linear');
const extTextureHalfFloat = gl.getExtension('OES_texture_half_float');
this.HALF_FLOAT = extTextureHalfFloat?.HALF_FLOAT_OES;
}
}
setDefault() {
this.unbindVAO();
this.clearColor.setDefault();
this.clearDepth.setDefault();
this.clearStencil.setDefault();
this.colorMask.setDefault();
this.depthMask.setDefault();
this.stencilMask.setDefault();
this.stencilFunc.setDefault();
this.stencilOp.setDefault();
this.stencilTest.setDefault();
this.depthRange.setDefault();
this.depthTest.setDefault();
this.depthFunc.setDefault();
this.blend.setDefault();
this.blendFunc.setDefault();
this.blendColor.setDefault();
this.blendEquation.setDefault();
this.cullFace.setDefault();
this.cullFaceSide.setDefault();
this.frontFace.setDefault();
this.program.setDefault();
this.activeTexture.setDefault();
this.bindFramebuffer.setDefault();
this.pixelStoreUnpack.setDefault();
this.pixelStoreUnpackPremultiplyAlpha.setDefault();
this.pixelStoreUnpackFlipY.setDefault();
}
setDirty() {
this.clearColor.dirty = true;
this.clearDepth.dirty = true;
this.clearStencil.dirty = true;
this.colorMask.dirty = true;
this.depthMask.dirty = true;
this.stencilMask.dirty = true;
this.stencilFunc.dirty = true;
this.stencilOp.dirty = true;
this.stencilTest.dirty = true;
this.depthRange.dirty = true;
this.depthTest.dirty = true;
this.depthFunc.dirty = true;
this.blend.dirty = true;
this.blendFunc.dirty = true;
this.blendColor.dirty = true;
this.blendEquation.dirty = true;
this.cullFace.dirty = true;
this.cullFaceSide.dirty = true;
this.frontFace.dirty = true;
this.program.dirty = true;
this.activeTexture.dirty = true;
this.viewport.dirty = true;
this.bindFramebuffer.dirty = true;
this.bindRenderbuffer.dirty = true;
this.bindTexture.dirty = true;
this.bindVertexBuffer.dirty = true;
this.bindElementBuffer.dirty = true;
this.bindVertexArray.dirty = true;
this.pixelStoreUnpack.dirty = true;
this.pixelStoreUnpackPremultiplyAlpha.dirty = true;
this.pixelStoreUnpackFlipY.dirty = true;
}
createIndexBuffer(array: TriangleIndexArray | LineIndexArray | LineStripIndexArray, dynamicDraw?: boolean) {
return new IndexBuffer(this, array, dynamicDraw);
}
createVertexBuffer(array: StructArray, attributes: ReadonlyArray<StructArrayMember>, dynamicDraw?: boolean) {
return new VertexBuffer(this, array, attributes, dynamicDraw);
}
createRenderbuffer(storageFormat: number, width: number, height: number) {
const gl = this.gl;
const rbo = gl.createRenderbuffer();
this.bindRenderbuffer.set(rbo);
gl.renderbufferStorage(gl.RENDERBUFFER, storageFormat, width, height);
this.bindRenderbuffer.set(null);
return rbo;
}
createFramebuffer(width: number, height: number, hasDepth: boolean, hasStencil: boolean) {
return new Framebuffer(this, width, height, hasDepth, hasStencil);
}
clear({
color,
depth,
stencil
}: ClearArgs) {
const gl = this.gl;
let mask = 0;
if (color) {
mask |= gl.COLOR_BUFFER_BIT;
this.clearColor.set(color);
this.colorMask.set([true, true, true, true]);
}
if (typeof depth !== 'undefined') {
mask |= gl.DEPTH_BUFFER_BIT;
// Workaround for platforms where clearDepth doesn't seem to work
// without resetting the depthRange. See https://github.com/mapbox/mapbox-gl-js/issues/3437
this.depthRange.set([0, 1]);
this.clearDepth.set(depth);
this.depthMask.set(true);
}
if (typeof stencil !== 'undefined') {
mask |= gl.STENCIL_BUFFER_BIT;
this.clearStencil.set(stencil);
this.stencilMask.set(0xFF);
}
gl.clear(mask);
}
setCullFace(cullFaceMode: Readonly<CullFaceMode>) {
if (cullFaceMode.enable === false) {
this.cullFace.set(false);
} else {
this.cullFace.set(true);
this.cullFaceSide.set(cullFaceMode.mode);
this.frontFace.set(cullFaceMode.frontFace);
}
}
setDepthMode(depthMode: Readonly<DepthMode>) {
if (depthMode.func === this.gl.ALWAYS && !depthMode.mask) {
this.depthTest.set(false);
} else {
this.depthTest.set(true);
this.depthFunc.set(depthMode.func);
this.depthMask.set(depthMode.mask);
this.depthRange.set(depthMode.range);
}
}
setStencilMode(stencilMode: Readonly<StencilMode>) {
if (stencilMode.test.func === this.gl.ALWAYS && !stencilMode.mask) {
this.stencilTest.set(false);
} else {
this.stencilTest.set(true);
this.stencilMask.set(stencilMode.mask);
this.stencilOp.set([stencilMode.fail, stencilMode.depthFail, stencilMode.pass]);
this.stencilFunc.set({
func: stencilMode.test.func,
ref: stencilMode.ref,
mask: stencilMode.test.mask
});
}
}
setColorMode(colorMode: Readonly<ColorMode>) {
if (deepEqual(colorMode.blendFunction, ColorMode.Replace)) {
this.blend.set(false);
} else {
this.blend.set(true);
this.blendFunc.set(colorMode.blendFunction);
this.blendColor.set(colorMode.blendColor);
}
this.colorMask.set(colorMode.mask);
}
createVertexArray(): WebGLVertexArrayObject | undefined {
if (isWebGL2(this.gl))
return this.gl.createVertexArray();
return this.gl.getExtension('OES_vertex_array_object')?.createVertexArrayOES();
}
deleteVertexArray(x: WebGLVertexArrayObject | undefined) {
if (isWebGL2(this.gl))
return this.gl.deleteVertexArray(x);
return this.gl.getExtension('OES_vertex_array_object')?.deleteVertexArrayOES(x);
}
unbindVAO() {
// Unbinding the VAO prevents other things (custom layers, new buffer creation) from
// unintentionally changing the state of the last VAO used.
this.bindVertexArray.set(null);
}
}
+34
View File
@@ -0,0 +1,34 @@
import type {CullFaceModeType, FrontFaceType} from './types';
const FRONT = 0x0404;
const BACK = 0x0405;
const CCW = 0x0901;
export class CullFaceMode {
enable: boolean;
mode: CullFaceModeType;
frontFace: FrontFaceType;
constructor(enable: boolean, mode: CullFaceModeType, frontFace: FrontFaceType) {
this.enable = enable;
this.mode = mode;
this.frontFace = frontFace;
}
static disabled: Readonly<CullFaceMode>;
/**
* The standard GL cull mode. Culls backfacing triangles when counterclockwise vertex order is used.
* Use for 3D geometry such as terrain.
*/
static backCCW: Readonly<CullFaceMode>;
/**
* Opposite of {@link backCCW}. Culls front-facing triangles when counterclockwise vertex order is used.
*/
static frontCCW: Readonly<CullFaceMode>;
}
CullFaceMode.disabled = new CullFaceMode(false, BACK, CCW);
CullFaceMode.backCCW = new CullFaceMode(true, BACK, CCW);
CullFaceMode.frontCCW = new CullFaceMode(true, FRONT, CCW);
+26
View File
@@ -0,0 +1,26 @@
import type {DepthFuncType, DepthMaskType, DepthRangeType} from './types';
const ALWAYS = 0x0207;
export class DepthMode {
func: DepthFuncType;
mask: DepthMaskType;
range: DepthRangeType;
// DepthMask enums
static ReadOnly: boolean;
static ReadWrite: boolean;
constructor(depthFunc: DepthFuncType, depthMask: DepthMaskType, depthRange: DepthRangeType) {
this.func = depthFunc;
this.mask = depthMask;
this.range = depthRange;
}
static disabled: Readonly<DepthMode>;
}
DepthMode.ReadOnly = false;
DepthMode.ReadWrite = true;
DepthMode.disabled = new DepthMode(ALWAYS, DepthMode.ReadOnly, [0, 1]);
+49
View File
@@ -0,0 +1,49 @@
import {ColorAttachment, DepthAttachment, DepthStencilAttachment} from './value';
import type {Context} from './context';
import {createFramebufferNotCompleteError} from '../util/framebuffer_error';
/**
* @internal
* A framebuffer holder object
*/
export class Framebuffer {
context: Context;
width: number;
height: number;
framebuffer: WebGLFramebuffer;
colorAttachment: ColorAttachment;
depthAttachment: DepthAttachment;
constructor(context: Context, width: number, height: number, hasDepth: boolean, hasStencil: boolean) {
this.context = context;
this.width = width;
this.height = height;
const gl = context.gl;
const fbo = this.framebuffer = gl.createFramebuffer();
this.colorAttachment = new ColorAttachment(context, fbo);
if (hasDepth) {
this.depthAttachment = hasStencil ? new DepthStencilAttachment(context, fbo) : new DepthAttachment(context, fbo);
} else if (hasStencil) {
throw new Error('Stencil cannot be set without depth');
}
if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) {
throw createFramebufferNotCompleteError();
}
}
destroy() {
const gl = this.context.gl;
const texture = this.colorAttachment.get();
if (texture) gl.deleteTexture(texture);
if (this.depthAttachment) {
const renderbuffer = this.depthAttachment.get();
if (renderbuffer) gl.deleteRenderbuffer(renderbuffer);
}
gl.deleteFramebuffer(this.framebuffer);
}
}
+55
View File
@@ -0,0 +1,55 @@
import type {StructArray} from '../util/struct_array';
import type {TriangleIndexArray, LineIndexArray, LineStripIndexArray} from '../data/index_array_type';
import type {Context} from '../gl/context';
/**
* @internal
* an index buffer class
*/
export class IndexBuffer {
context: Context;
buffer: WebGLBuffer;
dynamicDraw: boolean;
constructor(context: Context, array: TriangleIndexArray | LineIndexArray | LineStripIndexArray, dynamicDraw?: boolean) {
this.context = context;
const gl = context.gl;
this.buffer = gl.createBuffer();
this.dynamicDraw = Boolean(dynamicDraw);
// The bound index buffer is part of vertex array object state. We don't want to
// modify whatever VAO happens to be currently bound, so make sure the default
// vertex array provided by the context is bound instead.
this.context.unbindVAO();
context.bindElementBuffer.set(this.buffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, array.arrayBuffer, this.dynamicDraw ? gl.DYNAMIC_DRAW : gl.STATIC_DRAW);
if (!this.dynamicDraw) {
array.freeBufferAfterUpload();
}
}
bind() {
this.context.bindElementBuffer.set(this.buffer);
}
updateData(array: StructArray) {
const gl = this.context.gl;
if (!this.dynamicDraw) throw new Error('Attempted to update data while not in dynamic mode.');
// The right VAO will get this buffer re-bound later in VertexArrayObject.bind
// See https://github.com/mapbox/mapbox-gl-js/issues/5620
this.context.unbindVAO();
this.bind();
gl.bufferSubData(gl.ELEMENT_ARRAY_BUFFER, 0, array.arrayBuffer);
}
destroy() {
const gl = this.context.gl;
if (this.buffer) {
gl.deleteBuffer(this.buffer);
delete this.buffer;
}
}
}
+70
View File
@@ -0,0 +1,70 @@
import {describe, test, expect, vi} from 'vitest';
import {Context} from './context';
import {RenderPool} from './render_pool';
describe('render pool', () => {
const POOL_SIZE = 3;
function createAndFillPool(): RenderPool {
const gl = document.createElement('canvas').getContext('webgl');
vi.spyOn(gl, 'checkFramebufferStatus').mockReturnValue(gl.FRAMEBUFFER_COMPLETE);
const pool = new RenderPool(new Context(gl), POOL_SIZE, 512);
for (let i = 0; i < POOL_SIZE; i++) {
pool.useObject(pool.getOrCreateFreeObject());
}
return pool;
}
test('create pool should not be full', () => {
const gl = document.createElement('canvas').getContext('webgl');
const pool = new RenderPool(new Context(gl), POOL_SIZE, 512);
expect(pool.isFull()).toBeFalsy();
});
test('create pool should be full', () => {
const pool = createAndFillPool();
expect(() => pool.getOrCreateFreeObject()).toThrow('No free RenderPool available, call freeAllObjects() required!');
});
test('create pool and fill it', () => {
const pool = createAndFillPool();
expect(pool.isFull()).toBeTruthy();
});
test('check recently used after using two objects', () => {
const pool = createAndFillPool();
pool.freeAllObjects();
const obj0 = pool.getObjectForId(0);
pool.useObject(obj0);
pool.freeAllObjects();
const obj1 = pool.getOrCreateFreeObject();
expect(obj1.id).toBe(1);
});
test('not full after freeing an object', () => {
const pool = createAndFillPool();
const obj = pool.getObjectForId(0);
pool.freeObject(obj);
expect(pool.isFull()).toBeFalsy();
expect(obj.stamp).toBe(-1);
});
test('stamp object should get stamped', () => {
const pool = createAndFillPool();
const obj = pool.getObjectForId(0);
pool.stampObject(obj);
expect(obj.stamp).toBe(1);
});
test('free all objects, first object should be the first free object', () => {
const pool = createAndFillPool();
pool.freeAllObjects();
expect(pool.getOrCreateFreeObject().id).toBe(0);
});
test('destruct should remove textures', () => {
const pool = createAndFillPool();
pool.destruct();
expect(pool.getObjectForId(0).texture.texture).toBeNull();
});
});
+96
View File
@@ -0,0 +1,96 @@
import {Texture} from '../render/texture';
import {type Context} from './context';
import {type Framebuffer} from './framebuffer';
export type PoolObject = {
id: number;
fbo: Framebuffer;
texture: Texture;
stamp: number;
inUse: boolean;
};
/**
* @internal
* `RenderPool` is a resource pool for textures and framebuffers
*/
export class RenderPool {
private _objects: Array<PoolObject>;
/**
* An index array of recently used pool objects.
* Items that are used recently are last in the array
*/
private _recentlyUsed: Array<number>;
private _stamp: number;
constructor(
private readonly _context: Context,
private readonly _size: number,
private readonly _tileSize: number) {
this._objects = [];
this._recentlyUsed = [];
this._stamp = 0;
}
public destruct() {
for (const obj of this._objects) {
obj.texture.destroy();
obj.fbo.destroy();
}
}
private _createObject(id: number): PoolObject {
const fbo = this._context.createFramebuffer(this._tileSize, this._tileSize, true, true);
const texture = new Texture(this._context, {width: this._tileSize, height: this._tileSize, data: null}, this._context.gl.RGBA);
texture.bind(this._context.gl.LINEAR, this._context.gl.CLAMP_TO_EDGE);
if (this._context.extTextureFilterAnisotropic) {
this._context.gl.texParameterf(this._context.gl.TEXTURE_2D, this._context.extTextureFilterAnisotropic.TEXTURE_MAX_ANISOTROPY_EXT, this._context.extTextureFilterAnisotropicMax);
}
fbo.depthAttachment.set(this._context.createRenderbuffer(this._context.gl.DEPTH_STENCIL, this._tileSize, this._tileSize));
fbo.colorAttachment.set(texture.texture);
return {id, fbo, texture, stamp: -1, inUse: false};
}
public getObjectForId(id: number): PoolObject {
return this._objects[id];
}
public useObject(obj: PoolObject) {
obj.inUse = true;
this._recentlyUsed = this._recentlyUsed.filter(id => obj.id !== id);
this._recentlyUsed.push(obj.id);
}
public stampObject(obj: PoolObject) {
obj.stamp = ++this._stamp;
}
public getOrCreateFreeObject(): PoolObject {
// check for free existing object
for (const id of this._recentlyUsed) {
if (!this._objects[id].inUse)
return this._objects[id];
}
if (this._objects.length >= this._size)
throw new Error('No free RenderPool available, call freeAllObjects() required!');
// create new object
const obj = this._createObject(this._objects.length);
this._objects.push(obj);
return obj;
}
public freeObject(obj: PoolObject) {
obj.inUse = false;
}
public freeAllObjects() {
for (const obj of this._objects)
this.freeObject(obj);
}
public isFull(): boolean {
if (this._objects.length < this._size) {
return false;
}
return this._objects.some(o => !o.inUse) === false;
}
}
+126
View File
@@ -0,0 +1,126 @@
import {describe, test, expect} from 'vitest';
import {type IValue, ClearColor, ClearDepth, ClearStencil, ColorMask, DepthMask, StencilMask, StencilFunc, StencilOp, StencilTest, DepthRange, DepthTest, DepthFunc, Blend, BlendFunc, BlendColor, ProgramValue, ActiveTextureUnit, Viewport, BindFramebuffer, BindRenderbuffer, BindTexture, BindVertexBuffer, BindElementBuffer, BindVertexArray, PixelStoreUnpack, PixelStoreUnpackPremultiplyAlpha} from './value';
import {Context} from './context';
import {Color} from '@maplibre/maplibre-gl-style-spec';
import {deepEqual} from '../util/util';
describe('Value classes', () => {
const gl = document.createElement('canvas').getContext('webgl') as WebGL2RenderingContext;
// Remove when https://github.com/Adamfsk/jest-webgl-canvas-mock/pull/5 is merged
gl.createVertexArray = gl.getExtension('OES_vertex_array_object')?.createVertexArrayOES;
gl.bindVertexArray = gl.getExtension('OES_vertex_array_object')?.bindVertexArrayOES;
const context = new Context(gl);
const valueTest = <T>(Constructor: new (...args:any[]) => IValue<T>,
options: {
setValue: T;
equality?: (a: T, b: T) => boolean;
}
) => {
test('constructor', () => {
const v = new Constructor(context);
expect(v).toBeTruthy();
const currentV = v.get();
expect(typeof currentV).not.toBe('undefined');
});
test('set', () => {
const v = new Constructor(context);
v.set(options.setValue);
const equality = (options.equality) || ((a, b) => deepEqual(a, b));
expect(equality(v.get(), options.setValue)).toBeTruthy();
});
};
valueTest(ClearColor, {
setValue: new Color(1, 1, 0, 1)
});
valueTest(ClearDepth, {
setValue: 0.5
});
valueTest(ClearStencil, {
setValue: 0.5
});
valueTest(ColorMask, {
setValue: [false, false, true, true]
});
valueTest(DepthMask, {
setValue: false
});
valueTest(StencilMask, {
setValue: 0x00
});
valueTest(StencilFunc, {
setValue: {
func: context.gl.LEQUAL,
ref: 1,
mask: 0xFF
}
});
valueTest(StencilOp, {
setValue: [context.gl.KEEP, context.gl.REPLACE, context.gl.REPLACE]
});
valueTest(StencilTest, {
setValue: true
});
valueTest(DepthRange, {
setValue: [0, 0.1]
});
valueTest(DepthTest, {
setValue: true
});
valueTest(DepthFunc, {
setValue: context.gl.EQUAL
});
valueTest(Blend, {
setValue: false
});
valueTest(BlendFunc, {
setValue: [context.gl.SRC_ALPHA, context.gl.SRC_ALPHA]
});
valueTest(BlendColor, {
setValue: Color.white
});
valueTest(ProgramValue, {
equality: (a, b) => a === b,
setValue: context.gl.createProgram()
});
valueTest(ActiveTextureUnit, {
setValue: context.gl.TEXTURE1
});
valueTest(Viewport, {
setValue: [0, 0, 1, 1]
});
valueTest(BindFramebuffer, {
equality: (a, b) => a === b,
setValue: context.gl.createFramebuffer()
});
valueTest(BindRenderbuffer, {
equality: (a, b) => a === b,
setValue: context.gl.createRenderbuffer()
});
valueTest(BindTexture, {
equality: (a, b) => a === b,
setValue: context.gl.createTexture()
});
valueTest(BindVertexBuffer, {
equality: (a, b) => a === b,
setValue: context.gl.createBuffer()
});
valueTest(BindElementBuffer, {
equality: (a, b) => a === b,
setValue: context.gl.createBuffer()
});
valueTest(BindVertexArray, {
equality: (a, b) => a === b,
setValue: context.createVertexArray()
});
valueTest(PixelStoreUnpack, {
setValue: 8
});
valueTest(PixelStoreUnpackPremultiplyAlpha, {
setValue: true
});
});
+27
View File
@@ -0,0 +1,27 @@
import type {StencilOpConstant, StencilTestGL} from './types';
const ALWAYS = 0x0207;
const KEEP = 0x1E00;
export class StencilMode {
test: StencilTestGL;
ref: number;
mask: number;
fail: StencilOpConstant;
depthFail: StencilOpConstant;
pass: StencilOpConstant;
constructor(test: StencilTestGL, ref: number, mask: number, fail: StencilOpConstant,
depthFail: StencilOpConstant, pass: StencilOpConstant) {
this.test = test;
this.ref = ref;
this.mask = mask;
this.fail = fail;
this.depthFail = depthFail;
this.pass = pass;
}
static disabled: Readonly<StencilMode>;
}
StencilMode.disabled = new StencilMode({func: ALWAYS, mask: 0}, 0, 0, KEEP, KEEP, KEEP);
+59
View File
@@ -0,0 +1,59 @@
type BlendFuncConstant = WebGLRenderingContextBase['ZERO'] | WebGLRenderingContextBase['ONE'] | WebGLRenderingContextBase['SRC_COLOR'] | WebGLRenderingContextBase['ONE_MINUS_SRC_COLOR'] | WebGLRenderingContextBase['DST_COLOR'] | WebGLRenderingContextBase['ONE_MINUS_DST_COLOR'] | WebGLRenderingContextBase['SRC_ALPHA'] | WebGLRenderingContextBase['ONE_MINUS_SRC_ALPHA'] | WebGLRenderingContextBase['DST_ALPHA'] | WebGLRenderingContextBase['ONE_MINUS_DST_ALPHA'] | WebGLRenderingContextBase['CONSTANT_COLOR'] | WebGLRenderingContextBase['ONE_MINUS_CONSTANT_COLOR'] | WebGLRenderingContextBase['CONSTANT_ALPHA'] | WebGLRenderingContextBase['ONE_MINUS_CONSTANT_ALPHA'] | WebGLRenderingContextBase['BLEND_COLOR'];
export type BlendFuncType = [BlendFuncConstant, BlendFuncConstant];
export type BlendEquationType = WebGLRenderingContextBase['FUNC_ADD'] | WebGLRenderingContextBase['FUNC_SUBTRACT'] | WebGLRenderingContextBase['FUNC_REVERSE_SUBTRACT'];
export type ColorMaskType = [boolean, boolean, boolean, boolean];
export type CompareFuncType = WebGLRenderingContextBase['NEVER'] | WebGLRenderingContextBase['LESS'] | WebGLRenderingContextBase['EQUAL'] | WebGLRenderingContextBase['LEQUAL'] | WebGLRenderingContextBase['GREATER'] | WebGLRenderingContextBase['NOTEQUAL'] | WebGLRenderingContextBase['GEQUAL'] | WebGLRenderingContextBase['ALWAYS'];
export type DepthMaskType = boolean;
export type DepthRangeType = [number, number];
export type DepthFuncType = CompareFuncType;
export type StencilFuncType = {
func: CompareFuncType;
ref: number;
mask: number;
};
export type StencilOpConstant = WebGLRenderingContextBase['KEEP'] | WebGLRenderingContextBase['ZERO'] | WebGLRenderingContextBase['REPLACE'] | WebGLRenderingContextBase['INCR'] | WebGLRenderingContextBase['INCR_WRAP'] | WebGLRenderingContextBase['DECR'] | WebGLRenderingContextBase['DECR_WRAP'] | WebGLRenderingContextBase['INVERT'];
export type StencilOpType = [StencilOpConstant, StencilOpConstant, StencilOpConstant];
export type TextureUnitType = number;
export type ViewportType = [number, number, number, number];
export type StencilTestGL = {
func: WebGLRenderingContextBase['NEVER'];
mask: 0;
} | {
func: WebGLRenderingContextBase['LESS'];
mask: number;
} | {
func: WebGLRenderingContextBase['EQUAL'];
mask: number;
} | {
func: WebGLRenderingContextBase['LEQUAL'];
mask: number;
} | {
func: WebGLRenderingContextBase['GREATER'];
mask: number;
} | {
func: WebGLRenderingContextBase['NOTEQUAL'];
mask: number;
} | {
func: WebGLRenderingContextBase['GEQUAL'];
mask: number;
} | {
func: WebGLRenderingContextBase['ALWAYS'];
mask: 0;
};
export type CullFaceModeType = WebGLRenderingContextBase['FRONT'] | WebGLRenderingContextBase['BACK'] | WebGLRenderingContextBase['FRONT_AND_BACK'];
export type FrontFaceType = WebGLRenderingContextBase['CW'] | WebGLRenderingContextBase['CCW'];

Some files were not shown because too many files have changed in this diff Show More