Initial commit
@@ -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;
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 12 KiB |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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']});
|
||||
@@ -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'},
|
||||
]);
|
||||
@@ -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;
|
||||
@@ -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'}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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']});
|
||||
@@ -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;
|
||||
@@ -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'}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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']});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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'}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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']});
|
||||
@@ -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'},
|
||||
]);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'}
|
||||
]);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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};
|
||||
@@ -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});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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) : []};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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};
|
||||
@@ -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;
|
||||
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createLayout} from '../util/struct_array';
|
||||
|
||||
export default createLayout([
|
||||
{name: 'a_pos3d', type: 'Int16', components: 3}
|
||||
]);
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createLayout} from '../util/struct_array';
|
||||
|
||||
export default createLayout([
|
||||
{name: 'a_pos', type: 'Int16', components: 2}
|
||||
]);
|
||||
@@ -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);
|
||||
@@ -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}
|
||||
]);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}>;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>]');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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]);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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]);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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'];
|
||||