From 86ed382324ad605e6174b19da7bf2fc3de5b0904 Mon Sep 17 00:00:00 2001 From: NathanMOlson Date: Thu, 17 Oct 2024 05:08:02 -0700 Subject: [PATCH] Camera Roll (#4780) * Globe - basic infrastructure, raster layer adaptation for globe (#3783) * Port changes from main globe branch - basics Fix minor issues so that it compiles. * Fix PI redefinitions * Fix stencil shader * Port adaptation of raster layer for globe from main globe branch * Add globe.html example from pheonor's repo Minor changes (remove terrain, set initial zoom 0, change title and description) * Better map projection parameter doc comment, warn when using unknown projection * Mercator projectionData handles negative zoom correctly * Comment clarification * Fix spelling of "granularity" * Add missing docs * Convert ProjectionBase to an interface * Do not leak GL object in globe projection error measurement, add a destroy method to projection * Fix chrome performance warning, refactor error measurement Warning fixed by changing ring buffer size to 1, making ring buffer pointless, so I removed it. * Fix granularity capitalization * Fix capitalization * Fix typo * Fix stencil mask triangle index order (this was causing failing render tests) * Cleanup vertex shader projection interface * Move projection creation function into its own file * Remove getProjectionName * Added comment for deduplicateWrapped * Remove unused vertex-buffer-related code from image source * Add globe raster layer render test * More render tests - test transition to mercator * Remove pointless test, add test descriptions * Render test for rendering poles on globe * SubdivisionGranularitySetting constructor takes an object * Remove "defines" parameter from useProgram * Refactor useProgram and Program constructor * Properly format translatePosMatrix comment * Refactor globe-specific code outside projection classes, remove stencil-specific granularity settings * Refactor granularity settings to be more readable * Minor refactor of ProjectionErrorMeasurement * Refactor draw_raster.ts * Move globe utility functions to utils.ts, use easeCubicInOut instead of smoothStep * Simplify imports in globe.ts * globe.ts refactor * Move ProjectionErrorMeasurement to a separate file * Refactor ProjectionErrorMeasurement Change parseRGBA8float to a private static function, use isWebGL2 function instead of instanceof * Refactor draw_raster.ts * Refactor globe projection error measurement to not use Painter * Painter.clearStencil creates custom ProjectionData instead of calling getProjectionData(null, null) * Remove "deduplicateWrapped" functionality from source_cache.ts * Globe projection no longer requires a map instance * Painter doesn't pass `this` to `updateGPUdependent` * isRenderingDirty is now a function * Rename ProjectionBase to Projection * Replace globeView property with setGlobeViewAllowed * Add mercator and globe projection unit tests * Remove tests that test for exact clipping planes * Update build test with new bundle size * isRenderingDirty is now a function * Globe - fill layer (#3882) * Port changes from main globe branch - basics Fix minor issues so that it compiles. * Fix PI redefinitions * Fix stencil shader * Port adaptation of raster layer for globe from main globe branch * Add globe.html example from pheonor's repo Minor changes (remove terrain, set initial zoom 0, change title and description) * Better map projection parameter doc comment, warn when using unknown projection * Mercator projectionData handles negative zoom correctly * Comment clarification * Fix spelling of "granularity" * Add missing docs * Convert ProjectionBase to an interface * Do not leak GL object in globe projection error measurement, add a destroy method to projection * Fix chrome performance warning, refactor error measurement Warning fixed by changing ring buffer size to 1, making ring buffer pointless, so I removed it. * Fix granularity capitalization * Fix capitalization * Fix typo * Fix stencil mask triangle index order (this was causing failing render tests) * Cleanup vertex shader projection interface * Move projection creation function into its own file * Remove getProjectionName * Added comment for deduplicateWrapped * Remove unused vertex-buffer-related code from image source * Add globe raster layer render test * More render tests - test transition to mercator * Remove pointless test, add test descriptions * Render test for rendering poles on globe * SubdivisionGranularitySetting constructor takes an object * Remove "defines" parameter from useProgram * Refactor useProgram and Program constructor * Properly format translatePosMatrix comment * Refactor globe-specific code outside projection classes, remove stencil-specific granularity settings * Refactor granularity settings to be more readable * Minor refactor of ProjectionErrorMeasurement * Refactor draw_raster.ts * Move globe utility functions to utils.ts, use easeCubicInOut instead of smoothStep * Simplify imports in globe.ts * globe.ts refactor * Move ProjectionErrorMeasurement to a separate file * Refactor ProjectionErrorMeasurement Change parseRGBA8float to a private static function, use isWebGL2 function instead of instanceof * Refactor draw_raster.ts * Refactor globe projection error measurement to not use Painter * Painter.clearStencil creates custom ProjectionData instead of calling getProjectionData(null, null) * Remove "deduplicateWrapped" functionality from source_cache.ts * Globe projection no longer requires a map instance * Painter doesn't pass `this` to `updateGPUdependent` * isRenderingDirty is now a function * Rename ProjectionBase to Projection * Replace globeView property with setGlobeViewAllowed * Add mercator and globe projection unit tests * Remove tests that test for exact clipping planes * Update build test with new bundle size * isRenderingDirty is now a function * Fill, fill-extrusion, line layers, subdivision: Import changes from kubapelc/globe-vector branch * Fix unit tests * Subdivision: ensure consistent triangle winding order, fix unit tests * Fix terrain * Fix fill extrusion not working with terrain * Fix typos * Fix line gradient bug * Subdivision: fix line ring handling * Subdivision: fix unit test expecting an invalid line segment * Fix fill-extrusion ring handling * Fill-extrusion refactor and fix failing test * Update terrain fill extrusion test expected image * Render tests for fill, line and fill-extrusion for globe * Move fillArrays function into a separate file * Add vector globe example * Remove changes for line and fill-extrusion layers to make the PR smaller * Add unit tests for fillArrays() * fillArrays unit test has better segment size limits * Update build test build size * Fix html example description * Fix missing docs for granularity settings * Rename globe fill render test tile source layer to "vector_tiles" * Fix classifyRings comment format * Move subdivisionGranularitySettingsNoSubdivision constant to a static readonly field, shorten the name * Use `import type` for SubdivisionGranularitySetting where possible * Fix typo * Revert fill_attributes back to default exports * Improve comment for scanline subdivision * Subdivision: break up scanline subdivision function into more functions * Move SubdivisionGranularitySetting into its own file * Unit tests: use mock of MercatorProjection instead of the full class * Add SegmentVector unit tests * Subdivision: unit tests for poles, ring triangulation, fix bug in ring triangulation * Subdivision: more pole unit tests * Subdivision: fix wireframe generation, add unit test for wireframe * Rename subdivisionGranularitySettings.ts to subdivision_granularity_settings.ts * Move granularity settings registration to subdivision * Update build size * Rename `fillArrays` to `fillLargeMeshArrays` * Move virtual buffers to a test util file * Better warning for segments.ts vertex overflow * Better comment for projection subdivision granularity * Clarify mesh comparison in fill_large_mesh_arrays.test.ts * Move mesh creating functions into a separate file, add tests for mesh comparison and grid creation * Refactor and add better doc comment for `fillLargeMeshArrays` * Refactor fill_large_mesh_arrays by removing duplicated code * Move debug functions to mesh_utils.ts * Unit tests: use StructArrays instead of VirtualVertexBuffer, etc. * Subdivision: refactor * Subdivision: rename subdivideFill to subdividePolygon, remove wireframe function * Subdivision: throw when a vertex is outside int16 range * Subdivision: refactor generatePoleQuad into a proper function * Subdivision: add subdivision benchmark * Subdivision: split scanline subdivision into smaller functions * Remove wireframe generation function * Subdivision: better doc comments for scanline subdivision * Fix 'as any' in segment.ts * Reuse condition in fill_large_arrays * Deduplicate code in fill_large_arrays * Subdivision: remove redundant function in tests * Subdivision: improve scanline subdivision comments * Subdivision: benchmark is not async * Rename SegmentVector's invalidateLast to forceNewSegmentOnTextPrepare * More tests for segment.ts * Fix typo in forceNewSegmentOnNextPrepare * Subdivision: more tests for fillLargeMeshArrays * Subdivision: better comment in fillLargeMeshArrays * Fix build due to bad merge. * Globe - line layer (#3961) * Fix merge * Import line layer changes from kubapelc/globe-vector * Lines: shorten line_bucket.test.ts subdivision settings * Lines: minor refactor * Lines: update build size * Lines: minor refactor * Globe - fill extrusion layer (#3968) * Import changes for fill-extrusion from main vector globe branch * Fill extrusion: refactor * Fill extrusion: indent shader ifdefs * Fill extrusion: add example * Fill extrusion: update build size * Move globe specific projection methods to projection interface * Fix failing unit test * Use vec3.clone() instead of manually copying vector components * Kubapelc/globe pr hillshade (#3979) * Import background layer changes from main vector globe branch * Import hillshade layer changes from main vector globe branch * Subdivision: explicit types * Fix single-pixel seams in the oceans * Add render test for background pattern on globe * Refactor drawBackground * Refactor drawHillshade * Update build size * Update globe background-pattern render test with results from CI * Hillshade: refactor prepareHillshade * Add a render test for fill layer seams fix * Globe - circle and heatmap layers (#4015) * Import changes for circle and heatmap layers from the main vector globe branch * Minor refactors * Update build size * Use "/ 8.0" in shader instead of "* 0.125" * Update shader comments * Use a thin type instead of full Transform in projection * Only import types in projection.ts * getPixelScale and getCircleRadiusCorrection only need map center as argument * Only import types where possible in projection classes * Smaller refactors * Fix failing unit test * Add heatmap render test * More explicit types in projection interface * Globe plane equation is a vec4 * Fix wrong args in projection functions * Improve readibility of build test and fix it. * Globe - symbols & symbol bugfixes (#4067) * Import changes from main vector globe branch * Fix import * Remove unused code * Remove unused imports * Update build size test * Remove unused function * Add render test results for Debian * Add another Debian render test variant * Add more render test variants * Hide collision boxes on the backfacing side of the globe * Fix pitch-aligned texts getting hidden when their anchor is beyond horizon * Update build size * Fix merge * Better comment in draw_collision_debug * Update build size The 10 kb size increase seems to come from the main branch * Minor refactor * Use explicit types, even for unused parameters * Refactor screenspace path projection * Refactor imports for projection.ts and collision_index.ts * Fix import in collision_index.ts * Globe - example images (#4140) * Add example images * Add "-" into example name * Remove basic globe example * Globe - clipping fix (#4146) * HiSilicon fix: enable face culling whereever possible (cherry picked from commit fe439a55c447cb4dc40db966372ca6ea2ede91aa) * Improve circle layer performance by discarding empty pixels (cherry picked from commit 266897d3b70a812f7a798ef9eea81a6992b95b00) * HiSilicon fix: software clipping of polygon outlines (cherry picked from commit 98167ba32fface6dff07319a34fcf177d2ecd4d2) * HiSilicon fix: software clipping for line layer (cherry picked from commit d521e955106c9a6a5cfb9a28344d35c9dfb5819c) * HiSilicon fix: circle software clipping (cherry picked from commit f2ed744c3e4e217bf2f42c971215ee6fe70dc3c5) * HiSilicon fix: enable backface culling for symbols (cherry picked from commit 54e3632964761c696df0e26b9aa880958c7075e7) * Update build test * Fix terrain using a mirrored projection matrix * Fix typos * Fix terrain coord textures being flipped vertically * Update build size * Fix rendering of images with face culling, fix image rendering near pole regions * Add render test for images on a globe * Update comment in circle.vertex.glsl * Fix bad merge * Fix lint * Fix location of old vertex count * replace expected file for terrain changes * Fix rename in main merge branch * Fix build test * Move projection to style class (#4267) * Move projection to style class * Fix lint * Fix unit tests * Increase build size * Update docs, fix test * Fix lint * Add test to cover projection change * Added more tests * Add an Atmosphere layer for Globe (#3888) (#4020) * Port of PoC atmosphere layer. * Fix resize for draw_atmosphere * Add some options. * Allow to change sun date and time * Fix import warning * Render atmosphere only when a Globe projection is selected * Add some comments * Add some comments * Change key * Update changelog * Fix merge with globe branch * Fix documentation and default background color. * Use black clear color only when atmosphere is on * Use atmosphere uniform for globe position, raidus in camera frame and inv projection matrix. * Remove unused project method * Update maplibre-gl-style-spec to 20.3.0 and use sky atmosphere parameter * Fix globe tests and use light position as Sun position. * Avoid type name collisions. * Add atmosphere test for globe projection. * Update expectedBytes for build test. * Fix PR comments. * Update Style test. * Remove unused method on projection * Add Sky Test. * Fix style test and add sky unit test. * Move getSunPos method * Fix mercator updateProjection * Remove isGlobe method and fix merge. * Fix globe atmosphere tests with new projection style. * Clean-up some projection and light. Fix setSky and add tests. * Remove sky test during update. * Clean-up * missing fix from merge * Fix lint * Terrain fix (#4343) * Fix missing image for globe example * Update atmosphere (#4345) * Merge Sky and Atmosphere code. * Update changelog * Fix generate-struct-arrays * Globe - transform+projection changes (#4341) * Delete unused file * Rename projection.name to projection.projectionName Since this interface will be implemented by the transfrom class soon * Symbols: displayed collision circles now exactly match their computed positions * Globe: use mercator projection for symbol placement when globe rendering is disabled * Group all getters/setters in the transform class * Transform: move transform-related stuff from the projection interface to transform class (WIP) * Transform: finish moving parts of projection into mercator_transform.ts * Transform: remove posMatrix usage from line symbol placement (WIP) * Transform: temporarily remove globe stuff (WIP, compilable) * Transform: fix line symbols * Symbols: fix wrong function names * Fix line point projections * Fix line rendering Some things are still broken * Fix line symbols sometimes being incorrectly oriented * Fix some failing unit tests * Fix single glyph orientation * Add another image to render test No idea why it is shifted by a few pixels but I assume that the new expected image is also correct * Add another expected image to textFit-grid-long test It was only failing on my machine, works fine in github CI windows tests * Fix some failing unit tests * Simplify getProjectionData interface and terrain matrix passing * Change comment at calculatePosMatrix * Fix symbols not rendering, remove unused shader parameters * Bring back globe src files * globe.test.ts is now globe_transform.test.ts * Move stuff from globe.ts to globe_transform.ts * Fix showTileBoundaries not working Fix the three render tests related to showTileBoundaries timing out. * Remove irrelevant test * Fix failing unit test * Transform: move more stuff from globe to globe_transform * Transform: better comments * Transfrom: isRenderingDirty cleanup * Transform: no more errors in globe_transform.ts * Transform: remove `get point()` from transform class * Transform: globe_transform.ts is compilable * Re-enable globe projection * Fix source_cache.ts sometimes crashing * Fix globe.ts - globe_transform.ts circular dependency * Fix and refactor getProjectionData interface Now it is actually compilable, with many bugs * Transform: fix failing unit tests * Transform: fix symbols not rendering on globe * Transform: minor fixes * Transform: update globe symbol render tests * Transform: unify how symbol/projection.ts exports stuff * Transform: improve comments * Remove unused function in painter * Transform: cleanup unneeded abstract functions * Transform: replace abstract getHorizon function with more generic isPointOnMapSurface function * Fix useGlobeRendering not being set properly * Transform: proper implementation of isPointOnMapSurface and screen pixel unprojection for globe * Transform: adapt more functions for globe * Transform: fix locationPoint implementation * Controls: globe panning experiments * Controls: reasonable globe panning * Controls: centering zoom for globe experiment * Transform: fix globe unit tests * Transform: fix remaining unit tests * Move mercator_transform.test to src/geo/projection * Transform: globe bugfixes and more unit tests * Transform: bugfix globe setLocationAtPoint * Transform: isolate accesses to globe projection to avoid unintentional transform's state changes * Transform: move related tests so they are near each other * Transform: improve globe unprojection accuracy * Transform: fix globe bugs * Transform: move globe unit tests * Transform: another globe setLocationAtPoint implementation * Transform: fix globe zoom adjustment not working * Transform: fix setLocationAtPoint * Transform: setLocationAtPoint and zoom WIP * Transform: adjust unit test to accept positive longitudes * Transform: improve globe math precision (fp64) * Transform: precision improvement, better camera position * Transform: another test WIP * Transform: fix setLocationAtPoint condition * Transform: more reasonable zoom for globe Still has bugs though * Transform: globe zoom works well when cursor is outside the globe * Transform: globe more consistent zoom logic * Transform: experimental pole edge clamp for globe * Transform: fix maxLatitudeForZoomLevel math * Transform: globe constrain experiment * Transform: minor improvements * Transform: globe panning 2.0 * Transform: globe panning 2.1 Adjust more constants! * Transform: some math for globe zoom * Transform: globe: working zoom controls without glitches * Transform: globe zoom: fix some more glitching * Transform: globe zoom: reduce panning when zoom pixel is far from the planet * Transform: zoom globe: simplify, better behaviour around poles * Transform: globe zoom: exact zooming * Transform: globe zoom: better comments * Transform: temporarily disable camera easeTo and flyTo * Transform: calling project/unproject on a globe should fail, rename project/unproject to be more descriptive * Transform: fitBounds: initial implementation for globe_transform Not working * Transform: fitBounds: zoom is now correct * Transform: fitBounds: padding works for north/south * Transform: fitBounds: just build on top of mercator code * Transform: fitBounds: the original way * Transform: fitBounds: back to mercator-buildon + done * Transform: tighter bounds for zoom heuristic transition * Transform: easeTo: probably works * Transform: attempt to handle camera options apparent zoom for globe * Transform: easeTo fixes WIP * Transform: easeTo: mostly working implementation (still WIP) * Transform: easeTo: small fixes * Transform: easeTo: intertia works for panning * Transform: globe zoom: add globe radius based slowing factor * Transform: globe zoom adjustments * Transform: jumpTo adapted for globe * Transform: camera flyTo works for globe * Make (un)projectToWorldCoordinates into standalone functions * Fix inertia sometimes rotating in the wrong direction * Fix transform center sometimes not getting wrapped, leading to visual artifacts * Transform: easeTo: slerp experiment * Transform: easeTo: revert slerp, add note on why it is not used * Transform: improve center animation for easeTo and flyTo * Minor refactor & remove some outdated TODOs * More refactor and TODOs * Transform: globe remembers its globeness state after clone, fixes improper collision box when globe gets soft-disabled * Terrain matrix refactor WIP * Terrain fixes * Transform: better comments, rename angularCoordinatesToVector to angularCoordinatesToSurfaceVector, some functions for globe WIP * Transform: getBounds for globe works * Transform: remove some comments * Fix merge * Remove globe.test.ts (it is now globe_transform.test.ts) * Rename Transform.updateProjection to newFrameUpdate * Revert globe.ts to pre-merge state * Revert mercator.ts to pre-merge state * New mechanism for creating specialized transforms, more merge fixes * Rename projectionMatrix to modelViewProjectionMatrix, refactor mercator_transform.ts a bit * More merge fixes, minor refactor of transforms * Add transform getters for atmosphere * Fix forgotten useGlobeControls uses * Fix cyclical dependency * Fix tests * Fix crashes * Fix manually triggered globe transition animation * Fix collision boxes not respecting mercator transition * Blend out atmosphere when transitioning to mercator * Fix globe transitions when mercator should be constrained * Reload all tiles upon projection change * Fix failing style tests * Fix terrain source cache tests * Fix map zoom¢er being applied in wrong order, causing zoom to be wrongly constrained under globe * Update globe pole render tests with correct zoom * Update globe unit test zooms * Fix more unit tests * Fix transform.apply not copying everything, fix globe controls not wrapping longitudes * Fix some globe tests * Fix globe setLocationAtPoint * Fix docs & lint * Increase globe setLocationAtPoint test desired precision * Some camera tests for globe * Fix easeTo test suite name and placing * Add rotated setLocationAtPoint test for globe, fix failing test * Fix globe easeTo & flyTo with bearing to follow spec, add tests * easeTo globe tests * All relevant camera tests for globe implemented * Update build size test * Fix symbols not respecting mercator * Update build size again * Terrain fix * Fix merge * Fix terrain shaders * Fix merge * Revert controls changes * Fix reverted files * Fix reverted camera tests * Revert forgotten file, fix lint * Update build size * Feedback comments for unit tests * Convert setters to functions: runtime code * Convert setters to functions: test code * Convert last setter to function * Fix some tests * Transform is now an interface * Rename Transform to ITransform * Remove abstract functions from transform base class * TransformHelper wip * Rename transform files * Finish transform rewrite * Fix mercator transform tests * Fix mercator_transform constructor * Fix symbol bucket test * Fix source cache tests * Fix transform clone bug & tests * Improve comments * More comments * Fix import * Move helper functions in tests to beginning of file * Fix collision index test accessing a private field * Remove unneeded null check * New utils tests + quadratic solve fix * Add remapSaturate tests * Add explicit types to line glyph placement * Refactor placeGlyphsAlongLine args into an object * Fix merge, cleanup draw_custom.test, fix missing perspective offset in globe transform * Fix draw_custom test * Update build test * Fix crashes * Fix transform_helper apply function not setting bearing correctly * Add test for TransformHelper * Fix TransformHelper.apply * Fix flipped text placement * Add new expected image to render test * Fix marker tests * Update build size * Move functions from mercator_transform.ts to mercator_utils.ts * Refactor un/projectToWorldCoordinates function args * Make zoomScale and scaleZoom standalone functions * Fix unprojectFromWorldCoordinates arg type * Move globe functions to separate file * Fix private member access in source_cache.test.ts * Fix deck.gl missing dot * Fix missing globe_utils.ts * Better `angleToRotateBetweenVectors2D` doccomment * Remove unneeded `protected` * Cleanup transform interface and remove duplicate comments * Split mercator_utils tests into a separate file * Fix tests * Split globe locationPoint tests a bit * Add more mercator tests * More globe tests * Fix globe getBounds and add tests for it * Remove unneeded function, update build size * projectTileCoordinates for globe now covered by test * Add globe_utils tests * Split up globe tests more * Fix missing doccomment * Rename transform's projection/unprojection functions * Better ray intersection comment and type * Reduce indentation * Improve unproject math readability * Add point-plane distance util function * Move tileCoordinatesToMercatorCoordinates to mercator_utils * Better name for location to mercator coordinate functions * Move angleToRotateBetweenVectors2D to utils * Refactor _globeness usage * Remove _initialized from GlobeTransform * Remove translatePosition from transform interface * Add IReadonlyTransform interface * Update build size * Remove unneeded comment * Fix painter test * Fix lint * Update test/build/min.test.ts * Update changelog * Globe - camera controls (#4408) * Camera controls changes from dev branch * Move stuff from globe_control_utils to globe_utils * Better globe_utils comments * Fix markers not being updated when globe is toggled * Fix globe tests * Update build size * Better comments for camera helper functions * Move camera helper functions to beginning of file * Camera: more and better comments * Update build size * Fix globe transform error correction handling * Better comments for _last* fields in globe transform * Refactor newFrameUpdate * Better comments for CoveringTilesOptions type members. * Refactor globe camera tests to use more describe statements * Remove isTilePositionOccluded function from transform interface * Fix camera tests * Add more mercator_utils test * Add more globe_transform tests * Fix failing render tests * Make camera helper functions static * Remove `around` from flyTo options. * Update build size * CameraHelper: initial implementation, inertia handling * Move createVec* functions to util.ts * CameraHelper: panning and zooming * CameraHelpers: implement cameraForBounds * CameraHelpers: handle jumpTo * CameraHelper: easeTo * CameraHelper: flyTo * Projection event contains new projection name and is fired by changing style's projection * Fix lint * Fix test camera/map not having proper CameraHelper * Fix easeTo not emitting zoom events * Fix cameraForBoxAndBearing globe not returning anything, rename camera helter types * Fix globe easeTo ignoring offset * Fix one flyTo test not creating camera properly * Update build size * Add projection transition event tests * Add example on how to compensate for how globe size changes with latitude * Revert scrollzoom delete removal * Remove apparentZoom parameter * CameraHelper is set in camera constuctor * Use spy for projection event unit tests * Remove unnecessary done() in tests * Update build size * Remove more unneeded done() calls * Do not use map.once callback in projection events tests * Better zoom delta example title and description * Rename globe zoom delta and planet size function example * Add zoom planet size function example image * Reduce size of some globe example images using compresspng * Globe: bugfixes: raster layer & projection change (#4546) * Port bugfix changes * Update build size * Fix render tests * Add render test result for debian * Increase raster tile granularity some more * Adjust warped raster tile render test * Add missing tsdoc param * Use single checkerboard image for render test * Globe examples now use setProjection * Add new raster-pole render test image * Add another raster-warped expected image * Use "style.load" event on map instead of on style * Adapt new heatmap code for globe, update build size * Fix render tests Most tests had subpixel shifts * Globe - custom layers API and examples, globe dev guide (#4577) * Port custom layer changes and globe docs * Port transform changes * Fix custom layer unit test * Fix failing render tests * Update build size * Update globe custom layer example descritions, remove forgotten code * Remove unused util function * Incorporate globe docs feedback * Refactor and expose tile mesh generation * Refactor custom layers to get smaller args object and access map transform directly * Simplify more of the custom layer API * Clean up and adapt more examples * Fix mercator matrix precision * Fix 3D model on terrain example * Rename projectionDataForMercatorCoords to defaultProjectionData * Document ProjectionData type * Update build size * Update developer-guides/globe.md Co-authored-by: Harel M * Decouple ProjectionData from rendering code Rename fields to camelCase, move it to a separate file * Rename ProjectionData members * Fix mercator transform unit tests * Add an example to createTileMesh * Rename CustomRenderMethodInput.shader to shaderData * Add shaderData examples * Document TileMesh and CreateTileMeshOptions types * Fix custom layers in render tests * Update render tests Fails other than raster-warped were caused by increasing pos matrix precision in mercator_transform to 64 bit floats * Add render test result from linux * Update build size * Update src/render/program/projection_program.ts Co-authored-by: Harel M * Rename createTileMeshInternal to createTileMeshWithBuffers * Update build size * Improve doc comments --------- Co-authored-by: Harel M * Globe - Covering tiles (#4615) * Import coveringTiles changes from dev branch * Remove duplicated tiles used in render tests * Remove unused function * Fix typo * Properly handle tile wraps and LOD across antimeridian * Discard previous changes and use custom wrap values instead * Update build size * Add render test for LOD at antimeridian * Convert visibility numbers to enum * Refactor globe covering tiles into a separate file * Add yet another raster-warped expected image * Add unit tests for globe covering tiles * Refactor globe coveringTiles math to assume worldSize=1 instead of tileSize=1 * Split globe coveringTiles into more functions * Explain radiusOfMaxLvlLodInTiles value * Explain why checking 4 tile corners is (mostly) enough to construct an AABB. * Move mercator coveringTiles into a separate file * Yet another raster-warped expected image * Remove ITileVisibilityProvider interface * Use explicit types * PR feedback * Rename coveringTiles stack types * fix typo * Remove sky disabling in examples as this is no longer needed. * Fix spelling * Fix spelling - unencode * Fix more spelling * Fix lint * Add basic roll capability * code hygiene * update sky shader to use roll angle * fix horizon /sky display when rolled * reduce repeat calls to potentially expensive functions * use slerp instread of euler angle interpolation for mercator easeTo(). Also reverses definition of roll. * safe roll pitch bearing near singularity * add option to use slerp or old linear interpolation of Euler angles * code hygiene * code hygiene * fix unit tests * add roll unit tests * change euler angle behavior at the end of easeTo() to use the representation specified by the caller. This only matters at the singularity (pitch = 0) * unit tests fro R2D and D2R * add euler angle unit tests * add roll to transform unit tests * add roll to transform helper test * code hygiene * add sky and fog render tests * code hygiene * this rotates labels as I intended it to when pitchWithMap = true, rotateWithMap= false, but I don't think that's what we want * Revert "this rotates labels as I intended it to when pitchWithMap = true, rotateWithMap= false, but I don't think that's what we want" This reverts commit 01845a98406be66f8669ff8335e013d28702e59c. * extend rotateWithMap to include the effects of camera roll * Add roll icon render tests * getPitchedLabelPlaneMatrix unit tests * add getPitchedLabelPlaneMatrix unit test * add getGlCoordMatrix unit tests * remove console.log * code hygiene * add roll text alignment render tests * fix lint * add text upright with roll render test * add combined rotation text upright render test * add text-pitch-scaling render test with roll * fix collision box when camera is rolled * add skew matrix tests at horizon. * faster getTileSkewMatrix * update changelog * updated expected build size * remove "useSlerp" flag from mercator transform, and use slerp in globe transform as well. * fix merge error * increase test coverage * switch from _underscore fields to getters * add render text for different text alignments with rolled horizon present. * change private angle names in transform to include prefix "InRadians", and add getters directly in radians. Remove ambiguous member "angle", which was negative bearingInRadians. * updated library size * use *InRadians where possible * combine duplicate interpolation code from mercator and globe * add roll to root style spec * change getTileSkewMatrix to return vectors instead of mat2 * fix lint * Add roll visualization to Navigation Control, and add mouse control of roll using Ctrl + right click. * Update style spec and remove "as any" in jumpTo() * rearrrange if-else * add roll and pitch control and visualization to navigation example * update build size * fix bad changelog merge * add mouseRollHandler and DragRotateHandler unit tests --------- Co-authored-by: Jakub Pelc <57600346+kubapelc@users.noreply.github.com> Co-authored-by: HarelM Co-authored-by: Larrieu Vivian Co-authored-by: Jakub Pelc --- CHANGELOG.md | 1 + src/geo/projection/camera_helper.ts | 34 ++- src/geo/projection/globe_camera_helper.ts | 26 +- src/geo/projection/globe_transform.test.ts | 10 + src/geo/projection/globe_transform.ts | 39 ++- src/geo/projection/globe_utils.ts | 4 +- src/geo/projection/mercator_camera_helper.ts | 22 +- src/geo/projection/mercator_transform.test.ts | 13 +- src/geo/projection/mercator_transform.ts | 62 +++-- src/geo/transform_helper.test.ts | 4 +- src/geo/transform_helper.ts | 69 +++-- src/geo/transform_interface.ts | 18 +- src/render/draw_sky.ts | 5 +- src/render/draw_symbol.ts | 2 +- src/render/program/fill_extrusion_program.ts | 2 +- src/render/program/hillshade_program.ts | 2 +- src/render/program/sky_program.ts | 27 +- src/shaders/sky.fragment.glsl | 8 +- src/source/source_cache.ts | 4 +- src/style/style_layer/circle_style_layer.ts | 2 +- .../style_layer/fill_extrusion_style_layer.ts | 2 +- src/style/style_layer/fill_style_layer.ts | 2 +- src/style/style_layer/line_style_layer.ts | 2 +- src/symbol/collision_index.ts | 14 +- src/symbol/placement.ts | 6 +- src/symbol/projection.test.ts | 148 ++++++++++- src/symbol/projection.ts | 52 +++- src/ui/camera.test.ts | 250 ++++++++++++++++-- src/ui/camera.ts | 87 +++++- src/ui/control/navigation_control.ts | 31 ++- src/ui/handler/drag_handler.ts | 7 +- src/ui/handler/drag_rotate.test.ts | 35 +++ src/ui/handler/mouse.ts | 29 +- .../handler/mouse_handler_interface.test.ts | 37 ++- src/ui/handler/shim/drag_rotate.ts | 19 +- src/ui/handler_inertia.ts | 13 + src/ui/handler_manager.ts | 29 +- src/ui/map.ts | 20 +- src/util/util.test.ts | 37 ++- src/util/util.ts | 60 ++++- test/build/min.test.ts | 2 +- test/examples/navigation.html | 10 +- .../auto-rotation-alignment-map/expected.png | Bin 0 -> 368 bytes .../auto-rotation-alignment-map/style.json | 37 +++ .../expected.png | Bin 0 -> 223 bytes .../style.json | 36 +++ .../expected.png | Bin 0 -> 353 bytes .../style.json | 38 +++ .../expected.png | Bin 0 -> 406 bytes .../style.json | 38 +++ .../render/tests/sky/roll10/expected.png | Bin 0 -> 31134 bytes .../render/tests/sky/roll10/style.json | 39 +++ .../render/tests/sky/roll180/expected.png | Bin 0 -> 2081 bytes .../render/tests/sky/roll180/style.json | 39 +++ .../tests/sky/roll45-with-text/expected.png | Bin 0 -> 49018 bytes .../tests/sky/roll45-with-text/style.json | 141 ++++++++++ .../render/tests/sky/roll90/expected.png | Bin 0 -> 2247 bytes .../render/tests/sky/roll90/style.json | 39 +++ .../fog-no-sky-blend-roll/expected.png | Bin 0 -> 4284 bytes .../terrain/fog-no-sky-blend-roll/style.json | 47 ++++ .../terrain/fog-sky-blend-roll/expected.png | Bin 0 -> 52372 bytes .../terrain/fog-sky-blend-roll/style.json | 63 +++++ .../expected.png | Bin 0 -> 26948 bytes .../style.json | 58 ++++ .../line-placement-true-rolled/expected.png | Bin 0 -> 24292 bytes .../line-placement-true-rolled/style.json | 56 ++++ .../line-half-roll/expected.png | Bin 0 -> 79406 bytes .../line-half-roll/style.json | 64 +++++ .../auto-symbol-placement-line/expected.png | Bin 0 -> 973 bytes .../auto-symbol-placement-line/style.json | 47 ++++ .../auto-symbol-placement-point/expected.png | Bin 0 -> 377 bytes .../auto-symbol-placement-point/style.json | 39 +++ .../map-symbol-placement-line/expected.png | Bin 0 -> 973 bytes .../map-symbol-placement-line/style.json | 47 ++++ .../map-symbol-placement-point/expected.png | Bin 0 -> 401 bytes .../map-symbol-placement-point/style.json | 39 +++ .../expected.png | Bin 0 -> 1418 bytes .../style.json | 47 ++++ .../expected.png | Bin 0 -> 950 bytes .../style.json | 39 +++ .../expected.png | Bin 0 -> 874 bytes .../viewport-symbol-placement-line/style.json | 47 ++++ .../expected.png | Bin 0 -> 377 bytes .../style.json | 39 +++ 84 files changed, 2096 insertions(+), 189 deletions(-) create mode 100644 test/integration/render/tests/icon-roll-alignment/auto-rotation-alignment-map/expected.png create mode 100644 test/integration/render/tests/icon-roll-alignment/auto-rotation-alignment-map/style.json create mode 100644 test/integration/render/tests/icon-roll-alignment/auto-rotation-alignment-viewport/expected.png create mode 100644 test/integration/render/tests/icon-roll-alignment/auto-rotation-alignment-viewport/style.json create mode 100644 test/integration/render/tests/icon-roll-alignment/map-rotation-alignment-viewport/expected.png create mode 100644 test/integration/render/tests/icon-roll-alignment/map-rotation-alignment-viewport/style.json create mode 100644 test/integration/render/tests/icon-roll-alignment/viewport-rotation-alignment-map/expected.png create mode 100644 test/integration/render/tests/icon-roll-alignment/viewport-rotation-alignment-map/style.json create mode 100644 test/integration/render/tests/sky/roll10/expected.png create mode 100644 test/integration/render/tests/sky/roll10/style.json create mode 100644 test/integration/render/tests/sky/roll180/expected.png create mode 100644 test/integration/render/tests/sky/roll180/style.json create mode 100644 test/integration/render/tests/sky/roll45-with-text/expected.png create mode 100644 test/integration/render/tests/sky/roll45-with-text/style.json create mode 100644 test/integration/render/tests/sky/roll90/expected.png create mode 100644 test/integration/render/tests/sky/roll90/style.json create mode 100644 test/integration/render/tests/terrain/fog-no-sky-blend-roll/expected.png create mode 100644 test/integration/render/tests/terrain/fog-no-sky-blend-roll/style.json create mode 100644 test/integration/render/tests/terrain/fog-sky-blend-roll/expected.png create mode 100644 test/integration/render/tests/terrain/fog-sky-blend-roll/style.json create mode 100644 test/integration/render/tests/text-keep-upright/line-placement-true-roll-pitch-bearing/expected.png create mode 100644 test/integration/render/tests/text-keep-upright/line-placement-true-roll-pitch-bearing/style.json create mode 100644 test/integration/render/tests/text-keep-upright/line-placement-true-rolled/expected.png create mode 100644 test/integration/render/tests/text-keep-upright/line-placement-true-rolled/style.json create mode 100644 test/integration/render/tests/text-pitch-scaling/line-half-roll/expected.png create mode 100644 test/integration/render/tests/text-pitch-scaling/line-half-roll/style.json create mode 100644 test/integration/render/tests/text-roll-alignment/auto-symbol-placement-line/expected.png create mode 100644 test/integration/render/tests/text-roll-alignment/auto-symbol-placement-line/style.json create mode 100644 test/integration/render/tests/text-roll-alignment/auto-symbol-placement-point/expected.png create mode 100644 test/integration/render/tests/text-roll-alignment/auto-symbol-placement-point/style.json create mode 100644 test/integration/render/tests/text-roll-alignment/map-symbol-placement-line/expected.png create mode 100644 test/integration/render/tests/text-roll-alignment/map-symbol-placement-line/style.json create mode 100644 test/integration/render/tests/text-roll-alignment/map-symbol-placement-point/expected.png create mode 100644 test/integration/render/tests/text-roll-alignment/map-symbol-placement-point/style.json create mode 100644 test/integration/render/tests/text-roll-alignment/viewport-glyph-symbol-placement-line/expected.png create mode 100644 test/integration/render/tests/text-roll-alignment/viewport-glyph-symbol-placement-line/style.json create mode 100644 test/integration/render/tests/text-roll-alignment/viewport-glyph-symbol-placement-point/expected.png create mode 100644 test/integration/render/tests/text-roll-alignment/viewport-glyph-symbol-placement-point/style.json create mode 100644 test/integration/render/tests/text-roll-alignment/viewport-symbol-placement-line/expected.png create mode 100644 test/integration/render/tests/text-roll-alignment/viewport-symbol-placement-line/style.json create mode 100644 test/integration/render/tests/text-roll-alignment/viewport-symbol-placement-point/expected.png create mode 100644 test/integration/render/tests/text-roll-alignment/viewport-symbol-placement-point/style.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d41fffb4a..a6ff5ab7f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## main ### ✨ Features and improvements +- Add support for camera roll angle ([#4717](https://github.com/maplibre/maplibre-gl-js/issues/4717)) - _...Add new stuff here..._ ### 🐞 Bug fixes diff --git a/src/geo/projection/camera_helper.ts b/src/geo/projection/camera_helper.ts index 93937271fb..a34ea06bce 100644 --- a/src/geo/projection/camera_helper.ts +++ b/src/geo/projection/camera_helper.ts @@ -4,13 +4,15 @@ import {LngLat, LngLatLike} from '../lng_lat'; import {CameraForBoundsOptions, PointLike} from '../../ui/camera'; import {PaddingOptions} from '../edge_insets'; import {LngLatBounds} from '../lng_lat_bounds'; -import {warnOnce} from '../../util/util'; +import {getRollPitchBearing, RollPitchBearing, warnOnce} from '../../util/util'; +import {quat} from 'gl-matrix'; export type MapControlsDeltas = { panDelta: Point; zoomDelta: number; bearingDelta: number; pitchDelta: number; + rollDelta: number; around: Point; } @@ -23,6 +25,7 @@ export type CameraForBoxAndBearingHandlerResult = { export type EaseToHandlerOptions = { bearing: number; pitch: number; + roll: number; padding: PaddingOptions; offsetAsPoint: Point; around?: LngLat; @@ -41,6 +44,7 @@ export type EaseToHandlerResult = { export type FlyToHandlerOptions = { bearing: number; pitch: number; + roll: number; padding: PaddingOptions; offsetAsPoint: Point; center?: LngLatLike; @@ -78,7 +82,7 @@ export interface ICameraHelper { easingOffset: Point; }; - handleMapControlsPitchBearingZoom(deltas: MapControlsDeltas, tr: ITransform): void; + handleMapControlsRollPitchBearingZoom(deltas: MapControlsDeltas, tr: ITransform): void; handleMapControlsPan(deltas: MapControlsDeltas, tr: ITransform, preZoomAroundLoc: LngLat): void; @@ -90,3 +94,29 @@ export interface ICameraHelper { handleFlyTo(tr: ITransform, options: FlyToHandlerOptions): FlyToHandlerResult; } + +/** + * @internal + * Set a transform's rotation to a value interpolated between startRotation and endRotation + * @param startRotation - the starting rotation (rotation when k = 0) + * @param endRotation - the end rotation (rotation when k = 1) + * @param endEulerAngles - the end Euler angles. This is needed in case `endRotation` has an ambiguous Euler angle representation. + * @param tr - the transform to be updated + * @param k - the interpolation fraction, between 0 and 1. + */ +export function updateRotation(startRotation: quat, endRotation: quat, endEulerAngles: RollPitchBearing, tr: ITransform, k: number) { + // At pitch ==0, the Euler angle representation is ambiguous. In this case, set the Euler angles + // to the representation requested by the caller + if (k < 1) { + const rotation: quat = new Float64Array(4) as any; + quat.slerp(rotation, startRotation, endRotation, k); + const eulerAngles = getRollPitchBearing(rotation); + tr.setRoll(eulerAngles.roll); + tr.setPitch(eulerAngles.pitch); + tr.setBearing(eulerAngles.bearing); + } else { + tr.setRoll(endEulerAngles.roll); + tr.setPitch(endEulerAngles.pitch); + tr.setBearing(endEulerAngles.bearing); + } +} diff --git a/src/geo/projection/globe_camera_helper.ts b/src/geo/projection/globe_camera_helper.ts index dd67b8fa88..89e50ee6fe 100644 --- a/src/geo/projection/globe_camera_helper.ts +++ b/src/geo/projection/globe_camera_helper.ts @@ -1,12 +1,12 @@ import Point from '@mapbox/point-geometry'; import {IReadonlyTransform, ITransform} from '../transform_interface'; -import {cameraBoundsWarning, CameraForBoxAndBearingHandlerResult, EaseToHandlerResult, EaseToHandlerOptions, FlyToHandlerResult, FlyToHandlerOptions, ICameraHelper, MapControlsDeltas} from './camera_helper'; +import {cameraBoundsWarning, CameraForBoxAndBearingHandlerResult, EaseToHandlerResult, EaseToHandlerOptions, FlyToHandlerResult, FlyToHandlerOptions, ICameraHelper, MapControlsDeltas, updateRotation} from './camera_helper'; import {GlobeProjection} from './globe'; import {LngLat, LngLatLike} from '../lng_lat'; import {MercatorCameraHelper} from './mercator_camera_helper'; import {angularCoordinatesToSurfaceVector, computeGlobePanCenter, getGlobeRadiusPixels, getZoomAdjustment, globeDistanceOfLocationsPixels, interpolateLngLatForGlobe} from './globe_utils'; -import {clamp, createVec3f64, differenceOfAnglesDegrees, remapSaturate, warnOnce} from '../../util/util'; -import {mat4, vec3} from 'gl-matrix'; +import {clamp, createVec3f64, differenceOfAnglesDegrees, remapSaturate, rollPitchBearingToQuat, warnOnce} from '../../util/util'; +import {mat4, quat, vec3} from 'gl-matrix'; import {MAX_VALID_LATITUDE, normalizeCenter, scaleZoom, zoomScale} from '../transform_helper'; import {CameraForBoundsOptions} from '../../ui/camera'; import {LngLatBounds} from '../lng_lat_bounds'; @@ -48,9 +48,9 @@ export class GlobeCameraHelper implements ICameraHelper { }; } - handleMapControlsPitchBearingZoom(deltas: MapControlsDeltas, tr: ITransform): void { + handleMapControlsRollPitchBearingZoom(deltas: MapControlsDeltas, tr: ITransform): void { if (!this.useGlobeControls) { - this._mercatorCameraHelper.handleMapControlsPitchBearingZoom(deltas, tr); + this._mercatorCameraHelper.handleMapControlsRollPitchBearingZoom(deltas, tr); return; } @@ -59,6 +59,7 @@ export class GlobeCameraHelper implements ICameraHelper { 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; @@ -191,6 +192,7 @@ export class GlobeCameraHelper implements ICameraHelper { clonedTr.setCenter(result.center); clonedTr.setBearing(result.bearing); clonedTr.setPitch(0); + clonedTr.setRoll(0); clonedTr.setZoom(result.zoom); const matrix = clonedTr.modelViewProjectionMatrix; @@ -259,9 +261,12 @@ export class GlobeCameraHelper implements ICameraHelper { } const startZoom = tr.zoom; - const startBearing = tr.bearing; - const startPitch = tr.pitch; const startCenter = tr.center; + const startRotation = rollPitchBearingToQuat(tr.roll, tr.pitch, 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 endRotation = rollPitchBearingToQuat(endRoll, endPitch, endBearing); const optionsZoom = typeof options.zoom !== 'undefined'; @@ -312,11 +317,8 @@ export class GlobeCameraHelper implements ICameraHelper { isZooming = (endZoomWithShift !== startZoom); const easeFunc = (k: number) => { - if (startBearing !== options.bearing) { - tr.setBearing(interpolates.number(startBearing, options.bearing, k)); - } - if (startPitch !== options.pitch) { - tr.setPitch(interpolates.number(startPitch, options.pitch, k)); + if (!quat.equals(startRotation, endRotation)) { + updateRotation(startRotation, endRotation, {roll: endRoll, pitch: endPitch, bearing: endBearing}, tr, k); } if (options.around) { diff --git a/src/geo/projection/globe_transform.test.ts b/src/geo/projection/globe_transform.test.ts index 9fe4749189..151057ac8e 100644 --- a/src/geo/projection/globe_transform.test.ts +++ b/src/geo/projection/globe_transform.test.ts @@ -130,6 +130,16 @@ describe('GlobeTransform', () => { globeTransform.setBearing(70); expectToBeCloseToArray(globeTransform.cameraPosition as Array, [-0.7098603286961542, 2.002400604307631, 0.6154310261827212], precisionDigits); + globeTransform.setPitch(35); + globeTransform.setBearing(70); + globeTransform.setRoll(40); + expectToBeCloseToArray(globeTransform.cameraPosition as Array, [-0.7098603286961542, 2.002400604307631, 0.6154310261827212], precisionDigits); + + globeTransform.setPitch(35); + globeTransform.setBearing(70); + globeTransform.setRoll(180); + expectToBeCloseToArray(globeTransform.cameraPosition as Array, [-0.7098603286961542, 2.002400604307631, 0.6154310261827212], precisionDigits); + globeTransform.setCenter(new LngLat(-10, 42)); expectToBeCloseToArray(globeTransform.cameraPosition as Array, [-3.8450970996236364, 2.9368285470351516, 4.311953269048194], precisionDigits); }); diff --git a/src/geo/projection/globe_transform.ts b/src/geo/projection/globe_transform.ts index 34146a6a9a..659a23d184 100644 --- a/src/geo/projection/globe_transform.ts +++ b/src/geo/projection/globe_transform.ts @@ -93,6 +93,9 @@ export class GlobeTransform implements ITransform { setPitch(pitch: number): void { this._helper.setPitch(pitch); } + setRoll(roll: number): void { + this._helper.setRoll(roll); + } setFov(fov: number): void { this._helper.setFov(fov); } @@ -151,9 +154,6 @@ export class GlobeTransform implements ITransform { get height(): number { return this._helper.height; } - get angle(): number { - return this._helper.angle; - } get lngRange(): [number, number] { return this._helper.lngRange; } @@ -181,12 +181,27 @@ export class GlobeTransform implements ITransform { 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; } @@ -472,13 +487,13 @@ export class GlobeTransform implements ITransform { // - "cam" is camera origin // - "C" is globe center // - "B" is the point on "top" of the globe - camera is looking at B - "B" is the intersection between the camera center ray and the globe - // - this._pitch is the angle at B between points cam,B,A + // - this._pitchInRadians is the angle at B between points cam,B,A // - this.cameraToCenterDistance is the distance from camera to "B" // - globe radius is (0.5 * this.worldSize) // - "T" is any point where a tangent line from "cam" touches the globe surface // - elevation is assumed to be zero - globe rendering must be separate from terrain rendering anyway - const pitch = this.pitch * Math.PI / 180.0; + const pitch = this.pitchInRadians; // scale things so that the globe radius is 1 const distanceCameraToB = this.cameraToCenterDistance / globeRadiusPixels; const radius = 1; @@ -504,7 +519,7 @@ export class GlobeTransform implements ITransform { // Note the swizzled components const planeVector: vec3 = [0, vectorCtoCamX, vectorCtoCamY]; // Apply transforms - lat, lng and angle (NOT pitch - already accounted for, as it affects the tangent plane) - vec3.rotateZ(planeVector, planeVector, [0, 0, 0], this.angle); + vec3.rotateZ(planeVector, planeVector, [0, 0, 0], -this.bearingInRadians); vec3.rotateX(planeVector, planeVector, [0, 0, 0], -1 * this.center.lat * Math.PI / 180.0); vec3.rotateY(planeVector, planeVector, [0, 0, 0], this.center.lng * Math.PI / 180.0); // Scale the plane vector up @@ -609,7 +624,7 @@ export class GlobeTransform implements ITransform { const globeMatrixUncorrected = createMat4f64(); this._nearZ = 0.5; this._farZ = this.cameraToCenterDistance + globeRadiusPixels * 2.0; // just set the far plane far enough - we will calculate our own z in the vertex shader anyway - mat4.perspective(globeMatrix, this.fov * Math.PI / 180, this.width / this.height, this._nearZ, this._farZ); + mat4.perspective(globeMatrix, this.fovInRadians, this.width / this.height, this._nearZ, this._farZ); // Apply center of perspective offset const offset = this.centerOffset; @@ -620,8 +635,9 @@ export class GlobeTransform implements ITransform { this._globeProjMatrixInverted = createMat4f64(); mat4.invert(this._globeProjMatrixInverted, globeMatrix); mat4.translate(globeMatrix, globeMatrix, [0, 0, -this.cameraToCenterDistance]); - mat4.rotateX(globeMatrix, globeMatrix, -this.pitch * Math.PI / 180); - mat4.rotateZ(globeMatrix, globeMatrix, -this.angle); + mat4.rotateZ(globeMatrix, globeMatrix, this.rollInRadians); + mat4.rotateX(globeMatrix, globeMatrix, -this.pitchInRadians); + mat4.rotateZ(globeMatrix, globeMatrix, this.bearingInRadians); mat4.translate(globeMatrix, globeMatrix, [0.0, 0, -globeRadiusPixels]); // Rotate the sphere to center it on viewed coordinates @@ -647,8 +663,9 @@ export class GlobeTransform implements ITransform { const zero = createVec3f64(); this._cameraPosition = createVec3f64(); this._cameraPosition[2] = this.cameraToCenterDistance / globeRadiusPixels; - vec3.rotateX(this._cameraPosition, this._cameraPosition, zero, this.pitch * Math.PI / 180); - vec3.rotateZ(this._cameraPosition, this._cameraPosition, zero, this.angle); + vec3.rotateZ(this._cameraPosition, this._cameraPosition, zero, -this.rollInRadians); + vec3.rotateX(this._cameraPosition, this._cameraPosition, zero, this.pitchInRadians); + vec3.rotateZ(this._cameraPosition, this._cameraPosition, zero, -this.bearingInRadians); vec3.add(this._cameraPosition, this._cameraPosition, [0, 0, 1]); vec3.rotateX(this._cameraPosition, this._cameraPosition, zero, -this.center.lat * Math.PI / 180.0); vec3.rotateY(this._cameraPosition, this._cameraPosition, zero, this.center.lng * Math.PI / 180.0); diff --git a/src/geo/projection/globe_utils.ts b/src/geo/projection/globe_utils.ts index c14c3da5ad..5e32459a47 100644 --- a/src/geo/projection/globe_utils.ts +++ b/src/geo/projection/globe_utils.ts @@ -137,13 +137,13 @@ export function getDegreesPerPixel(worldSize: number, lat: number): number { * @returns New center location to set to the map's transform to apply the specified panning. */ export function computeGlobePanCenter(panDelta: Point, tr: { - readonly angle: number; + 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.angle); + 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. diff --git a/src/geo/projection/mercator_camera_helper.ts b/src/geo/projection/mercator_camera_helper.ts index 0d9a105aa9..a7603f349c 100644 --- a/src/geo/projection/mercator_camera_helper.ts +++ b/src/geo/projection/mercator_camera_helper.ts @@ -1,14 +1,15 @@ import Point from '@mapbox/point-geometry'; import {LngLat, LngLatLike} from '../lng_lat'; import {IReadonlyTransform, ITransform} from '../transform_interface'; -import {cameraBoundsWarning, CameraForBoxAndBearingHandlerResult, EaseToHandlerResult, EaseToHandlerOptions, FlyToHandlerResult, FlyToHandlerOptions, ICameraHelper, MapControlsDeltas} from './camera_helper'; +import {cameraBoundsWarning, CameraForBoxAndBearingHandlerResult, EaseToHandlerResult, EaseToHandlerOptions, FlyToHandlerResult, FlyToHandlerOptions, ICameraHelper, MapControlsDeltas, updateRotation} from './camera_helper'; import {CameraForBoundsOptions} from '../../ui/camera'; import {PaddingOptions} from '../edge_insets'; import {LngLatBounds} from '../lng_lat_bounds'; import {normalizeCenter, scaleZoom, zoomScale} from '../transform_helper'; -import {degreesToRadians} from '../../util/util'; +import {degreesToRadians, rollPitchBearingToQuat} from '../../util/util'; import {projectToWorldCoordinates, unprojectFromWorldCoordinates} from './mercator_utils'; import {interpolates} from '@maplibre/maplibre-gl-style-spec'; +import {quat} from 'gl-matrix'; /** * @internal @@ -26,9 +27,10 @@ export class MercatorCameraHelper implements ICameraHelper { }; } - handleMapControlsPitchBearingZoom(deltas: MapControlsDeltas, tr: ITransform): void { + 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); } @@ -119,9 +121,12 @@ export class MercatorCameraHelper implements ICameraHelper { handleEaseTo(tr: ITransform, options: EaseToHandlerOptions): EaseToHandlerResult { const startZoom = tr.zoom; - const startBearing = tr.bearing; - const startPitch = tr.pitch; const startPadding = tr.padding; + const startRotation = rollPitchBearingToQuat(tr.roll, tr.pitch, 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 endRotation = rollPitchBearingToQuat(endRoll, endPitch, endBearing); const optionsZoom = typeof options.zoom !== 'undefined'; @@ -149,11 +154,8 @@ export class MercatorCameraHelper implements ICameraHelper { if (isZooming) { tr.setZoom(interpolates.number(startZoom, endZoom, k)); } - if (startBearing !== options.bearing) { - tr.setBearing(interpolates.number(startBearing, options.bearing, k)); - } - if (startPitch !== options.pitch) { - tr.setPitch(interpolates.number(startPitch, options.pitch, k)); + if (!quat.equals(startRotation, endRotation)) { + updateRotation(startRotation, endRotation, {roll: endRoll, pitch: endPitch, bearing: endBearing}, tr, k); } if (doPadding) { tr.interpolatePadding(startPadding, options.padding, k); diff --git a/src/geo/projection/mercator_transform.test.ts b/src/geo/projection/mercator_transform.test.ts index 6a86c022ae..865e2f26a5 100644 --- a/src/geo/projection/mercator_transform.test.ts +++ b/src/geo/projection/mercator_transform.test.ts @@ -50,7 +50,7 @@ describe('transform', () => { 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], 6); - expect([...transform.modelViewProjectionMatrix.values()]).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([...transform.modelViewProjectionMatrix.values()]).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(transform.locationToScreenPoint(new LngLat(0, 0))).toEqual({x: 250, y: 250}); @@ -83,6 +83,17 @@ describe('transform', () => { expect(fixedLngLat(transform.screenPointToLocation(new Point(15, 45)))).toEqual({lng: 13, lat: 10}); }); + test('setLocationAt tilted rolled', () => { + const transform = new MercatorTransform(0, 22, 0, 60, 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(0, 22, 0, 60, true); transform.resize(500, 500); diff --git a/src/geo/projection/mercator_transform.ts b/src/geo/projection/mercator_transform.ts index 8866a70033..23c347b374 100644 --- a/src/geo/projection/mercator_transform.ts +++ b/src/geo/projection/mercator_transform.ts @@ -1,7 +1,7 @@ import {LngLat, LngLatLike} from '../lng_lat'; import {MercatorCoordinate, mercatorXfromLng, mercatorYfromLat, mercatorZfromAltitude} from '../mercator_coordinate'; import Point from '@mapbox/point-geometry'; -import {wrap, clamp, createIdentityMat4f64, createMat4f64} from '../../util/util'; +import {wrap, clamp, createIdentityMat4f64, createMat4f64, degreesToRadians} from '../../util/util'; import {mat2, mat4, vec3, vec4} from 'gl-matrix'; import {UnwrappedTileID, OverscaledTileID, CanonicalTileID, calculateTileKey} from '../../source/tile_id'; import {Terrain} from '../../render/terrain'; @@ -68,6 +68,9 @@ export class MercatorTransform implements ITransform { setPitch(pitch: number): void { this._helper.setPitch(pitch); } + setRoll(roll: number): void { + this._helper.setRoll(roll); + } setFov(fov: number): void { this._helper.setFov(fov); } @@ -126,9 +129,6 @@ export class MercatorTransform implements ITransform { get height(): number { return this._helper.height; } - get angle(): number { - return this._helper.angle; - } get lngRange(): [number, number] { return this._helper.lngRange; } @@ -156,12 +156,27 @@ export class MercatorTransform implements ITransform { 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; } @@ -260,7 +275,7 @@ export class MercatorTransform implements ITransform { recalculateZoom(terrain: Terrain): void { const origElevation = this.elevation; - const origAltitude = Math.cos(this._helper._pitch) * this._cameraToCenterDistance / this._helper._pixelPerMeter; + const origAltitude = Math.cos(this.pitchInRadians) * this._cameraToCenterDistance / this._helper._pixelPerMeter; // find position the camera is looking on const center = this.screenPointToLocation(this.centerPoint, terrain); @@ -271,8 +286,8 @@ export class MercatorTransform implements ITransform { // The camera's altitude off the ground + the ground's elevation = a constant: // this means the camera stays at the same total height. const requiredAltitude = origAltitude + origElevation - elevation; - // Since altitude = Math.cos(this._pitch) * this.cameraToCenterDistance / pixelPerMeter: - const requiredPixelPerMeter = Math.cos(this._helper._pitch) * this._cameraToCenterDistance / requiredAltitude; + // Since altitude = Math.cos(this._pitchInRadians) * this.cameraToCenterDistance / pixelPerMeter: + const requiredPixelPerMeter = Math.cos(this.pitchInRadians) * this._cameraToCenterDistance / requiredAltitude; // Since pixelPerMeter = mercatorZfromAltitude(1, center.lat) * worldSize: const requiredWorldSize = requiredPixelPerMeter / mercatorZfromAltitude(1, center.lat); // Since worldSize = this.tileSize * scale: @@ -511,7 +526,7 @@ export class MercatorTransform implements ITransform { _calcMatrices(): void { if (!this._helper._height) return; - const halfFov = this._helper._fov / 2; + const halfFov = this._helper._fovInRadians / 2; const offset = this.centerOffset; const point = projectToWorldCoordinates(this.worldSize, this.center); const x = point.x, y = point.y; @@ -519,18 +534,19 @@ export class MercatorTransform implements ITransform { this._helper._pixelPerMeter = mercatorZfromAltitude(1, this.center.lat) * this.worldSize; // Calculate the camera to sea-level distance in pixel in respect of terrain - const cameraToSeaLevelDistance = this._cameraToCenterDistance + this._helper._elevation * this._helper._pixelPerMeter / Math.cos(this._helper._pitch); + const cameraToSeaLevelDistance = this._cameraToCenterDistance + this._helper._elevation * this._helper._pixelPerMeter / Math.cos(this.pitchInRadians); // In case of negative minimum elevation (e.g. the dead see, under the sea maps) use a lower plane for calculation const minElevation = Math.min(this.elevation, this.minElevationForCurrentTile); - const cameraToLowestPointDistance = cameraToSeaLevelDistance - minElevation * this._helper._pixelPerMeter / Math.cos(this._helper._pitch); + const cameraToLowestPointDistance = cameraToSeaLevelDistance - minElevation * this._helper._pixelPerMeter / Math.cos(this.pitchInRadians); 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._helper._pitch; - const fovAboveCenter = this._helper._fov * (0.5 + offset.y / this._helper._height); + 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 @@ -542,7 +558,7 @@ export class MercatorTransform implements ITransform { // 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._farZ = (Math.cos(Math.PI / 2 - this._helper._pitch) * topHalfMinDistance + lowestPlane) * 1.01; + this._farZ = (Math.cos(Math.PI / 2 - this.pitchInRadians) * topHalfMinDistance + lowestPlane) * 1.01; // The larger the value of nearZ is // - the more depth precision is available for features (good) @@ -556,7 +572,7 @@ export class MercatorTransform implements ITransform { // matrix for conversion from location to clip space(-1 .. 1) let m: mat4; m = new Float64Array(16) as any; - mat4.perspective(m, this._helper._fov, this._helper._width / this._helper._height, this._nearZ, this._farZ); + mat4.perspective(m, this.fovInRadians, this._helper._width / this._helper._height, this._nearZ, this._farZ); this._invProjMatrix = new Float64Array(16) as any as mat4; mat4.invert(this._invProjMatrix, m); @@ -567,8 +583,9 @@ export class MercatorTransform implements ITransform { mat4.scale(m, m, [1, -1, 1]); mat4.translate(m, m, [0, 0, -this._cameraToCenterDistance]); - mat4.rotateX(m, m, this._helper._pitch); - mat4.rotateZ(m, m, this._helper._angle); + 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 @@ -597,13 +614,14 @@ export class MercatorTransform implements ITransform { // 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._helper._fov, this.width / this.height, cameraToSeaLevelDistance, this._farZ); + mat4.perspective(this._fogMatrix, this.fovInRadians, this.width / this.height, cameraToSeaLevelDistance, this._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.rotateX(this._fogMatrix, this._fogMatrix, this._helper._pitch); - mat4.rotateZ(this._fogMatrix, this._fogMatrix, this.angle); + 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 @@ -618,7 +636,7 @@ export class MercatorTransform implements ITransform { // 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._helper._angle), angleSin = Math.sin(this._helper._angle), + 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; @@ -650,13 +668,13 @@ export class MercatorTransform implements ITransform { } getCameraPoint(): Point { - const pitch = this._helper._pitch; + const pitch = this.pitchInRadians; const yOffset = Math.tan(pitch) * (this._cameraToCenterDistance || 1); return this.centerPoint.add(new Point(0, yOffset)); } getCameraAltitude(): number { - const altitude = Math.cos(this._helper._pitch) * this._cameraToCenterDistance / this._helper._pixelPerMeter; + const altitude = Math.cos(this.pitchInRadians) * this._cameraToCenterDistance / this._helper._pixelPerMeter; return altitude + this.elevation; } diff --git a/src/geo/transform_helper.test.ts b/src/geo/transform_helper.test.ts index 066eb81bf7..5a1fdcbbec 100644 --- a/src/geo/transform_helper.test.ts +++ b/src/geo/transform_helper.test.ts @@ -27,6 +27,7 @@ describe('TransformHelper', () => { left: 3, }); original.setPitch(3); + original.setRoll(7); original.setRenderWorldCopies(false); original.setZoom(2.3); @@ -40,7 +41,7 @@ describe('TransformHelper', () => { expect(cloned.worldSize).toEqual(original.worldSize); expect(cloned.width).toEqual(original.width); expect(cloned.height).toEqual(original.height); - expect(cloned.angle).toEqual(original.angle); + expect(cloned.bearingInRadians).toEqual(original.bearingInRadians); expect(cloned.lngRange).toEqual(original.lngRange); expect(cloned.latRange).toEqual(original.latRange); expect(cloned.minZoom).toEqual(original.minZoom); @@ -50,6 +51,7 @@ describe('TransformHelper', () => { 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); diff --git a/src/geo/transform_helper.ts b/src/geo/transform_helper.ts index 7a845ea456..bf5d0df754 100644 --- a/src/geo/transform_helper.ts +++ b/src/geo/transform_helper.ts @@ -93,16 +93,19 @@ export class TransformHelper implements ITransformGetters { /** * Vertical field of view in radians. */ - _fov: number; + _fovInRadians: number; /** * This transform's bearing in radians. - * Note that the sign of this variable is *opposite* to the sign of {@link bearing} */ - _angle: number; + _bearingInRadians: number; /** * Pitch in radians. */ - _pitch: number; + _pitchInRadians: number; + /** + * Roll in radians. + */ + _rollInRadians: number; _zoom: number; _renderWorldCopies: boolean; _minZoom: number; @@ -142,9 +145,10 @@ export class TransformHelper implements ITransformGetters { this._zoom = 0; this._tileZoom = getTileZoom(this._zoom); this._scale = zoomScale(this._zoom); - this._angle = 0; - this._fov = 0.6435011087932844; - this._pitch = 0; + this._bearingInRadians = 0; + this._fovInRadians = 0.6435011087932844; + this._pitchInRadians = 0; + this._rollInRadians = 0; this._unmodified = true; this._edgeInsets = new EdgeInsets(); this._minElevationForCurrentTile = 0; @@ -161,9 +165,10 @@ export class TransformHelper implements ITransformGetters { this._zoom = thatI.zoom; this._tileZoom = getTileZoom(this._zoom); this._scale = zoomScale(this._zoom); - this._angle = -thatI.bearing * Math.PI / 180; - this._fov = thatI.fov * Math.PI / 180; - this._pitch = thatI.pitch * Math.PI / 180; + 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; @@ -202,7 +207,7 @@ export class TransformHelper implements ITransformGetters { /** * Gets the transform's bearing in radians. */ - get angle(): number { return this._angle; } + get bearingInRadians(): number { return this._bearingInRadians; } get lngRange(): [number, number] { return this._lngRange; } get latRange(): [number, number] { return this._latRange; } @@ -264,41 +269,61 @@ export class TransformHelper implements ITransformGetters { } get bearing(): number { - return -this._angle / Math.PI * 180; + return this._bearingInRadians / Math.PI * 180; } setBearing(bearing: number) { - const b = -wrap(bearing, -180, 180) * Math.PI / 180; - if (this._angle === b) return; + const b = wrap(bearing, -180, 180) * Math.PI / 180; + if (this._bearingInRadians === b) return; this._unmodified = false; - this._angle = b; + this._bearingInRadians = b; this._calcMatrices(); // 2x2 matrix for rotating points this._rotationMatrix = mat2.create(); - mat2.rotate(this._rotationMatrix, this._rotationMatrix, this._angle); + 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._pitch / Math.PI * 180; + return this._pitchInRadians / Math.PI * 180; } setPitch(pitch: number) { const p = clamp(pitch, this.minPitch, this.maxPitch) / 180 * Math.PI; - if (this._pitch === p) return; + 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._pitch = p; + this._rollInRadians = r; this._calcMatrices(); } + get fovInRadians(): number { + return this._fovInRadians; + } get fov(): number { - return this._fov / Math.PI * 180; + return this._fovInRadians / Math.PI * 180; } setFov(fov: number) { fov = Math.max(0.01, Math.min(60, fov)); - if (this._fov === fov) return; + if (this._fovInRadians === fov) return; this._unmodified = false; - this._fov = fov / 180 * Math.PI; + this._fovInRadians = fov / 180 * Math.PI; this._calcMatrices(); } diff --git a/src/geo/transform_interface.ts b/src/geo/transform_interface.ts index 755d80a5b9..f26135e8dd 100644 --- a/src/geo/transform_interface.ts +++ b/src/geo/transform_interface.ts @@ -75,11 +75,6 @@ export interface ITransformGetters { */ get height(): number; - /** - * Gets the transform's bearing in radians. - */ - get angle(): number; - get lngRange(): [number, number]; get latRange(): [number, number]; @@ -90,18 +85,26 @@ export interface ITransformGetters { 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; @@ -152,6 +155,11 @@ interface ITransformMutators { * 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. diff --git a/src/render/draw_sky.ts b/src/render/draw_sky.ts index 590484ce07..27b4d0c357 100644 --- a/src/render/draw_sky.ts +++ b/src/render/draw_sky.ts @@ -64,8 +64,9 @@ function getSunPos(light: Light, transform: IReadonlyTransform): vec3 { const lightMat = mat4.identity(new Float64Array(16) as any); if (light.properties.get('anchor') === 'map') { - mat4.rotateX(lightMat, lightMat, -transform.pitch * Math.PI / 180); - mat4.rotateZ(lightMat, lightMat, -transform.angle); + mat4.rotateZ(lightMat, lightMat, transform.rollInRadians); + mat4.rotateX(lightMat, lightMat, -transform.pitchInRadians); + mat4.rotateZ(lightMat, lightMat, transform.bearingInRadians); mat4.rotateX(lightMat, lightMat, transform.center.lat * Math.PI / 180.0); mat4.rotateY(lightMat, lightMat, -transform.center.lng * Math.PI / 180.0); } diff --git a/src/render/draw_symbol.ts b/src/render/draw_symbol.ts index 0aa9db876f..6d212f67ae 100644 --- a/src/render/draw_symbol.ts +++ b/src/render/draw_symbol.ts @@ -245,7 +245,7 @@ function updateVariableAnchorsForBucket( const shift = calculateVariableRenderShift(anchor, width, height, textOffset, textBoxScale, renderTextSize); const pitchedTextCorrection = transform.getPitchedTextCorrection(tileAnchor.x + translation[0], tileAnchor.y + translation[1], unwrappedTileID); - const shiftedAnchor = getShiftedAnchor(projectedAnchor.point, projectionContext, rotateWithMap, shift, transform.angle, pitchedTextCorrection); + const shiftedAnchor = getShiftedAnchor(projectedAnchor.point, projectionContext, rotateWithMap, shift, -transform.bearingInRadians, pitchedTextCorrection); const angle = (bucket.allowVerticalPlacement && symbol.placedOrientation === WritingMode.vertical) ? Math.PI / 2 : 0; for (let g = 0; g < symbol.numGlyphs; g++) { diff --git a/src/render/program/fill_extrusion_program.ts b/src/render/program/fill_extrusion_program.ts index d600bca5a0..4974335301 100644 --- a/src/render/program/fill_extrusion_program.ts +++ b/src/render/program/fill_extrusion_program.ts @@ -88,7 +88,7 @@ const fillExtrusionUniformValues = ( const lightPos = [_lp.x, _lp.y, _lp.z] as vec3; const lightMat = mat3.create(); if (light.properties.get('anchor') === 'viewport') { - mat3.fromRotation(lightMat, -painter.transform.angle); + mat3.fromRotation(lightMat, painter.transform.bearingInRadians); } vec3.transformMat3(lightPos, lightPos, lightMat); const transformedLightPos = painter.transform.transformLightDirection(lightPos); diff --git a/src/render/program/hillshade_program.ts b/src/render/program/hillshade_program.ts index 32ea9b4d6c..7dcdcf65a7 100644 --- a/src/render/program/hillshade_program.ts +++ b/src/render/program/hillshade_program.ts @@ -65,7 +65,7 @@ const hillshadeUniformValues = ( let azimuthal = layer.paint.get('hillshade-illumination-direction') * (Math.PI / 180); // modify azimuthal angle by map rotation if light is anchored at the viewport if (layer.paint.get('hillshade-illumination-anchor') === 'viewport') { - azimuthal -= painter.transform.angle; + azimuthal += painter.transform.bearingInRadians; } return { 'u_image': 0, diff --git a/src/render/program/sky_program.ts b/src/render/program/sky_program.ts index 524b67fc48..ce5a4d8014 100644 --- a/src/render/program/sky_program.ts +++ b/src/render/program/sky_program.ts @@ -1,4 +1,4 @@ -import {UniformColor, Uniform1f} from '../uniform_binding'; +import {UniformColor, Uniform1f, Uniform2f} from '../uniform_binding'; import type {Context} from '../../gl/context'; import type {UniformValues, UniformLocations} from '../uniform_binding'; import {IReadonlyTransform} from '../../geo/transform_interface'; @@ -8,22 +8,31 @@ import {getMercatorHorizon} from '../../geo/projection/mercator_utils'; export type SkyUniformsType = { 'u_sky_color': UniformColor; 'u_horizon_color': UniformColor; - 'u_horizon': Uniform1f; + 'u_horizon': Uniform2f; + 'u_horizon_normal': Uniform2f; 'u_sky_horizon_blend': Uniform1f; }; const skyUniforms = (context: Context, locations: UniformLocations): SkyUniformsType => ({ 'u_sky_color': new UniformColor(context, locations.u_sky_color), 'u_horizon_color': new UniformColor(context, locations.u_horizon_color), - 'u_horizon': new Uniform1f(context, locations.u_horizon), + 'u_horizon': new Uniform2f(context, locations.u_horizon), + 'u_horizon_normal': new Uniform2f(context, locations.u_horizon_normal), 'u_sky_horizon_blend': new Uniform1f(context, locations.u_sky_horizon_blend), }); -const skyUniformValues = (sky: Sky, transform: IReadonlyTransform, pixelRatio: number): UniformValues => ({ - 'u_sky_color': sky.properties.get('sky-color'), - 'u_horizon_color': sky.properties.get('horizon-color'), - 'u_horizon': (transform.height / 2 + getMercatorHorizon(transform)) * pixelRatio, - 'u_sky_horizon_blend': (sky.properties.get('sky-horizon-blend') * transform.height / 2) * pixelRatio, -}); +const skyUniformValues = (sky: Sky, transform: IReadonlyTransform, pixelRatio: number): UniformValues => { + const cosRoll = Math.cos(transform.rollInRadians); + const sinRoll = Math.sin(transform.rollInRadians); + const mercatorHorizon = getMercatorHorizon(transform); + return { + 'u_sky_color': sky.properties.get('sky-color'), + 'u_horizon_color': sky.properties.get('horizon-color'), + 'u_horizon': [(transform.width / 2 - mercatorHorizon * sinRoll) * pixelRatio, + (transform.height / 2 + mercatorHorizon * cosRoll) * pixelRatio], + 'u_horizon_normal': [-sinRoll, cosRoll], + 'u_sky_horizon_blend': (sky.properties.get('sky-horizon-blend') * transform.height / 2) * pixelRatio, + }; +}; export {skyUniforms, skyUniformValues}; diff --git a/src/shaders/sky.fragment.glsl b/src/shaders/sky.fragment.glsl index e65b97c030..3317a86028 100644 --- a/src/shaders/sky.fragment.glsl +++ b/src/shaders/sky.fragment.glsl @@ -1,13 +1,15 @@ uniform vec4 u_sky_color; uniform vec4 u_horizon_color; -uniform float u_horizon; +uniform vec2 u_horizon; +uniform vec2 u_horizon_normal; uniform float u_sky_horizon_blend; void main() { + float x = gl_FragCoord.x; float y = gl_FragCoord.y; - if (y > u_horizon) { - float blend = y - u_horizon; + float blend = (y - u_horizon.y) * u_horizon_normal.y + (x - u_horizon.x) * u_horizon_normal.x; + if (blend > 0.0) { if (blend < u_sky_horizon_blend) { gl_FragColor = mix(u_sky_color, u_horizon_color, pow(1.0 - blend / u_sky_horizon_blend, 2.0)); } else { diff --git a/src/source/source_cache.ts b/src/source/source_cache.ts index d3ede7a368..20b5a07518 100644 --- a/src/source/source_cache.ts +++ b/src/source/source_cache.ts @@ -232,8 +232,8 @@ export class SourceCache extends Evented { return renderables.sort((a_: Tile, b_: Tile) => { const a = a_.tileID; const b = b_.tileID; - const rotatedA = (new Point(a.canonical.x, a.canonical.y))._rotate(this.transform.angle); - const rotatedB = (new Point(b.canonical.x, b.canonical.y))._rotate(this.transform.angle); + const rotatedA = (new Point(a.canonical.x, a.canonical.y))._rotate(-this.transform.bearingInRadians); + const rotatedB = (new Point(b.canonical.x, b.canonical.y))._rotate(-this.transform.bearingInRadians); return a.overscaledZ - b.overscaledZ || rotatedB.y - rotatedA.y || rotatedB.x - rotatedA.x; }).map(tile => tile.tileID.key); } diff --git a/src/style/style_layer/circle_style_layer.ts b/src/style/style_layer/circle_style_layer.ts index fb11dfa680..d5755e34c2 100644 --- a/src/style/style_layer/circle_style_layer.ts +++ b/src/style/style_layer/circle_style_layer.ts @@ -52,7 +52,7 @@ export class CircleStyleLayer extends StyleLayer { const translatedPolygon = translate(queryGeometry, this.paint.get('circle-translate'), this.paint.get('circle-translate-anchor'), - transform.angle, pixelsToTileUnits); + -transform.bearingInRadians, pixelsToTileUnits); const radius = this.paint.get('circle-radius').evaluate(feature, featureState); const stroke = this.paint.get('circle-stroke-width').evaluate(feature, featureState); const size = radius + stroke; diff --git a/src/style/style_layer/fill_extrusion_style_layer.ts b/src/style/style_layer/fill_extrusion_style_layer.ts index aaad255331..4a5086ee98 100644 --- a/src/style/style_layer/fill_extrusion_style_layer.ts +++ b/src/style/style_layer/fill_extrusion_style_layer.ts @@ -52,7 +52,7 @@ export class FillExtrusionStyleLayer extends StyleLayer { const translatedPolygon = translate(queryGeometry, this.paint.get('fill-extrusion-translate'), this.paint.get('fill-extrusion-translate-anchor'), - transform.angle, pixelsToTileUnits); + -transform.bearingInRadians, pixelsToTileUnits); const height = this.paint.get('fill-extrusion-height').evaluate(feature, featureState); const base = this.paint.get('fill-extrusion-base').evaluate(feature, featureState); diff --git a/src/style/style_layer/fill_style_layer.ts b/src/style/style_layer/fill_style_layer.ts index b07cc7011d..5b7cff86ae 100644 --- a/src/style/style_layer/fill_style_layer.ts +++ b/src/style/style_layer/fill_style_layer.ts @@ -55,7 +55,7 @@ export class FillStyleLayer extends StyleLayer { const translatedPolygon = translate(queryGeometry, this.paint.get('fill-translate'), this.paint.get('fill-translate-anchor'), - transform.angle, pixelsToTileUnits); + -transform.bearingInRadians, pixelsToTileUnits); return polygonIntersectsMultiPolygon(translatedPolygon, geometry); } diff --git a/src/style/style_layer/line_style_layer.ts b/src/style/style_layer/line_style_layer.ts index a993b964a9..b462a56651 100644 --- a/src/style/style_layer/line_style_layer.ts +++ b/src/style/style_layer/line_style_layer.ts @@ -105,7 +105,7 @@ export class LineStyleLayer extends StyleLayer { const translatedPolygon = translate(queryGeometry, this.paint.get('line-translate'), this.paint.get('line-translate-anchor'), - transform.angle, pixelsToTileUnits); + -transform.bearingInRadians, pixelsToTileUnits); const halfWidth = pixelsToTileUnits / 2 * getLineWidth( this.paint.get('line-width').evaluate(feature, featureState), this.paint.get('line-gap-width').evaluate(feature, featureState)); diff --git a/src/symbol/collision_index.ts b/src/symbol/collision_index.ts index 683b0c345c..072c795b03 100644 --- a/src/symbol/collision_index.ts +++ b/src/symbol/collision_index.ts @@ -15,7 +15,7 @@ import type { } from '../data/array_types.g'; import type {OverlapMode} from '../style/style_layer/overlap_mode'; import {UnwrappedTileID} from '../source/tile_id'; -import {type PointProjection, SymbolProjectionContext, pathSlicedToLongestUnoccluded, placeFirstAndLastGlyph, projectPathSpecialProjection, xyTransformMat4} from '../symbol/projection'; +import {type PointProjection, SymbolProjectionContext, getTileSkewVectors, pathSlicedToLongestUnoccluded, placeFirstAndLastGlyph, projectPathSpecialProjection, xyTransformMat4} from '../symbol/projection'; import {clamp, getAABB} from '../util/util'; // When a symbol crosses the edge that causes it to be included in @@ -542,13 +542,11 @@ export class CollisionIndex { vecSouthY = cos; } else if (!rotateWithMap && pitchWithMap) { // Handles pitch-align: map texts that are always aligned with the viewport's X axis. - const angle = -this.transform.angle; - const sin = Math.sin(angle); - const cos = Math.cos(angle); - vecEastX = cos; - vecEastY = sin; - vecSouthX = -sin; - vecSouthY = cos; + const skew = getTileSkewVectors(this.transform); + vecEastX = skew.vecEast[0]; + vecEastY = skew.vecEast[1]; + vecSouthX = skew.vecSouth[0]; + vecSouthY = skew.vecSouth[1]; } // Configuration for screen space offsets diff --git a/src/symbol/placement.ts b/src/symbol/placement.ts index f0baee82e2..ee68d873a9 100644 --- a/src/symbol/placement.ts +++ b/src/symbol/placement.ts @@ -818,7 +818,7 @@ export class Placement { if (zOrderByViewportY) { if (bucketPart.symbolInstanceStart !== 0) throw new Error('bucket.bucketInstanceId should be 0'); - const symbolIndexes = bucket.getSortedSymbolIndexes(this.transform.angle); + const symbolIndexes = bucket.getSortedSymbolIndexes(-this.transform.bearingInRadians); for (let i = symbolIndexes.length - 1; i >= 0; --i) { const symbolIndex = symbolIndexes[i]; placeSymbol(bucket.symbolInstances.get(symbolIndex), bucket.collisionArrays[symbolIndex], symbolIndex); @@ -1174,7 +1174,7 @@ export class Placement { variableOffset.textOffset, variableOffset.textBoxScale); if (rotateWithMap) { - shift._rotate(pitchWithMap ? this.transform.angle : -this.transform.angle); + shift._rotate(pitchWithMap ? -this.transform.bearingInRadians : this.transform.bearingInRadians); } } else { // No offset -> this symbol hasn't been placed since coming on-screen @@ -1213,7 +1213,7 @@ export class Placement { } } - bucket.sortFeatures(this.transform.angle); + bucket.sortFeatures(-this.transform.bearingInRadians); if (this.retainedQueryData[bucket.bucketInstanceId]) { this.retainedQueryData[bucket.bucketInstanceId].featureSortOrder = bucket.featureSortOrder; } diff --git a/src/symbol/projection.test.ts b/src/symbol/projection.test.ts index c0f1674819..8f9fcb325b 100644 --- a/src/symbol/projection.test.ts +++ b/src/symbol/projection.test.ts @@ -1,9 +1,10 @@ -import {SymbolProjectionContext, ProjectionSyntheticVertexArgs, findOffsetIntersectionPoint, projectWithMatrix, transformToOffsetNormal, projectLineVertexToLabelPlane} from './projection'; +import {SymbolProjectionContext, ProjectionSyntheticVertexArgs, findOffsetIntersectionPoint, projectWithMatrix, transformToOffsetNormal, projectLineVertexToLabelPlane, getPitchedLabelPlaneMatrix, getGlCoordMatrix, getTileSkewVectors} from './projection'; import Point from '@mapbox/point-geometry'; import {mat4} from 'gl-matrix'; import {SymbolLineVertexArray} from '../data/array_types.g'; import {MercatorTransform} from '../geo/projection/mercator_transform'; +import {expectToBeCloseToArray} from '../util/test/util'; describe('Projection', () => { test('matrix float precision', () => { @@ -171,4 +172,149 @@ describe('Find offset line intersections', () => { expect(intersectionPoint.y).toBeCloseTo(1); }); + test('getPitchedLabelPlaneMatrix: bearing and roll', () => { + const transform = new MercatorTransform(); + transform.setBearing(0); + transform.setPitch(45); + transform.setRoll(45); + + expectToBeCloseToArray([...getPitchedLabelPlaneMatrix(false, transform, 2).values()], + [0.4330127239227295, -0.4330127239227295, 0, 0, 0.3061862289905548, 0.3061862289905548, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], 9); + expectToBeCloseToArray([...getPitchedLabelPlaneMatrix(true, transform, 2).values()], + [0.5, 0, 0, 0, 0, 0.5, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], 9); + }); + + test('getPitchedLabelPlaneMatrix: bearing and pitch', () => { + const transform = new MercatorTransform(); + transform.setBearing(45); + transform.setPitch(45); + transform.setRoll(0); + + expectToBeCloseToArray([...getPitchedLabelPlaneMatrix(false, transform, 2).values()], + [0.3535533845424652, -0.3535533845424652, 0, 0, 0.3535533845424652, 0.3535533845424652, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], 9); + expectToBeCloseToArray([...getPitchedLabelPlaneMatrix(true, transform, 2).values()], + [0.5, 0, 0, 0, 0, 0.5, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], 9); + }); + + test('getPitchedLabelPlaneMatrix: bearing, pitch, and roll', () => { + const transform = new MercatorTransform(); + transform.setBearing(45); + transform.setPitch(45); + transform.setRoll(45); + + expectToBeCloseToArray([...getPitchedLabelPlaneMatrix(false, transform, 2).values()], + [0.08967986702919006, -0.5226925611495972, 0, 0, 0.5226925611495972, -0.08967986702919006, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], 9); + expectToBeCloseToArray([...getPitchedLabelPlaneMatrix(true, transform, 2).values()], + [0.5, 0, 0, 0, 0, 0.5, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], 9); + }); + + test('getGlCoordMatrix: bearing, pitch, and roll', () => { + const transform = new MercatorTransform(); + transform.resize(128, 128); + transform.setBearing(45); + transform.setPitch(45); + transform.setRoll(45); + + expectToBeCloseToArray([...getGlCoordMatrix(false, false, transform, 2).values()], + [...transform.pixelsToClipSpaceMatrix.values()], 9); + expectToBeCloseToArray([...getGlCoordMatrix(false, true, transform, 2).values()], + [...transform.pixelsToClipSpaceMatrix.values()], 9); + expectToBeCloseToArray([...getGlCoordMatrix(true, false, transform, 2).values()], + [-0.33820396661758423, 1.9711971282958984, 0, 0, -1.9711971282958984, 0.33820396661758423, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], 9); + expectToBeCloseToArray([...getGlCoordMatrix(true, true, transform, 2).values()], + [2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], 9); + }); + + test('getTileSkewVectors: bearing', () => { + const transform = new MercatorTransform(); + transform.setBearing(45); + transform.setPitch(0); + transform.setRoll(0); + + expectToBeCloseToArray([...getTileSkewVectors(transform).vecEast.values()], + [0.7071067690849304, 0.7071067690849304]); + expectToBeCloseToArray([...getTileSkewVectors(transform).vecSouth.values()], + [-0.7071067690849304, 0.7071067690849304], 9); + }); + + test('getTileSkewVectors: roll', () => { + const transform = new MercatorTransform(); + transform.setBearing(0); + transform.setPitch(0); + transform.setRoll(45); + + expectToBeCloseToArray([...getTileSkewVectors(transform).vecEast.values()], + [0.7071067690849304, 0.7071067690849304]); + expectToBeCloseToArray([...getTileSkewVectors(transform).vecSouth.values()], + [-0.7071067690849304, 0.7071067690849304], 9); + }); + + test('getTileSkewVectors: pitch', () => { + const transform = new MercatorTransform(); + transform.setBearing(0); + transform.setPitch(45); + transform.setRoll(0); + + expectToBeCloseToArray([...getTileSkewVectors(transform).vecEast.values()], + [1.0, 0.0]); + expectToBeCloseToArray([...getTileSkewVectors(transform).vecSouth.values()], + [0.0, 1.0], 9); + }); + + test('getTileSkewVectors: roll pitch bearing', () => { + const transform = new MercatorTransform(); + transform.setBearing(45); + transform.setPitch(45); + transform.setRoll(45); + + expectToBeCloseToArray([...getTileSkewVectors(transform).vecEast.values()], + [-0.16910198330879211, 0.9855985641479492]); + expectToBeCloseToArray([...getTileSkewVectors(transform).vecSouth.values()], + [-0.9855985641479492, 0.16910198330879211], 9); + }); + + test('getTileSkewVectors: pitch 90 degrees', () => { + const transform = new MercatorTransform(); + transform.setMaxPitch(180); + transform.setBearing(0); + transform.setPitch(89); + transform.setRoll(0); + + expectToBeCloseToArray([...getTileSkewVectors(transform).vecEast.values()], + [1, 0]); + expectToBeCloseToArray([...getTileSkewVectors(transform).vecSouth.values()], + [0, 1], 9); + + transform.setPitch(90); + expectToBeCloseToArray([...getTileSkewVectors(transform).vecEast.values()], + [0, 0]); + expectToBeCloseToArray([...getTileSkewVectors(transform).vecSouth.values()], + [0, 1], 9); + + transform.setBearing(90); + expectToBeCloseToArray([...getTileSkewVectors(transform).vecEast.values()], + [0, 0]); + expectToBeCloseToArray([...getTileSkewVectors(transform).vecSouth.values()], + [-1, 0], 9); + }); + + test('getTileSkewVectors: pitch 90 degrees with roll and bearing', () => { + const transform = new MercatorTransform(); + transform.setMaxPitch(180); + transform.setBearing(45); + transform.setPitch(89); + transform.setRoll(45); + + expectToBeCloseToArray([...getTileSkewVectors(transform).vecEast.values()], + [-0.6946603059768677, 0.7193379402160645]); + expectToBeCloseToArray([...getTileSkewVectors(transform).vecSouth.values()], + [-0.7193379402160645, 0.6946603059768677], 9); + + transform.setPitch(90); + expectToBeCloseToArray([...getTileSkewVectors(transform).vecEast.values()], + [-0.7071067690849304, 0.7071067690849304]); + expectToBeCloseToArray([...getTileSkewVectors(transform).vecSouth.values()], + [-0.7071067690849304, 0.7071067690849304], 9); + }); + }); diff --git a/src/symbol/projection.ts b/src/symbol/projection.ts index 54a24b1d05..bc843ef785 100644 --- a/src/symbol/projection.ts +++ b/src/symbol/projection.ts @@ -1,6 +1,6 @@ import Point from '@mapbox/point-geometry'; -import {mat4, vec4} from 'gl-matrix'; +import {mat2, mat4, vec2, vec4} from 'gl-matrix'; import * as symbolSize from './symbol_size'; import {addDynamicAttributes} from '../data/bucket/symbol_bucket'; @@ -97,10 +97,20 @@ export function getPitchedLabelPlaneMatrix( transform: IReadonlyTransform, pixelsToTileUnits: number) { const m = mat4.create(); - mat4.scale(m, m, [1 / pixelsToTileUnits, 1 / pixelsToTileUnits, 1]); if (!rotateWithMap) { - mat4.rotateZ(m, m, transform.angle); + const {vecSouth, vecEast} = getTileSkewVectors(transform); + const skew = mat2.create(); + skew[0] = vecEast[0]; + skew[1] = vecEast[1]; + skew[2] = vecSouth[0]; + skew[3] = vecSouth[1]; + mat2.invert(skew, skew); + m[0] = skew[0]; + m[1] = skew[1]; + m[4] = skew[2]; + m[5] = skew[3]; } + mat4.scale(m, m, [1 / pixelsToTileUnits, 1 / pixelsToTileUnits, 1]); return m; } @@ -115,16 +125,48 @@ export function getGlCoordMatrix( pixelsToTileUnits: number) { if (pitchWithMap) { const m = mat4.create(); - mat4.scale(m, m, [pixelsToTileUnits, pixelsToTileUnits, 1]); if (!rotateWithMap) { - mat4.rotateZ(m, m, -transform.angle); + const {vecSouth, vecEast} = getTileSkewVectors(transform); + m[0] = vecEast[0]; + m[1] = vecEast[1]; + m[4] = vecSouth[0]; + m[5] = vecSouth[1]; } + mat4.scale(m, m, [pixelsToTileUnits, pixelsToTileUnits, 1]); return m; } else { return transform.pixelsToClipSpaceMatrix; } } +export function getTileSkewVectors(transform: IReadonlyTransform): {vecEast: vec2; vecSouth: vec2} { + const cosRoll = Math.cos(transform.rollInRadians); + const sinRoll = Math.sin(transform.rollInRadians); + const cosPitch = Math.cos(transform.pitchInRadians); + const cosBearing = Math.cos(transform.bearingInRadians); + const sinBearing = Math.sin(transform.bearingInRadians); + const vecSouth = vec2.create(); + vecSouth[0] = -cosBearing * cosPitch * sinRoll - sinBearing * cosRoll; + vecSouth[1] = -sinBearing * cosPitch * sinRoll + cosBearing * cosRoll; + const vecSouthLen = vec2.length(vecSouth); + if (vecSouthLen < 1.0e-9) { + vec2.zero(vecSouth); + } else { + vec2.scale(vecSouth, vecSouth, 1 / vecSouthLen); + } + const vecEast = vec2.create(); + vecEast[0] = cosBearing * cosPitch * cosRoll - sinBearing * sinRoll; + vecEast[1] = sinBearing * cosPitch * cosRoll + cosBearing * sinRoll; + const vecEastLen = vec2.length(vecEast); + if (vecEastLen < 1.0e-9) { + vec2.zero(vecEast); + } else { + vec2.scale(vecEast, vecEast, 1 / vecEastLen); + } + + return {vecEast, vecSouth}; +} + /** * Projects a point using a specified matrix, including the perspective divide. * Uses a fast path if `getElevation` is undefined. diff --git a/src/ui/camera.test.ts b/src/ui/camera.test.ts index 9aeb51a6a0..39d9eaf36d 100644 --- a/src/ui/camera.test.ts +++ b/src/ui/camera.test.ts @@ -95,12 +95,14 @@ describe('#calculateCameraOptionsFromTo', () => { expect(cameraOptions).toBeDefined(); expect(cameraOptions.center).toBeDefined(); expect(cameraOptions.bearing).toBeCloseTo(0); + expect(cameraOptions.roll).toBeUndefined(); }); test('look at west', () => { const cameraOptions = camera.calculateCameraOptionsFromTo({lng: 1, lat: 0}, 0, {lng: 0, lat: 0}); expect(cameraOptions).toBeDefined(); expect(cameraOptions.bearing).toBeCloseTo(-90); + expect(cameraOptions.roll).toBeUndefined(); }); test('pitch 45', () => { @@ -109,12 +111,14 @@ describe('#calculateCameraOptionsFromTo', () => { const cameraOptions: CameraOptions = camera.calculateCameraOptionsFromTo({lng: 1, lat: 0}, 111200, {lng: 0, lat: 0}); expect(cameraOptions).toBeDefined(); expect(cameraOptions.pitch).toBeCloseTo(45); + expect(cameraOptions.roll).toBeUndefined(); }); test('pitch 90', () => { const cameraOptions = camera.calculateCameraOptionsFromTo({lng: 1, lat: 0}, 0, {lng: 0, lat: 0}); expect(cameraOptions).toBeDefined(); expect(cameraOptions.pitch).toBeCloseTo(90); + expect(cameraOptions.roll).toBeUndefined(); }); test('pitch 153.435', () => { @@ -125,6 +129,7 @@ describe('#calculateCameraOptionsFromTo', () => { const cameraOptions: CameraOptions = camera.calculateCameraOptionsFromTo({lng: 1, lat: 0}, 111200, {lng: 0, lat: 0}, 111200 * 3); expect(cameraOptions).toBeDefined(); expect(cameraOptions.pitch).toBeCloseTo(153.435); + expect(cameraOptions.roll).toBeUndefined(); }); test('zoom distance 1000', () => { @@ -133,6 +138,7 @@ describe('#calculateCameraOptionsFromTo', () => { expect(cameraOptions).toBeDefined(); expect(cameraOptions.zoom).toBeCloseTo(expectedZoom); + expect(cameraOptions.roll).toBeUndefined(); }); test('zoom distance 1 lng (111.2km), 111.2km altitude away', () => { @@ -141,6 +147,7 @@ describe('#calculateCameraOptionsFromTo', () => { expect(cameraOptions).toBeDefined(); expect(cameraOptions.zoom).toBeCloseTo(expectedZoom); + expect(cameraOptions.roll).toBeUndefined(); }); test('same To as From error', () => { @@ -198,6 +205,17 @@ describe('#jumpTo', () => { expect(camera.getPitch()).toBe(45); }); + test('sets roll', () => { + camera.jumpTo({pitch: 0, roll: 45}); + expect(camera.getRoll()).toBe(45); + expect(camera.getPitch()).toBe(0); + }); + + test('keeps current roll if not specified', () => { + camera.jumpTo({}); + expect(camera.getRoll()).toBe(45); + }); + test('sets multiple properties', () => { camera.jumpTo({ center: [10, 20], @@ -211,6 +229,21 @@ describe('#jumpTo', () => { expect(camera.getPitch()).toBe(60); }); + test('sets more properties', () => { + camera.jumpTo({ + center: [1, 2], + zoom: 9, + bearing: 120, + pitch: 40, + roll: 20 + }); + expect(camera.getCenter()).toEqual({lng: 1, lat: 2}); + expect(camera.getZoom()).toBe(9); + expect(camera.getBearing()).toBe(120); + expect(camera.getPitch()).toBe(40); + expect(camera.getRoll()).toBe(20); + }); + test('emits move events, preserving eventData', () => { let started, moved, ended; const eventData = {data: 'ok'}; @@ -271,6 +304,21 @@ describe('#jumpTo', () => { expect(ended).toBe('ok'); }); + test('emits roll events, preserving eventData', () => { + let started, rolled, ended; + const eventData = {data: 'ok'}; + + camera + .on('rollstart', (d) => { started = d.data; }) + .on('roll', (d) => { rolled = d.data; }) + .on('rollend', (d) => { ended = d.data; }); + + camera.jumpTo({roll: 10}, eventData); + expect(started).toBe('ok'); + expect(rolled).toBe('ok'); + expect(ended).toBe('ok'); + }); + test('cancels in-progress easing', () => { camera.panTo([3, 4]); expect(camera.isEasing()).toBeTruthy(); @@ -383,13 +431,50 @@ describe('#setBearing', () => { }); test('cancels in-progress easing', () => { - camera.panTo([3, 4]); + camera.panTo([4, 3]); expect(camera.isEasing()).toBeTruthy(); camera.setBearing(6); expect(!camera.isEasing()).toBeTruthy(); }); }); +describe('#setRoll', () => { + const camera = createCamera(); + + test('sets roll', () => { + camera.setRoll(4); + expect(camera.getRoll()).toBe(4); + }); + + test('emits move and roll events, preserving eventData', () => { + let movestarted, moved, moveended, rollstarted, rolled, rollended; + const eventData = {data: 'ok'}; + + camera + .on('movestart', (d) => { movestarted = d.data; }) + .on('move', (d) => { moved = d.data; }) + .on('moveend', (d) => { moveended = d.data; }) + .on('rollstart', (d) => { rollstarted = d.data; }) + .on('roll', (d) => { rolled = d.data; }) + .on('rollend', (d) => { rollended = d.data; }); + + camera.setRoll(5, eventData); + expect(movestarted).toBe('ok'); + expect(moved).toBe('ok'); + expect(moveended).toBe('ok'); + expect(rollstarted).toBe('ok'); + expect(rolled).toBe('ok'); + expect(rollended).toBe('ok'); + }); + + test('cancels in-progress easing', () => { + camera.panTo([3, 4]); + expect(camera.isEasing()).toBeTruthy(); + camera.setRoll(6); + expect(!camera.isEasing()).toBeTruthy(); + }); +}); + describe('#setPadding', () => { test('sets padding', () => { const camera = createCamera(); @@ -685,7 +770,29 @@ describe('#easeTo', () => { test('pitches to specified pitch', () => { const camera = createCamera(); camera.easeTo({pitch: 45, duration: 0}); - expect(camera.getPitch()).toBe(45); + expect(camera.getPitch()).toBeCloseTo(45, 6); + }); + + test('rolls to specified roll', () => { + const camera = createCamera(); + camera.easeTo({pitch: 1, roll: 45, duration: 0}); + expect(camera.getRoll()).toBeCloseTo(45, 6); + }); + + test('roll behavior at Euler angle singularity', () => { + const camera = createCamera(); + camera.easeTo({bearing: 0, pitch: 0, roll: 45, duration: 0}); + expect(camera.getRoll()).toBeCloseTo(45, 6); + expect(camera.getPitch()).toBeCloseTo(0, 6); + expect(camera.getBearing()).toBeCloseTo(0, 6); + }); + + test('bearing behavior at Euler angle singularity', () => { + const camera = createCamera(); + camera.easeTo({bearing: 45, pitch: 0, roll: 0, duration: 0}); + expect(camera.getRoll()).toBeCloseTo(0, 6); + expect(camera.getPitch()).toBeCloseTo(0, 6); + expect(camera.getBearing()).toBeCloseTo(45, 6); }); test('pans and zooms', () => { @@ -780,12 +887,12 @@ describe('#easeTo', () => { expect(fixedLngLat(camera.getCenter())).toEqual(fixedLngLat({lng: -70.3125, lat: 0.000002552471840999715})); }); - test('emits move, zoom, rotate, and pitch events, preserving eventData', () => { + test('emits move, zoom, rotate, pitch, and roll events, preserving eventData', () => { const camera = createCamera(); - let movestarted, moved, zoomstarted, zoomed, rotatestarted, rotated, pitchstarted, pitched; + let movestarted, moved, zoomstarted, zoomed, rotatestarted, rotated, pitchstarted, pitched, rollstarted, rolled; const eventData = {data: 'ok'}; - expect.assertions(18); + expect.assertions(23); camera .on('movestart', (d) => { movestarted = d.data; }) @@ -794,11 +901,13 @@ describe('#easeTo', () => { expect(camera._zooming).toBeFalsy(); expect(camera._panning).toBeFalsy(); expect(camera._rotating).toBeFalsy(); + expect(camera._rolling).toBeFalsy(); expect(movestarted).toBe('ok'); expect(moved).toBe('ok'); expect(zoomed).toBe('ok'); expect(rotated).toBe('ok'); + expect(rolled).toBe('ok'); expect(pitched).toBe('ok'); expect(d.data).toBe('ok'); }); @@ -830,8 +939,17 @@ describe('#easeTo', () => { expect(d.data).toBe('ok'); }); + camera + .on('rollstart', (d) => { rollstarted = d.data; }) + .on('roll', (d) => { rolled = d.data; }) + .on('rollend', (d) => { + expect(rollstarted).toBe('ok'); + expect(rolled).toBe('ok'); + expect(d.data).toBe('ok'); + }); + camera.easeTo( - {center: [100, 0], zoom: 3.2, bearing: 90, duration: 0, pitch: 45}, + {center: [100, 0], zoom: 3.2, bearing: 90, duration: 0, pitch: 45, roll: 30}, eventData); }); @@ -1163,7 +1281,29 @@ describe('#flyTo', () => { test('tilts to specified pitch', () => { const camera = createCamera(); camera.flyTo({pitch: 45, animate: false}); - expect(camera.getPitch()).toBe(45); + expect(camera.getPitch()).toBeCloseTo(45, 6); + }); + + test('rolls to specified roll', () => { + const camera = createCamera(); + camera.flyTo({pitch: 1, roll: 45, animate: false}); + expect(camera.getRoll()).toBeCloseTo(45, 6); + }); + + test('roll behavior at Euler angle singularity', () => { + const camera = createCamera(); + camera.flyTo({bearing: 0, pitch: 0, roll: 45, animate: false}); + expect(camera.getRoll()).toBeCloseTo(45, 6); + expect(camera.getPitch()).toBeCloseTo(0, 6); + expect(camera.getBearing()).toBeCloseTo(0, 6); + }); + + test('bearing behavior at Euler angle singularity', () => { + const camera = createCamera(); + camera.flyTo({bearing: 45, pitch: 0, roll: 0, animate: false}); + expect(camera.getRoll()).toBeCloseTo(0, 6); + expect(camera.getPitch()).toBeCloseTo(0, 6); + expect(camera.getBearing()).toBeCloseTo(45, 6); }); test('pans and zooms', () => { @@ -1223,11 +1363,11 @@ describe('#flyTo', () => { expect(fixedLngLat(camera.getCenter())).toEqual({lng: 170.3125, lat: 0}); }); - test('emits move, zoom, rotate, and pitch events, preserving eventData', () => { - expect.assertions(18); + test('emits move, zoom, rotate, pitch, and roll events, preserving eventData', () => { + expect.assertions(22); const camera = createCamera(); - let movestarted, moved, zoomstarted, zoomed, rotatestarted, rotated, pitchstarted, pitched; + let movestarted, moved, zoomstarted, zoomed, rotatestarted, rotated, pitchstarted, pitched, rollstarted, rolled; const eventData = {data: 'ok'}; camera @@ -1235,6 +1375,7 @@ describe('#flyTo', () => { .on('move', (d) => { moved = d.data; }) .on('rotate', (d) => { rotated = d.data; }) .on('pitch', (d) => { pitched = d.data; }) + .on('roll', (d) => { rolled = d.data; }) .on('moveend', (d) => { expect(camera._zooming).toBeFalsy(); expect(camera._panning).toBeFalsy(); @@ -1245,6 +1386,7 @@ describe('#flyTo', () => { expect(zoomed).toBe('ok'); expect(rotated).toBe('ok'); expect(pitched).toBe('ok'); + expect(rolled).toBe('ok'); expect(d.data).toBe('ok'); }); @@ -1275,8 +1417,17 @@ describe('#flyTo', () => { expect(d.data).toBe('ok'); }); + camera + .on('rollstart', (d) => { rollstarted = d.data; }) + .on('roll', (d) => { rolled = d.data; }) + .on('rollend', (d) => { + expect(rollstarted).toBe('ok'); + expect(rolled).toBe('ok'); + expect(d.data).toBe('ok'); + }); + camera.flyTo( - {center: [100, 0], zoom: 3.2, bearing: 90, duration: 0, pitch: 45, animate: false}, + {center: [100, 0], zoom: 3.2, bearing: 90, duration: 0, pitch: 45, roll: 20, animate: false}, eventData); }); @@ -2403,6 +2554,16 @@ describe('#jumpTo globe projection', () => { expect(camera.getPitch()).toBe(45); }); + test('sets roll', () => { + camera.jumpTo({roll: 45}); + expect(camera.getRoll()).toBe(45); + }); + + test('keeps current roll if not specified', () => { + camera.jumpTo({}); + expect(camera.getRoll()).toBe(45); + }); + test('sets multiple properties', () => { camera.jumpTo({ center: [10, 20], @@ -2531,6 +2692,29 @@ describe('#easeTo globe projection', () => { expect(camera.getPitch()).toBe(45); }); + test('rolls to specified roll', () => { + const camera = createCameraGlobe(); + camera.easeTo({pitch: 1, roll: 45, duration: 0}); + expect(camera.getPitch()).toBeCloseTo(1, 6); + expect(camera.getRoll()).toBeCloseTo(45, 6); + }); + + test('roll behavior at Euler angle singularity', () => { + const camera = createCameraGlobe(); + camera.easeTo({bearing: 0, pitch: 0, roll: 45, duration: 0}); + expect(camera.getRoll()).toBeCloseTo(45, 6); + expect(camera.getPitch()).toBeCloseTo(0, 6); + expect(camera.getBearing()).toBeCloseTo(0, 6); + }); + + test('bearing behavior at Euler angle singularity', () => { + const camera = createCameraGlobe(); + camera.easeTo({bearing: 45, pitch: 0, roll: 0, duration: 0}); + expect(camera.getRoll()).toBeCloseTo(0, 6); + expect(camera.getPitch()).toBeCloseTo(0, 6); + expect(camera.getBearing()).toBeCloseTo(45, 6); + }); + test('pans and zooms', () => { const camera = createCameraGlobe(); camera.easeTo({center: [100, 0], zoom: 3.2, duration: 0}); @@ -2830,6 +3014,29 @@ describe('#flyTo globe projection', () => { expect(camera.getPitch()).toBe(45); }); + test('rolls to specified roll', () => { + const camera = createCameraGlobe(); + camera.flyTo({pitch: 1, roll: 45, animate: false}); + expect(camera.getPitch()).toBeCloseTo(1, 6); + expect(camera.getRoll()).toBeCloseTo(45, 6); + }); + + test('roll behavior at Euler angle singularity', () => { + const camera = createCameraGlobe(); + camera.flyTo({bearing: 0, pitch: 0, roll: 45, animate: false}); + expect(camera.getRoll()).toBeCloseTo(45, 6); + expect(camera.getPitch()).toBeCloseTo(0, 6); + expect(camera.getBearing()).toBeCloseTo(0, 6); + }); + + test('bearing behavior at Euler angle singularity', () => { + const camera = createCameraGlobe(); + camera.flyTo({bearing: 45, pitch: 0, roll: 0, animate: false}); + expect(camera.getRoll()).toBeCloseTo(0, 6); + expect(camera.getPitch()).toBeCloseTo(0, 6); + expect(camera.getBearing()).toBeCloseTo(45, 6); + }); + test('pans and zooms', () => { const camera = createCameraGlobe(); camera.flyTo({center: [100, 0], zoom: 3.2, animate: false}); @@ -2890,11 +3097,11 @@ describe('#flyTo globe projection', () => { expect(fixedLngLat(camera.getCenter())).toEqual({lng: -174.079717746, lat: 0}); }); - test('emits move, zoom, rotate, and pitch events, preserving eventData', () => { - expect.assertions(18); + test('emits move, zoom, rotate, pitch, and roll events, preserving eventData', () => { + expect.assertions(24); const camera = createCameraGlobe(); - let movestarted, moved, zoomstarted, zoomed, rotatestarted, rotated, pitchstarted, pitched; + let movestarted, moved, zoomstarted, zoomed, rotatestarted, rotated, pitchstarted, pitched, rollstarted, rolled; const eventData = {data: 'ok'}; camera @@ -2902,16 +3109,20 @@ describe('#flyTo globe projection', () => { .on('move', (d) => { moved = d.data; }) .on('rotate', (d) => { rotated = d.data; }) .on('pitch', (d) => { pitched = d.data; }) + .on('roll', (d) => { rolled = d.data; }) .on('moveend', (d) => { expect(camera._zooming).toBeFalsy(); expect(camera._panning).toBeFalsy(); expect(camera._rotating).toBeFalsy(); + expect(camera._pitching).toBeFalsy(); + expect(camera._rolling).toBeFalsy(); expect(movestarted).toBe('ok'); expect(moved).toBe('ok'); expect(zoomed).toBe('ok'); expect(rotated).toBe('ok'); expect(pitched).toBe('ok'); + expect(rolled).toBe('ok'); expect(d.data).toBe('ok'); }); @@ -2942,8 +3153,17 @@ describe('#flyTo globe projection', () => { expect(d.data).toBe('ok'); }); + camera + .on('rollstart', (d) => { rollstarted = d.data; }) + .on('roll', (d) => { rolled = d.data; }) + .on('rollend', (d) => { + expect(rollstarted).toBe('ok'); + expect(rolled).toBe('ok'); + expect(d.data).toBe('ok'); + }); + camera.flyTo( - {center: [100, 0], zoom: 3.2, bearing: 90, duration: 0, pitch: 45, animate: false}, + {center: [100, 0], zoom: 3.2, bearing: 90, duration: 0, pitch: 45, roll: 10, animate: false}, eventData); }); diff --git a/src/ui/camera.ts b/src/ui/camera.ts index 0a202bdffe..2a8600141f 100644 --- a/src/ui/camera.ts +++ b/src/ui/camera.ts @@ -37,7 +37,7 @@ export type RequireAtLeastOne = { [K in keyof T]-?: Required> & Pa /** * Options common to {@link Map#jumpTo}, {@link Map#easeTo}, and {@link Map#flyTo}, controlling the desired location, - * zoom, bearing, and pitch of the camera. All properties are optional, and when a property is omitted, the current + * zoom, bearing, pitch, and roll of the camera. All properties are optional, and when a property is omitted, the current * camera value for that property will remain unchanged. * * @example @@ -65,6 +65,10 @@ export type CameraOptions = CenterZoomBearing & { * Increasing the pitch value is often used to display 3D objects. */ pitch?: number; + /** + * The desired roll in degrees. The roll is the angle about the camera boresight. + */ + roll?: number; }; /** @@ -232,12 +236,14 @@ export type AnimationOptions = { export type CameraUpdateTransformFunction = (next: { center: LngLat; zoom: number; + roll: number; pitch: number; bearing: number; elevation: number; }) => { center?: LngLat; zoom?: number; + roll?: number; pitch?: number; bearing?: number; elevation?: number; @@ -253,6 +259,7 @@ export abstract class Camera extends Evented { _zooming: boolean; _rotating: boolean; _pitching: boolean; + _rolling: boolean; _padding: boolean; _bearingSnap: number; @@ -575,9 +582,9 @@ export abstract class Camera extends Evented { } /** - * Rotates and pitches the map so that north is up (0° bearing) and pitch is 0°, with an animated transition. + * Rotates and pitches the map so that north is up (0° bearing) and pitch and roll are 0°, with an animated transition. * - * Triggers the following events: `movestart`, `move`, `moveend`, `pitchstart`, `pitch`, `pitchend`, and `rotate`. + * Triggers the following events: `movestart`, `move`, `moveend`, `pitchstart`, `pitch`, `pitchend`, `rollstart`, `roll`, `rollend`, and `rotate`. * * @param options - Options object * @param eventData - Additional properties to be added to event objects of events triggered by this method. @@ -586,6 +593,7 @@ export abstract class Camera extends Evented { this.easeTo(extend({ bearing: 0, pitch: 0, + roll: 0, duration: 1000 }, options), eventData); return this; @@ -627,6 +635,26 @@ export abstract class Camera extends Evented { return this; } + /** + * Returns the map's current roll angle. + * + * @returns The map's current roll, measured in degrees about the camera boresight. + */ + getRoll(): number { return this.transform.roll; } + + /** + * Sets the map's roll angle. Equivalent to `jumpTo({roll: roll})`. + * + * Triggers the following events: `movestart`, `moveend`, `rollstart`, and `rollend`. + * + * @param roll - The roll to set, measured in degrees about the camera boresight + * @param eventData - Additional properties to be added to event objects of events triggered by this method. + */ + setRoll(roll: number, eventData?: any): this { + this.jumpTo({roll}, eventData); + return this; + } + /** * @param bounds - Calculate the center for these bounds in the viewport and use * the highest zoom level up to and including `Map#getMaxZoom()` that fits @@ -776,12 +804,12 @@ export abstract class Camera extends Evented { } /** - * Changes any combination of center, zoom, bearing, and pitch, without + * Changes any combination of center, zoom, bearing, pitch, and roll, without * an animated transition. The map will retain its current values for any * details not specified in `options`. * * Triggers the following events: `movestart`, `move`, `moveend`, `zoomstart`, `zoom`, `zoomend`, `pitchstart`, - * `pitch`, `pitchend`, and `rotate`. + * `pitch`, `pitchend`, `rollstart`, `roll`, `rollend` and `rotate`. * * @param options - Options object * @param eventData - Additional properties to be added to event objects of events triggered by this method. @@ -806,6 +834,7 @@ export abstract class Camera extends Evented { const tr = this._getTransformForUpdate(); let bearingChanged = false, pitchChanged = false; + let rollChanged = false; const oldZoom = tr.zoom; @@ -823,6 +852,11 @@ export abstract class Camera extends Evented { tr.setPitch(+options.pitch); } + if ('roll' in options && tr.roll !== +options.roll) { + rollChanged = true; + tr.setRoll(+options.roll); + } + if (options.padding != null && !tr.isPaddingEqual(options.padding)) { tr.setPadding(options.padding); } @@ -849,6 +883,12 @@ export abstract class Camera extends Evented { .fire(new Event('pitchend', eventData)); } + if (rollChanged) { + this.fire(new Event('rollstart', eventData)) + .fire(new Event('roll', eventData)) + .fire(new Event('rollend', eventData)); + } + return this.fire(new Event('moveend', eventData)); } @@ -887,7 +927,7 @@ export abstract class Camera extends Evented { } /** - * Changes any combination of `center`, `zoom`, `bearing`, `pitch`, and `padding` with an animated transition + * Changes any combination of `center`, `zoom`, `bearing`, `pitch`, `roll`, and `padding` with an animated transition * between old and new values. The map will retain its current values for any * details not specified in `options`. * @@ -896,7 +936,7 @@ export abstract class Camera extends Evented { * unless `options` includes `essential: true`. * * Triggers the following events: `movestart`, `move`, `moveend`, `zoomstart`, `zoom`, `zoomend`, `pitchstart`, - * `pitch`, `pitchend`, and `rotate`. + * `pitch`, `pitchend`, `rollstart`, `roll`, `rollend`, and `rotate`. * * @param options - Options describing the destination and animation of the transition. * Accepts {@link CameraOptions} and {@link AnimationOptions}. @@ -922,8 +962,10 @@ export abstract class Camera extends Evented { const tr = this._getTransformForUpdate(); const startBearing = this.getBearing(), startPitch = tr.pitch, + startRoll = tr.roll, bearing = 'bearing' in options ? this._normalizeBearing(options.bearing, startBearing) : startBearing, pitch = 'pitch' in options ? +options.pitch : startPitch, + roll = 'roll' in options ? this._normalizeBearing(options.roll, startRoll) : startRoll, padding = ('padding' in options ? options.padding : tr.padding) as PaddingOptions; const offsetAsPoint = Point.convert(options.offset); @@ -938,12 +980,14 @@ export abstract class Camera extends Evented { moving: this._moving, zooming: this._zooming, rotating: this._rotating, - pitching: this._pitching + pitching: this._pitching, + rolling: this._rolling }; const easeHandler = this.cameraHelper.handleEaseTo(tr, { bearing, pitch, + roll, padding, around, aroundPoint, @@ -955,6 +999,7 @@ export abstract class Camera extends Evented { this._rotating = this._rotating || (startBearing !== bearing); this._pitching = this._pitching || (pitch !== startPitch); + this._rolling = this._rolling || (roll !== startRoll); this._padding = !tr.isPaddingEqual(padding as PaddingOptions); this._zooming = this._zooming || easeHandler.isZooming; this._easeId = options.easeId; @@ -993,6 +1038,9 @@ export abstract class Camera extends Evented { if (this._pitching && !currently.pitching) { this.fire(new Event('pitchstart', eventData)); } + if (this._rolling && !currently.rolling) { + this.fire(new Event('rollstart', eventData)); + } } _prepareElevation(center: LngLat) { @@ -1088,12 +1136,14 @@ export abstract class Camera extends Evented { const { center, zoom, + roll, pitch, bearing, elevation } = modifier(nextTransform); if (center) nextTransform.setCenter(center); if (zoom !== undefined) nextTransform.setZoom(zoom); + if (roll !== undefined) nextTransform.setRoll(roll); if (pitch !== undefined) nextTransform.setPitch(pitch); if (bearing !== undefined) nextTransform.setBearing(bearing); if (elevation !== undefined) nextTransform.setElevation(elevation); @@ -1113,6 +1163,9 @@ export abstract class Camera extends Evented { if (this._pitching) { this.fire(new Event('pitch', eventData)); } + if (this._rolling) { + this.fire(new Event('roll', eventData)); + } } _afterEase(eventData?: any, easeId?: string) { @@ -1126,10 +1179,12 @@ export abstract class Camera extends Evented { const wasZooming = this._zooming; const wasRotating = this._rotating; const wasPitching = this._pitching; + const wasRolling = this._rolling; this._moving = false; this._zooming = false; this._rotating = false; this._pitching = false; + this._rolling = false; this._padding = false; if (wasZooming) { @@ -1141,11 +1196,14 @@ export abstract class Camera extends Evented { if (wasPitching) { this.fire(new Event('pitchend', eventData)); } + if (wasRolling) { + this.fire(new Event('rollend', eventData)); + } this.fire(new Event('moveend', eventData)); } /** - * Changes any combination of center, zoom, bearing, and pitch, animating the transition along a curve that + * Changes any combination of center, zoom, bearing, pitch, and roll, animating the transition along a curve that * evokes flight. The animation seamlessly incorporates zooming and panning to help * the user maintain her bearings even after traversing a great distance. * @@ -1154,7 +1212,7 @@ export abstract class Camera extends Evented { * unless 'options' includes `essential: true`. * * Triggers the following events: `movestart`, `move`, `moveend`, `zoomstart`, `zoom`, `zoomend`, `pitchstart`, - * `pitch`, `pitchend`, and `rotate`. + * `pitch`, `pitchend`, `rollstart`, `roll`, `rollend`, and `rotate`. * * @param options - Options describing the destination and animation of the transition. * Accepts {@link CameraOptions}, {@link AnimationOptions}, @@ -1182,7 +1240,7 @@ export abstract class Camera extends Evented { flyTo(options: FlyToOptions, eventData?: any): this { // Fall through to jumpTo if user has set prefers-reduced-motion if (!options.essential && browser.prefersReducedMotion) { - const coercedOptions = pick(options, ['center', 'zoom', 'bearing', 'pitch']) as CameraOptions; + const coercedOptions = pick(options, ['center', 'zoom', 'bearing', 'pitch', 'roll']) as CameraOptions; return this.jumpTo(coercedOptions, eventData); } @@ -1206,10 +1264,12 @@ export abstract class Camera extends Evented { const tr = this._getTransformForUpdate(), startBearing = tr.bearing, startPitch = tr.pitch, + startRoll = tr.roll, startPadding = tr.padding; const bearing = 'bearing' in options ? this._normalizeBearing(options.bearing, startBearing) : startBearing; const pitch = 'pitch' in options ? +options.pitch : startPitch; + const roll = 'roll' in options ? this._normalizeBearing(options.roll, startRoll) : startRoll; const padding = ('padding' in options ? options.padding : tr.padding) as PaddingOptions; const offsetAsPoint = Point.convert(options.offset); @@ -1219,6 +1279,7 @@ export abstract class Camera extends Evented { const flyToHandler = this.cameraHelper.handleFlyTo(tr, { bearing, pitch, + roll, padding, locationAtOffset, offsetAsPoint, @@ -1305,6 +1366,7 @@ export abstract class Camera extends Evented { this._zooming = true; this._rotating = (startBearing !== bearing); this._pitching = (pitch !== startPitch); + this._rolling = (roll !== startRoll); this._padding = !tr.isPaddingEqual(padding as PaddingOptions); this._prepareEase(eventData, false); @@ -1321,6 +1383,9 @@ export abstract class Camera extends Evented { if (this._pitching) { tr.setPitch(interpolates.number(startPitch, pitch, k)); } + if (this._rolling) { + tr.setRoll(interpolates.number(startRoll, roll, k)); + } if (this._padding) { tr.interpolatePadding(startPadding, padding as PaddingOptions, k); // When padding is being applied, Transform#centerPoint is changing continuously, diff --git a/src/ui/control/navigation_control.ts b/src/ui/control/navigation_control.ts index 2546948886..a3eaf19517 100644 --- a/src/ui/control/navigation_control.ts +++ b/src/ui/control/navigation_control.ts @@ -24,12 +24,17 @@ type NavigationControlOptions = { * If `true` the pitch is visualized by rotating X-axis of compass. */ visualizePitch?: boolean; + /** + * If `true` the roll is visualized by rotating the compass. + */ + visualizeRoll?: boolean; }; const defaultOptions: NavigationControlOptions = { showCompass: true, showZoom: true, - visualizePitch: false + visualizePitch: false, + visualizeRoll: true }; /** @@ -93,11 +98,19 @@ export class NavigationControl implements IControl { }; _rotateCompassArrow = () => { - const rotate = this.options.visualizePitch ? - `scale(${1 / Math.pow(Math.cos(this._map.transform.pitch * (Math.PI / 180)), 0.5)}) rotateX(${this._map.transform.pitch}deg) rotateZ(${this._map.transform.angle * (180 / Math.PI)}deg)` : - `rotate(${this._map.transform.angle * (180 / Math.PI)}deg)`; - - this._compassIcon.style.transform = rotate; + if (this.options.visualizePitch && this.options.visualizeRoll) { + this._compassIcon.style.transform = `scale(${1 / Math.pow(Math.cos(this._map.transform.pitchInRadians), 0.5)}) rotateZ(${-this._map.transform.roll}deg) rotateX(${this._map.transform.pitch}deg) rotateZ(${-this._map.transform.bearing}deg)`; + return; + } + if (this.options.visualizePitch) { + this._compassIcon.style.transform = `scale(${1 / Math.pow(Math.cos(this._map.transform.pitchInRadians), 0.5)}) rotateX(${this._map.transform.pitch}deg) rotateZ(${-this._map.transform.bearing}deg)`; + return; + } + if (this.options.visualizeRoll) { + this._compassIcon.style.transform = `rotate(${-this._map.transform.bearing - this._map.transform.roll}deg)`; + return; + } + this._compassIcon.style.transform = `rotate(${-this._map.transform.bearing}deg)`; }; /** {@inheritDoc IControl.onAdd} */ @@ -114,6 +127,9 @@ export class NavigationControl implements IControl { if (this.options.visualizePitch) { this._map.on('pitch', this._rotateCompassArrow); } + if (this.options.visualizeRoll) { + this._map.on('roll', this._rotateCompassArrow); + } this._map.on('rotate', this._rotateCompassArrow); this._rotateCompassArrow(); this._handler = new MouseRotateWrapper(this._map, this._compass, this.options.visualizePitch); @@ -131,6 +147,9 @@ export class NavigationControl implements IControl { if (this.options.visualizePitch) { this._map.off('pitch', this._rotateCompassArrow); } + if (this.options.visualizeRoll) { + this._map.off('roll', this._rotateCompassArrow); + } this._map.off('rotate', this._rotateCompassArrow); this._handler.off(); delete this._handler; diff --git a/src/ui/handler/drag_handler.ts b/src/ui/handler/drag_handler.ts index 7f08be1118..c440be1069 100644 --- a/src/ui/handler/drag_handler.ts +++ b/src/ui/handler/drag_handler.ts @@ -6,6 +6,7 @@ import {Handler} from '../handler_manager'; interface DragMovementResult { bearingDelta?: number; pitchDelta?: number; + rollDelta?: number; around?: Point; panDelta?: Point; } @@ -23,6 +24,10 @@ export interface DragPitchResult extends DragMovementResult { pitchDelta: number; } +export interface DragRollResult extends DragMovementResult { + rollDelta: number; +} + type DragMoveFunction = (lastPoint: Point, point: Point) => T; export interface DragMoveHandler extends Handler { @@ -103,7 +108,7 @@ export class DragHandler implemen _move(...params: Parameters>) { const move = this._moveFunction(...params); - if (move.bearingDelta || move.pitchDelta || move.around || move.panDelta) { + if (move.bearingDelta || move.pitchDelta || move.rollDelta || move.around || move.panDelta) { this._active = true; return move; } diff --git a/src/ui/handler/drag_rotate.test.ts b/src/ui/handler/drag_rotate.test.ts index 6155d24984..a2203b41bf 100644 --- a/src/ui/handler/drag_rotate.test.ts +++ b/src/ui/handler/drag_rotate.test.ts @@ -74,6 +74,41 @@ describe('drag rotate', () => { map.remove(); }); + test('DragRotateHandler fires rollstart, roll, and rollend events at appropriate times in response to a Ctrl-right-click drag', () => { + const map = createMap({rollEnabled: true}); + + // Prevent inertial rotation. + jest.spyOn(browser, 'now').mockReturnValue(0); + + const rollstart = jest.fn(); + const roll = jest.fn(); + const rollend = jest.fn(); + + map.on('rollstart', rollstart); + map.on('roll', roll); + map.on('rollend', rollend); + + simulate.mousedown(map.getCanvas(), {buttons: 2, button: 2, ctrlKey: true}); + map._renderTaskQueue.run(); + expect(rollstart).toHaveBeenCalledTimes(0); + expect(roll).toHaveBeenCalledTimes(0); + expect(rollend).toHaveBeenCalledTimes(0); + + simulate.mousemove(map.getCanvas(), {buttons: 2, clientX: 10, clientY: 10}); + map._renderTaskQueue.run(); + expect(rollstart).toHaveBeenCalledTimes(1); + expect(roll).toHaveBeenCalledTimes(1); + expect(rollend).toHaveBeenCalledTimes(0); + + simulate.mouseup(map.getCanvas(), {buttons: 0, button: 2}); + map._renderTaskQueue.run(); + expect(rollstart).toHaveBeenCalledTimes(1); + expect(roll).toHaveBeenCalledTimes(1); + expect(rollend).toHaveBeenCalledTimes(1); + + map.remove(); + }); + test('DragRotateHandler stops firing events after mouseup', () => { const map = createMap(); diff --git a/src/ui/handler/mouse.ts b/src/ui/handler/mouse.ts index 3b327ef0a0..0fa8431b5e 100644 --- a/src/ui/handler/mouse.ts +++ b/src/ui/handler/mouse.ts @@ -1,7 +1,7 @@ import type Point from '@mapbox/point-geometry'; import {DOM} from '../../util/dom'; -import {DragMoveHandler, DragPanResult, DragRotateResult, DragPitchResult, DragHandler} from './drag_handler'; +import {DragMoveHandler, DragPanResult, DragRotateResult, DragPitchResult, DragHandler, DragRollResult} from './drag_handler'; import {MouseMoveStateManager} from './drag_move_state_manager'; /** @@ -16,6 +16,10 @@ export interface MouseRotateHandler extends DragMoveHandler {} +/** + * `MouseRollHandler` allows the user to roll the camera by holding `Ctrl`, right-clicking and dragging + */ +export interface MouseRollHandler extends DragMoveHandler {} const LEFT_BUTTON = 0; const RIGHT_BUTTON = 2; @@ -55,7 +59,7 @@ export const generateMouseRotationHandler = ({enable, clickTolerance, bearingDeg const mouseMoveStateManager = new MouseMoveStateManager({ checkCorrectEvent: (e: MouseEvent): boolean => (DOM.mouseButton(e) === LEFT_BUTTON && e.ctrlKey) || - (DOM.mouseButton(e) === RIGHT_BUTTON), + (DOM.mouseButton(e) === RIGHT_BUTTON && !e.ctrlKey), }); return new DragHandler({ clickTolerance, @@ -90,3 +94,24 @@ export const generateMousePitchHandler = ({enable, clickTolerance, pitchDegreesP assignEvents, }); }; + +export const generateMouseRollHandler = ({enable, clickTolerance, rollDegreesPerPixelMoved = 0.8}: { + clickTolerance: number; + rollDegreesPerPixelMoved?: number; + enable?: boolean; +}): MouseRollHandler => { + const mouseMoveStateManager = new MouseMoveStateManager({ + checkCorrectEvent: (e: MouseEvent): boolean => + (DOM.mouseButton(e) === RIGHT_BUTTON && e.ctrlKey), + }); + return new DragHandler({ + clickTolerance, + move: (lastPoint: Point, point: Point) => + ({rollDelta: (point.x - lastPoint.x) * rollDegreesPerPixelMoved}), + // prevent browser context menu when necessary; we don't allow it with roll + // because we can't discern roll gesture start from contextmenu on Mac + moveStateManager: mouseMoveStateManager, + enable, + assignEvents, + }); +}; diff --git a/src/ui/handler/mouse_handler_interface.test.ts b/src/ui/handler/mouse_handler_interface.test.ts index 6220cb6b56..b9dedf250c 100644 --- a/src/ui/handler/mouse_handler_interface.test.ts +++ b/src/ui/handler/mouse_handler_interface.test.ts @@ -1,6 +1,6 @@ import Point from '@mapbox/point-geometry'; -import {generateMousePanHandler, generateMousePitchHandler, generateMouseRotationHandler} from './mouse'; +import {generateMousePanHandler, generateMousePitchHandler, generateMouseRollHandler, generateMouseRotationHandler} from './mouse'; describe('mouse handler tests', () => { test('MouseRotateHandler', () => { @@ -108,4 +108,39 @@ describe('mouse handler tests', () => { expect(mousePan.dragMove(overToleranceMove, new Point(10, 10))).toBeUndefined(); expect(mousePan.isActive()).toBe(false); }); + + test('MouseRollHandler', () => { + const mouseRoll = generateMouseRollHandler({clickTolerance: 2}); + + expect(mouseRoll.isActive()).toBe(false); + expect(mouseRoll.isEnabled()).toBe(false); + mouseRoll.enable(); + expect(mouseRoll.isEnabled()).toBe(true); + + mouseRoll.dragStart(new MouseEvent('mousedown', {buttons: 2, button: 2, ctrlKey: true}), new Point(0, 0)); + expect(mouseRoll.isActive()).toBe(false); + + const underToleranceMove = new MouseEvent('mousemove', {buttons: 2, clientX: 1, clientY: 1}); + expect(mouseRoll.dragMove(underToleranceMove, new Point(1, 1))).toBeUndefined(); + expect(mouseRoll.isActive()).toBe(false); + + const overToleranceMove = new MouseEvent('mousemove', {buttons: 2, clientX: 10, clientY: 10}); + expect(mouseRoll.dragMove(overToleranceMove, new Point(10, 10))).toEqual({'rollDelta': 8}); + expect(mouseRoll.isActive()).toBe(true); + + mouseRoll.dragEnd(new MouseEvent('mouseup', {buttons: 0, button: 2})); + expect(mouseRoll.isActive()).toBe(false); + + mouseRoll.disable(); + expect(mouseRoll.isEnabled()).toBe(false); + + mouseRoll.dragStart(new MouseEvent('mousedown', {buttons: 2, button: 2}), new Point(0, 0)); + expect(mouseRoll.isActive()).toBe(false); + + expect(mouseRoll.dragMove(underToleranceMove, new Point(1, 1))).toBeUndefined(); + expect(mouseRoll.isActive()).toBe(false); + + expect(mouseRoll.dragMove(overToleranceMove, new Point(10, 10))).toBeUndefined(); + expect(mouseRoll.isActive()).toBe(false); + }); }); diff --git a/src/ui/handler/shim/drag_rotate.ts b/src/ui/handler/shim/drag_rotate.ts index ec3c1bf248..f10b31a762 100644 --- a/src/ui/handler/shim/drag_rotate.ts +++ b/src/ui/handler/shim/drag_rotate.ts @@ -1,4 +1,4 @@ -import type {MousePitchHandler, MouseRotateHandler} from '../mouse'; +import type {MousePitchHandler, MouseRollHandler, MouseRotateHandler} from '../mouse'; /** * Options object for `DragRotateHandler`. @@ -9,6 +9,11 @@ export type DragRotateHandlerOptions = { * @defaultValue true */ pitchWithRotate: boolean; + /** + * Control the map roll in addition to the bearing + * @defaultValue false + */ + rollEnabled: boolean; } /** @@ -21,13 +26,17 @@ export class DragRotateHandler { _mouseRotate: MouseRotateHandler; _mousePitch: MousePitchHandler; + _mouseRoll: MouseRollHandler; _pitchWithRotate: boolean; + _rollEnabled: boolean; /** @internal */ - constructor(options: DragRotateHandlerOptions, mouseRotate: MouseRotateHandler, mousePitch: MousePitchHandler) { + constructor(options: DragRotateHandlerOptions, mouseRotate: MouseRotateHandler, mousePitch: MousePitchHandler, mouseRoll: MouseRollHandler) { this._pitchWithRotate = options.pitchWithRotate; + this._rollEnabled = options.rollEnabled; this._mouseRotate = mouseRotate; this._mousePitch = mousePitch; + this._mouseRoll = mouseRoll; } /** @@ -41,6 +50,7 @@ export class DragRotateHandler { enable() { this._mouseRotate.enable(); if (this._pitchWithRotate) this._mousePitch.enable(); + if (this._rollEnabled) this._mouseRoll.enable(); } /** @@ -54,6 +64,7 @@ export class DragRotateHandler { disable() { this._mouseRotate.disable(); this._mousePitch.disable(); + this._mouseRoll.disable(); } /** @@ -62,7 +73,7 @@ export class DragRotateHandler { * @returns `true` if the "drag to rotate" interaction is enabled. */ isEnabled() { - return this._mouseRotate.isEnabled() && (!this._pitchWithRotate || this._mousePitch.isEnabled()); + return this._mouseRotate.isEnabled() && (!this._pitchWithRotate || this._mousePitch.isEnabled()) && (!this._rollEnabled || this._mouseRoll.isEnabled()); } /** @@ -71,6 +82,6 @@ export class DragRotateHandler { * @returns `true` if the "drag to rotate" interaction is active. */ isActive() { - return this._mouseRotate.isActive() || this._mousePitch.isActive(); + return this._mouseRotate.isActive() || this._mousePitch.isActive() || this._mouseRoll.isActive(); } } diff --git a/src/ui/handler_inertia.ts b/src/ui/handler_inertia.ts index 69f0b24767..6eba214b14 100644 --- a/src/ui/handler_inertia.ts +++ b/src/ui/handler_inertia.ts @@ -30,6 +30,11 @@ const defaultPitchInertiaOptions = extend({ maxSpeed: 90 }, defaultInertiaOptions); +const defaultRollInertiaOptions = extend({ + deceleration: 1000, + maxSpeed: 360 +}, defaultInertiaOptions); + export type InertiaOptions = { linearity: number; easing: (t: number) => number; @@ -77,6 +82,7 @@ export class HandlerInertia { zoom: 0, bearing: 0, pitch: 0, + roll: 0, pan: new Point(0, 0), pinchAround: undefined, around: undefined @@ -86,6 +92,7 @@ export class HandlerInertia { deltas.zoom += settings.zoomDelta || 0; deltas.bearing += settings.bearingDelta || 0; deltas.pitch += settings.pitchDelta || 0; + deltas.roll += settings.rollDelta || 0; if (settings.panDelta) deltas.pan._add(settings.panDelta); if (settings.around) deltas.around = settings.around; if (settings.pinchAround) deltas.pinchAround = settings.pinchAround; @@ -123,6 +130,12 @@ export class HandlerInertia { extendDuration(easeOptions, result); } + if (deltas.roll) { + const result = calculateEasing(deltas.roll, duration, defaultRollInertiaOptions); + easeOptions.roll = this._map.transform.roll + clamp(result.amount, -179, 179); + extendDuration(easeOptions, result); + } + if (easeOptions.zoom || easeOptions.bearing) { const last = deltas.pinchAround === undefined ? deltas.around : deltas.pinchAround; easeOptions.around = last ? this._map.unproject(last) : this._map.getCenter(); diff --git a/src/ui/handler_manager.ts b/src/ui/handler_manager.ts index 49c9a562dd..aac62cc90b 100644 --- a/src/ui/handler_manager.ts +++ b/src/ui/handler_manager.ts @@ -5,7 +5,7 @@ import {HandlerInertia} from './handler_inertia'; import {MapEventHandler, BlockableMapEventHandler} from './handler/map_event'; import {BoxZoomHandler} from './handler/box_zoom'; import {TapZoomHandler} from './handler/tap_zoom'; -import {generateMouseRotationHandler, generateMousePitchHandler, generateMousePanHandler} from './handler/mouse'; +import {generateMouseRotationHandler, generateMousePitchHandler, generateMousePanHandler, generateMouseRollHandler} from './handler/mouse'; import {TouchPanHandler} from './handler/touch_pan'; import {TwoFingersTouchZoomHandler, TwoFingersTouchRotateHandler, TwoFingersTouchPitchHandler} from './handler/two_fingers_touch'; import {KeyboardHandler} from './handler/keyboard'; @@ -22,7 +22,7 @@ import {browser} from '../util/browser'; import Point from '@mapbox/point-geometry'; import {MapControlsDeltas} from '../geo/projection/camera_helper'; -const isMoving = (p: EventsInProgress) => p.zoom || p.drag || p.pitch || p.rotate; +const isMoving = (p: EventsInProgress) => p.zoom || p.drag || p.roll || p.pitch || p.rotate; class RenderFrameEvent extends Event { type: 'renderFrame'; @@ -83,6 +83,7 @@ export type HandlerResult = { zoomDelta?: number; bearingDelta?: number; pitchDelta?: number; + rollDelta?: number; /** * the point to not move when changing the camera */ @@ -117,13 +118,14 @@ export type EventInProgress = { export type EventsInProgress = { zoom?: EventInProgress; + roll?: EventInProgress; pitch?: EventInProgress; rotate?: EventInProgress; drag?: EventInProgress; } function hasChange(result: HandlerResult) { - return (result.panDelta && result.panDelta.mag()) || result.zoomDelta || result.bearingDelta || result.pitchDelta; + return (result.panDelta && result.panDelta.mag()) || result.zoomDelta || result.bearingDelta || result.pitchDelta || result.rollDelta; } export class HandlerManager { @@ -254,9 +256,11 @@ export class HandlerManager { const mouseRotate = generateMouseRotationHandler(options); const mousePitch = generateMousePitchHandler(options); - map.dragRotate = new DragRotateHandler(options, mouseRotate, mousePitch); + const mouseRoll = generateMouseRollHandler(options); + map.dragRotate = new DragRotateHandler(options, mouseRotate, mousePitch, mouseRoll); this._add('mouseRotate', mouseRotate, ['mousePitch']); - this._add('mousePitch', mousePitch, ['mouseRotate']); + this._add('mousePitch', mousePitch, ['mouseRotate', 'mouseRoll']); + this._add('mouseRoll', mouseRoll, ['mousePitch']); if (options.interactive && options.dragRotate) { map.dragRotate.enable(); } @@ -448,6 +452,9 @@ export class HandlerManager { if (handlerResult.panDelta !== undefined) { eventsInProgress.drag = eventData; } + if (handlerResult.rollDelta !== undefined) { + eventsInProgress.roll = eventData; + } if (handlerResult.pitchDelta !== undefined) { eventsInProgress.pitch = eventData; } @@ -468,6 +475,7 @@ export class HandlerManager { if (change.zoomDelta) combined.zoomDelta = (combined.zoomDelta || 0) + change.zoomDelta; if (change.bearingDelta) combined.bearingDelta = (combined.bearingDelta || 0) + change.bearingDelta; if (change.pitchDelta) combined.pitchDelta = (combined.pitchDelta || 0) + change.pitchDelta; + if (change.rollDelta) combined.rollDelta = (combined.rollDelta || 0) + change.rollDelta; if (change.around !== undefined) combined.around = change.around; if (change.pinchAround !== undefined) combined.pinchAround = change.pinchAround; if (change.noInertia) combined.noInertia = change.noInertia; @@ -494,7 +502,7 @@ export class HandlerManager { // stop any ongoing camera animations (easeTo, flyTo) map._stop(true); - let {panDelta, zoomDelta, bearingDelta, pitchDelta, around, pinchAround} = combinedResult; + let {panDelta, zoomDelta, bearingDelta, pitchDelta, rollDelta, around, pinchAround} = combinedResult; if (pinchAround !== undefined) { around = pinchAround; @@ -509,6 +517,7 @@ export class HandlerManager { const deltasForHelper: MapControlsDeltas = { panDelta, zoomDelta, + rollDelta, pitchDelta, bearingDelta, around, @@ -521,13 +530,13 @@ export class HandlerManager { const preZoomAroundLoc = tr.screenPointToLocation(panDelta ? around.sub(panDelta) : around); if (!terrain) { - // Apply zoom, bearing, pitch - this._map.cameraHelper.handleMapControlsPitchBearingZoom(deltasForHelper, tr); + // Apply zoom, bearing, pitch, roll + this._map.cameraHelper.handleMapControlsRollPitchBearingZoom(deltasForHelper, tr); // Apply panning this._map.cameraHelper.handleMapControlsPan(deltasForHelper, tr, preZoomAroundLoc); } else { - // Apply zoom, bearing, pitch - this._map.cameraHelper.handleMapControlsPitchBearingZoom(deltasForHelper, tr); + // Apply zoom, bearing, pitch, roll + this._map.cameraHelper.handleMapControlsRollPitchBearingZoom(deltasForHelper, tr); // when 3d-terrain is enabled act a little different: // - dragging do not drag the picked point itself, instead it drags the map by pixel-delta. // With this approach it is no longer possible to pick a point from somewhere near diff --git a/src/ui/map.ts b/src/ui/map.ts index e1e50525bd..ef1bcc087f 100644 --- a/src/ui/map.ts +++ b/src/ui/map.ts @@ -1,4 +1,4 @@ -import {extend, warnOnce, uniqueId, isImageBitmap, Complete} from '../util/util'; +import {extend, warnOnce, uniqueId, isImageBitmap, Complete, pick} from '../util/util'; import {browser} from '../util/browser'; import {DOM} from '../util/dom'; import packageJSON from '../../package.json' with {type: 'json'}; @@ -227,6 +227,11 @@ export type MapOptions = { * @defaultValue 0 */ pitch?: number; + /** + * The initial roll angle of the map, measured in degrees counter-clockwise about the camera boresight. If `roll` is not specified in the constructor options, MapLibre GL JS will look for it in the map's style object. If it is not specified in the style, either, it will default to `0`. + * @defaultValue 0 + */ + roll?: number; /** * If `true`, multiple copies of the world will be rendered side by side beyond -180 and 180 degrees longitude. If set to `false`: * @@ -313,6 +318,11 @@ export type MapOptions = { * @defaultValue true */ pitchWithRotate?: boolean; + /** + * If `false`, the map's roll control with "drag to rotate" interaction will be disabled. + * @defaultValue false + */ + rollEnabled?: boolean; /** * The pixel ratio. * The canvas' `width` attribute will be `container.clientWidth * pixelRatio` and its `height` attribute will be `container.clientHeight * pixelRatio`. Defaults to `devicePixelRatio` if not specified. @@ -394,6 +404,7 @@ const defaultOptions: Readonly> = { zoom: 0, bearing: 0, pitch: 0, + roll: 0, renderWorldCopies: true, maxTileCacheSize: null, @@ -405,6 +416,7 @@ const defaultOptions: Readonly> = { clickTolerance: 3, localIdeographFontFamily: 'sans-serif', pitchWithRotate: true, + rollEnabled: false, validateStyle: true, /**Because GL MAX_TEXTURE_SIZE is usually at least 4096px. */ maxCanvasSize: [4096, 4096], @@ -687,7 +699,8 @@ export class Map extends Camera { center: resolvedOptions.center, zoom: resolvedOptions.zoom, bearing: resolvedOptions.bearing, - pitch: resolvedOptions.pitch + pitch: resolvedOptions.pitch, + roll: resolvedOptions.roll }); if (resolvedOptions.bounds) { @@ -711,7 +724,8 @@ export class Map extends Camera { this.on('style.load', () => { if (this.transform.unmodified) { - this.jumpTo(this.style.stylesheet as any); + const coercedOptions = pick(this.style.stylesheet, ['center', 'zoom', 'bearing', 'pitch', 'roll']) as CameraOptions; + this.jumpTo(coercedOptions); } }); this.on('data', (event: MapDataEvent) => { diff --git a/src/util/util.test.ts b/src/util/util.test.ts index 82a9e0de0c..a57c29fcf1 100644 --- a/src/util/util.test.ts +++ b/src/util/util.test.ts @@ -1,5 +1,5 @@ import Point from '@mapbox/point-geometry'; -import {arraysIntersect, bezier, clamp, clone, deepEqual, easeCubicInOut, extend, filterObject, findLineIntersection, isCounterClockwise, isPowerOfTwo, keysDifference, mapObject, nextPowerOfTwo, parseCacheControl, pick, readImageDataUsingOffscreenCanvas, readImageUsingVideoFrame, uniqueId, wrap, mod, distanceOfAnglesRadians, distanceOfAnglesDegrees, differenceOfAnglesRadians, differenceOfAnglesDegrees, solveQuadratic, remapSaturate} from './util'; +import {arraysIntersect, bezier, clamp, clone, deepEqual, easeCubicInOut, extend, filterObject, findLineIntersection, isCounterClockwise, isPowerOfTwo, keysDifference, mapObject, nextPowerOfTwo, parseCacheControl, pick, readImageDataUsingOffscreenCanvas, readImageUsingVideoFrame, uniqueId, wrap, mod, distanceOfAnglesRadians, distanceOfAnglesDegrees, differenceOfAnglesRadians, differenceOfAnglesDegrees, solveQuadratic, remapSaturate, radiansToDegrees, degreesToRadians, rollPitchBearingToQuat, getRollPitchBearing} from './util'; import {Canvas} from 'canvas'; describe('util', () => { @@ -120,6 +120,14 @@ describe('util', () => { expect(mod(-1, 3)).toBe(2); }); + test('degreesToRadians', () => { + expect(degreesToRadians(1.0)).toBe(Math.PI / 180.0); + }); + + test('radiansToDegrees', () => { + expect(radiansToDegrees(1.0)).toBe(180.0 / Math.PI); + }); + test('distanceOfAnglesRadians', () => { const digits = 10; expect(distanceOfAnglesRadians(0, 1)).toBeCloseTo(1, digits); @@ -465,3 +473,30 @@ describe('util readImageDataUsingOffscreenCanvas', () => { ]); }); }); + +describe('util rotations', () => { + test('rollPitchBearingToQuat', () => { + const roll = 10; + const pitch = 20; + const bearing = 30; + + const rotation = rollPitchBearingToQuat(roll, pitch, bearing); + const angles = getRollPitchBearing(rotation); + + expect(angles.roll).toBeCloseTo(roll, 6); + expect(angles.pitch).toBeCloseTo(pitch, 6); + expect(angles.bearing).toBeCloseTo(bearing, 6); + }); + + test('rollPitchBearingToQuat sinuglarity', () => { + const roll = 10; + const pitch = 0; + const bearing = 30; + + const rotation = rollPitchBearingToQuat(roll, pitch, bearing); + const angles = getRollPitchBearing(rotation); + + expect(angles.pitch).toBeCloseTo(0, 5); + expect(wrap(angles.bearing + angles.roll, -180, 180)).toBeCloseTo(wrap(bearing + roll, -180, 180), 6); + }); +}); diff --git a/src/util/util.ts b/src/util/util.ts index ab0a4dc019..d849e31b34 100644 --- a/src/util/util.ts +++ b/src/util/util.ts @@ -3,7 +3,7 @@ import UnitBezier from '@mapbox/unitbezier'; import {isOffscreenCanvasDistorted} from './offscreen_canvas_distorted'; import type {Size} from './image'; import type {WorkerGlobalScopeInterface} from './web_worker'; -import {mat4, vec3, vec4} from 'gl-matrix'; +import {mat3, mat4, quat, vec3, vec4} from 'gl-matrix'; import {pixelsToTileUnits} from '../source/pixels_to_tile_units'; import {OverscaledTileID} from '../source/tile_id'; @@ -33,7 +33,7 @@ export function createIdentityMat4f64(): mat4 { * @param inViewportPixelUnitsUnits - True when the units accepted by the matrix are in viewport pixels instead of tile units. */ export function translatePosition( - transform: { angle: number; zoom: number }, + transform: { bearingInRadians: number; zoom: number }, tile: { tileID: OverscaledTileID; tileSize: number }, translate: [number, number], translateAnchor: 'map' | 'viewport', @@ -42,8 +42,8 @@ export function translatePosition( if (!translate[0] && !translate[1]) return [0, 0]; const angle = inViewportPixelUnitsUnits ? - (translateAnchor === 'map' ? transform.angle : 0) : - (translateAnchor === 'viewport' ? -transform.angle : 0); + (translateAnchor === 'map' ? -transform.bearingInRadians : 0) : + (translateAnchor === 'viewport' ? transform.bearingInRadians : 0); if (angle) { const sinA = Math.sin(angle); @@ -882,6 +882,58 @@ export function degreesToRadians(degrees: number): number { return degrees * Math.PI / 180; } +/** + * This method converts radians to degrees. + * The return value is the degrees value. + * @param degrees - The number of radians + * @returns degrees + */ +export function radiansToDegrees(degrees: number): number { + return degrees / Math.PI * 180; +} + +export type RollPitchBearing = { + roll: number; + pitch: number; + bearing: number; +}; + +/** + * This method converts a rotation quaternion to roll, pitch, and bearing angles in degrees. + * @param rotation - The rotation quaternion + * @returns roll, pitch, and bearing angles in degrees + */ +export function getRollPitchBearing(rotation: quat): RollPitchBearing { + const m: mat3 = new Float64Array(9) as any; + mat3.fromQuat(m, rotation); + + const xAngle = radiansToDegrees(-Math.asin(clamp(m[2], -1, 1))); + let roll: number; + let bearing: number; + if (Math.hypot(m[5], m[8]) < 1.0e-3) { + roll = 0.0; + bearing = -radiansToDegrees(Math.atan2(m[3], m[4])); + } else { + roll = radiansToDegrees((m[5] === 0.0 && m[8] === 0.0) ? 0.0 : Math.atan2(m[5], m[8])); + bearing = radiansToDegrees((m[1] === 0.0 && m[0] === 0.0) ? 0.0 : Math.atan2(m[1], m[0])); + } + + return {roll, pitch: xAngle + 90.0, bearing}; +} + +/** + * This method converts roll, pitch, and bearing angles in degrees to a rotation quaternion. + * @param roll - Roll angle in degrees + * @param pitch - Pitch angle in degrees + * @param bearing - Bearing angle in degrees + * @returns The rotation quaternion + */ +export function rollPitchBearingToQuat(roll: number, pitch: number, bearing: number): quat { + const rotation: quat = new Float64Array(4) as any; + quat.fromEuler(rotation, roll, pitch - 90.0, bearing); + return rotation; +} + /** * Makes optional keys required and add the the undefined type. * diff --git a/test/build/min.test.ts b/test/build/min.test.ts index 6ab8fe9bdf..47b07a7ed9 100644 --- a/test/build/min.test.ts +++ b/test/build/min.test.ts @@ -36,7 +36,7 @@ describe('test min build', () => { const decreaseQuota = 4096; // feel free to update this value after you've checked that it has changed on purpose :-) - const expectedBytes = 879615; + const expectedBytes = 886914; expect(actualBytes).toBeLessThan(expectedBytes + increaseQuota); expect(actualBytes).toBeGreaterThan(expectedBytes - decreaseQuota); diff --git a/test/examples/navigation.html b/test/examples/navigation.html index 53c9f65ab6..2fd9116dca 100644 --- a/test/examples/navigation.html +++ b/test/examples/navigation.html @@ -20,11 +20,17 @@ style: 'https://api.maptiler.com/maps/streets/style.json?key=get_your_own_OpIi9ZULNHzrESv6T2vL', center: [-74.5, 40], // starting position - zoom: 9 // starting zoom + zoom: 9, // starting zoom + rollEnabled: true // Enable mouse control of camera roll angle with `Ctrl` + right-click and drag }); // Add zoom and rotation controls to the map. - map.addControl(new maplibregl.NavigationControl()); + map.addControl(new maplibregl.NavigationControl({ + visualizePitch: true, + visualizeRoll: true, + showZoom: true, + showCompass: true + })); \ No newline at end of file diff --git a/test/integration/render/tests/icon-roll-alignment/auto-rotation-alignment-map/expected.png b/test/integration/render/tests/icon-roll-alignment/auto-rotation-alignment-map/expected.png new file mode 100644 index 0000000000000000000000000000000000000000..bc926174731bd9caf7aa5eab9d4b9908f93d252b GIT binary patch literal 368 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7T#lEU^Mr1aSW+oe0p%d*R({L;}6gO z{iN72t&MH*(u2Yo8vF}wE9MKRd}NT3nG>7E-I`9M3eK0&v(j1 z{^|atrf2cC2WpaU#-ys5=B{!bWZSJkV{5XI1`?&aE2d>&%))7m$Z|C1@#e7av z!F5}`jQ!k;_FE+Tk7Y)^+510lZRxuiA~&pTY99F3-dC0G}?O4J<(F z)gGziFO`oj@yPyjCqnXHkWbed{qz1i*opLj${pRBQ})}q_jYLlLz2PM)z4*}Q$iB} DPOzHg literal 0 HcmV?d00001 diff --git a/test/integration/render/tests/icon-roll-alignment/auto-rotation-alignment-map/style.json b/test/integration/render/tests/icon-roll-alignment/auto-rotation-alignment-map/style.json new file mode 100644 index 0000000000..7af8e57146 --- /dev/null +++ b/test/integration/render/tests/icon-roll-alignment/auto-rotation-alignment-map/style.json @@ -0,0 +1,37 @@ +{ + "version": 8, + "metadata": { + "test": { + "width": 64, + "height": 64 + } + }, + "bearing": 45, + "pitch": 45, + "roll": 45, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "Point", + "coordinates": [ + 0, + 0 + ] + } + } + }, + "sprite": "local://sprites/sprite", + "layers": [ + { + "id": "symbol", + "type": "symbol", + "source": "geojson", + "layout": { + "symbol-placement": "point", + "icon-rotation-alignment": "map", + "icon-image": "oneway" + } + } + ] +} diff --git a/test/integration/render/tests/icon-roll-alignment/auto-rotation-alignment-viewport/expected.png b/test/integration/render/tests/icon-roll-alignment/auto-rotation-alignment-viewport/expected.png new file mode 100644 index 0000000000000000000000000000000000000000..c38cf492db08b45c597bcfcac51653006538d560 GIT binary patch literal 223 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=Or9=|Ar*{or0OmKc{4z~w+9`$ znj9F~F5Z8Wl-m-e*{Y~m()@v~tkuS0_Z^LOcVGHGlQZ&@UFgcgT>G9As0#}IIj&)_ ziMu;x$xO-idnYzWFj~y{^gK;C{`>Ko=@7BW9`j6cpMP0ey7l|*r!l9SJs2}?oqKuM z^6WLe(onIZTe*7E3>KKF3=9X(?`2%SORF$%n%{hosHdx+%Q~loCIJ5{ BQ(*uA literal 0 HcmV?d00001 diff --git a/test/integration/render/tests/icon-roll-alignment/auto-rotation-alignment-viewport/style.json b/test/integration/render/tests/icon-roll-alignment/auto-rotation-alignment-viewport/style.json new file mode 100644 index 0000000000..f181d95576 --- /dev/null +++ b/test/integration/render/tests/icon-roll-alignment/auto-rotation-alignment-viewport/style.json @@ -0,0 +1,36 @@ +{ + "version": 8, + "metadata": { + "test": { + "width": 64, + "height": 64 + } + }, + "bearing": 45, + "roll": 45, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "Point", + "coordinates": [ + 0, + 0 + ] + } + } + }, + "sprite": "local://sprites/sprite", + "layers": [ + { + "id": "symbol", + "type": "symbol", + "source": "geojson", + "layout": { + "symbol-placement": "point", + "icon-rotation-alignment": "auto", + "icon-image": "oneway" + } + } + ] +} diff --git a/test/integration/render/tests/icon-roll-alignment/map-rotation-alignment-viewport/expected.png b/test/integration/render/tests/icon-roll-alignment/map-rotation-alignment-viewport/expected.png new file mode 100644 index 0000000000000000000000000000000000000000..7fb1bfeb8d2763aaba6ff37205402dd9b7959b42 GIT binary patch literal 353 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7T#lEVASw*aSW+oe0tE{OQw*a?cw+B z`?RtIH*zfbELO!M%`S57)Gen89#+}hjxHO7SpN6Ek<2M#h?b7l6^&$>d~@S>2IHgO zZG`*|d0KF)KUA61GP!^-XGwZ#?D;(=sZW#teEU#c{d;bo=lOZ}e;UqsT$7jh@%_c8 zXTQGw^5EZsRdz2sN?!bU^={XJtZ0qapp4s$t(qG9Ga|R`*!E?;hg>4KKw8fXwkQA+m0RJ iw4F$VJCVHeomJh*deZyCjpe|gWAJqKb6Mw<&;$VffSz># literal 0 HcmV?d00001 diff --git a/test/integration/render/tests/icon-roll-alignment/map-rotation-alignment-viewport/style.json b/test/integration/render/tests/icon-roll-alignment/map-rotation-alignment-viewport/style.json new file mode 100644 index 0000000000..1bc7f7b2b1 --- /dev/null +++ b/test/integration/render/tests/icon-roll-alignment/map-rotation-alignment-viewport/style.json @@ -0,0 +1,38 @@ +{ + "version": 8, + "metadata": { + "test": { + "width": 64, + "height": 64 + } + }, + "bearing": 45, + "pitch": 45, + "roll": 45, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "Point", + "coordinates": [ + 0, + 0 + ] + } + } + }, + "sprite": "local://sprites/sprite", + "layers": [ + { + "id": "symbol", + "type": "symbol", + "source": "geojson", + "layout": { + "symbol-placement": "point", + "icon-rotation-alignment": "viewport", + "icon-pitch-alignment": "map", + "icon-image": "oneway" + } + } + ] +} diff --git a/test/integration/render/tests/icon-roll-alignment/viewport-rotation-alignment-map/expected.png b/test/integration/render/tests/icon-roll-alignment/viewport-rotation-alignment-map/expected.png new file mode 100644 index 0000000000000000000000000000000000000000..a22865b20631d4257f21fae3a879dc910654765a GIT binary patch literal 406 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7T#lEV2t&2aSW+oe0p$WR&b%jv4{7| zf2oBTh%|{5YRTSG)RVR1c*SZlO~Rls>v)i072{0Nm`kP?PICFOeKeXAJ!|XcfIBTq zp8wg~U3ciW&7nC4>Ss8MiKHg=9DlrO^XAK;TlnmG+2cKwCfVG*TOAf5$98dk&Fuew zEMDn*CPWD|G4!qr4xDTzB{R?xQnadBn-?9JA4Gdu(~ zeq)(szjQ;-0@Lk$VsEC2Pu@H_UL0}t z_8ckes$6R0OQCEtRrvv{FSEGr`!<&jHb-H}LzcqVOr;7UFa{ZtD g9K?DL7!dy%%9UMLm_9vu85rISp00i_>zopr0KxXEDgXcg literal 0 HcmV?d00001 diff --git a/test/integration/render/tests/icon-roll-alignment/viewport-rotation-alignment-map/style.json b/test/integration/render/tests/icon-roll-alignment/viewport-rotation-alignment-map/style.json new file mode 100644 index 0000000000..9007373d3c --- /dev/null +++ b/test/integration/render/tests/icon-roll-alignment/viewport-rotation-alignment-map/style.json @@ -0,0 +1,38 @@ +{ + "version": 8, + "metadata": { + "test": { + "width": 64, + "height": 64 + } + }, + "bearing": 45, + "pitch": 45, + "roll": 45, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "Point", + "coordinates": [ + 0, + 0 + ] + } + } + }, + "sprite": "local://sprites/sprite", + "layers": [ + { + "id": "symbol", + "type": "symbol", + "source": "geojson", + "layout": { + "symbol-placement": "point", + "icon-rotation-alignment": "map", + "icon-pitch-alignment": "viewport", + "icon-image": "oneway" + } + } + ] +} diff --git a/test/integration/render/tests/sky/roll10/expected.png b/test/integration/render/tests/sky/roll10/expected.png new file mode 100644 index 0000000000000000000000000000000000000000..ed186e69aea460db6813a834f17be475dd3eaf38 GIT binary patch literal 31134 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 literal 0 HcmV?d00001 diff --git a/test/integration/render/tests/sky/roll10/style.json b/test/integration/render/tests/sky/roll10/style.json new file mode 100644 index 0000000000..f2893622b9 --- /dev/null +++ b/test/integration/render/tests/sky/roll10/style.json @@ -0,0 +1,39 @@ +{ + "version": 8, + "sprite": "local://sprites/sprite", + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "timeout": 60000, + "metadata": { + "test": { + "height": 512, + "width": 512, + "maxPitch": 85, + "operations": [ + ["wait"] + ] + } + }, + "center": [ + 35.38, + 31.55 + ], + "zoom": 15, + "pitch": 85, + "roll": 10, + "sources": { }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "#ccaa83" + } + } + ], + "sky": { + "sky-color": "#199EF3", + "horizon-color": "#daeff0", + "sky-horizon-blend": 1, + "fog-ground-blend": 1 + } +} diff --git a/test/integration/render/tests/sky/roll180/expected.png b/test/integration/render/tests/sky/roll180/expected.png new file mode 100644 index 0000000000000000000000000000000000000000..b818c1b930f156355db33130e09db10f7608853a GIT binary patch literal 2081 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 Q9D+YOnn;b}E4M@b3+uf)tpET3 literal 0 HcmV?d00001 diff --git a/test/integration/render/tests/sky/roll45-with-text/style.json b/test/integration/render/tests/sky/roll45-with-text/style.json new file mode 100644 index 0000000000..46ff4c4c64 --- /dev/null +++ b/test/integration/render/tests/sky/roll45-with-text/style.json @@ -0,0 +1,141 @@ +{ + "version": 8, + "sprite": "local://sprites/sprite", + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "timeout": 60000, + "metadata": { + "test": { + "height": 512, + "width": 512, + "maxPitch": 85, + "collisionDebug": true, + "operations": [ + ["wait"] + ] + } + }, + "center": [ + 0, + 0 + ], + "zoom": 15, + "pitch": 75, + "roll": 45, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "Point", + "coordinates": [ + 0, + -0.007 + ] + } + }, + "geojson1": { + "type": "geojson", + "data": { + "type": "Point", + "coordinates": [ + 0, + 0 + ] + } + }, + "geojson2": { + "type": "geojson", + "data": { + "type": "Point", + "coordinates": [ + -0.004, + 0.004 + ] + } + }, + "geojson3": { + "type": "geojson", + "data": { + "type": "Point", + "coordinates": [ + 0.004, + 0.004 + ] + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "#ccaa83" + } + }, + { + "id": "symbol", + "type": "symbol", + "source": "geojson", + "layout": { + "symbol-placement": "point", + "text-rotation-alignment": "map", + "text-pitch-alignment": "map", + "text-field": "pitch=map, rotate=map", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ] + } + }, + { + "id": "symbol1", + "type": "symbol", + "source": "geojson1", + "layout": { + "symbol-placement": "point", + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "map", + "text-field": "pitch=map, rotate=viewport", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ] + } + }, + { + "id": "symbol2", + "type": "symbol", + "source": "geojson2", + "layout": { + "symbol-placement": "point", + "text-rotation-alignment": "map", + "text-pitch-alignment": "viewport", + "text-field": "pitch=viewport, rotate=map", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ] + } + }, + { + "id": "symbol3", + "type": "symbol", + "source": "geojson3", + "layout": { + "symbol-placement": "point", + "text-rotation-alignment": "viewport", + "text-pitch-alignment": "viewport", + "text-field": "pitch=viewport, rotate=viewport", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ] + } + } + ], + "sky": { + "sky-color": "#199EF3", + "horizon-color": "#daeff0", + "sky-horizon-blend": 1, + "fog-ground-blend": 1 + } +} diff --git a/test/integration/render/tests/sky/roll90/expected.png b/test/integration/render/tests/sky/roll90/expected.png new file mode 100644 index 0000000000000000000000000000000000000000..0940f8c3aa741b0eefea3794516f8a4e76c55366 GIT binary patch literal 2247 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&zE~)R&4YzZe)e`aE46Ln;{G9%P)T5+Uk( z@U!+hVU@=W(kG%Ao!zgqRXj+2_V45R{r|pf|NiUiuld*S$L-x07ytfi{QC9R`@@06 z_3FP>wR`LKAQ4wzzusH7_tn?Oe_ww6x(cXo6-a5--wGh(dNmR+9Hj5+>U*of!r^S}=~*4j5JBXZamA0zaqu>|Y0LpfGs4`njxgN@xNA DJH;p= literal 0 HcmV?d00001 diff --git a/test/integration/render/tests/sky/roll90/style.json b/test/integration/render/tests/sky/roll90/style.json new file mode 100644 index 0000000000..57cf5a57fe --- /dev/null +++ b/test/integration/render/tests/sky/roll90/style.json @@ -0,0 +1,39 @@ +{ + "version": 8, + "sprite": "local://sprites/sprite", + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "timeout": 60000, + "metadata": { + "test": { + "height": 512, + "width": 512, + "maxPitch": 85, + "operations": [ + ["wait"] + ] + } + }, + "center": [ + 35.38, + 31.55 + ], + "zoom": 15, + "pitch": 85, + "roll": 90, + "sources": { }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "#ccaa83" + } + } + ], + "sky": { + "sky-color": "#199EF3", + "horizon-color": "#daeff0", + "sky-horizon-blend": 1, + "fog-ground-blend": 1 + } +} diff --git a/test/integration/render/tests/terrain/fog-no-sky-blend-roll/expected.png b/test/integration/render/tests/terrain/fog-no-sky-blend-roll/expected.png new file mode 100644 index 0000000000000000000000000000000000000000..4fc9a0539ecd35bab9f371cc73254e5b4a1e4cbe GIT binary patch literal 4284 zcmeI0eRCVt6~^zbmzc%P2-q1k)H4<@g`G@_R4Bx@q}riQFdjE+NEn8WRhtkJ0wL1+ zCC(0`Sc{G8y2gYQiU{7#3vVLrxGgY=)KrmH#z{(uX%d5nBtpK}0pb*gHIwOR7}f4Q zNBsc(0R1b^+}Zm(&pG#=qw5SjpwutD=7-l1LY6k%ziKTZJd-@RdI|gIiuTF(gk0O( zu^UmICU@>*=FC^pAx zU&WP2VzqbP(_BvMT}os3w_CmJ*W7 z&C0(RpIkzc2Pc|d1CZ7nYyAm=UQyDU5j1C{9z`)?q>3wBjZ_BF%~d;ILi9(z9dDp$ z^zC@RxZ?eph2Pv}Ry>Z|+ZD&%KvtK)qX$N38_-Y7-6 zZ@QmG(QLYd#T6r03>B|BGJv~0z9YMeqWZ{g6s=v))FC;2;mX?>tNYyS&>ARV;pK^8 z^pjs7a}-Nhr??(Nw9BmdJBFX~?kE~ccYePTL6J*W_9JNFAG5th(9VheBIwT(OEIy+ z@!8w)8}H|cQ9EM8c>cj}I?;}`#tdRwiTx8rHqZV@>EL z_u0VS6#z;ekkWWqTAkc|7D1bo@P{bwQ^LiS+m-MjqL&)^6^K4+<{w7UXXd+5tTwgZ z;5R*r|0HZz@02TwiM}W~Dj`;2Y#@e-x#`#hR?E{heE=Ogz4M)j88kFJP(=KbTgG23rUN@SV_XkL{muGksHT7(fo>rA~CDWzU6@+1Uu zbnpw701_pmP69{@<}kZBCi_A$k3f<~fiOuZEj%PRMBX&Cf+We1-^ z>&#E8xYEX_j)KDw6~e<%3io9p{6`eu2;sk>xFq=TB%Gam#U|hkR&x%t*k|ZCr|B(< zwGI>GWcnofc~`O@f@&pCiP~Ynv|JF9i1Da=3u0s+=Ru5Q>QS`ZpjfwI5`s+cLd;(z zbs1p%r^JK-n2HNR09)0e^6o9*Ce+2d;c=jcI(awrsvc_RbEA;e6cv)#@u`bK^7pVk z#a2uZ-KRyZ5y*ue$v%W<(IC?iIC5K3tn=un)YNxEA6t$p&INs}M^wJ%1fU}`LKqD# zjEedD5i}s#?;@yIvQMKhC3_ylxR}71xG*PVVU$^Orrr$4oj53|I1=OA_#m7%eMPZw zBC_{l;vjRXH&=6U=nb`8)+1mm(I;6Uz}DnMx*f$ikvdRJinIwT7=BZ-TH*fZt7JV5 z_uqa{A&byjbb~><;b-2eui^@D|MmEGJ}*KJp*g{R2|zgpasnv%mSlP0@@hU=$DyPx zX98E4p%9eEzCj^VaIxrjOhAj%Gfcn@qwgsMp8{%wte0ZOhGdmMnFA+8ioNEZ5o`gX zM^#??9r%g*I6q7>tI;HT5VKJs9t?H8tUCdt9hR&z!1zBE=~1w}=PN6|!E5q3%#f2p%u=BPKAt zMkbIGHAxO$Q8sL1vi;-g9PDim)Io{s8(egPit{Y6=%1?7pzD&t~k1Qx(I$l7L4CeO4g>_-n*wHE=_#YPv+CFu=m^GXd#^-(UiIHT;r9LhyLWFOf+O zI^JH*1WXS5DkhLKsF37aKz17Jj}C~{wDkL^~vtAdIr$w}Lgoj{DdMOAtn4 zVniZwIPG{RPhld?u)k8X@VLvK5Oo-cL+7hFE6r>jyTc%~l40lP6=K8unm^4%DTsHO zkYV?&CZQe(aJ-tcrdSR`JKK3Z0U}9+61lIp#v!B9nXrfDSQG z8G!C!ASXm+e@6JsMP#dpT!lAdaWU%F1zw?~=_2wUU-h9(2`(Fvtuv%I5r=LIC^B-N2hlv0H literal 0 HcmV?d00001 diff --git a/test/integration/render/tests/terrain/fog-no-sky-blend-roll/style.json b/test/integration/render/tests/terrain/fog-no-sky-blend-roll/style.json new file mode 100644 index 0000000000..156e7e052c --- /dev/null +++ b/test/integration/render/tests/terrain/fog-no-sky-blend-roll/style.json @@ -0,0 +1,47 @@ +{ + "version": 8, + "sprite": "local://sprites/sprite", + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "timeout": 60000, + "metadata": { + "test": { + "height": 512, + "width": 512, + "maxPitch": 85, + "operations": [ + ["wait"] + ] + } + }, + "center": [ + 35.38, + 31.55 + ], + "zoom": 15, + "pitch": 85, + "roll": 135, + "sources": { + "terrain": { + "type": "raster-dem", + "tiles": [ + "local://tiles/zero-elevation-terrain-tile.png" + ], + "minzoom": 7, + "maxzoom": 12, + "tileSize": 256 + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "red" + } + } + ], + "terrain": { + "source": "terrain", + "exaggeration": 1 + } +} diff --git a/test/integration/render/tests/terrain/fog-sky-blend-roll/expected.png b/test/integration/render/tests/terrain/fog-sky-blend-roll/expected.png new file mode 100644 index 0000000000000000000000000000000000000000..6fa175db323d742cde7b593e0b422468abd86101 GIT binary patch literal 52372 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¬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 literal 0 HcmV?d00001 diff --git a/test/integration/render/tests/text-keep-upright/line-placement-true-roll-pitch-bearing/style.json b/test/integration/render/tests/text-keep-upright/line-placement-true-roll-pitch-bearing/style.json new file mode 100644 index 0000000000..7e01d237eb --- /dev/null +++ b/test/integration/render/tests/text-keep-upright/line-placement-true-roll-pitch-bearing/style.json @@ -0,0 +1,58 @@ +{ + "version": 8, + "metadata": { + "test": { + "height": 256, + "operations": [ + [ "setBearing", 90 ], + [ "setPitch", 45 ], + [ "setRoll", 90 ], + [ "wait" ] + ] + } + }, + "center": [ + 13.418056, + 52.499167 + ], + "zoom": 14, + "sources": { + "maplibre": { + "type": "vector", + "maxzoom": 14, + "tiles": [ + "local://tiles/{z}-{x}-{y}.mvt" + ] + } + }, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "line-placement-true", + "type": "symbol", + "source": "maplibre", + "source-layer": "road", + "layout": { + "symbol-placement": "line", + "text-allow-overlap": true, + "text-ignore-placement": true, + "text-field": "{class} {class}", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-keep-upright": true + }, + "paint": { + "text-opacity": 1 + } + } + ] +} diff --git a/test/integration/render/tests/text-keep-upright/line-placement-true-rolled/expected.png b/test/integration/render/tests/text-keep-upright/line-placement-true-rolled/expected.png new file mode 100644 index 0000000000000000000000000000000000000000..db0c4f27469664dde64401727771e123ed5fbd36 GIT binary patch literal 24292 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 literal 0 HcmV?d00001 diff --git a/test/integration/render/tests/text-keep-upright/line-placement-true-rolled/style.json b/test/integration/render/tests/text-keep-upright/line-placement-true-rolled/style.json new file mode 100644 index 0000000000..0767dc8119 --- /dev/null +++ b/test/integration/render/tests/text-keep-upright/line-placement-true-rolled/style.json @@ -0,0 +1,56 @@ +{ + "version": 8, + "metadata": { + "test": { + "height": 256, + "operations": [ + [ "setRoll", 180 ], + [ "wait" ] + ] + } + }, + "center": [ + 13.418056, + 52.499167 + ], + "zoom": 14, + "sources": { + "maplibre": { + "type": "vector", + "maxzoom": 14, + "tiles": [ + "local://tiles/{z}-{x}-{y}.mvt" + ] + } + }, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "line-placement-true", + "type": "symbol", + "source": "maplibre", + "source-layer": "road", + "layout": { + "symbol-placement": "line", + "text-allow-overlap": true, + "text-ignore-placement": true, + "text-field": "{class} {class}", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-keep-upright": true + }, + "paint": { + "text-opacity": 1 + } + } + ] +} diff --git a/test/integration/render/tests/text-pitch-scaling/line-half-roll/expected.png b/test/integration/render/tests/text-pitch-scaling/line-half-roll/expected.png new file mode 100644 index 0000000000000000000000000000000000000000..9bda18c099a9121b004d0f62fb1c8ec9963435d7 GIT binary patch literal 79406 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", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ] + } + } + ] +} diff --git a/test/integration/render/tests/text-roll-alignment/auto-symbol-placement-point/expected.png b/test/integration/render/tests/text-roll-alignment/auto-symbol-placement-point/expected.png new file mode 100644 index 0000000000000000000000000000000000000000..b9e732c1bb0f40630da200eb410b467c5899f321 GIT binary patch literal 377 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7uRSOr9=|Ar*{or0Om)FfbZ{cyA9b z%nD^>Xn6R)YMNTr#6%I39SH`#Pk%Br<=*fSJ3Ps*;VgS$#-@mt)ST)#j|gzgez4g>kYW$y9EXN8-uwvV-1 zyD#+k)xTNWqt@G*1a4mTw(#p3Z}+QirQ4Toe&t&la(6}9s=KcOzwZ4Nv3D)djJ288 zEA7HJPumwJeYJnuxmA<1)>j4HUEQ}j{Oa;o<-+FO(N}k8t@*b4@GFzBQ)#$80+cB* Z)nj-Stiv{ClWR0cp{J{#%Q~loCIH#Hl2iZy literal 0 HcmV?d00001 diff --git a/test/integration/render/tests/text-roll-alignment/auto-symbol-placement-point/style.json b/test/integration/render/tests/text-roll-alignment/auto-symbol-placement-point/style.json new file mode 100644 index 0000000000..068ad2cc2c --- /dev/null +++ b/test/integration/render/tests/text-roll-alignment/auto-symbol-placement-point/style.json @@ -0,0 +1,39 @@ +{ + "version": 8, + "metadata": { + "test": { + "width": 128, + "height": 128 + } + }, + "roll": 45, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "Point", + "coordinates": [ + 0, + 0 + ] + } + } + }, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "layers": [ + { + "id": "symbol", + "type": "symbol", + "source": "geojson", + "layout": { + "symbol-placement": "point", + "text-rotation-alignment": "auto", + "text-field": "-->", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ] + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/text-roll-alignment/map-symbol-placement-line/expected.png b/test/integration/render/tests/text-roll-alignment/map-symbol-placement-line/expected.png new file mode 100644 index 0000000000000000000000000000000000000000..131832bbffb98e34a4818c04ade58794b1c716e4 GIT binary patch literal 973 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7uRSOr9=|Ar*{or0Om)Ffg})cyABJ zu9(Nn!2aR>?>+r;Vs?P%bM!m@k4TQ)rAnfyKHeC56W@70wEgGK#kj|=1l7%rWm z`?*jsEP?gh;qVIP$6AX-_J$;JhCMQp>G#~zHiZ-O3Z!NHXcT~jg5O>xp#c$W0q+@#) zH8c9GS{d}`okdKC<{IbYc9XN#DArv*B(n4%O5dWT2*<-Cp?kqe77Xm*RUkseGcwt0#+;+ ze!H3F`D4{TN@*FZ=8L^MB**iJ$EJbP(0OHCRL0_N!", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ] + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/text-roll-alignment/map-symbol-placement-point/expected.png b/test/integration/render/tests/text-roll-alignment/map-symbol-placement-point/expected.png new file mode 100644 index 0000000000000000000000000000000000000000..a95f047f919b5550eb209763c08af0bd98a4a1ee GIT binary patch literal 401 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7uRSOr9=|Ar*{or0Om)Ffe+7cyA9b z%(_y@aNy(lyZ@xp1a#QFm1fj19i6J(v_`@wY4?j{9p5jaIo@jBj$$>ewt5)?6B`|K zj9;)cb8v<6{;su`_nf3cB>HK-?Mc4ajEAd@^VIh1Z0NRq^T<-p;;~wCbb@&Gj;_x? zI=m~g873FLP41N4b4Ym40o^wbW-|I5EA8;NIK+0pkgt5>F>B@-J=x8@_OZuPZOl4$ z{&^>7_h6~xnWt=ekGS$4bHwu`8EsPy`_bcG(Yu_@$atMK7N}GS= z<=^=2a_7M_J?WjlEsn4qXEQUOrW$tRP_>NoojVrWHXhRC`{34A%zm9%PkB!I!*0*; Y`HVLInaO^", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ] + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/text-roll-alignment/viewport-glyph-symbol-placement-line/expected.png b/test/integration/render/tests/text-roll-alignment/viewport-glyph-symbol-placement-line/expected.png new file mode 100644 index 0000000000000000000000000000000000000000..c7b7f57dad79e07e8f9e1dd53715f633a608755f GIT binary patch literal 1418 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrU=8$iaSW+oe0#9I=US;O!-wm| z-|Z}(sEBssQ`c@gHtANuCNs6{sI-5nJO62v zZru8?L*DR2y~k&E%P5xFrzUu}GBkEuTg)??XP?JoDPh0udG-A7uSJ6{I%!3DxN@(| zP!!c&vZys=%Y-g5$^~~#xn;m@zCboVQTyLo&z6#vXO0z>Br=q4NtCbaxjM_iWBQ@? z4-)H+zu&<1|8VJxLt?&4OS~V-ojIUipxnHV$ITV; zWm!pzbJv$`dF&|T#{MjkkuOQ;-LW8^%pk?s4#u2=_f2eC`4jgae4_JM*mJ|!VaFRX0c-=#Rea*QUht)p>*YK=QoUJB)Y3Gdo z_{E~}N2_>`Z(z4J`6DcU+}uF?w#m*3=Z?loI6cr?W473$60_?IrR&|f z^NyPE%Dy`QR_9aQ+@DD*h1p~(aht7Z4!^M$l(r71h zBtGDvE0@iV9xJ$aCQjOE&jQFs=$b&L%2;DdU}s^{tIAhgnJz1ph63 zVdMGwc)dA00)CuzPF8v^EKMs!MlV5sXhH9!`Wip`?49c#4WZRZi z!B@OdVzzJ5_Xlev~9OPXo9NZgLP&?I%Q`DKOl_l4Z?F6L2%EbAA=J07}mwQ2og z@yCU_eGk+t*v>x)^f_1|b1aTW+e2b^)()A;7rF{1j%=+E=SvdUEvgd|&%LjWFXvbo z@8Jp-{>5#YJNUmR@P~OheZAZ9^u-bVkG9HehD#F7uRYR!G0BhX>UM$08znz)ykjlh zuD9j!hLWHa+fHxf*u8<#-erAT*qzRq68-!MX8)Simri!tdberj56f#whGGvR-y8|? zapOI{QLaC+8KlWh=AhJ`mgyIIQ-AaZ^FC@Q=?aLQyMx(!1NZTbg4`b^*GxF46%ww$ z?qG^dkJY{2urCi4Wv04`u39Ntn{iNXPq;HL^A6794W3e=E0)ci(XJcO!N{xo!$|Ik zI7F=V#ew-BmIbdpYzopr026$7$p8QV literal 0 HcmV?d00001 diff --git a/test/integration/render/tests/text-roll-alignment/viewport-glyph-symbol-placement-line/style.json b/test/integration/render/tests/text-roll-alignment/viewport-glyph-symbol-placement-line/style.json new file mode 100644 index 0000000000..cb136786dd --- /dev/null +++ b/test/integration/render/tests/text-roll-alignment/viewport-glyph-symbol-placement-line/style.json @@ -0,0 +1,47 @@ +{ + "version": 8, + "metadata": { + "test": { + "width": 128, + "height": 128 + } + }, + "roll": 45, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "LineString", + "coordinates": [ + [ + -40, + 0 + ], + [ + 40, + 0 + ] + ] + } + } + }, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "layers": [ + { + "id": "symbol", + "type": "symbol", + "source": "geojson", + "layout": { + "symbol-placement": "line", + "symbol-spacing": 20, + "text-allow-overlap": true, + "text-rotation-alignment": "viewport-glyph", + "text-field": "ABC", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ] + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/text-roll-alignment/viewport-glyph-symbol-placement-point/expected.png b/test/integration/render/tests/text-roll-alignment/viewport-glyph-symbol-placement-point/expected.png new file mode 100644 index 0000000000000000000000000000000000000000..8b565fcb7d341c6dba0db0dbb44f9f709dfe29f3 GIT binary patch literal 950 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrV6OFaaSW+oe0$LI#B*jD_6N`Z z|1$X;mFCmQcGGB~=FRCkGn3x%E}qjNBjYjYP^*r5ky49}qNS!cZ~MZGD=|JEk$lNX zQu$BjR&GAC=ggxI_Ivg|eDLGnzWdKR!=koaS~OK`?UszCuG+d$nHRmf2SzBV!fgld z=?{ixGu}JRNj#@hcToJtEqmRgM=f~04SUajWUSkK;OK`nNB&yKtWRqG8TQaI=Xm~% z14eI-cJC0c?w#9yynt`n24U|F_U{gV7TX~)&(wnBb)jb8QL$n-iHDkdx?L?;`5*G_ z>FzJ!nRmdmf^Gd{ahbl)8_zKu*&x%f^+p$ef!MVNo}b?y6)}^%-N9X;+-Fo~?01}d z2YbEb?H+#~7oO9W7Lw;5*4}9`tzg@IRx*)Exo3mS@dUQ`M4|9R^SFcYY+7xpx@AeS z`;NH9bQ*8GIEV92)9H;8$CK6N4lIx8dphG#8nf8pLJhsc{5P_4nDY)u<(QPNkhhTV zHq2d+B;gj(%~{jznY?+%vCfFr?Y(ag>z$Fh-SoY{_S}KXH+n)Xc%ykjcn)*!V3}RO zn%2`>pc{ASYsKyT(JjG-o%c6N=Rb71(|P{GTub2_*$hb%>kbO-X`X-PyJFn|*NDU% z`!z?sbdD8ThzD0(zLC8{V)`T9HOJ3e@Em`rSd-V--I{NB?%4K-=kx0hS*DYPY8Z~L8Zz2Wej+<)^keWVu*beaTbjQxzpHZyFW-|_7R=0^rk LS3j3^P6zv zg>w>T*@@Y10dC@noR-4bD+5+ftKYWsXOr9yzO@RspWUv$f8O1NC~$m6E}O>DkefYE ze{iaASrOzjRr`aA+;JwJ2Q6a3T`P+i*>4wjg zR(ElKY*{Aw=CG0P{YMSUtj;|UsWOniD^WBt^k{u4^E{`84@I6m67-FK!0_y$;hlcg zA6&0Du&ZxvaEj@eZNi((VCrk1WIOM8{tuz|$@a&#PH^&>dc9D{x*(urP7u`f*_uaP zxN;6!^etNFJ2gp3^;(j!SfYS?;Po^w!--aQhZXO1UERUG|AEG zLwut|v&ACbDOUcCQdJkbnPPWv?R|0Z{fcah_PUz>pAn6EZ!WNL&3(>jvwS7LYTlIs zzWhXez2ib_jtG586yNr6hFQQ1o9oG(T)g)m2E{B+l#zd&Y16a1P#}6Ed-E+1MKvSo z+aH;?CF&n@bzj-p0Rb}?NcS)D{CBZ~Tv`_GT3Ra>IK(>3GT-Q3Unn1U$iPl~RocAtg4u0W z+mhBU3(PrWuqvWs>JDk!3Tf-3@j$t}E2dWpInOOxl_(>+=8)E%#^xKXZ!J{LX>VEX zS|Q$C!N2?C>K7853)ELBLBOJ`Ul+Lh> zEWKvZ;=hr9^9Dxk3$1l?#3nB?FplXqz0u*g#%2DG`>U4SPYW^WdC0qG(Mz7V%O$FE zNA|urR@c+v8ECq9mjQn$uksFAQxU1QfCR&^WXZOVym3eC=7<7K`THzG>bU%j#hD`6 vAJqCDFxPZAFG|)qYIR0b5I?y1@E?o2`6B*IziCf^nTNsC)z4*}Q$iB}9foMW literal 0 HcmV?d00001 diff --git a/test/integration/render/tests/text-roll-alignment/viewport-symbol-placement-line/style.json b/test/integration/render/tests/text-roll-alignment/viewport-symbol-placement-line/style.json new file mode 100644 index 0000000000..74f4129ed5 --- /dev/null +++ b/test/integration/render/tests/text-roll-alignment/viewport-symbol-placement-line/style.json @@ -0,0 +1,47 @@ +{ + "version": 8, + "metadata": { + "test": { + "width": 128, + "height": 128 + } + }, + "roll": 45, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "LineString", + "coordinates": [ + [ + -40, + 0 + ], + [ + 40, + 0 + ] + ] + } + } + }, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "layers": [ + { + "id": "symbol", + "type": "symbol", + "source": "geojson", + "layout": { + "symbol-placement": "line", + "symbol-spacing": 20, + "text-allow-overlap": true, + "text-rotation-alignment": "viewport", + "text-field": "-->", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ] + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render/tests/text-roll-alignment/viewport-symbol-placement-point/expected.png b/test/integration/render/tests/text-roll-alignment/viewport-symbol-placement-point/expected.png new file mode 100644 index 0000000000000000000000000000000000000000..b9e732c1bb0f40630da200eb410b467c5899f321 GIT binary patch literal 377 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7uRSOr9=|Ar*{or0Om)FfbZ{cyA9b z%nD^>Xn6R)YMNTr#6%I39SH`#Pk%Br<=*fSJ3Ps*;VgS$#-@mt)ST)#j|gzgez4g>kYW$y9EXN8-uwvV-1 zyD#+k)xTNWqt@G*1a4mTw(#p3Z}+QirQ4Toe&t&la(6}9s=KcOzwZ4Nv3D)djJ288 zEA7HJPumwJeYJnuxmA<1)>j4HUEQ}j{Oa;o<-+FO(N}k8t@*b4@GFzBQ)#$80+cB* Z)nj-Stiv{ClWR0cp{J{#%Q~loCIH#Hl2iZy literal 0 HcmV?d00001 diff --git a/test/integration/render/tests/text-roll-alignment/viewport-symbol-placement-point/style.json b/test/integration/render/tests/text-roll-alignment/viewport-symbol-placement-point/style.json new file mode 100644 index 0000000000..ac3d48b991 --- /dev/null +++ b/test/integration/render/tests/text-roll-alignment/viewport-symbol-placement-point/style.json @@ -0,0 +1,39 @@ +{ + "version": 8, + "metadata": { + "test": { + "width": 128, + "height": 128 + } + }, + "roll": 45, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "Point", + "coordinates": [ + 0, + 0 + ] + } + } + }, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "layers": [ + { + "id": "symbol", + "type": "symbol", + "source": "geojson", + "layout": { + "symbol-placement": "point", + "text-rotation-alignment": "viewport", + "text-field": "-->", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ] + } + } + ] +} \ No newline at end of file