├── .editorconfig ├── .github └── workflows │ ├── build-wheels.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .pylintrc ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── assets ├── no-texture-example.jpg └── north_cascades.jpg ├── pyproject.toml ├── quantized_mesh_encoder ├── __init__.py ├── bounding_sphere.py ├── constants.py ├── ecef.py ├── ellipsoid.py ├── encode.py ├── extensions.py ├── normals.py ├── occlusion.py ├── py.typed ├── util.py ├── util_cy.pyi └── util_cy.pyx ├── setup.cfg ├── setup.py ├── site ├── .gitignore ├── README.md ├── package.json ├── public │ ├── index.html │ ├── no-texture-example.jpg │ └── robots.txt ├── src │ ├── App.css │ ├── App.js │ ├── App.test.js │ ├── index.css │ ├── index.js │ ├── info-box.js │ ├── logo.svg │ ├── quantized-mesh-layer.js │ ├── serviceWorker.js │ └── setupTests.js └── yarn.lock └── test ├── test_bounding_sphere.py ├── test_ecef.py ├── test_encode.py ├── test_util_cy.py └── test_zigzag.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | charset = utf-8 12 | indent_style = space 13 | indent_size = 2 14 | 15 | # 4 space indentation 16 | [*.{py,pyx}] 17 | indent_size = 4 18 | 19 | # Tab indentation (no size specified) 20 | [Makefile] 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /.github/workflows/build-wheels.yml: -------------------------------------------------------------------------------- 1 | name: Build Wheels 2 | 3 | # Only run on new tags starting with `v` 4 | on: 5 | push: 6 | tags: 7 | - 'v*' 8 | 9 | jobs: 10 | build_wheels: 11 | name: Build wheel on ${{ matrix.os }} 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest, windows-latest, macos-latest] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | 20 | - name: Build wheels 21 | uses: pypa/cibuildwheel@v2.11.2 22 | env: 23 | # From rio-color here: 24 | # https://github.com/mapbox/rio-color/blob/0ab59ad8e2db99ad1d0c8bd8c2e4cf8d0c3114cf/appveyor.yml#L3 25 | CIBW_SKIP: "cp2* cp35* pp* *-win32 *-manylinux_i686" 26 | CIBW_ARCHS_MACOS: x86_64 arm64 27 | 28 | - uses: actions/upload-artifact@v2 29 | with: 30 | path: ./wheelhouse/*.whl 31 | 32 | build_sdist: 33 | name: Build source distribution 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v2 37 | 38 | - uses: actions/setup-python@v2 39 | name: Install Python 40 | with: 41 | python-version: '3.8' 42 | 43 | - name: Install dependencies 44 | run: | 45 | python -m pip install numpy Cython 46 | 47 | - name: Build sdist 48 | run: python setup.py sdist 49 | 50 | - uses: actions/upload-artifact@v2 51 | with: 52 | path: dist/*.tar.gz 53 | 54 | upload_pypi: 55 | needs: [build_wheels, build_sdist] 56 | runs-on: ubuntu-latest 57 | steps: 58 | - uses: actions/download-artifact@v2 59 | with: 60 | name: artifact 61 | path: dist 62 | 63 | - uses: pypa/gh-action-pypi-publish@master 64 | with: 65 | user: __token__ 66 | password: ${{ secrets.PYPI_PASSWORD }} 67 | # To test: repository_url: https://test.pypi.org/legacy/ 68 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | # On every pull request, but only on push to master 4 | on: 5 | push: 6 | branches: 7 | - master 8 | tags: 9 | - '*' 10 | pull_request: 11 | 12 | jobs: 13 | tests: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python-version: [3.6, 3.7, 3.8, 3.9] 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | python -m pip install numpy Cython 31 | python -m pip install . 32 | python -m pip install '.[test]' 33 | 34 | - name: Run tests 35 | run: pytest 36 | 37 | # Run pre-commit (only for python-3.8) 38 | - name: run pre-commit 39 | if: matrix.python-version == 3.8 40 | run: | 41 | python -m pip install pre-commit 42 | pre-commit run --all-files 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .envrc 2 | 3 | # Generated C files from cython 4 | *.c 5 | *.terrain 6 | *.png 7 | 8 | # Byte-compiled / optimized / DLL files 9 | __pycache__/ 10 | *.py[cod] 11 | *$py.class 12 | 13 | # C extensions 14 | *.so 15 | 16 | # Distribution / packaging 17 | .Python 18 | build/ 19 | develop-eggs/ 20 | dist/ 21 | downloads/ 22 | eggs/ 23 | .eggs/ 24 | lib/ 25 | lib64/ 26 | parts/ 27 | sdist/ 28 | var/ 29 | wheels/ 30 | pip-wheel-metadata/ 31 | share/python-wheels/ 32 | *.egg-info/ 33 | .installed.cfg 34 | *.egg 35 | MANIFEST 36 | 37 | # PyInstaller 38 | # Usually these files are written by a python script from a template 39 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 40 | *.manifest 41 | *.spec 42 | 43 | # Installer logs 44 | pip-log.txt 45 | pip-delete-this-directory.txt 46 | 47 | # Unit test / coverage reports 48 | htmlcov/ 49 | .tox/ 50 | .nox/ 51 | .coverage 52 | .coverage.* 53 | .cache 54 | nosetests.xml 55 | coverage.xml 56 | *.cover 57 | *.py,cover 58 | .hypothesis/ 59 | .pytest_cache/ 60 | 61 | # Translations 62 | *.mo 63 | *.pot 64 | 65 | # Django stuff: 66 | *.log 67 | local_settings.py 68 | db.sqlite3 69 | db.sqlite3-journal 70 | 71 | # Flask stuff: 72 | instance/ 73 | .webassets-cache 74 | 75 | # Scrapy stuff: 76 | .scrapy 77 | 78 | # Sphinx documentation 79 | docs/_build/ 80 | 81 | # PyBuilder 82 | target/ 83 | 84 | # Jupyter Notebook 85 | .ipynb_checkpoints 86 | 87 | # IPython 88 | profile_default/ 89 | ipython_config.py 90 | 91 | # pyenv 92 | .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 102 | __pypackages__/ 103 | 104 | # Celery stuff 105 | celerybeat-schedule 106 | celerybeat.pid 107 | 108 | # SageMath parsed files 109 | *.sage.py 110 | 111 | # Environments 112 | .env 113 | .venv 114 | env/ 115 | venv/ 116 | ENV/ 117 | env.bak/ 118 | venv.bak/ 119 | 120 | # Spyder project settings 121 | .spyderproject 122 | .spyproject 123 | 124 | # Rope project settings 125 | .ropeproject 126 | 127 | # mkdocs documentation 128 | 129 | # mypy 130 | .mypy_cache/ 131 | .dmypy.json 132 | dmypy.json 133 | 134 | # Pyre type checker 135 | .pyre/ 136 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | 4 | # Default to Python 3 5 | default_language_version: 6 | python: python3 7 | 8 | # Optionally both commit and push 9 | default_stages: [commit] 10 | 11 | repos: 12 | - repo: https://github.com/pre-commit/pre-commit-hooks 13 | rev: v2.4.0 14 | hooks: 15 | - id: trailing-whitespace 16 | - id: end-of-file-fixer 17 | - id: check-added-large-files 18 | 19 | - repo: https://github.com/PyCQA/isort 20 | rev: 5.4.2 21 | hooks: 22 | - id: isort 23 | language_version: python3 24 | 25 | - repo: https://github.com/psf/black 26 | rev: 22.10.0 27 | hooks: 28 | - id: black 29 | args: 30 | [ 31 | "--skip-string-normalization", 32 | ] 33 | language_version: python3 34 | 35 | - repo: https://github.com/PyCQA/pylint 36 | rev: pylint-2.6.0 37 | hooks: 38 | - id: pylint 39 | 40 | - repo: https://github.com/pre-commit/mirrors-mypy 41 | rev: v0.812 42 | hooks: 43 | - id: mypy 44 | language_version: python3 45 | args: ["--ignore-missing-imports"] 46 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # A comma-separated list of package or module names from where C extensions may 4 | # be loaded. Extensions are loading into the active Python interpreter and may 5 | # run arbitrary code. 6 | extension-pkg-whitelist= 7 | 8 | # Specify a score threshold to be exceeded before program exits with error. 9 | fail-under=10.0 10 | 11 | # Add files or directories to the blacklist. They should be base names, not 12 | # paths. 13 | ignore=CVS 14 | 15 | # Add files or directories matching the regex patterns to the blacklist. The 16 | # regex matches against base names, not paths. 17 | ignore-patterns= 18 | 19 | # Python code to execute, usually for sys.path manipulation such as 20 | # pygtk.require(). 21 | #init-hook= 22 | 23 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 24 | # number of processors available to use. 25 | jobs=0 26 | 27 | # Control the amount of potential inferred values when inferring a single 28 | # object. This can help the performance when dealing with large functions or 29 | # complex, nested conditions. 30 | limit-inference-results=100 31 | 32 | # List of plugins (as comma separated values of python module names) to load, 33 | # usually to register additional checkers. 34 | load-plugins= 35 | 36 | # Pickle collected data for later comparisons. 37 | persistent=yes 38 | 39 | # When enabled, pylint would attempt to guess common misconfiguration and emit 40 | # user-friendly hints instead of false-positive error messages. 41 | suggestion-mode=yes 42 | 43 | # Allow loading of arbitrary C extensions. Extensions are imported into the 44 | # active Python interpreter and may run arbitrary code. 45 | unsafe-load-any-extension=yes 46 | 47 | 48 | [MESSAGES CONTROL] 49 | 50 | # Only show warnings with the listed confidence levels. Leave empty to show 51 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. 52 | confidence= 53 | 54 | # Disable the message, report, category or checker with the given id(s). You 55 | # can either give multiple identifiers separated by comma (,) or put this 56 | # option multiple times (only on the command line, not in the configuration 57 | # file where it should appear only once). You can also use "--disable=all" to 58 | # disable everything first and then reenable specific checks. For example, if 59 | # you want to run only the similarities checker, you can use "--disable=all 60 | # --enable=similarities". If you want to run only the classes checker, but have 61 | # no Warning level messages displayed, use "--disable=all --enable=classes 62 | # --disable=W". 63 | disable=all 64 | 65 | # Enable the message, report, category or checker with the given id(s). You can 66 | # either give multiple identifier separated by comma (,) or put this option 67 | # multiple time (only on the command line, not in the configuration file where 68 | # it should appear only once). See also the "--disable" option for examples. 69 | # 70 | # Note: For a description of any of these, you can search 71 | # https://pycodequ.al/docs/search.html?q=${error-message} 72 | # Example: 73 | # https://pycodequ.al/docs/search.html?q=assignment-from-no-return 74 | enable= 75 | assignment-from-no-return, 76 | dangerous-default-value, 77 | f-string-without-interpolation, 78 | import-outside-toplevel, 79 | invalid-overridden-method, 80 | no-self-argument, 81 | pointless-string-statement, 82 | redefined-outer-name, 83 | super-with-arguments, 84 | trailing-comma-tuple, 85 | undefined-variable, 86 | unnecessary-comprehension, 87 | unnecessary-lambda, 88 | unused-argument, 89 | unused-import, 90 | unused-variable, 91 | useless-object-inheritance, 92 | 93 | [REPORTS] 94 | 95 | # Python expression which should return a score less than or equal to 10. You 96 | # have access to the variables 'error', 'warning', 'refactor', and 'convention' 97 | # which contain the number of messages in each category, as well as 'statement' 98 | # which is the total number of statements analyzed. This score is used by the 99 | # global evaluation report (RP0004). 100 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 101 | 102 | # Template used to display messages. This is a python new-style format string 103 | # used to format the message information. See doc for all details. 104 | #msg-template= 105 | 106 | # Set the output format. Available formats are text, parseable, colorized, json 107 | # and msvs (visual studio). You can also give a reporter class, e.g. 108 | # mypackage.mymodule.MyReporterClass. 109 | output-format=text 110 | 111 | # Tells whether to display a full report or only the messages. 112 | reports=no 113 | 114 | # Activate the evaluation score. 115 | score=yes 116 | 117 | 118 | [REFACTORING] 119 | 120 | # Maximum number of nested blocks for function / method body 121 | max-nested-blocks=5 122 | 123 | # Complete name of functions that never returns. When checking for 124 | # inconsistent-return-statements if a never returning function is called then 125 | # it will be considered as an explicit return statement and no message will be 126 | # printed. 127 | never-returning-functions=sys.exit 128 | 129 | 130 | [VARIABLES] 131 | 132 | # Tells whether unused global variables should be treated as a violation. 133 | allow-global-unused-variables=yes 134 | 135 | # Tells whether we should check for unused import in __init__ files. 136 | init-import=no 137 | 138 | # List of qualified module names which can have objects that can redefine 139 | # builtins. 140 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io 141 | 142 | 143 | [BASIC] 144 | 145 | # Naming style matching correct argument names. 146 | argument-naming-style=snake_case 147 | 148 | # Regular expression matching correct argument names. Overrides argument- 149 | # naming-style. 150 | #argument-rgx= 151 | 152 | # Naming style matching correct attribute names. 153 | attr-naming-style=snake_case 154 | 155 | # Regular expression matching correct attribute names. Overrides attr-naming- 156 | # style. 157 | #attr-rgx= 158 | 159 | # Naming style matching correct class attribute names. 160 | class-attribute-naming-style=any 161 | 162 | # Regular expression matching correct class attribute names. Overrides class- 163 | # attribute-naming-style. 164 | #class-attribute-rgx= 165 | 166 | # Naming style matching correct class names. 167 | class-naming-style=PascalCase 168 | 169 | # Regular expression matching correct class names. Overrides class-naming- 170 | # style. 171 | #class-rgx= 172 | 173 | # Naming style matching correct constant names. 174 | const-naming-style=UPPER_CASE 175 | 176 | # Regular expression matching correct constant names. Overrides const-naming- 177 | # style. 178 | #const-rgx= 179 | 180 | # Minimum line length for functions/classes that require docstrings, shorter 181 | # ones are exempt. 182 | docstring-min-length=-1 183 | 184 | # Naming style matching correct function names. 185 | function-naming-style=snake_case 186 | 187 | # Regular expression matching correct function names. Overrides function- 188 | # naming-style. 189 | #function-rgx= 190 | 191 | # Good variable names which should always be accepted, separated by a comma. 192 | # Note: These take effect if you enable the `invalid-name` rule 193 | good-names= 194 | _, # unused return output 195 | df, # pandas or dask DataFrame 196 | f, # open file handle 197 | h, # hexagon id 198 | i, 199 | j, 200 | k, 201 | r, # requests response 202 | zf, # open zip file handle 203 | 204 | # Good variable names regexes, separated by a comma. If names match any regex, 205 | # they will always be accepted 206 | good-names-rgxs= 207 | 208 | # Regular expression matching correct inline iteration names. Overrides 209 | # inlinevar-naming-style. 210 | #inlinevar-rgx= 211 | 212 | # Naming style matching correct method names. 213 | method-naming-style=snake_case 214 | 215 | # Regular expression matching correct method names. Overrides method-naming- 216 | # style. 217 | #method-rgx= 218 | 219 | # Naming style matching correct module names. 220 | module-naming-style=snake_case 221 | 222 | # Regular expression matching correct module names. Overrides module-naming- 223 | # style. 224 | #module-rgx= 225 | 226 | # Colon-delimited sets of names that determine each other's naming style when 227 | # the name regexes allow several styles. 228 | name-group= 229 | 230 | # Regular expression which should only match function or class names that do 231 | # not require a docstring. 232 | no-docstring-rgx=^_ 233 | 234 | # List of decorators that produce properties, such as abc.abstractproperty. Add 235 | # to this list to register other decorators that produce valid properties. 236 | # These decorators are taken in consideration only for invalid-name. 237 | property-classes=abc.abstractproperty 238 | 239 | # Naming style matching correct variable names. 240 | variable-naming-style=snake_case 241 | 242 | # Regular expression matching correct variable names. Overrides variable- 243 | # naming-style. 244 | #variable-rgx= 245 | 246 | 247 | [STRING] 248 | 249 | # This flag controls whether inconsistent-quotes generates a warning when the 250 | # character used as a quote delimiter is used inconsistently within a module. 251 | check-quote-consistency=no 252 | 253 | # This flag controls whether the implicit-str-concat should generate a warning 254 | # on implicit string concatenation in sequences defined over several lines. 255 | check-str-concat-over-line-jumps=no 256 | 257 | 258 | [IMPORTS] 259 | 260 | # List of modules that can be imported at any level, not just the top level 261 | # one. 262 | allow-any-import-level= 263 | 264 | # Allow wildcard imports from modules that define __all__. 265 | allow-wildcard-with-all=yes 266 | 267 | # Deprecated modules which should not be used, separated by a comma. 268 | deprecated-modules=optparse,tkinter.tix 269 | 270 | 271 | [CLASSES] 272 | 273 | # List of method names used to declare (i.e. assign) instance attributes. 274 | defining-attr-methods=__init__, 275 | __new__, 276 | setUp, 277 | __post_init__ 278 | 279 | # List of member names, which should be excluded from the protected access 280 | # warning. 281 | exclude-protected=_asdict, 282 | _fields, 283 | _replace, 284 | _source, 285 | _make 286 | 287 | # List of valid names for the first argument in a class method. 288 | valid-classmethod-first-arg=cls 289 | 290 | # List of valid names for the first argument in a metaclass class method. 291 | valid-metaclass-classmethod-first-arg=cls 292 | 293 | 294 | ; [DESIGN] 295 | ; 296 | ; # Maximum number of arguments for function / method. 297 | ; max-args=5 298 | ; 299 | ; # Maximum number of attributes for a class (see R0902). 300 | ; max-attributes=7 301 | ; 302 | ; # Maximum number of boolean expressions in an if statement (see R0916). 303 | ; max-bool-expr=5 304 | ; 305 | ; # Maximum number of branch for function / method body. 306 | ; max-branches=12 307 | ; 308 | ; # Maximum number of locals for function / method body. 309 | ; max-locals=15 310 | ; 311 | ; # Maximum number of parents for a class (see R0901). 312 | ; max-parents=7 313 | ; 314 | ; # Maximum number of public methods for a class (see R0904). 315 | ; max-public-methods=20 316 | ; 317 | ; # Maximum number of return / yield for function / method body. 318 | ; max-returns=6 319 | ; 320 | ; # Maximum number of statements in function / method body. 321 | ; max-statements=50 322 | ; 323 | ; # Minimum number of public methods for a class (see R0903). 324 | ; min-public-methods=2 325 | 326 | 327 | [EXCEPTIONS] 328 | 329 | # Exceptions that will emit a warning when being caught. Defaults to 330 | # "BaseException, Exception". 331 | overgeneral-exceptions=BaseException, 332 | Exception 333 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.4.3] - 2022-11-18 4 | 5 | - PEP 518-compliant with `pyproject.toml` and `build-system` 6 | 7 | ## [0.4.2] - 2021-12-05 8 | 9 | - Build wheels for Python 3.10 10 | - Use `oldest-supported-numpy` in wheel builds for greatest compatibility 11 | 12 | ## [0.4.1] - 2021-08-08 13 | 14 | - Fix wheel builds on CI 15 | 16 | ## [0.4.0] - 2021-08-08 17 | 18 | - Add support for Terrain Lighting, Water Mask, and Metadata extensions 19 | - Vertex normals computation as part of Terrain Lighting extension 20 | - Configurable Ellipsoid support 21 | - Improved mypy typing 22 | 23 | ## [0.3.1] - 2020-10-19 24 | 25 | - Add pyx file to sdist 26 | - Use Github Actions for CI testing 27 | 28 | ## [0.3.0] - 2020-10-05 29 | 30 | - Allow 2D input for both `positions` and `indices` 31 | 32 | ## [0.2.2] - 2020-07-10 33 | 34 | - Don't build wheels for PyPy. See https://github.com/joerick/cibuildwheel/issues/402 35 | 36 | ## [0.2.1] - 2020-07-09 37 | 38 | - Try to rebuild wheels 39 | 40 | ## [0.2.0] - 2020-07-01 41 | 42 | - New methods for creating a bounding sphere. #10 43 | 44 | ## [0.1.2] - 2020-06-01 45 | 46 | - Try again to publish to PyPI directly 47 | 48 | ## [0.1.1] - 2020-06-01 49 | 50 | - Try building wheels on CI with `cibuildwheel` 51 | 52 | ## [0.1.0] - 2020-05-30 53 | 54 | - Initial release 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Kyle Barron 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGELOG.md 2 | include LICENSE 3 | include README.md 4 | include quantized_mesh_encoder/py.typed 5 | 6 | global-include *.pyx 7 | global-include *.pyi 8 | 9 | recursive-include tests * 10 | recursive-exclude * __pycache__ 11 | recursive-exclude * *.py[co] 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # quantized-mesh-encoder 2 | 3 | [![Build Status](https://travis-ci.org/kylebarron/quantized-mesh-encoder.svg?branch=master)](https://travis-ci.org/kylebarron/quantized-mesh-encoder) 4 | 5 | A fast Python [Quantized Mesh][quantized_mesh_spec] encoder. Encodes a mesh with 6 | 100k coordinates and 180k triangles in 20ms. [Example viewer][example]. 7 | 8 | [![][image_url]][example] 9 | 10 | [image_url]: https://raw.githubusercontent.com/kylebarron/quantized-mesh-encoder/master/assets/no-texture-example.jpg 11 | [example]: https://kylebarron.dev/quantized-mesh-encoder 12 | 13 | The Grand Canyon and Walhalla Plateau. The mesh is created using 14 | [`pydelatin`][pydelatin] or [`pymartini`][pymartini], encoded using 15 | `quantized-mesh-encoder`, served on-demand using [`dem-tiler`][dem-tiler], and 16 | rendered with [deck.gl](https://deck.gl). 17 | 18 | [pymartini]: https://github.com/kylebarron/pymartini 19 | [pydelatin]: https://github.com/kylebarron/pydelatin 20 | [dem-tiler]: https://github.com/kylebarron/dem-tiler 21 | 22 | ## Overview 23 | 24 | [Quantized Mesh][quantized_mesh_spec] is a format to encode terrain meshes for 25 | efficient client-side terrain rendering. Such files are supported in 26 | [Cesium][cesium] and [deck.gl][deck.gl]. 27 | 28 | This library is designed to support performant server-side on-demand terrain 29 | mesh generation. 30 | 31 | [quantized_mesh_spec]: https://github.com/CesiumGS/quantized-mesh 32 | [cesium]: https://github.com/CesiumGS/cesium 33 | [deck.gl]: https://deck.gl/ 34 | 35 | ## Install 36 | 37 | With pip: 38 | 39 | ``` 40 | pip install quantized-mesh-encoder 41 | ``` 42 | 43 | or with Conda: 44 | 45 | ``` 46 | conda install -c conda-forge quantized-mesh-encoder 47 | ``` 48 | 49 | ## Using 50 | 51 | ### API 52 | 53 | #### `quantized_mesh_encoder.encode` 54 | 55 | Arguments: 56 | 57 | - `f`: a writable file-like object in which to write encoded bytes 58 | - `positions`: (`array[float]`): either a 1D Numpy array or a 2D Numpy array of 59 | shape `(-1, 3)` containing 3D positions. 60 | - `indices` (`array[int]`): either a 1D Numpy array or a 2D Numpy array of shape 61 | `(-1, 3)` indicating triples of coordinates from `positions` to make 62 | triangles. For example, if the first three values of `indices` are `0`, `1`, 63 | `2`, then that defines a triangle formed by the first 9 values in `positions`, 64 | three for the first vertex (index `0`), three for the second vertex, and three 65 | for the third vertex. 66 | 67 | Keyword arguments: 68 | 69 | - `bounds` (`List[float]`, optional): a list of bounds, `[minx, miny, maxx, 70 | maxy]`. By default, inferred as the minimum and maximum values of `positions`. 71 | - `sphere_method` (`str`, optional): As part of the header information when 72 | encoding Quantized Mesh, it's necessary to compute a [_bounding 73 | sphere_][bounding_sphere], which contains all positions of the mesh. 74 | `sphere_method` designates the algorithm to use for creating the bounding 75 | sphere. Must be one of `'bounding_box'`, `'naive'`, `'ritter'` or `None`. 76 | Default is `None`. 77 | - `'bounding_box'`: Finds the bounding box of all positions, then defines 78 | the center of the sphere as the center of the bounding box, and defines 79 | the radius as the distance back to the corner. This method produces the 80 | largest bounding sphere, but is the fastest: roughly 70 µs on my computer. 81 | - `'naive'`: Finds the bounding box of all positions, then defines the 82 | center of the sphere as the center of the bounding box. It then checks the 83 | distance to every other point and defines the radius as the maximum of 84 | these distances. This method will produce a slightly smaller bounding 85 | sphere than the `bounding_box` method when points are not in the 3D 86 | corners. This is the next fastest at roughly 160 µs on my computer. 87 | - `'ritter'`: Implements the Ritter Method for bounding spheres. It first 88 | finds the center of the longest span, then checks every point for 89 | containment, enlarging the sphere if necessary. This _can_ produce smaller 90 | bounding spheres than the naive method, but it does not always, so often 91 | both are run, see next option. This is the slowest method, at roughly 300 92 | µs on my computer. 93 | - `None`: Runs both the naive and the ritter methods, then returns the 94 | smaller of the two. Since this runs both algorithms, it takes around 500 95 | µs on my computer 96 | - `ellipsoid` (`quantized_mesh_encoder.Ellipsoid`, optional): ellipsoid defined by its semi-major `a` 97 | and semi-minor `b` axes. 98 | Default: WGS84 ellipsoid. 99 | - extensions: list of extensions to encode in quantized mesh object. These must be `Extension` instances. See [Quantized Mesh Extensions](#quantized-mesh-extensions). 100 | 101 | 102 | [bounding_sphere]: https://en.wikipedia.org/wiki/Bounding_sphere 103 | 104 | #### `quantized_mesh_encoder.Ellipsoid` 105 | 106 | Ellipsoid used for mesh calculations. 107 | 108 | Arguments: 109 | 110 | - `a` (`float`): semi-major axis 111 | - `b` (`float`): semi-minor axis 112 | 113 | #### `quantized_mesh_encoder.WGS84` 114 | 115 | Default [WGS84 ellipsoid](https://en.wikipedia.org/wiki/World_Geodetic_System#1984_version). Has a semi-major axis `a` of 6378137.0 meters and semi-minor axis `b` of 6356752.3142451793 meters. 116 | 117 | #### Quantized Mesh Extensions 118 | 119 | There are a variety of [extensions](https://github.com/CesiumGS/quantized-mesh#extensions) to the Quantized Mesh spec. 120 | 121 | ##### `quantized_mesh_encoder.VertexNormalsExtension` 122 | 123 | Implements the [Terrain Lighting](https://github.com/CesiumGS/quantized-mesh#terrain-lighting) extension. Per-vertex normals will be generated from your mesh data. 124 | 125 | Keyword Arguments: 126 | 127 | - `indices`: mesh indices 128 | - `positions`: mesh positions 129 | - `ellipsoid`: instance of Ellipsoid class, default: WGS84 ellipsoid 130 | 131 | ##### `quantized_mesh_encoder.WaterMaskExtension` 132 | 133 | Implements the [Water Mask](https://github.com/CesiumGS/quantized-mesh#water-mask) extension. 134 | 135 | Keyword Arguments: 136 | 137 | - `data` (`Union[np.ndarray, np.uint8, int]`): Data for water mask. 138 | 139 | ##### `quantized_mesh_encoder.MetadataExtension` 140 | 141 | Implements the [Metadata](https://github.com/CesiumGS/quantized-mesh#metadata) extension. 142 | 143 | - `data` (`Union[Dict, bytes]`): Metadata data to encode. If a dictionary, `json.dumps` will be called to create bytes in UTF-8 encoding. 144 | 145 | ### Examples 146 | 147 | #### Write to file 148 | 149 | ```py 150 | from quantized_mesh_encoder import encode 151 | with open('output.terrain', 'wb') as f: 152 | encode(f, positions, indices) 153 | ``` 154 | 155 | Quantized mesh files are usually saved gzipped. An easy way to create a gzipped 156 | file is to use `gzip.open`: 157 | 158 | ```py 159 | import gzip 160 | from quantized_mesh_encoder import encode 161 | with gzip.open('output.terrain', 'wb') as f: 162 | encode(f, positions, indices) 163 | ``` 164 | 165 | #### Write to buffer 166 | 167 | It's also pretty simple to write to an in-memory buffer instead of a file 168 | 169 | ```py 170 | from io import BytesIO 171 | from quantized_mesh_encoder import encode 172 | with BytesIO() as bio: 173 | encode(bio, positions, indices) 174 | ``` 175 | 176 | Or to gzip the in-memory buffer: 177 | 178 | ```py 179 | import gzip 180 | from io import BytesIO 181 | with BytesIO() as bio: 182 | with gzip.open(bio, 'wb') as gzipf: 183 | encode(gzipf, positions, indices) 184 | ``` 185 | 186 | 187 | #### Alternate Ellipsoid 188 | 189 | By default, the [WGS84 190 | ellipsoid](https://en.wikipedia.org/wiki/World_Geodetic_System#1984_version) is 191 | used for all calculations. An alternate ellipsoid may be useful for non-Earth 192 | planetary bodies. 193 | 194 | ```py 195 | from quantized_mesh_encoder import encode, Ellipsoid 196 | 197 | # From https://ui.adsabs.harvard.edu/abs/2010EM%26P..106....1A/abstract 198 | mars_ellipsoid = Ellipsoid(3_395_428, 3_377_678) 199 | 200 | with open('output.terrain', 'wb') as f: 201 | encode(f, positions, indices, ellipsoid=mars_ellipsoid) 202 | ``` 203 | 204 | #### Quantized Mesh Extensions 205 | 206 | ```py 207 | from quantized_mesh_encoder import encode, VertexNormalsExtension, MetadataExtension 208 | 209 | vertex_normals = VertexNormalsExtension(positions=positions, indices=indices) 210 | metadata = MetadataExtension(data={'hello': 'world'}) 211 | 212 | with open('output.terrain', 'wb') as f: 213 | encode(f, positions, indices, extensions=(vertex_normals, metadata)) 214 | ``` 215 | 216 | #### Generating the mesh 217 | 218 | To encode a mesh into a quantized mesh file, you first need a mesh! This project 219 | was designed to be used with [`pydelatin`][pydelatin] or 220 | [`pymartini`][pymartini], fast elevation heightmap to terrain mesh generators. 221 | 222 | ```py 223 | import quantized_mesh_encoder 224 | from imageio import imread 225 | from pymartini import decode_ele, Martini, rescale_positions 226 | import mercantile 227 | 228 | png = imread(png_path) 229 | terrain = decode_ele(png, 'terrarium') 230 | terrain = terrain.T 231 | martini = Martini(png.shape[0] + 1) 232 | tile = martini.create_tile(terrain) 233 | vertices, triangles = tile.get_mesh(10) 234 | 235 | # Use mercantile to find the bounds in WGS84 of this tile 236 | bounds = mercantile.bounds(mercantile.Tile(x, y, z)) 237 | 238 | # Rescale positions to WGS84 239 | rescaled = rescale_positions( 240 | vertices, 241 | terrain, 242 | bounds=bounds, 243 | flip_y=True 244 | ) 245 | 246 | with BytesIO() as f: 247 | quantized_mesh_encoder.encode(f, rescaled, triangles) 248 | f.seek(0) 249 | return ("OK", "application/vnd.quantized-mesh", f.read()) 250 | ``` 251 | 252 | You can also look at the source of 253 | [`_mesh()`](https://github.com/kylebarron/dem-tiler/blob/5b50a216a014eb32febee84fe3063ca99e71c7f6/dem_tiler/handlers/app.py#L234) 254 | in [`dem-tiler`][dem-tiler] for a working reference. 255 | 256 | ## License 257 | 258 | Much of this code is ported or derived from 259 | [`quantized-mesh-tile`][quantized-mesh-tile] in some way. `quantized-mesh-tile` 260 | is also released under the MIT license. 261 | 262 | [pymartini]: https://github.com/kylebarron/pymartini 263 | [quantized-mesh-tile]: https://github.com/loicgasser/quantized-mesh-tile 264 | -------------------------------------------------------------------------------- /assets/no-texture-example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kylebarron/quantized-mesh-encoder/0812c1830fd3aefe2e6ca27bf15b7bfe6cb5f8a0/assets/no-texture-example.jpg -------------------------------------------------------------------------------- /assets/north_cascades.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kylebarron/quantized-mesh-encoder/0812c1830fd3aefe2e6ca27bf15b7bfe6cb5f8a0/assets/north_cascades.jpg -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel", "cython>=0.29.29", "oldest-supported-numpy"] 3 | -------------------------------------------------------------------------------- /quantized_mesh_encoder/__init__.py: -------------------------------------------------------------------------------- 1 | """Top-level package for quantized_mesh_encoder.""" 2 | 3 | __author__ = """Kyle Barron""" 4 | __email__ = 'kylebarron2@gmail.com' 5 | __version__ = '0.4.3' 6 | 7 | from .constants import WGS84 8 | from .ellipsoid import Ellipsoid 9 | from .encode import encode 10 | from .extensions import MetadataExtension, VertexNormalsExtension, WaterMaskExtension 11 | -------------------------------------------------------------------------------- /quantized_mesh_encoder/bounding_sphere.py: -------------------------------------------------------------------------------- 1 | """ 2 | Compute bounding spheres 3 | 4 | Resources: 5 | https://help.agi.com/AGIComponents/html/BlogBoundingSphere.htm#! 6 | http://geomalgorithms.com/a08-_containers.html 7 | 8 | C code for Ritter's algorithm: 9 | http://www.realtimerendering.com/resources/GraphicsGems/gems/BoundSphere.c 10 | 11 | Math.gl: 12 | https://github.com/uber-web/math.gl/blob/master/modules/culling/src/algorithms/bounding-sphere-from-points.js 13 | """ 14 | from typing import Tuple 15 | 16 | import numpy as np 17 | 18 | from .util_cy import ritter_second_pass 19 | 20 | 21 | def bounding_sphere( 22 | positions: np.ndarray, *, method: str = None 23 | ) -> Tuple[np.ndarray, float]: 24 | """Create bounding sphere from positions 25 | 26 | Args: 27 | - positions: an array of shape (-1, 3) and of dtype np.float32 with 3d 28 | positions 29 | 30 | Kwargs: 31 | - method: a string designating the algorithm to use for creating the 32 | bounding sphere. Must be one of `'bounding_box'`, `'naive'`, 33 | `'ritter'` or `None`. 34 | 35 | - bounding_box: Finds the bounding box of all positions, then defines 36 | the center of the sphere as the center of the bounding box, and 37 | defines the radius as the distance back to the corner. This method 38 | produces the largest bounding sphere, but is the fastest: roughly 70 39 | µs on my computer. 40 | - naive: Finds the bounding box of all positions, then defines the 41 | center of the sphere as the center of the bounding box. It then 42 | checks the distance to every other point and defines the radius as 43 | the maximum of these distances. This method will produce a slightly 44 | smaller bounding sphere than the `bounding_box` method when points 45 | are not in the 3D corners. This is the next fastest at roughly 160 46 | µs on my computer. 47 | - ritter: Implements the Ritter Method for bounding spheres. It first 48 | finds the center of the longest span, then checks every point for 49 | containment, enlarging the sphere if necessary. This _can_ produce 50 | smaller bounding spheres than the naive method, but it does not 51 | always, so often both are run, see next option. This is the slowest 52 | method, at roughly 300 µs on my computer. 53 | - None: Runs both the naive and the ritter methods, then returns the 54 | smaller of the two. Since this runs both algorithms, it takes around 55 | 500 µs on my computer 56 | 57 | Returns: 58 | center, radius: where center is a Numpy array of length 3 representing 59 | the center of the bounding sphere, and radius is a float representing 60 | the radius of the bounding sphere. 61 | """ 62 | if method == 'bounding_box': 63 | return bounding_sphere_from_bounding_box(positions) 64 | 65 | if method == 'naive': 66 | return bounding_sphere_naive(positions) 67 | 68 | if method == 'ritter': 69 | return bounding_sphere_ritter(positions) 70 | 71 | # Defaults to both ritter and naive, and choosing the one with smaller 72 | # radius 73 | naive_center, naive_radius = bounding_sphere_naive(positions) 74 | ritter_center, ritter_radius = bounding_sphere_ritter(positions) 75 | 76 | if naive_radius < ritter_radius: 77 | return naive_center, naive_radius 78 | 79 | return ritter_center, ritter_radius 80 | 81 | 82 | def bounding_sphere_ritter(positions: np.ndarray) -> Tuple[np.ndarray, float]: 83 | """ 84 | Implements Ritter's algorithm 85 | 86 | 1. Find points containing minimum and maximum of each dimension. 87 | 2. Pick pair with maximum distance 88 | 89 | Slowest, but overall still quite fast: 304 µs 90 | """ 91 | # Find points containing smallest and largest component 92 | min_x_idx = np.where(positions[:, 0] == positions[:, 0].min())[0][0] 93 | min_y_idx = np.where(positions[:, 1] == positions[:, 1].min())[0][0] 94 | min_z_idx = np.where(positions[:, 2] == positions[:, 2].min())[0][0] 95 | max_x_idx = np.where(positions[:, 0] == positions[:, 0].max())[0][0] 96 | max_y_idx = np.where(positions[:, 1] == positions[:, 1].max())[0][0] 97 | max_z_idx = np.where(positions[:, 2] == positions[:, 2].max())[0][0] 98 | 99 | bbox = [ 100 | positions[min_x_idx], 101 | positions[min_y_idx], 102 | positions[min_z_idx], 103 | positions[max_x_idx], 104 | positions[max_y_idx], 105 | positions[max_z_idx], 106 | ] 107 | 108 | # Pick the pair with the maximum point-to-point separation 109 | # (which could be greater than the maximum dimensional span) 110 | 111 | # Compute x-, y-, and z-spans (distances between each component's min. and 112 | # max.). 113 | x_span = np.linalg.norm(bbox[0] - bbox[3]) 114 | y_span = np.linalg.norm(bbox[1] - bbox[4]) 115 | z_span = np.linalg.norm(bbox[2] - bbox[5]) 116 | 117 | # Find largest span 118 | l = [x_span, y_span, z_span] 119 | max_idx = l.index(max(l)) 120 | 121 | # Get the two bounding points with the selected dimension 122 | min_pt = bbox[max_idx] 123 | max_pt = bbox[max_idx + 3] 124 | 125 | # Calculate the center and radius of the initial sphere found by Ritter's 126 | # algorithm 127 | center = (min_pt + max_pt) / 2 128 | radius = np.linalg.norm(max_pt - center) 129 | 130 | return ritter_second_pass(positions, center, radius) 131 | 132 | 133 | def bounding_sphere_from_bounding_box( 134 | positions: np.ndarray, 135 | ) -> Tuple[np.ndarray, float]: 136 | """Create bounding sphere from axis aligned bounding box 137 | 138 | 1. Find axis-aligned bounding box, 139 | 2. Find center of box 140 | 3. Radius is distance back to corner 141 | 142 | Fastest method; around 70 µs 143 | """ 144 | bbox = axis_aligned_bounding_box(positions) 145 | center = np.average(bbox, axis=0) 146 | radius = np.linalg.norm(center - bbox[0, :]) 147 | return center, radius 148 | 149 | 150 | def bounding_sphere_naive(positions: np.ndarray) -> Tuple[np.ndarray, float]: 151 | """Create bounding sphere by checking all points 152 | 153 | 1. Find axis-aligned bounding box, 154 | 2. Find center of box 155 | 3. Find distance from center of box to every position: radius is max 156 | 157 | Still very fast method, with tighter radius; around 160 µs 158 | """ 159 | bbox = axis_aligned_bounding_box(positions) 160 | center = np.average(bbox, axis=0) 161 | radius = np.linalg.norm(center - positions, axis=1).max() 162 | return center, radius 163 | 164 | 165 | def axis_aligned_bounding_box(positions: np.ndarray) -> np.ndarray: 166 | return np.vstack([np.amin(positions, 0), np.amax(positions, 0)]) 167 | -------------------------------------------------------------------------------- /quantized_mesh_encoder/constants.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from .ellipsoid import Ellipsoid 4 | 5 | WGS84 = Ellipsoid(a=6378137.0, b=6356752.3142451793) 6 | 7 | NP_STRUCT_TYPES = {np.float32: ' determines the size of the 3 following arrays 26 | 'vertexCount': ' np.ndarray: 8 | """Convert positions to earth-centered, earth-fixed coordinates 9 | 10 | Ported from 11 | https://github.com/loicgasser/quantized-mesh-tile/blob/master/quantized_mesh_tile/llh_ecef.py 12 | under the MIT license. 13 | 14 | Originally from 15 | https://github.com/bistromath/gr-air-modes/blob/9e2515a56609658f168f0c833a14ca4d2332713e/python/mlat.py#L73-L86 16 | under the BSD-3 clause license. 17 | 18 | Args: 19 | - positions: expected to be an ndarray with shape (-1, 3) 20 | from latitude-longitude-height to ecef 21 | 22 | Kwargs: 23 | - ellipsoid: (`Ellipsoid`): ellipsoid defined by its semi-major `a` 24 | and semi-minor `b` axes. Default: WGS84 ellipsoid. 25 | """ 26 | msg = 'ellipsoid must be an instance of the Ellipsoid class' 27 | assert isinstance(ellipsoid, Ellipsoid), msg 28 | 29 | lon = positions[:, 0] * np.pi / 180 30 | lat = positions[:, 1] * np.pi / 180 31 | alt = positions[:, 2] 32 | 33 | n = lambda arr: ellipsoid.a / np.sqrt(1 - ellipsoid.e2 * (np.square(np.sin(arr)))) 34 | nlat = n(lat) 35 | 36 | x = (nlat + alt) * np.cos(lat) * np.cos(lon) 37 | y = (nlat + alt) * np.cos(lat) * np.sin(lon) 38 | z = (nlat * (1 - ellipsoid.e2) + alt) * np.sin(lat) 39 | 40 | # Do I need geoid correction? 41 | # https://github.com/bistromath/gr-air-modes/blob/9e2515a56609658f168f0c833a14ca4d2332713e/python/mlat.py#L88-L92 42 | return np.vstack([x, y, z]).T 43 | -------------------------------------------------------------------------------- /quantized_mesh_encoder/ellipsoid.py: -------------------------------------------------------------------------------- 1 | import attr 2 | 3 | 4 | @attr.s 5 | class Ellipsoid: 6 | """Ellipsoid used for mesh calculations 7 | 8 | Args: 9 | a (float): semi-major axis 10 | b (float): semi-minor axis 11 | """ 12 | 13 | a: float = attr.ib() 14 | b: float = attr.ib() 15 | e2: float = attr.ib(init=False) 16 | 17 | def __attrs_post_init__(self): 18 | self.e2 = 1 - (self.b**2 / self.a**2) 19 | -------------------------------------------------------------------------------- /quantized_mesh_encoder/encode.py: -------------------------------------------------------------------------------- 1 | from struct import pack 2 | from typing import Any, BinaryIO, Dict, Optional, Sequence, Tuple 3 | 4 | import numpy as np 5 | 6 | from .bounding_sphere import bounding_sphere 7 | from .constants import HEADER, NP_STRUCT_TYPES, VERTEX_DATA, WGS84 8 | from .ecef import to_ecef 9 | from .ellipsoid import Ellipsoid 10 | from .extensions import ExtensionBase 11 | from .occlusion import occlusion_point 12 | from .util import zig_zag_encode 13 | from .util_cy import encode_indices 14 | 15 | Bounds = Tuple[float, float, float, float] 16 | 17 | 18 | def encode( 19 | f: BinaryIO, 20 | positions: np.ndarray, 21 | indices: np.ndarray, 22 | *, 23 | bounds: Optional[Bounds] = None, 24 | sphere_method: Optional[str] = None, 25 | ellipsoid: Ellipsoid = WGS84, 26 | extensions: Sequence[ExtensionBase] = () 27 | ) -> None: 28 | """Create bounding sphere from positions 29 | 30 | Args: 31 | - f: a writable file-like object in which to write encoded bytes 32 | - positions: (ndarray[float]): either a 1D Numpy array or a 2D Numpy 33 | array of shape (-1, 3) containing 3D positions. 34 | - indices (ndarray[int]): either a 1D Numpy array or a 2D Numpy array of 35 | shape (-1, 3) indicating triples of coordinates from `positions` to 36 | make triangles. For example, if the first three values of `indices` 37 | are `0`, `1`, `2`, then that defines a triangle formed by the first 9 38 | values in `positions`, three for the first vertex (index `0`), three 39 | for the second vertex, and three for the third vertex. 40 | 41 | Kwargs: 42 | - bounds (List[float], optional): a list of bounds, `[minx, miny, maxx, 43 | maxy]`. By default, inferred as the minimum and maximum values of 44 | `positions`. 45 | - sphere_method: a string designating the algorithm to use for creating 46 | the bounding sphere. Must be one of `'bounding_box'`, `'naive'`, 47 | `'ritter'` or `None`. 48 | 49 | - bounding_box: Finds the bounding box of all positions, then defines 50 | the center of the sphere as the center of the bounding box, and 51 | defines the radius as the distance back to the corner. This method 52 | produces the largest bounding sphere, but is the fastest: roughly 70 53 | µs on my computer. 54 | - naive: Finds the bounding box of all positions, then defines the 55 | center of the sphere as the center of the bounding box. It then 56 | checks the distance to every other point and defines the radius as 57 | the maximum of these distances. This method will produce a slightly 58 | smaller bounding sphere than the `bounding_box` method when points 59 | are not in the 3D corners. This is the next fastest at roughly 160 60 | µs on my computer. 61 | - ritter: Implements the Ritter Method for bounding spheres. It first 62 | finds the center of the longest span, then checks every point for 63 | containment, enlarging the sphere if necessary. This _can_ produce 64 | smaller bounding spheres than the naive method, but it does not 65 | always, so often both are run, see next option. This is the slowest 66 | method, at roughly 300 µs on my computer. 67 | - None: Runs both the naive and the ritter methods, then returns the 68 | smaller of the two. Since this runs both algorithms, it takes around 69 | 500 µs on my computer 70 | - ellipsoid: (`Ellipsoid`): ellipsoid defined by its semi-major `a` 71 | and semi-minor `b` axes. Default: WGS84 ellipsoid. 72 | - extensions: list of instances of the ExtensionBase class. 73 | """ 74 | # Convert to ndarray 75 | positions = positions.reshape(-1, 3).astype(np.float32) 76 | indices = indices.reshape(-1, 3).astype(np.uint32) 77 | 78 | msg = 'ellipsoid must be an instance of the Ellipsoid class.' 79 | assert isinstance(ellipsoid, Ellipsoid), msg 80 | 81 | msg = 'extensions must be instances of the Extension class.' 82 | assert all(isinstance(ext, ExtensionBase) for ext in extensions), msg 83 | 84 | msg = 'extensions must have unique ids.' 85 | assert len({ext.id for ext in extensions}) == len(extensions), msg 86 | 87 | header = compute_header(positions, sphere_method, ellipsoid=ellipsoid) 88 | encode_header(f, header) 89 | 90 | # Linear interpolation to range u, v, h from 0-32767 91 | positions = interp_positions(positions, bounds=bounds) 92 | 93 | n_vertices = positions.shape[0] 94 | write_vertices(f, positions, n_vertices) 95 | 96 | write_indices(f, indices, n_vertices) 97 | 98 | write_edge_indices(f, positions, n_vertices) 99 | 100 | for ext in extensions: 101 | f.write(ext.encode()) 102 | 103 | 104 | def compute_header( 105 | positions: np.ndarray, sphere_method: Optional[str], *, ellipsoid: Ellipsoid = WGS84 106 | ) -> Dict[str, Any]: 107 | header = {} 108 | 109 | cartesian_positions = to_ecef(positions, ellipsoid=ellipsoid) 110 | 111 | ecef_min_x = cartesian_positions[:, 0].min() 112 | ecef_min_y = cartesian_positions[:, 1].min() 113 | ecef_min_z = cartesian_positions[:, 2].min() 114 | ecef_max_x = cartesian_positions[:, 0].max() 115 | ecef_max_y = cartesian_positions[:, 1].max() 116 | ecef_max_z = cartesian_positions[:, 2].max() 117 | 118 | header['centerX'] = (ecef_min_x + ecef_max_x) / 2 119 | header['centerY'] = (ecef_min_y + ecef_max_y) / 2 120 | header['centerZ'] = (ecef_min_z + ecef_max_z) / 2 121 | 122 | header['minimumHeight'] = positions[:, 2].min() 123 | header['maximumHeight'] = positions[:, 2].max() 124 | 125 | center, radius = bounding_sphere(cartesian_positions, method=sphere_method) 126 | header['boundingSphereCenterX'] = center[0] 127 | header['boundingSphereCenterY'] = center[1] 128 | header['boundingSphereCenterZ'] = center[2] 129 | header['boundingSphereRadius'] = radius 130 | 131 | occl_pt = occlusion_point(cartesian_positions, center, ellipsoid=ellipsoid) 132 | header['horizonOcclusionPointX'] = occl_pt[0] 133 | header['horizonOcclusionPointY'] = occl_pt[1] 134 | header['horizonOcclusionPointZ'] = occl_pt[2] 135 | 136 | return header 137 | 138 | 139 | def encode_header(f: BinaryIO, data: Dict[str, Any]) -> None: 140 | """Encode header data 141 | 142 | Args: 143 | - f: Opened file descriptor for writing 144 | - data: dict of header data 145 | """ 146 | f.write(pack(HEADER['centerX'], data['centerX'])) 147 | f.write(pack(HEADER['centerY'], data['centerY'])) 148 | f.write(pack(HEADER['centerZ'], data['centerZ'])) 149 | 150 | f.write(pack(HEADER['minimumHeight'], data['minimumHeight'])) 151 | f.write(pack(HEADER['maximumHeight'], data['maximumHeight'])) 152 | 153 | f.write(pack(HEADER['boundingSphereCenterX'], data['boundingSphereCenterX'])) 154 | f.write(pack(HEADER['boundingSphereCenterY'], data['boundingSphereCenterY'])) 155 | f.write(pack(HEADER['boundingSphereCenterZ'], data['boundingSphereCenterZ'])) 156 | f.write(pack(HEADER['boundingSphereRadius'], data['boundingSphereRadius'])) 157 | 158 | f.write(pack(HEADER['horizonOcclusionPointX'], data['horizonOcclusionPointX'])) 159 | f.write(pack(HEADER['horizonOcclusionPointY'], data['horizonOcclusionPointY'])) 160 | f.write(pack(HEADER['horizonOcclusionPointZ'], data['horizonOcclusionPointZ'])) 161 | 162 | 163 | def interp_positions( 164 | positions: np.ndarray, bounds: Optional[Bounds] = None 165 | ) -> np.ndarray: 166 | """Rescale positions to be integers ranging from min to max 167 | 168 | TODO allow 6 input elements, for min/max elevation too? 169 | 170 | Args: 171 | - positions 172 | - bounds: If provided should be [minx, miny, maxx, maxy] 173 | 174 | Returns: 175 | ndarray of shape (-1, 3) and dtype np.int16 176 | """ 177 | if bounds: 178 | minx, miny, maxx, maxy = bounds 179 | else: 180 | minx = positions[:, 0].min() 181 | maxx = positions[:, 0].max() 182 | miny = positions[:, 1].min() 183 | maxy = positions[:, 1].max() 184 | 185 | minh = positions[:, 2].min() 186 | maxh = positions[:, 2].max() 187 | 188 | u = np.interp(positions[:, 0], (minx, maxx), (0, 32767)).astype(np.int16) 189 | v = np.interp(positions[:, 1], (miny, maxy), (0, 32767)).astype(np.int16) 190 | h = np.interp(positions[:, 2], (minh, maxh), (0, 32767)).astype(np.int16) 191 | 192 | return np.vstack([u, v, h]).T 193 | 194 | 195 | def write_vertices(f: BinaryIO, positions: np.ndarray, n_vertices: int) -> None: 196 | assert positions.ndim == 2, 'positions must be 2 dimensions' 197 | 198 | # Write vertex count 199 | f.write(pack(VERTEX_DATA['vertexCount'], n_vertices)) 200 | 201 | u = positions[:, 0] 202 | v = positions[:, 1] 203 | h = positions[:, 2] 204 | 205 | u_diff = u[1:] - u[:-1] 206 | v_diff = v[1:] - v[:-1] 207 | h_diff = h[1:] - h[:-1] 208 | 209 | # Zig zag encode 210 | u_zz = zig_zag_encode(u_diff).astype(np.uint16) 211 | v_zz = zig_zag_encode(v_diff).astype(np.uint16) 212 | h_zz = zig_zag_encode(h_diff).astype(np.uint16) 213 | 214 | # Write first value 215 | f.write(pack(VERTEX_DATA['uVertexCount'], zig_zag_encode(u[0]))) 216 | # Write array. Must be uint16 217 | f.write(u_zz.tobytes()) 218 | 219 | # Write first value 220 | f.write(pack(VERTEX_DATA['vVertexCount'], zig_zag_encode(v[0]))) 221 | # Write array. Must be uint16 222 | f.write(v_zz.tobytes()) 223 | 224 | # Write first value 225 | f.write(pack(VERTEX_DATA['heightVertexCount'], zig_zag_encode(h[0]))) 226 | # Write array. Must be uint16 227 | f.write(h_zz.tobytes()) 228 | 229 | 230 | def write_indices(f: BinaryIO, indices: np.ndarray, n_vertices: int) -> None: 231 | """Write indices to file""" 232 | # If more than 65536 vertices, index data must be uint32 233 | index_32 = n_vertices > 65536 234 | 235 | # Enforce proper byte alignment 236 | # > padding is added before the IndexData to ensure 2 byte alignment for 237 | # > IndexData16 and 4 byte alignment for IndexData32. 238 | required_offset = 4 if index_32 else 2 239 | remainder = f.tell() % required_offset 240 | if remainder: 241 | # number of bytes to add 242 | n_bytes = required_offset - remainder 243 | # Write required number of bytes 244 | # Not sure the best way to write empty bytes, so I'll just pad with 245 | # ascii letters for now 246 | b = ('a' * n_bytes).encode('ascii') 247 | assert len(b) == n_bytes, 'Wrong number of bytes to pad' 248 | f.write(b) 249 | 250 | # Write number of triangles to file 251 | n_triangles = indices.shape[0] 252 | f.write(pack(NP_STRUCT_TYPES[np.uint32], n_triangles)) 253 | 254 | # Encode indices using high water mark encoding 255 | encoded_ind = encode_indices(indices.flatten()) 256 | 257 | # Write array. Must be either uint16 or uint32, depending on length of 258 | # vertices 259 | dtype = np.uint32 if index_32 else np.uint16 260 | encoded_ind = encoded_ind.astype(dtype) 261 | f.write(encoded_ind.tobytes()) 262 | 263 | 264 | def find_edge_indices( 265 | positions: np.ndarray, 266 | ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: 267 | u = positions[:, 0] 268 | v = positions[:, 1] 269 | 270 | # np.where returns a tuple for each dimension 271 | # Here we only care about the first 272 | left = np.where(u == 0)[0] 273 | bottom = np.where(v == 0)[0] 274 | right = np.where(u == 32767)[0] 275 | top = np.where(v == 32767)[0] 276 | 277 | return left, bottom, right, top 278 | 279 | 280 | def write_edge_indices(f: BinaryIO, positions: np.ndarray, n_vertices: int) -> None: 281 | left, bottom, right, top = find_edge_indices(positions) 282 | 283 | # If more than 65536 vertices, index data must be uint32 284 | index_32 = n_vertices > 65536 285 | dtype = np.uint32 if index_32 else np.uint16 286 | 287 | # No high-water mark encoding on edge indices 288 | left = left.astype(dtype) 289 | bottom = bottom.astype(dtype) 290 | right = right.astype(dtype) 291 | top = top.astype(dtype) 292 | 293 | f.write(pack(NP_STRUCT_TYPES[np.uint32], len(left))) 294 | f.write(left.tobytes()) 295 | 296 | f.write(pack(NP_STRUCT_TYPES[np.uint32], len(bottom))) 297 | f.write(bottom.tobytes()) 298 | 299 | f.write(pack(NP_STRUCT_TYPES[np.uint32], len(right))) 300 | f.write(right.tobytes()) 301 | 302 | f.write(pack(NP_STRUCT_TYPES[np.uint32], len(top))) 303 | f.write(top.tobytes()) 304 | -------------------------------------------------------------------------------- /quantized_mesh_encoder/extensions.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import json 3 | from enum import IntEnum 4 | from struct import pack 5 | from typing import Dict, Union 6 | 7 | import attr 8 | import numpy as np 9 | 10 | from .constants import EXTENSION_HEADER, WGS84 11 | from .ecef import to_ecef 12 | from .ellipsoid import Ellipsoid 13 | from .normals import compute_vertex_normals, oct_encode 14 | 15 | 16 | class ExtensionId(IntEnum): 17 | VERTEX_NORMALS = 1 18 | WATER_MASK = 2 19 | METADATA = 4 20 | 21 | 22 | @attr.s(kw_only=True) 23 | class ExtensionBase(metaclass=abc.ABCMeta): 24 | id: ExtensionId = attr.ib(validator=attr.validators.instance_of(ExtensionId)) 25 | 26 | @abc.abstractmethod 27 | def encode(self) -> bytes: 28 | """Return the encoded extension data""" 29 | ... 30 | 31 | 32 | @attr.s(kw_only=True) 33 | class VertexNormalsExtension(ExtensionBase): 34 | """Vertex Normals Extension 35 | 36 | Kwargs: 37 | indices: mesh indices 38 | positions: mesh positions 39 | ellipsoid: instance of Ellipsoid class 40 | """ 41 | 42 | id: ExtensionId = attr.ib( 43 | ExtensionId.VERTEX_NORMALS, validator=attr.validators.instance_of(ExtensionId) 44 | ) 45 | indices: np.ndarray = attr.ib(validator=attr.validators.instance_of(np.ndarray)) 46 | positions: np.ndarray = attr.ib(validator=attr.validators.instance_of(np.ndarray)) 47 | ellipsoid: Ellipsoid = attr.ib( 48 | WGS84, validator=attr.validators.instance_of(Ellipsoid) 49 | ) 50 | 51 | def encode(self) -> bytes: 52 | """Return encoded extension data""" 53 | positions = self.positions.reshape(-1, 3) 54 | cartesian_positions = to_ecef(positions, ellipsoid=self.ellipsoid) 55 | normals = compute_vertex_normals(cartesian_positions, self.indices) 56 | encoded = oct_encode(normals).tobytes('C') 57 | 58 | buf = b'' 59 | buf += pack(EXTENSION_HEADER['extensionId'], self.id.value) 60 | buf += pack(EXTENSION_HEADER['extensionLength'], len(encoded)) 61 | buf += encoded 62 | 63 | return buf 64 | 65 | 66 | @attr.s(kw_only=True) 67 | class WaterMaskExtension(ExtensionBase): 68 | """Water Mask Extension 69 | 70 | Kwargs: 71 | data: Either a numpy ndarray or an integer between 0 and 255 72 | """ 73 | 74 | id: ExtensionId = attr.ib( 75 | ExtensionId.WATER_MASK, validator=attr.validators.instance_of(ExtensionId) 76 | ) 77 | data: Union[np.ndarray, np.uint8, int] = attr.ib( 78 | validator=attr.validators.instance_of((np.ndarray, np.uint8, int)) 79 | ) 80 | 81 | def encode(self) -> bytes: 82 | encoded: bytes 83 | if isinstance(self.data, np.ndarray): 84 | # Minify output 85 | encoded = self.data.astype(np.uint8).tobytes('C') 86 | elif isinstance(self.data, (np.uint8, int)): 87 | encoded = np.uint8(self.data).tobytes('C') 88 | 89 | buf = b'' 90 | buf += pack(EXTENSION_HEADER['extensionId'], self.id.value) 91 | buf += pack(EXTENSION_HEADER['extensionLength'], len(encoded)) 92 | buf += encoded 93 | 94 | return buf 95 | 96 | 97 | @attr.s(kw_only=True) 98 | class MetadataExtension(ExtensionBase): 99 | """Metadata Extension 100 | 101 | Kwargs: 102 | data: Either a dictionary or bytes. If a dictionary, json.dumps will be called to create bytes in UTF-8 encoding. 103 | """ 104 | 105 | id: ExtensionId = attr.ib( 106 | ExtensionId.METADATA, validator=attr.validators.instance_of(ExtensionId) 107 | ) 108 | data: Union[Dict, bytes] = attr.ib( 109 | validator=attr.validators.instance_of((dict, bytes)) 110 | ) 111 | 112 | def encode(self) -> bytes: 113 | encoded: bytes 114 | if isinstance(self.data, dict): 115 | # Minify output 116 | encoded = json.dumps(self.data, separators=(',', ':')).encode() 117 | elif isinstance(self.data, bytes): 118 | encoded = self.data 119 | 120 | buf = b'' 121 | buf += pack(EXTENSION_HEADER['extensionId'], self.id.value) 122 | buf += pack(EXTENSION_HEADER['extensionLength'], len(encoded)) 123 | buf += encoded 124 | 125 | return buf 126 | -------------------------------------------------------------------------------- /quantized_mesh_encoder/normals.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from .util_cy import add_vertex_normals 4 | 5 | 6 | def compute_vertex_normals(positions: np.ndarray, indices: np.ndarray) -> np.ndarray: 7 | # Make sure indices and positions are both arrays of shape (-1, 3) 8 | positions = positions.reshape(-1, 3).astype('float64') 9 | indices = indices.reshape(-1, 3) 10 | 11 | # Perform coordinate lookup in positions using indices 12 | # positions and indices are both arrays of shape (-1, 3) 13 | # `coords` is then an array of shape (-1, 3, 3) where each block of (i, 3, 14 | # 3) represents all the coordinates of a single triangle 15 | tri_coords = positions[indices] 16 | 17 | # a, b, and c represent a single vertex for every triangle 18 | a = tri_coords[:, 0, :] 19 | b = tri_coords[:, 1, :] 20 | c = tri_coords[:, 2, :] 21 | 22 | # This computes the normal for each triangle "face". So there's one normal 23 | # vector for each triangle. 24 | face_normals = np.cross(b - a, c - a) 25 | 26 | # The magnitude of the cross product of b - a and c - a is the area of the 27 | # parallellogram spanned by these vectors; the triangle has half the area 28 | # https://math.stackexchange.com/q/3103543 29 | tri_areas = np.linalg.norm(face_normals, axis=1) / 2 30 | 31 | # Multiply each face normal by the area of that triangle 32 | weighted_face_normals = np.multiply(face_normals, tri_areas[:, np.newaxis]) 33 | 34 | # Sum up each vertex normal 35 | # According to the implementation this is ported from, since you weight the 36 | # face normals by the area, you can just sum up the vectors. 37 | vertex_normals = np.zeros(positions.shape, dtype=np.float64) 38 | add_vertex_normals(indices, weighted_face_normals, vertex_normals) 39 | 40 | # Normalize vertex normals by dividing by each vector's length 41 | normalized_vertex_normals = ( 42 | vertex_normals / np.linalg.norm(vertex_normals, axis=1)[:, np.newaxis] 43 | ) 44 | 45 | return normalized_vertex_normals 46 | 47 | 48 | def sign_not_zero(arr: np.ndarray) -> np.ndarray: 49 | """A variation of np.sign that coerces 0 to 1""" 50 | return np.where(arr < 0.0, -1, 1) 51 | 52 | 53 | def oct_encode(vec: np.ndarray) -> np.ndarray: 54 | """ 55 | Compress x, y, z 96-bit floating point into x, z 16-bit representation (2 snorm values) 56 | https://github.com/AnalyticalGraphicsInc/cesium/blob/b161b6429b9201c99e5fb6f6e6283f3e8328b323/Source/Core/AttributeCompression.js#L43 57 | https://github.com/loicgasser/quantized-mesh-tile/blob/750125d3885fd89e3e12dce8fe075fbdc0adc323/quantized_mesh_tile/utils.py#L90-L108 58 | 59 | This assumes input vectors are normalized 60 | """ 61 | 62 | l1_norm = np.linalg.norm(vec, ord=1, axis=1) 63 | result = vec[:, 0:2] / l1_norm[:, np.newaxis] 64 | 65 | negative = vec[:, 2] < 0.0 66 | x = np.copy(result[:, 0]) 67 | y = np.copy(result[:, 1]) 68 | result[:, 0] = np.where(negative, (1 - np.abs(y)) * sign_not_zero(x), result[:, 0]) 69 | result[:, 1] = np.where(negative, (1 - np.abs(x)) * sign_not_zero(y), result[:, 1]) 70 | 71 | # Converts a scalar value in the range [-1.0, 1.0] to a 8-bit 2's complement 72 | # number. 73 | oct_encoded = np.floor((np.clip(result, -1, 1) * 0.5 + 0.5) * 256).astype(np.uint8) 74 | 75 | return oct_encoded 76 | -------------------------------------------------------------------------------- /quantized_mesh_encoder/occlusion.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from .constants import WGS84 4 | from .ellipsoid import Ellipsoid 5 | 6 | 7 | def squared_norm(positions: np.ndarray) -> np.ndarray: 8 | return ( 9 | np.square(positions[:, 0]) 10 | + np.square(positions[:, 1]) 11 | + np.square(positions[:, 2]) 12 | ) 13 | 14 | 15 | def compute_magnitude(positions: np.ndarray, bounding_center: np.ndarray) -> np.ndarray: 16 | magnitude_squared = squared_norm(positions) 17 | magnitude = np.sqrt(magnitude_squared) 18 | 19 | # Can make this cleaner by broadcasting division 20 | direction = positions.copy() 21 | direction[:, 0] /= magnitude 22 | direction[:, 1] /= magnitude 23 | direction[:, 2] /= magnitude 24 | 25 | magnitude_squared = np.maximum(magnitude_squared, 1) 26 | magnitude = np.maximum(magnitude, 1) 27 | 28 | cos_alpha = np.dot(direction, bounding_center.T) 29 | sin_alpha = np.linalg.norm(np.cross(direction, bounding_center), axis=1) 30 | cos_beta = 1 / magnitude 31 | sin_beta = np.sqrt(magnitude_squared - 1.0) * cos_beta 32 | 33 | return 1 / (cos_alpha * cos_beta - sin_alpha * sin_beta) 34 | 35 | 36 | # https://cesiumjs.org/2013/05/09/Computing-the-horizon-occlusion-point/ 37 | def occlusion_point( 38 | positions: np.ndarray, bounding_center: np.ndarray, *, ellipsoid: Ellipsoid = WGS84 39 | ) -> np.ndarray: 40 | cartesian_ellipsoid = np.array([ellipsoid.a, ellipsoid.a, ellipsoid.b]) 41 | # Scale positions relative to ellipsoid 42 | positions /= cartesian_ellipsoid 43 | 44 | # Scale center relative to ellipsoid 45 | bounding_center /= cartesian_ellipsoid 46 | 47 | # Find magnitudes necessary for each position to not be visible 48 | magnitudes = compute_magnitude(positions, bounding_center) 49 | 50 | # Multiply by maximum magnitude and rescale to ellipsoid surface 51 | return bounding_center * magnitudes.max() * cartesian_ellipsoid 52 | -------------------------------------------------------------------------------- /quantized_mesh_encoder/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kylebarron/quantized-mesh-encoder/0812c1830fd3aefe2e6ca27bf15b7bfe6cb5f8a0/quantized_mesh_encoder/py.typed -------------------------------------------------------------------------------- /quantized_mesh_encoder/util.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | import numpy as np 4 | 5 | 6 | def zig_zag_encode( 7 | arr: Union[np.ndarray, int, np.number] 8 | ) -> Union[np.ndarray, np.uint16]: 9 | """ 10 | Input can be number or numpy array 11 | 12 | https://gist.github.com/mfuerstenau/ba870a29e16536fdbaba 13 | (i >> bitlength-1) ^ (i << 1) 14 | So I right shift 15 because these arrays are int16 15 | 16 | Note: since I'm only right-shifting 15 places, this will fail for values > 17 | int16 18 | """ 19 | if isinstance(arr, np.ndarray): 20 | assert arr.dtype == np.int16, 'zig zag encoding requires int16 input' 21 | 22 | encoded = np.bitwise_xor(np.right_shift(arr, 15), np.left_shift(arr, 1)) 23 | return encoded.astype(np.uint16) 24 | -------------------------------------------------------------------------------- /quantized_mesh_encoder/util_cy.pyi: -------------------------------------------------------------------------------- 1 | # pylint: disable=unused-argument 2 | from typing import Tuple 3 | 4 | import numpy as np # isort: skip 5 | 6 | def encode_indices(indices: np.ndarray) -> np.ndarray: ... 7 | def ritter_second_pass( 8 | positions: np.ndarray, center: np.ndarray, radius: float 9 | ) -> Tuple[np.ndarray, float]: ... 10 | def add_vertex_normals( 11 | indices: np.ndarray, normals: np.ndarray, out: np.ndarray 12 | ) -> None: ... 13 | -------------------------------------------------------------------------------- /quantized_mesh_encoder/util_cy.pyx: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | cimport numpy as np 3 | 4 | def encode_indices(indices): 5 | """High-water mark encoding 6 | """ 7 | cdef np.uint32_t[:] indices_view = indices 8 | cdef np.uint32_t[:] out_view 9 | cdef unsigned int highest, code 10 | cdef Py_ssize_t i 11 | cdef unsigned short idx 12 | 13 | out_view = np.zeros(len(indices), dtype=np.uint32) 14 | highest = 0 15 | for i in range(len(indices)): 16 | out_view[i] = highest - indices_view[i] 17 | if out_view[i] == 0: 18 | highest += 1 19 | 20 | return np.asarray(out_view, dtype=np.uint32) 21 | 22 | 23 | def ritter_second_pass( 24 | np.ndarray[np.float32_t, ndim=2] positions, 25 | np.ndarray[np.float32_t, ndim=1] center, 26 | float radius): 27 | 28 | cdef np.float32_t[:] dP 29 | cdef float dist, dist2, mult 30 | cdef Py_ssize_t i 31 | cdef float radius2 = radius ** 2 32 | 33 | cdef float centerX = center[0] 34 | cdef float centerY = center[1] 35 | cdef float centerZ = center[2] 36 | 37 | cdef float x, y, z 38 | cdef float dPx, dPy, dPz 39 | 40 | # Next, each point P of S is tested for inclusion in the current ball (by 41 | # simply checking that its distance from the center is less than or equal to 42 | # the radius). 43 | for i in range(positions.shape[0]): 44 | x = positions[i, 0] 45 | y = positions[i, 1] 46 | z = positions[i, 2] 47 | 48 | dPx = x - centerX 49 | dPy = y - centerY 50 | dPz = z - centerZ 51 | 52 | dist2 = (dPx ** 2) + (dPy ** 2) + (dPz ** 2) 53 | 54 | if dist2 <= radius2: 55 | continue 56 | 57 | # Enlarge ball 58 | # This is done by drawing a line from Pk+1 to the current center Ck of 59 | # Bk and extending it further to intersect the far side of Bk. 60 | 61 | # enlarge radius just enough 62 | dist = np.sqrt(dist2) 63 | radius = (radius + dist) / 2 64 | radius2 = radius ** 2 65 | 66 | mult = (dist - radius) / dist 67 | centerX += (mult * dPx) 68 | centerY += (mult * dPy) 69 | centerZ += (mult * dPz) 70 | 71 | return np.array([centerX, centerY, centerZ], dtype=np.float32), radius 72 | 73 | 74 | # Cython implementation of: 75 | # vertex_normals = np.zeros(positions.shape, dtype=np.float32) 76 | # for triangle, face_norm in zip(indices, weighted_face_normals): 77 | # for pos in triangle: 78 | # vertex_normals[pos] += face_norm 79 | def add_vertex_normals( 80 | np.ndarray[np.uint32_t, ndim=2] indices, 81 | np.ndarray[np.float64_t, ndim=2] normals, 82 | np.ndarray[np.float64_t, ndim=2] out): 83 | 84 | cdef long long indices_length = indices.shape[0] 85 | cdef Py_ssize_t i, j, k 86 | cdef long long vertex 87 | 88 | for i in range(indices_length): 89 | for j in range(3): 90 | for k in range(3): 91 | vertex = indices[i, j] 92 | out[vertex, k] += normals[i, k] 93 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.4.3 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | search = version="{current_version}" 8 | replace = version="{new_version}" 9 | 10 | [bumpversion:file:quantized_mesh_encoder/__init__.py] 11 | search = __version__ = '{current_version}' 12 | replace = __version__ = '{new_version}' 13 | 14 | [bdist_wheel] 15 | universal = 1 16 | 17 | [flake8] 18 | exclude = docs 19 | max-line-length = 80 20 | 21 | [aliases] 22 | test = pytest 23 | 24 | [tool:pytest] 25 | collect_ignore = ['setup.py'] 26 | 27 | [isort] 28 | profile = black 29 | 30 | [pycodestyle] 31 | max-line-length = 80 32 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Setup for quantized-mesh-encoder.""" 2 | from pathlib import Path 3 | 4 | import numpy as np 5 | from setuptools import find_packages, setup 6 | 7 | # setuptools must be before Cython 8 | from Cython.Build import cythonize # isort:skip 9 | 10 | with open("README.md") as f: 11 | readme = f.read() 12 | 13 | # Runtime requirements. 14 | inst_reqs = ["numpy", "attrs"] 15 | 16 | extra_reqs = { 17 | "test": ["pytest", "pytest-benchmark", "imageio", "quantized-mesh-tile"], 18 | } 19 | 20 | 21 | # Ref https://suzyahyah.github.io/cython/programming/2018/12/01/Gotchas-in-Cython.html 22 | def find_pyx(path='.'): 23 | return list(map(str, Path(path).glob('**/*.pyx'))) 24 | 25 | 26 | setup( 27 | name="quantized-mesh-encoder", 28 | version="0.4.3", 29 | python_requires=">=3.6", 30 | description="A fast Python Quantized Mesh encoder", 31 | long_description=readme, 32 | long_description_content_type="text/markdown", 33 | classifiers=[ 34 | "Intended Audience :: Information Technology", 35 | "Intended Audience :: Science/Research", 36 | "License :: OSI Approved :: MIT License", 37 | "Programming Language :: Python :: 3.6", 38 | "Programming Language :: Python :: 3.7", 39 | "Programming Language :: Python :: 3.8", 40 | "Topic :: Scientific/Engineering :: GIS", 41 | ], 42 | keywords="mesh heightmap elevation terrain numpy", 43 | author="Kyle Barron", 44 | author_email="kylebarron2@gmail.com", 45 | url="https://github.com/kylebarron/quantized-mesh-encoder", 46 | license="MIT", 47 | packages=find_packages(exclude=["ez_setup", "scripts", "examples", "test"]), 48 | include_package_data=True, 49 | zip_safe=False, 50 | install_requires=inst_reqs, 51 | extras_require=extra_reqs, 52 | ext_modules=cythonize(find_pyx(), language_level=3), 53 | # Include Numpy headers 54 | include_dirs=[np.get_include()], 55 | ) 56 | -------------------------------------------------------------------------------- /site/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /site/README.md: -------------------------------------------------------------------------------- 1 | ## Quantized Mesh Example 2 | 3 | This folder contains a simple example and viewer of quantized mesh tiles that 4 | are generated in a serverless AWS Lambda function. 5 | 6 | It uses [`pymartini`][pymartini] for mesh generation, `quantized-mesh-encoder` 7 | for encoding to quantized mesh, [`dem-tiler`][dem-tiler] for the serverless API, 8 | and [`deck.gl`](https://deck.gl) for rendering. You can also easily overlay a 9 | texture source, e.g. Mapbox Satellite tiles, with deck.gl. 10 | 11 | [pymartini]: https://github.com/kylebarron/pymartini 12 | [dem-tiler]: https://github.com/kylebarron/dem-tiler 13 | -------------------------------------------------------------------------------- /site/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "site", 3 | "version": "0.1.0", 4 | "private": true, 5 | "homepage": "https://kylebarron.dev/quantized-mesh-encoder", 6 | "dependencies": { 7 | "@loaders.gl/core": "^2.2.9", 8 | "@loaders.gl/images": "^2.2.9", 9 | "@loaders.gl/loader-utils": "2.2.9", 10 | "@loaders.gl/terrain": "^2.2.9", 11 | "@testing-library/jest-dom": "^4.2.4", 12 | "@testing-library/react": "^9.3.2", 13 | "@testing-library/user-event": "^7.1.2", 14 | "deck.gl": "^8.2.10", 15 | "react": "^16.13.1", 16 | "react-dom": "^16.13.1", 17 | "react-scripts": "3.4.1", 18 | "semantic-ui-react": "^0.88.2" 19 | }, 20 | "scripts": { 21 | "start": "react-scripts start", 22 | "build": "react-scripts build", 23 | "test": "react-scripts test", 24 | "eject": "react-scripts eject", 25 | "deploy": "gh-pages -d build -b gh-pages" 26 | }, 27 | "eslintConfig": { 28 | "extends": "react-app" 29 | }, 30 | "browserslist": { 31 | "production": [ 32 | ">0.2%", 33 | "not dead", 34 | "not op_mini all" 35 | ], 36 | "development": [ 37 | "last 1 chrome version", 38 | "last 1 firefox version", 39 | "last 1 safari version" 40 | ] 41 | }, 42 | "devDependencies": { 43 | "gh-pages": "^3.1.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /site/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | quantized-mesh-encoder 8 | 9 | 13 | 17 | 18 | 22 | 26 | 27 | 28 | 32 | 36 | 37 | 38 | 47 | 48 | 49 | 50 |
51 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /site/public/no-texture-example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kylebarron/quantized-mesh-encoder/0812c1830fd3aefe2e6ca27bf15b7bfe6cb5f8a0/site/public/no-texture-example.jpg -------------------------------------------------------------------------------- /site/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /site/src/App.css: -------------------------------------------------------------------------------- 1 | @import url('https://cdn.jsdelivr.net/npm/semantic-ui@2.4.2/dist/semantic.min.css'); 2 | 3 | .App { 4 | text-align: center; 5 | } 6 | 7 | .App-logo { 8 | height: 40vmin; 9 | pointer-events: none; 10 | } 11 | 12 | @media (prefers-reduced-motion: no-preference) { 13 | .App-logo { 14 | animation: App-logo-spin infinite 20s linear; 15 | } 16 | } 17 | 18 | .App-header { 19 | background-color: #282c34; 20 | min-height: 100vh; 21 | display: flex; 22 | flex-direction: column; 23 | align-items: center; 24 | justify-content: center; 25 | font-size: calc(10px + 2vmin); 26 | color: white; 27 | } 28 | 29 | .App-link { 30 | color: #61dafb; 31 | } 32 | 33 | @keyframes App-logo-spin { 34 | from { 35 | transform: rotate(0deg); 36 | } 37 | to { 38 | transform: rotate(360deg); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /site/src/App.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import DeckGL from "@deck.gl/react"; 3 | import { QuantizedMeshTerrainLayer } from "./quantized-mesh-layer"; 4 | import InfoBox from "./info-box"; 5 | import "./App.css"; 6 | 7 | const INITIAL_VIEW_STATE = { 8 | latitude: 48.7, 9 | longitude: -113.81, 10 | bearing: -3.3, 11 | pitch: 65, 12 | zoom: 11.6, 13 | maxPitch: 89, 14 | }; 15 | 16 | class App extends React.Component { 17 | state = { 18 | viewState: INITIAL_VIEW_STATE, 19 | zRange: null, 20 | meshAlgorithm: "pydelatin", 21 | loadTexture: true, 22 | }; 23 | 24 | // Update zRange of viewport 25 | onViewportLoad = (data) => { 26 | if (!data || data.length === 0 || data.every((x) => !x)) { 27 | return; 28 | } 29 | 30 | const { zRange } = this.state; 31 | const ranges = data.filter(Boolean).map((arr) => { 32 | const bounds = arr[0].header.boundingBox; 33 | return bounds.map((bound) => bound[2]); 34 | }); 35 | const minZ = Math.min(...ranges.map((x) => x[0])); 36 | const maxZ = Math.max(...ranges.map((x) => x[1])); 37 | 38 | if (!zRange || minZ < zRange[0] || maxZ > zRange[1]) { 39 | this.setState({ zRange: [minZ, maxZ] }); 40 | } 41 | }; 42 | 43 | render() { 44 | const { viewState, zRange, meshAlgorithm, loadTexture } = this.state; 45 | 46 | const layers = [ 47 | QuantizedMeshTerrainLayer({ 48 | onViewportLoad: this.onViewportLoad, 49 | zRange, 50 | meshAlgorithm, 51 | loadTexture, 52 | }), 53 | ]; 54 | 55 | return ( 56 |
57 | this.setState({ viewState })} 65 | controller={{ touchRotate: true }} 66 | glOptions={{ 67 | // Tell browser to use discrete GPU if available 68 | powerPreference: "high-performance", 69 | }} 70 | > 71 | this.setState(newState)} 75 | /> 76 | 77 |
78 | ); 79 | } 80 | } 81 | 82 | export default App; 83 | 84 | document.body.style.margin = 0; 85 | -------------------------------------------------------------------------------- /site/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | const { getByText } = render(); 7 | const linkElement = getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /site/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /site/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import * as serviceWorker from './serviceWorker'; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById('root') 12 | ); 13 | 14 | // If you want your app to work offline and load faster, you can change 15 | // unregister() to register() below. Note this comes with some pitfalls. 16 | // Learn more about service workers: https://bit.ly/CRA-PWA 17 | serviceWorker.unregister(); 18 | -------------------------------------------------------------------------------- /site/src/info-box.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Accordion, 4 | Container, 5 | Checkbox, 6 | Icon, 7 | Select, 8 | } from "semantic-ui-react"; 9 | 10 | const MESH_OPTIONS = [ 11 | { key: "pydelatin", value: "pydelatin", text: "Algorithm: Delatin" }, 12 | { key: "pymartini", value: "pymartini", text: "Algorithm: Martini" }, 13 | ]; 14 | 15 | const VIEW_STATE_OPTIONS = [ 16 | { 17 | key: "glac", 18 | value: { 19 | latitude: 48.7, 20 | longitude: -113.81, 21 | bearing: -3.3, 22 | pitch: 65, 23 | zoom: 11.6, 24 | maxPitch: 89, 25 | }, 26 | text: "Glacier National Park", 27 | }, 28 | { 29 | key: "grca", 30 | value: { 31 | latitude: 36.07091852096502, 32 | longitude: -112.00934837595949, 33 | bearing: -35.19642857142857, 34 | pitch: 60, 35 | zoom: 13.574472859832357, 36 | maxPitch: 89, 37 | }, 38 | text: "Grand Canyon", 39 | }, 40 | { 41 | key: "yose", 42 | value: { 43 | latitude: 37.74831303498057, 44 | longitude: -119.54799204629128, 45 | bearing: 78.74986923166337, 46 | pitch: 65, 47 | zoom: 12.1, 48 | maxPitch: 89, 49 | }, 50 | text: "Yosemite Valley", 51 | }, 52 | { 53 | key: "mtsthelens", 54 | value: { 55 | latitude: 46.2099889639587, 56 | longitude: -122.18025571716424, 57 | bearing: 156.227493316285, 58 | pitch: 53, 59 | zoom: 12.5, 60 | maxPitch: 89, 61 | }, 62 | text: "Mt. St. Helens", 63 | }, 64 | { 65 | key: "montblanc", 66 | value: { 67 | latitude: 45.86306112220158, 68 | longitude: 6.861778870346716, 69 | bearing: 31.589576310589322, 70 | pitch: 62.6, 71 | zoom: 11.7, 72 | maxPitch: 89, 73 | }, 74 | text: "Mont Blanc", 75 | }, 76 | ]; 77 | 78 | export default function InfoBox(props) { 79 | const { meshAlgorithm, loadTexture, onChange } = props; 80 | 81 | const panels = [ 82 | { 83 | key: "main-panel", 84 | title: "Serverless 3D Terrain", 85 | content: { 86 | content: ( 87 |
88 |

89 | Uses{" "} 90 | 95 | 96 | pydelatin 97 | {" "} 98 | or{" "} 99 | 104 | 105 | pymartini 106 | {" "} 107 | for mesh generation,{" "} 108 | 113 | 114 | quantized-mesh-encoder 115 | {" "} 116 | for encoding to{" "} 117 | 122 | quantized mesh 123 | 124 | ,{" "} 125 | 130 | 131 | dem-tiler 132 | {" "} 133 | for the serverless API, and{" "} 134 | 139 | deck.gl 140 | {" "} 141 | for rendering.{" "} 142 | 147 | 148 | Example source. 149 | 150 |

151 | 152 |

153 | If you look closely, you should be able to see small differences 154 | between the Delatin and Martini meshes. Namely all of Martini's 155 | triangles are right triangles, while Delatin doesn't have 156 | that restriction, allowing it to have a more efficient mesh (fewer 157 | triangles) for a given maximum error. 158 |

159 | 160 | 164 | onChange({ loadTexture: checked }) 165 | } 166 | /> 167 |
168 | 180 | onChange({ meshAlgorithm: value }) 181 | } 182 | /> 183 |
184 | ), 185 | }, 186 | }, 187 | ]; 188 | 189 | return ( 190 | 204 | 212 | 213 | ); 214 | } 215 | -------------------------------------------------------------------------------- /site/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /site/src/quantized-mesh-layer.js: -------------------------------------------------------------------------------- 1 | import { TileLayer } from "@deck.gl/geo-layers"; 2 | import { SimpleMeshLayer } from "@deck.gl/mesh-layers"; 3 | import { COORDINATE_SYSTEM } from "@deck.gl/core"; 4 | import { load } from "@loaders.gl/core"; 5 | import { ImageLoader } from "@loaders.gl/images"; 6 | import { QuantizedMeshLoader } from "@loaders.gl/terrain"; 7 | import { Matrix4 } from "math.gl"; 8 | 9 | const DUMMY_DATA = [1]; 10 | 11 | // With create react app, env variables need to be prefixed with REACT_APP 12 | const MapboxAccessToken = process.env.REACT_APP_MAPBOX_ACCESS_TOKEN; 13 | 14 | // Error suggestion from here 15 | // https://www.linkedin.com/pulse/fast-cesium-terrain-rendering-new-quantized-mesh-output-alvaro-huarte/ 16 | function getMeshMaxError(z) { 17 | return (77067.34 / (1 << z)).toFixed(2); 18 | } 19 | 20 | function quantizedMeshUrl(opts) { 21 | const { 22 | x, 23 | y, 24 | z, 25 | mosaicUrl = "terrarium", 26 | meshAlgorithm = "pydelatin", 27 | meshMaxError = 10, 28 | } = opts; 29 | const params = { 30 | url: mosaicUrl, 31 | mesh_max_error: meshMaxError, 32 | mesh_algorithm: meshAlgorithm, 33 | // True for pydelatin, false for pymartini. Not sure why... 34 | flip_y: meshAlgorithm === "pydelatin", 35 | }; 36 | const searchParams = new URLSearchParams(params); 37 | let baseUrl = `https://us-east-1-lambda.kylebarron.dev/dem/mesh/${z}/${x}/${y}.terrain?`; 38 | return baseUrl + searchParams.toString(); 39 | } 40 | 41 | export function QuantizedMeshTerrainLayer(opts) { 42 | const { 43 | minZoom = 0, 44 | maxZoom = 15, 45 | onViewportLoad, 46 | zRange, 47 | meshAlgorithm, 48 | loadTexture, 49 | } = opts || {}; 50 | return new TileLayer({ 51 | id: "quantized-mesh-tile", 52 | minZoom, 53 | maxZoom, 54 | getTileData: (args) => getTileData({ ...args, meshAlgorithm, loadTexture }), 55 | renderSubLayers, 56 | onViewportLoad, 57 | zRange, 58 | refinementStrategy: "no-overlap", 59 | tileSize: 256, 60 | updateTriggers: { 61 | getTileData: [meshAlgorithm, loadTexture], 62 | }, 63 | }); 64 | } 65 | 66 | async function getTileData({ x, y, z, meshAlgorithm, loadTexture }) { 67 | const meshMaxError = getMeshMaxError(z); 68 | const terrainUrl = quantizedMeshUrl({ x, y, z, meshMaxError, meshAlgorithm }); 69 | const terrain = load(terrainUrl, QuantizedMeshLoader); 70 | 71 | const imageUrl = `https://api.mapbox.com/v4/mapbox.satellite/${z}/${x}/${y}.png?access_token=${MapboxAccessToken}`; 72 | let image; 73 | if (loadTexture) { 74 | image = load(imageUrl, ImageLoader); 75 | } 76 | return Promise.all([terrain, image]); 77 | } 78 | 79 | function renderSubLayers(props) { 80 | const { data, tile } = props; 81 | 82 | const [mesh, texture] = data || []; 83 | 84 | return [ 85 | new SimpleMeshLayer(props, { 86 | data: DUMMY_DATA, 87 | mesh, 88 | texture, 89 | getPolygonOffset: null, 90 | coordinateSystem: COORDINATE_SYSTEM.CARTESIAN, 91 | modelMatrix: getModelMatrix(tile), 92 | getPosition: (d) => [0, 0, 0], 93 | // Color to use if surfaceImage is unavailable 94 | getColor: [200, 200, 200], 95 | // wireframe: true, 96 | }), 97 | ]; 98 | } 99 | 100 | // From https://github.com/uber/deck.gl/blob/b1901b11cbdcb82b317e1579ff236d1ca1d03ea7/modules/geo-layers/src/mvt-tile-layer/mvt-tile-layer.js#L41-L52 101 | // Necessary when using COORDINATE_SYSTEM.CARTESIAN with the standard web 102 | // mercator viewport 103 | function getModelMatrix(tile) { 104 | const WORLD_SIZE = 512; 105 | const worldScale = Math.pow(2, tile.z); 106 | 107 | const xScale = WORLD_SIZE / worldScale; 108 | const yScale = -xScale; 109 | 110 | const xOffset = (WORLD_SIZE * tile.x) / worldScale; 111 | const yOffset = WORLD_SIZE * (1 - tile.y / worldScale); 112 | 113 | return new Matrix4() 114 | .translate([xOffset, yOffset, 0]) 115 | .scale([xScale, yScale, 1]); 116 | } 117 | -------------------------------------------------------------------------------- /site/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl, { 104 | headers: { 'Service-Worker': 'script' }, 105 | }) 106 | .then(response => { 107 | // Ensure service worker exists, and that we really are getting a JS file. 108 | const contentType = response.headers.get('content-type'); 109 | if ( 110 | response.status === 404 || 111 | (contentType != null && contentType.indexOf('javascript') === -1) 112 | ) { 113 | // No service worker found. Probably a different app. Reload the page. 114 | navigator.serviceWorker.ready.then(registration => { 115 | registration.unregister().then(() => { 116 | window.location.reload(); 117 | }); 118 | }); 119 | } else { 120 | // Service worker found. Proceed as normal. 121 | registerValidSW(swUrl, config); 122 | } 123 | }) 124 | .catch(() => { 125 | console.log( 126 | 'No internet connection found. App is running in offline mode.' 127 | ); 128 | }); 129 | } 130 | 131 | export function unregister() { 132 | if ('serviceWorker' in navigator) { 133 | navigator.serviceWorker.ready 134 | .then(registration => { 135 | registration.unregister(); 136 | }) 137 | .catch(error => { 138 | console.error(error.message); 139 | }); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /site/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /test/test_bounding_sphere.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from quantized_mesh_encoder.bounding_sphere import bounding_sphere 5 | 6 | 7 | def test_bounding_sphere_unit_cube(): 8 | """Test bounding sphere of the unit cube 9 | 10 | Here the naive implementation _does_ find the minimal bounding sphere. 11 | """ 12 | positions = [ 13 | -1, -1, -1, 14 | -1, -1, 1, 15 | -1, 1, -1, 16 | -1, 1, 1, 17 | 1, -1, -1, 18 | 1, -1, 1, 19 | 1, 1, -1, 20 | 1, 1, 1, 21 | ] # fmt: skip 22 | 23 | cube = np.array(positions).reshape(-1, 3).astype(np.float32) 24 | center, radius = bounding_sphere(cube) 25 | assert np.isclose(np.array([0, 0, 0]), center).all(), 'Incorrect center' 26 | assert np.isclose(np.sqrt(3), radius), 'Incorrect radius' 27 | 28 | 29 | # Each inner array must have a multiple of three positions 30 | # fmt: off 31 | BOUNDING_SPHERE_CONTAINMENT_CASES = [ 32 | [ 33 | 0, 0, 0, 34 | 1, 2, 3, 35 | 4, 5, 6, 36 | 20, -20, 10, 37 | -10, 30, 0, 38 | 0, 30, 30, 39 | ] 40 | ] 41 | # fmt: on 42 | 43 | 44 | @pytest.mark.parametrize("positions", BOUNDING_SPHERE_CONTAINMENT_CASES) 45 | def test_bounding_sphere_containment(positions): 46 | """ 47 | For each input of positions, creates a bounding sphere and then makes sure 48 | that each point is inside the sphere. 49 | """ 50 | positions = np.array(positions).reshape(-1, 3).astype(np.float32) 51 | center, radius = bounding_sphere(positions) 52 | 53 | # Distance from each point to the center 54 | distances = np.linalg.norm(positions - center, axis=1) 55 | 56 | # All distances to the center must be <= the radius 57 | assert (distances <= radius).all(), 'A position outside bounding sphere' 58 | 59 | 60 | @pytest.mark.parametrize("positions", BOUNDING_SPHERE_CONTAINMENT_CASES) 61 | def test_bounding_sphere_containment_ritter(positions): 62 | """ 63 | For each input of positions, creates a bounding sphere and then makes sure 64 | that each point is inside the sphere. 65 | """ 66 | positions = np.array(positions).reshape(-1, 3).astype(np.float32) 67 | center, radius = bounding_sphere(positions, method='ritter') 68 | 69 | # Distance from each point to the center 70 | distances = np.linalg.norm(positions - center, axis=1) 71 | 72 | # All distances to the center must be <= the radius 73 | assert (distances <= radius).all(), 'A position outside bounding sphere' 74 | -------------------------------------------------------------------------------- /test/test_ecef.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from quantized_mesh_encoder.ecef import to_ecef 5 | 6 | # Conversion reference 7 | # http://www.oc.nps.edu/oc2902w/coord/llhxyz.htm 8 | 9 | # Test cases taken from 10 | # https://github.com/loicgasser/quantized-mesh-tile/blob/master/tests/test_llh_ecef.py 11 | # Compare against rounded integers 12 | TO_ECEF_TEST_CASES = [ 13 | ([0, 0, 0], [6378137, 0, 0]), 14 | ([7.43861, 46.951103, 552], [4325328, 564726.2, 4638459]), 15 | ([7.81512, 46.30447, 635.0], [4373351, 600250.4, 4589151]), 16 | ([7.81471, 46.306686, 635.0], [4373179, 600194.8, 4589321]), 17 | ] 18 | 19 | 20 | @pytest.mark.parametrize("llh_positions,exp_positions", TO_ECEF_TEST_CASES) 21 | def test_to_ecef(llh_positions, exp_positions): 22 | arr = np.array(llh_positions, dtype=np.float32).reshape(-1, 3) 23 | cart_positions = to_ecef(arr) 24 | 25 | assert np.isclose(round(cart_positions[0, 0], 1), exp_positions[0]) 26 | assert np.isclose(round(cart_positions[0, 1], 1), exp_positions[1]) 27 | assert np.isclose(round(cart_positions[0, 2], 1), exp_positions[2]) 28 | -------------------------------------------------------------------------------- /test/test_encode.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | from numbers import Number 3 | 4 | import numpy as np 5 | from quantized_mesh_tile import TerrainTile 6 | 7 | from quantized_mesh_encoder import extensions 8 | from quantized_mesh_encoder.ecef import to_ecef 9 | from quantized_mesh_encoder.encode import ( 10 | compute_header, 11 | encode, 12 | encode_header, 13 | interp_positions, 14 | ) 15 | from quantized_mesh_encoder.normals import compute_vertex_normals 16 | 17 | 18 | def test_compute_header(): 19 | positions = np.array([0, 0, 0, 1, 1, 1, 0, 1, 4], dtype=np.float32).reshape(-1, 3) 20 | header = compute_header(positions, sphere_method=None) 21 | keys = [ 22 | 'centerX', 23 | 'centerY', 24 | 'centerZ', 25 | 'minimumHeight', 26 | 'maximumHeight', 27 | 'boundingSphereCenterX', 28 | 'boundingSphereCenterY', 29 | 'boundingSphereCenterZ', 30 | 'boundingSphereRadius', 31 | 'horizonOcclusionPointX', 32 | 'horizonOcclusionPointY', 33 | 'horizonOcclusionPointZ', 34 | ] 35 | assert all(k in header.keys() for k in keys), 'Header key missing' 36 | assert all( 37 | isinstance(v, Number) for v in header.values() 38 | ), 'Header value not numeric' 39 | 40 | 41 | def test_encode_header(): 42 | header = { 43 | 'centerX': 6377169.5, 44 | 'centerY': 55648.50390625, 45 | 'centerZ': 55284.421875, 46 | 'minimumHeight': 0.0, 47 | 'maximumHeight': 4.0, 48 | 'boundingSphereCenterX': 6377169.5, 49 | 'boundingSphereCenterY': 55648.504, 50 | 'boundingSphereCenterZ': 55284.42, 51 | 'boundingSphereRadius': 78447.81, 52 | 'horizonOcclusionPointX': 6378226.71931529, 53 | 'horizonOcclusionPointY': 55657.731270568445, 54 | 'horizonOcclusionPointZ': 55293.58686988714, 55 | } 56 | 57 | buf = BytesIO() 58 | encode_header(buf, header) 59 | buf.seek(0) 60 | b = buf.read() 61 | 62 | assert len(b) == 88, 'Header incorrect number of bytes' 63 | 64 | 65 | def test_encode_decode(): 66 | 67 | positions = np.array( 68 | [0, 0, 0, 1, 1, 1, 0, 1, 4, 2, 3, 4, 8, 9, 10, 12, 13, 14], dtype=np.float32 69 | ) 70 | triangles = np.array([0, 1, 2, 1, 2, 3, 2, 3, 4, 3, 4, 5], dtype=np.uint32) 71 | cartesian_positions = to_ecef(positions.reshape(-1, 3)) 72 | 73 | normals_ext = extensions.VertexNormalsExtension( 74 | positions=positions, indices=triangles 75 | ) 76 | 77 | f = BytesIO() 78 | encode(f, positions, triangles, extensions=[normals_ext]) 79 | 80 | f.seek(0) 81 | tile = TerrainTile() 82 | tile.fromBytesIO(f, hasLighting=True) 83 | 84 | assert np.array_equal( 85 | triangles, np.array(tile.indices, dtype=np.uint32) 86 | ), 'Indices incorrect' 87 | u, v, h = interp_positions(positions.reshape(-1, 3)).T 88 | 89 | assert np.array_equal(u, np.array(tile.u, dtype=np.uint32)), 'Vertices incorrect' 90 | assert np.array_equal(v, np.array(tile.v, dtype=np.uint32)), 'Vertices incorrect' 91 | assert np.array_equal(h, np.array(tile.h, dtype=np.uint32)), 'Vertices incorrect' 92 | 93 | assert tile.westI == [0, 2] 94 | assert tile.southI == [0] 95 | assert tile.eastI == [5] 96 | assert tile.northI == [5] 97 | 98 | normals = compute_vertex_normals(cartesian_positions, triangles) 99 | 100 | # Can't test for exact equality with octencode/decode 101 | assert np.allclose( 102 | normals, tile.vLight, atol=0.01, rtol=0 103 | ), 'VertexNormals incorrect' 104 | -------------------------------------------------------------------------------- /test/test_util_cy.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from quantized_mesh_encoder.util_cy import encode_indices 5 | 6 | 7 | # From quantized_mesh_tile.utils 8 | def decode_indices(indices): 9 | out = [] 10 | highest = 0 11 | for i in indices: 12 | out.append(highest - i) 13 | if i == 0: 14 | highest += 1 15 | return out 16 | 17 | 18 | ENCODE_INDICES_CASES = [[0, 1, 2, 1, 2, 3, 3, 4, 5, 2, 3, 4]] 19 | 20 | 21 | @pytest.mark.parametrize("indices", ENCODE_INDICES_CASES) 22 | def test_encode_indices(indices): 23 | arr = np.array(indices, dtype=np.uint32) 24 | out = decode_indices(encode_indices(arr)) 25 | assert indices == out, 'Incorrect index encoding' 26 | -------------------------------------------------------------------------------- /test/test_zigzag.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from quantized_mesh_encoder.util import zig_zag_encode 5 | 6 | ZIG_ZAG_ENCODE_TEST_CASES = [ 7 | (-1, 1), 8 | (-2, 3), 9 | (0, 0), 10 | (1, 2), 11 | (2, 4), 12 | (np.array([-1, -2], dtype=np.int16), np.array([1, 3], dtype=np.int16)), 13 | ] 14 | 15 | 16 | @pytest.mark.parametrize("value,expected", ZIG_ZAG_ENCODE_TEST_CASES) 17 | def test_zig_zag_encode(value, expected): 18 | if isinstance(value, np.ndarray) or isinstance(expected, np.ndarray): 19 | assert np.array_equal(zig_zag_encode(value), expected) 20 | else: 21 | assert zig_zag_encode(value) == expected 22 | --------------------------------------------------------------------------------