├── landcarve ├── constants.py ├── __init__.py ├── arial.ttf ├── commands │ ├── merge.py │ ├── flipy.py │ ├── smooth.py │ ├── decimate.py │ ├── fixnodata.py │ ├── step.py │ ├── bulkget.py │ ├── stats.py │ ├── decifit.py │ ├── exactfit.py │ ├── zfit.py │ ├── pipeline.py │ ├── tilesplit.py │ ├── elevalue.py │ ├── tileimage.py │ ├── lasdem.py │ ├── realise.py │ └── contour_image.py ├── utils │ ├── coords.py │ ├── stats.py │ ├── graphics.py │ └── io.py └── cli.py ├── .gitignore ├── examples ├── national-parks.txt └── london-tiles.txt ├── setup.py ├── standalone └── csv_to_profile_svgs.py └── README.rst /landcarve/constants.py: -------------------------------------------------------------------------------- 1 | NODATA = -1000 2 | -------------------------------------------------------------------------------- /landcarve/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.0" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.stl 2 | *.tif 3 | *.tiff 4 | *.asc 5 | *.egg-info 6 | /build 7 | /dist 8 | -------------------------------------------------------------------------------- /landcarve/arial.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewgodwin/landcarve/HEAD/landcarve/arial.ttf -------------------------------------------------------------------------------- /landcarve/commands/merge.py: -------------------------------------------------------------------------------- 1 | import click 2 | import subprocess 3 | 4 | from landcarve.cli import main 5 | 6 | 7 | @main.command() 8 | @click.argument("input_paths", nargs=-1) 9 | @click.argument("output_path") 10 | def merge( 11 | input_paths, output_path, 12 | ): 13 | """ 14 | Merges DEMs together 15 | """ 16 | 17 | subprocess.call(["gdal_merge.py", "-o", output_path] + list(input_paths)) 18 | -------------------------------------------------------------------------------- /examples/national-parks.txt: -------------------------------------------------------------------------------- 1 | # Example pipeline to make small terrain models of National Parks. 2 | # Designed for pre-cut-out heightmaps (around the park boundary) exported from QGIS. 3 | 4 | # Decimate input so that it's more reasonable 5 | decifit --xy-steps=500 6 | 7 | # Fix NODATA values 8 | fixnodata --nodata=0 9 | 10 | # Linearly fit-scale height 11 | zfit --fit=3 12 | 13 | # Render to an STL 14 | realise --xy-scale=0.04 --base=0.1 15 | -------------------------------------------------------------------------------- /examples/london-tiles.txt: -------------------------------------------------------------------------------- 1 | # Example pipeline to make London tiles from Environment Agency data 2 | # Find an example .asc file to run through this here: https://drive.google.com/open?id=1MrEqwUxykgZLV0ux3oBdjTPuMRFWJ6Kn 3 | 4 | # Decimate input so that it's more reasonable 5 | decifit --xy-steps=500 6 | 7 | # Fix NODATA values 8 | fixnodata --nodata=-100 9 | 10 | # Smooth 11 | smooth --factor=4 12 | 13 | # Render to an STL 14 | realise --xy-scale=0.1 --z-scale=0.15 --base=0.5 --solid 15 | -------------------------------------------------------------------------------- /landcarve/utils/coords.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | 4 | def latlong_to_xy(lat, long, zoom): 5 | """ 6 | Converts a latitude and longitude into tile X and Y coords 7 | """ 8 | lat_rad = math.radians(lat) 9 | n = 2.0**zoom 10 | x = int((long + 180.0) / 360.0 * n) 11 | y = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n) 12 | return x, y 13 | 14 | 15 | def xy_to_latlong(x, y, zoom): 16 | n = 2.0**zoom 17 | lon_deg = x / n * 360.0 - 180.0 18 | lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * y / n))) 19 | lat_deg = math.degrees(lat_rad) 20 | return lat_deg, lon_deg 21 | -------------------------------------------------------------------------------- /landcarve/commands/flipy.py: -------------------------------------------------------------------------------- 1 | import click 2 | import numpy 3 | 4 | from landcarve.cli import main 5 | from landcarve.constants import NODATA 6 | from landcarve.utils.io import array_to_raster, raster_to_array 7 | 8 | 9 | @main.command() 10 | @click.argument("input_path") 11 | @click.argument("output_path") 12 | def flipy(input_path, output_path): 13 | """ 14 | Flips the image's up and down 15 | """ 16 | # Load the file using GDAL 17 | arr = raster_to_array(input_path) 18 | # Run stepper 19 | arr = numpy.flipud(arr) 20 | click.echo("Array flipped up/down") 21 | # Write out the array 22 | array_to_raster(arr, output_path) 23 | -------------------------------------------------------------------------------- /landcarve/utils/stats.py: -------------------------------------------------------------------------------- 1 | def clip(x, lower, upper): 2 | return min(upper, max(lower, x)) 3 | 4 | 5 | def mean(data): 6 | """Return the sample arithmetic mean of data.""" 7 | n = len(data) 8 | if n < 1: 9 | raise ValueError("mean requires at least one data point") 10 | return sum(data) / float(n) 11 | 12 | 13 | def _ss(data): 14 | """Return sum of square deviations of sequence data.""" 15 | c = mean(data) 16 | ss = sum((x - c) ** 2 for x in data) 17 | return ss 18 | 19 | 20 | def pstdev(data): 21 | """Calculates the population standard deviation.""" 22 | n = len(data) 23 | if n < 2: 24 | raise ValueError("variance requires at least two data points") 25 | ss = _ss(data) 26 | pvar = ss / n # the population variance 27 | return pvar ** 0.5 28 | -------------------------------------------------------------------------------- /landcarve/commands/smooth.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from skimage.morphology import area_closing, area_opening 4 | 5 | from landcarve.cli import main 6 | from landcarve.utils.io import array_to_raster, raster_to_array 7 | 8 | 9 | @main.command() 10 | @click.option( 11 | "--factor", default=1.0, type=float, help="Smoothing factor", 12 | ) 13 | @click.argument("input_path") 14 | @click.argument("output_path") 15 | def smooth(input_path, output_path, factor): 16 | """ 17 | Fancier smoothing 18 | """ 19 | # Load the file using GDAL 20 | arr = raster_to_array(input_path) 21 | 22 | arr = area_closing(arr, area_threshold=32) 23 | arr = area_opening(arr, area_threshold=32) 24 | 25 | # Write out the array 26 | click.echo("Smooth2ed with factor %s" % factor, err=True) 27 | array_to_raster(arr, output_path) 28 | -------------------------------------------------------------------------------- /landcarve/commands/decimate.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from landcarve.cli import main 4 | from landcarve.utils.io import array_to_raster, raster_to_array 5 | 6 | 7 | @main.command() 8 | @click.option( 9 | "-d", 10 | "--divisor", 11 | default=2, 12 | type=int, 13 | help="The divisor on number of steps", 14 | ) 15 | @click.argument("input_path") 16 | @click.argument("output_path") 17 | def decimate(input_path, output_path, divisor): 18 | """ 19 | Scales raster layers down to a certain number of cells in X/Y, maintaining 20 | aspect ratio. 21 | """ 22 | # Load the file using GDAL 23 | arr = raster_to_array(input_path) 24 | # Downsample it 25 | arr = arr[::divisor, ::divisor] 26 | click.echo("Downsampled to {} x {}".format(arr.shape[0], arr.shape[1]), err=True) 27 | # Write out the array 28 | array_to_raster(arr, output_path) 29 | -------------------------------------------------------------------------------- /landcarve/commands/fixnodata.py: -------------------------------------------------------------------------------- 1 | import click 2 | import numpy 3 | 4 | from landcarve.cli import main 5 | from landcarve.constants import NODATA 6 | from landcarve.utils.io import array_to_raster, raster_to_array 7 | 8 | 9 | @main.command() 10 | @click.option( 11 | "--nodata", default=0.0, type=float, help="NODATA boundary for input", 12 | ) 13 | @click.argument("input_path") 14 | @click.argument("output_path") 15 | @click.pass_context 16 | def fixnodata(ctx, input_path, output_path, nodata): 17 | """ 18 | Fixes NODATA ranges on files to pin them to -1000. 19 | """ 20 | # Load the file using GDAL 21 | arr = raster_to_array(input_path) 22 | # Fix NODATA 23 | scaler = lambda x: x if x > nodata else NODATA 24 | arr = numpy.vectorize(scaler, otypes="f")(arr) 25 | click.echo("NODATA values set to {}".format(NODATA), err=True) 26 | # Write out the array 27 | array_to_raster(arr, output_path) 28 | -------------------------------------------------------------------------------- /landcarve/cli.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | 4 | @click.group() 5 | def main(): 6 | """ 7 | Top-level command entrypoint 8 | """ 9 | pass 10 | 11 | 12 | # Import all sub-commands 13 | import landcarve.commands.bulkget 14 | import landcarve.commands.contour_image 15 | import landcarve.commands.decifit 16 | import landcarve.commands.decimate 17 | import landcarve.commands.elevalue 18 | import landcarve.commands.exactfit 19 | import landcarve.commands.fixnodata 20 | import landcarve.commands.flipy 21 | import landcarve.commands.lasdem 22 | import landcarve.commands.pipeline 23 | import landcarve.commands.merge 24 | import landcarve.commands.realise 25 | import landcarve.commands.smooth 26 | import landcarve.commands.stats 27 | import landcarve.commands.step 28 | import landcarve.commands.tileimage 29 | import landcarve.commands.tilesplit 30 | import landcarve.commands.zfit 31 | 32 | 33 | if __name__ == "__main__": 34 | print(id(main)) 35 | main() 36 | -------------------------------------------------------------------------------- /landcarve/commands/step.py: -------------------------------------------------------------------------------- 1 | import click 2 | import numpy 3 | 4 | from landcarve.cli import main 5 | from landcarve.constants import NODATA 6 | from landcarve.utils.io import array_to_raster, raster_to_array 7 | 8 | 9 | @main.command() 10 | @click.option( 11 | "--interval", default=10, type=float, help="Stepping interval", 12 | ) 13 | @click.option( 14 | "--base", default=0, type=float, help="Offset for start of step", 15 | ) 16 | @click.argument("input_path") 17 | @click.argument("output_path") 18 | def step(input_path, output_path, interval, base): 19 | """ 20 | Snaps layer values to boundaries 21 | """ 22 | # Load the file using GDAL 23 | arr = raster_to_array(input_path) 24 | # Run stepper 25 | scaler = lambda x: round(x / interval) * interval 26 | arr = numpy.vectorize(scaler, otypes="f")(arr) 27 | click.echo( 28 | "Array stepped with interval {}, base {}".format(interval, base), err=True 29 | ) 30 | # Write out the array 31 | array_to_raster(arr, output_path) 32 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import find_packages, setup 4 | 5 | # We use the README as the long_description 6 | readme_path = os.path.join(os.path.dirname(__file__), "README.rst") 7 | with open(readme_path) as fp: 8 | long_description = fp.read() 9 | 10 | setup( 11 | name="landcarve", 12 | version="0.1", 13 | author="Andrew Godwin", 14 | author_email="andrew@aeracode.org", 15 | description="Django ASGI (HTTP/WebSocket) server", 16 | long_description=long_description, 17 | license="BSD", 18 | zip_safe=False, 19 | packages=find_packages(), 20 | include_package_data=True, 21 | install_requires=[ 22 | "gdal[numpy]~=3.6.0", 23 | "numpy~=1.16", 24 | "click~=7.0", 25 | "svgwrite~=1.4", 26 | "scikit-image~=0.16", 27 | "requests~=2.18", 28 | "simplification~=0.5", 29 | "laspy[lazrs]~=2.5.0", 30 | "trimesh~=4.4.1", 31 | "manifold3d~=2.5.1", 32 | ], 33 | entry_points={"console_scripts": ["landcarve = landcarve.cli:main"]}, 34 | ) 35 | -------------------------------------------------------------------------------- /landcarve/commands/bulkget.py: -------------------------------------------------------------------------------- 1 | import os 2 | import urllib.request 3 | 4 | import click 5 | import requests 6 | import shutil 7 | 8 | from landcarve.cli import main 9 | 10 | 11 | @main.command() 12 | @click.argument("input_path") 13 | @click.argument("output_path") 14 | def bulkget(input_path, output_path): 15 | """ 16 | Bulk downloads information from the national map downloader, auto-filtering 17 | out "useless" URLs. 18 | """ 19 | # Calculate the correct set of urls 20 | urls = [] 21 | with open(input_path) as fh: 22 | for line in fh: 23 | line = line.strip() 24 | if "metadata" in line or line.endswith(".html") or line.endswith("/"): 25 | continue 26 | urls.append(line) 27 | # Download them 28 | for n, url in enumerate(urls): 29 | filename = url.split("/")[-1] 30 | local_path = os.path.join(output_path, filename) 31 | click.echo(f"[{n+1}/{len(urls)}] {filename}") 32 | urllib.request.urlretrieve(url, local_path) 33 | 34 | urllib.request.urlcleanup() 35 | -------------------------------------------------------------------------------- /landcarve/commands/stats.py: -------------------------------------------------------------------------------- 1 | import click 2 | import numpy 3 | 4 | from landcarve.cli import main 5 | from landcarve.constants import NODATA 6 | from landcarve.utils.io import array_to_raster, raster_to_array 7 | 8 | 9 | @main.command() 10 | @click.argument("input_paths", nargs=-1) 11 | def stats(input_paths): 12 | """ 13 | Gives stats on a DEM 14 | """ 15 | for input_path in input_paths: 16 | click.echo(click.style(f"{input_path}", fg="blue", bold=True)) 17 | # Load the file using GDAL 18 | arr = raster_to_array(input_path) 19 | # Work out what the range of Z values is, ignoring NODATA 20 | min_value, max_value = value_range(arr, NODATA) 21 | value_delta = max_value - min_value 22 | click.echo( 23 | f"Z Value range: {min_value:.2f} to {max_value:.2f} ({value_delta:.2f})" 24 | ) 25 | 26 | 27 | def value_range(arr, NODATA=NODATA): 28 | """ 29 | Given an array and a NODATA limit, returns the range of values in the array. 30 | """ 31 | min_value = max_value = None 32 | for value in numpy.nditer(arr): 33 | value = value.item() 34 | if value > NODATA: 35 | if min_value is None or value < min_value: 36 | min_value = value 37 | if max_value is None or value > max_value: 38 | max_value = value 39 | return min_value, max_value 40 | -------------------------------------------------------------------------------- /landcarve/commands/decifit.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from landcarve.cli import main 4 | from landcarve.utils.io import array_to_raster, raster_to_array 5 | 6 | 7 | @main.command() 8 | @click.option( 9 | "-x", 10 | "--xy-steps", 11 | default=1000, 12 | type=int, 13 | help="The maximum number of steps on X and Y.", 14 | ) 15 | @click.argument("input_path") 16 | @click.argument("output_path") 17 | def decifit(input_path, output_path, xy_steps): 18 | """ 19 | Scales raster layers down to a certain number of cells in X/Y, maintaining 20 | aspect ratio. 21 | """ 22 | # Load the file using GDAL 23 | arr = raster_to_array(input_path) 24 | # Downsample it 25 | arr = downsample_array(arr, xy_steps) 26 | click.echo("Downsampled to {} x {}".format(arr.shape[0], arr.shape[1]), err=True) 27 | # Write out the array 28 | array_to_raster(arr, output_path) 29 | 30 | 31 | def downsample_array(arr, max_dimension): 32 | """ 33 | Takes an array and downsamples it so its longest dimension is less than 34 | or equal to max_dimension. 35 | """ 36 | # Work out the downsampling factors for that array 37 | width, height = arr.shape 38 | current_steps = max(width, height) 39 | downsample_factor = 1 40 | while (current_steps // downsample_factor) > max_dimension: 41 | downsample_factor += 1 42 | # Downsample the array 43 | print("Decimate factor: %s" % downsample_factor) 44 | return arr[::downsample_factor, ::downsample_factor] 45 | -------------------------------------------------------------------------------- /landcarve/commands/exactfit.py: -------------------------------------------------------------------------------- 1 | import click 2 | import numpy 3 | 4 | from landcarve.cli import main 5 | from landcarve.utils.io import array_to_raster, raster_to_array 6 | 7 | 8 | @main.command() 9 | @click.option( 10 | "-x", 11 | "--x-steps", 12 | default=1000, 13 | type=int, 14 | help="The exact number of steps on X", 15 | ) 16 | @click.option( 17 | "-y", 18 | "--y-steps", 19 | default=1000, 20 | type=int, 21 | help="The exact number of steps on Y", 22 | ) 23 | @click.argument("input_path") 24 | @click.argument("output_path") 25 | def exactfit(input_path, output_path, x_steps, y_steps): 26 | """ 27 | Scales raster layers down to a certain number of cells in X/Y exactly. 28 | """ 29 | # Load the file using GDAL 30 | arr = raster_to_array(input_path) 31 | # Downsample it 32 | arr = downsample_array(arr, x_steps, y_steps) 33 | click.echo("Downsampled to {} x {}".format(arr.shape[0], arr.shape[1]), err=True) 34 | # Write out the array 35 | array_to_raster(arr, output_path) 36 | 37 | 38 | def downsample_array(arr, x_dimension, y_dimension): 39 | """ 40 | Takes an array and downsamples it so its longest dimension is less than 41 | or equal to max_dimension. 42 | """ 43 | # Work out the downsampling factors for that array 44 | current_y, current_x = arr.shape 45 | x_step = current_x / x_dimension 46 | y_step = current_y / y_dimension 47 | # Create a new array and populate it 48 | new_arr = numpy.ndarray((y_dimension, x_dimension)) 49 | for dx in range(x_dimension): 50 | for dy in range(y_dimension): 51 | new_arr[dy][dx] = arr[int(dy * y_step)][int(dx * x_step)] 52 | return new_arr 53 | -------------------------------------------------------------------------------- /landcarve/commands/zfit.py: -------------------------------------------------------------------------------- 1 | import click 2 | import numpy 3 | 4 | from landcarve.cli import main 5 | from landcarve.constants import NODATA 6 | from landcarve.utils.io import array_to_raster, raster_to_array 7 | 8 | 9 | @main.command() 10 | @click.option( 11 | "--fit", 12 | default=1.0, 13 | type=float, 14 | help="Scale linearly from 0 to specified maximum value", 15 | ) 16 | @click.argument("input_path") 17 | @click.argument("output_path") 18 | def zfit(input_path, output_path, fit): 19 | """ 20 | Scales raster layers down to a certain number of cells in X/Y, maintaining 21 | aspect ratio. 22 | """ 23 | # Load the file using GDAL 24 | arr = raster_to_array(input_path) 25 | # Work out what the range of Z values is, ignoring NODATA 26 | min_value, max_value = value_range(arr, NODATA) 27 | value_delta = max_value - min_value 28 | click.echo("Value range: {} to {} ({})".format(min_value, max_value, value_delta)) 29 | # Scale the array to be more normalised 30 | scaler = lambda x: (((x - min_value) / value_delta) * fit) if x > NODATA else NODATA 31 | arr = numpy.vectorize(scaler, otypes="f")(arr) 32 | click.echo("Array scaled to range {} to {}".format(0, fit), err=True) 33 | # Write out the array 34 | array_to_raster(arr, output_path) 35 | 36 | 37 | def value_range(arr, NODATA=NODATA): 38 | """ 39 | Given an array and a NODATA limit, returns the range of values in the array. 40 | """ 41 | min_value = max_value = None 42 | for value in numpy.nditer(arr): 43 | value = value.item() 44 | if value > NODATA: 45 | if min_value is None or value < min_value: 46 | min_value = value 47 | if max_value is None or value > max_value: 48 | max_value = value 49 | return min_value, max_value 50 | -------------------------------------------------------------------------------- /landcarve/utils/graphics.py: -------------------------------------------------------------------------------- 1 | import os 2 | import PIL.Image 3 | import PIL.ImageDraw 4 | import PIL.ImageFont 5 | 6 | 7 | def bitmap_array_to_image(array): 8 | """ 9 | Converts an array containing True/False values into a 1-bit image 10 | """ 11 | mask_image = PIL.Image.new("1", (array.shape[1], array.shape[0])) 12 | for y in range(array.shape[0]): 13 | for x in range(array.shape[1]): 14 | mask_image.putpixel((x, y), int(array[y, x])) 15 | return mask_image 16 | 17 | 18 | def draw_border(image, colour=(100, 100, 100, 255)): 19 | """ 20 | Draws a one-pixel-thin border around an image. Operates on the image in-place. 21 | """ 22 | d = PIL.ImageDraw.Draw(image) 23 | mx = image.size[0] - 1 24 | my = image.size[1] - 1 25 | d.line([0, 0, mx, 0], colour) 26 | d.line([0, my, mx, my], colour) 27 | d.line([0, 0, 0, my], colour) 28 | d.line([mx, 0, mx, my], colour) 29 | 30 | 31 | def draw_crosshatch(image, colour=(10, 10, 10, 255), step=20, width=1): 32 | d = PIL.ImageDraw.Draw(image) 33 | mx = image.size[0] - 1 34 | my = image.size[1] - 1 35 | max_size = mx + my 36 | for x in range(0, max_size, step): 37 | d.line([x, 0, x - max_size, max_size], colour, width=width) 38 | for x in range(0 - my, mx, step): 39 | d.line([x, 0, x + max_size, max_size], colour, width=width) 40 | 41 | 42 | def draw_contours(image, contours, colour=(200, 10, 200, 255), width=2): 43 | d = PIL.ImageDraw.Draw(image) 44 | for contour in contours: 45 | d.line(contour, colour, width=width) 46 | 47 | 48 | def draw_labels(image, labels, colour=(200, 10, 200, 255), size=15): 49 | d = PIL.ImageDraw.Draw(image) 50 | font = PIL.ImageFont.truetype( 51 | os.path.join(os.path.dirname(__file__), "../arial.ttf"), size=size 52 | ) 53 | for label, offset in labels: 54 | d.text(offset, label, fill=colour, font=font) 55 | -------------------------------------------------------------------------------- /landcarve/commands/pipeline.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import shlex 4 | import subprocess 5 | import sys 6 | import tempfile 7 | 8 | import click 9 | 10 | from landcarve.cli import main 11 | from landcarve.utils.io import array_to_raster, raster_to_array 12 | 13 | 14 | @main.command() 15 | @click.option("--extension", default=".stl") 16 | @click.argument("pipeline_file") 17 | @click.argument("input_paths", nargs=-1) 18 | def pipeline(input_paths, extension, pipeline_file): 19 | """ 20 | Runs a series of commands from a predefined pipeline, handling file passing. 21 | """ 22 | # Load the pipeline file into memory 23 | click.echo("Pipeline: %s" % pipeline_file, err=True) 24 | pipeline = [] 25 | if not extension.startswith("."): 26 | extension = "." + extension 27 | for line in open(pipeline_file): 28 | # Skip blank lines and comments 29 | line = line.strip() 30 | if not line or line.startswith("#"): 31 | continue 32 | pipeline.append(line) 33 | # For each input... 34 | for input_path in input_paths: 35 | click.echo(click.style(f"Running on {input_path}", fg="blue", bold=True)) 36 | # Run through the pipeline 37 | current_path = input_path 38 | temporary_file_counter = 1 39 | input_is_temporary = False 40 | with tempfile.TemporaryDirectory(prefix="landcarve-") as tmpdir: 41 | for i, line in enumerate(pipeline): 42 | # Work out input/output filenames 43 | if i == len(pipeline) - 1: 44 | next_path = input_path + extension 45 | else: 46 | next_path = os.path.join( 47 | tmpdir, "output-%i.tmp" % temporary_file_counter 48 | ) 49 | temporary_file_counter += 1 50 | # Run the command 51 | command = ["landcarve", *shlex.split(line), current_path, next_path] 52 | click.echo(click.style("Running: %s" % command, fg="green", bold=True)) 53 | try: 54 | subprocess.check_call(command) 55 | except subprocess.CalledProcessError: 56 | click.echo(click.style("Subcommand failed", fg="red", bold=True)) 57 | sys.exit(1) 58 | # Delete any old temporary file 59 | if input_is_temporary: 60 | os.unlink(current_path) 61 | current_path = next_path 62 | input_is_temporary = True 63 | -------------------------------------------------------------------------------- /landcarve/commands/tilesplit.py: -------------------------------------------------------------------------------- 1 | import click 2 | import numpy 3 | 4 | from landcarve.cli import main 5 | from landcarve.constants import NODATA 6 | from landcarve.utils.io import array_to_raster, raster_to_array_and_projection 7 | 8 | 9 | @main.command() 10 | @click.option( 11 | "--x-size", 12 | default=1000, 13 | type=int, 14 | help="Size of new tiles in X dimension", 15 | ) 16 | @click.option( 17 | "--y-size", 18 | default=1000, 19 | type=int, 20 | help="Size of new tiles in Y dimension", 21 | ) 22 | @click.option( 23 | "--x-offset", 24 | default=0, 25 | type=int, 26 | help="Offset of start point in X", 27 | ) 28 | @click.option( 29 | "--y-offset", 30 | default=0, 31 | type=int, 32 | help="Offset of start point in Y", 33 | ) 34 | @click.option( 35 | "--naming-scheme", default="offset", type=str, help="One of offset or letter" 36 | ) 37 | @click.argument("input_path") 38 | @click.argument("output_path") 39 | def tilesplit( 40 | input_path, output_path, x_size, y_size, x_offset, y_offset, naming_scheme 41 | ): 42 | """ 43 | Splits a single big DEM into smaller ones 44 | """ 45 | # Load the file using GDAL 46 | arr, proj = raster_to_array_and_projection(input_path) 47 | # Prep output path 48 | if output_path.endswith(".tif"): 49 | output_path = output_path[:-4] 50 | # Work out Y slice sizes 51 | y_slices = [0] 52 | while y_slices[-1] + y_size < arr.shape[0]: 53 | y_slices.append(y_slices[-1] + y_size) 54 | if y_slices[-1] < arr.shape[0]: 55 | y_slices.append(arr.shape[0]) 56 | # Work out X slice sizes 57 | x_slices = [0] 58 | while x_slices[-1] + x_size < arr.shape[1]: 59 | x_slices.append(x_slices[-1] + x_size) 60 | if x_slices[-1] < arr.shape[1]: 61 | x_slices.append(arr.shape[1]) 62 | # Slice and dice 63 | for j, (y, y_next) in enumerate(zip(y_slices, y_slices[1:])): 64 | for i, (x, x_next) in enumerate(zip(x_slices, x_slices[1:])): 65 | tile = arr[y:y_next, x:x_next] 66 | # Write out the tile 67 | if naming_scheme == "letter": 68 | letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" 69 | suffix = f"{letters[i]}{j+1}" 70 | else: 71 | suffix = f"{x}_{y}" 72 | array_to_raster( 73 | tile, 74 | f"{output_path}_{suffix}.tif", 75 | offset_and_pixel=(x, y, 1, 1), 76 | projection=proj, 77 | ) 78 | click.echo(f"Wrote tile {x} {y}") 79 | -------------------------------------------------------------------------------- /landcarve/commands/elevalue.py: -------------------------------------------------------------------------------- 1 | import click 2 | import numpy 3 | 4 | from landcarve.cli import main 5 | from landcarve.utils.io import array_to_raster, raster_to_array 6 | 7 | 8 | @main.command() 9 | @click.argument("input_path") 10 | @click.argument("output_path") 11 | @click.argument("elevation_path") 12 | @click.option( 13 | "-m", 14 | "--min-value", 15 | type=int, 16 | help="Minimum value to preserve (inclusive)", 17 | ) 18 | @click.option( 19 | "-x", 20 | "--max-value", 21 | type=int, 22 | help="Maximum value to preserve (inclusive)", 23 | ) 24 | def elevalue(input_path, output_path, elevation_path, min_value, max_value): 25 | """ 26 | Takes a raster layer with discrete values, and a raster layer with 27 | continuous elevation values, and outputs elevation only where the discrete 28 | values match certain numbers (for landcover, water etc.) 29 | """ 30 | # Load the values file using GDAL 31 | values_arr = raster_to_array(input_path) 32 | # Load the elevation file using GDAL 33 | elevation_arr = raster_to_array(elevation_path) 34 | # Go through the values array and create a new array where things match with elevation 35 | x_step = elevation_arr.shape[0] / values_arr.shape[0] 36 | y_step = elevation_arr.shape[1] / values_arr.shape[1] 37 | 38 | # Create the value test function 39 | def has_value(dx, dy) -> bool: 40 | test = lambda dx, dy: min_value <= values_arr[dx][dy] <= max_value 41 | if not test(dx, dy): 42 | return False 43 | num_neighbours = 0 44 | for ox, oy in [ 45 | (dx - 1, dy - 1), 46 | (dx - 1, dy), 47 | (dx - 1, dy + 1), 48 | (dx, dy - 1), 49 | (dx, dy + 1), 50 | (dx + 1, dy - 1), 51 | (dx + 1, dy), 52 | (dx + 1, dy + 1), 53 | ]: 54 | if ( 55 | ox < 0 56 | or oy < 0 57 | or ox >= values_arr.shape[0] 58 | or oy >= values_arr.shape[1] 59 | ): 60 | continue 61 | if test(ox, oy): 62 | num_neighbours += 1 63 | if num_neighbours >= 3: 64 | return True 65 | return False 66 | 67 | # Create a new array and populate it 68 | new_arr = numpy.ndarray(values_arr.shape) 69 | for dx in range(values_arr.shape[0]): 70 | for dy in range(values_arr.shape[1]): 71 | if has_value(dx, dy): 72 | new_arr[dx][dy] = elevation_arr[int(dx * x_step)][int(dy * y_step)] 73 | else: 74 | new_arr[dx][dy] = -1000 75 | click.echo( 76 | "Elevalued to {} x {}".format(new_arr.shape[0], new_arr.shape[1]), err=True 77 | ) 78 | # Write out the array 79 | array_to_raster(new_arr, output_path) 80 | -------------------------------------------------------------------------------- /standalone/csv_to_profile_svgs.py: -------------------------------------------------------------------------------- 1 | """ 2 | CSV-to-SVG converter. 3 | """ 4 | 5 | from __future__ import unicode_literals 6 | 7 | import csv 8 | import argparse 9 | import svgwrite 10 | 11 | def main(): 12 | parser = argparse.ArgumentParser() 13 | parser.add_argument("-x", "--xspacing", type=float, default=1) 14 | parser.add_argument("-y", "--yspacing", type=float, default=50) 15 | parser.add_argument("-d", "--distortion", type=float, default=0.05) 16 | parser.add_argument("-r", "--root", type=float, default=1) 17 | parser.add_argument("-s", "--stroke", type=float, default=10) 18 | parser.add_argument("-m", "--smoothing", type=float, default=0) 19 | parser.add_argument("-g", "--merge", type=int, default=1) 20 | parser.add_argument("-p", "--printable", type=bool, default=False) 21 | parser.add_argument("-z", "--minyspacing", type=float, default=0) 22 | parser.add_argument("-o", "--output", default="output.svg") 23 | parser.add_argument("file") 24 | args = parser.parse_args() 25 | 26 | rows = {} 27 | 28 | # Load the CSV 29 | print("Loading CSV") 30 | with open(args.file) as csvfile: 31 | reader = csv.reader(csvfile) 32 | for x, y, _, value in reader: 33 | if x == "X": 34 | continue 35 | x, y, value = float(x), float(y), float(value) 36 | rows.setdefault(y, []) 37 | # Scale value compared to max height 38 | if value < 0: 39 | value = 0 40 | rows[y].append((x, (value ** args.root))) 41 | 42 | # Draw lines from sorted data 43 | print("Saving") 44 | image_height = (len(rows) + 2) * args.yspacing 45 | image_width = len(rows.values()[0]) * args.xspacing 46 | output = svgwrite.Drawing(args.output, (image_width, image_height), profile="tiny") 47 | if args.printable: 48 | output_y = 0 49 | else: 50 | output_y = image_height - args.yspacing 51 | for y, values in sorted(rows.items()): 52 | # Optionally reduce values down 53 | if args.merge > 1: 54 | new_values = [] 55 | value_iter = iter(sorted(values)) 56 | total = 0 57 | try: 58 | while True: 59 | total = 0 60 | for _ in range(args.merge): 61 | new_x, old_value = value_iter.next() 62 | total += old_value 63 | new_values.append((new_x, total / float(args.merge))) 64 | except StopIteration: 65 | pass 66 | values = new_values 67 | # Create points list 68 | points = [(0, output_y)] 69 | output_x = 0 70 | last_value = 0 71 | for x, value in sorted(values): 72 | output_x += (args.xspacing * args.merge) 73 | smoothed_value = (value * (1 - args.smoothing)) + (last_value * args.smoothing) 74 | points.append(( 75 | float(output_x), 76 | float(output_y - (smoothed_value * args.distortion)), 77 | )) 78 | last_value = value 79 | if args.printable: 80 | #points.extend((x, y - args.stroke) for (x, y) in reversed(list(points))) 81 | points.append((output_x, output_y + args.stroke)) 82 | points.append((0, output_y + args.stroke)) 83 | points.append((0, output_y)) 84 | output.add(output.polyline(points=points, stroke="none", fill="gray")) 85 | y_delta = output_y - (min(y for x, y in points) - args.yspacing - args.stroke) 86 | output_y -= max(y_delta, args.minyspacing) 87 | else: 88 | output.add(output.polyline(points=points, stroke="black", fill="none", stroke_width=args.stroke)) 89 | output_y -= args.yspacing 90 | output.save() 91 | print("Saved %i rows with %i columns" % (len(rows), len(values))) 92 | 93 | if __name__ == '__main__': 94 | main() 95 | -------------------------------------------------------------------------------- /landcarve/utils/io.py: -------------------------------------------------------------------------------- 1 | import io 2 | import PIL.Image 3 | import numpy 4 | import os 5 | import subprocess 6 | import requests 7 | from osgeo import gdal 8 | 9 | 10 | def raster_to_array(input_path): 11 | """ 12 | Takes an input raster file and turns it into a NumPy array. 13 | Only takes band 1 for now. 14 | """ 15 | if input_path == "-": 16 | input_path = "/dev/stdin" 17 | raster = gdal.Open(input_path) 18 | band = raster.GetRasterBand(1) 19 | arr = band.ReadAsArray() 20 | # If it's a negative-pixel thing, flip it 21 | # if raster.GetGeoTransform()[5] < 0: 22 | # arr = numpy.flipud(arr) 23 | return arr 24 | 25 | 26 | def raster_to_array_and_projection(input_path): 27 | """ 28 | Takes an input raster file and turns it into a NumPy array. 29 | Only takes band 1 for now. 30 | """ 31 | if input_path == "-": 32 | input_path = "/dev/stdin" 33 | raster = gdal.Open(input_path) 34 | band = raster.GetRasterBand(1) 35 | arr = band.ReadAsArray() 36 | return arr, raster.GetProjection() 37 | 38 | 39 | def array_to_raster(arr, output_path, offset_and_pixel=None, projection=None): 40 | """ 41 | Takes a NumPy array and outputs it to a GDAL file. 42 | 43 | offset_and_pixel is (x offset, y offset, pixel width, pixel height) 44 | """ 45 | if output_path == "-": 46 | output_path = "/dev/stdout" 47 | driver = gdal.GetDriverByName("GTiff") 48 | arr = numpy.flipud(arr) 49 | outdata = driver.Create( 50 | output_path, 51 | xsize=arr.shape[1], 52 | ysize=arr.shape[0], 53 | bands=1, 54 | eType=gdal.GDT_Float32, 55 | ) 56 | # Set projection and transform if we have them 57 | if offset_and_pixel: 58 | outdata.SetGeoTransform( 59 | [ 60 | offset_and_pixel[0], # X offset 61 | offset_and_pixel[2], # Pixel width 62 | 0, # Rotation coefficient 1 63 | offset_and_pixel[1] + arr.shape[0], # Y offset 64 | 0, # Rotation coefficient 2 65 | -offset_and_pixel[3], # Pixel height 66 | ] 67 | ) 68 | if projection: 69 | outdata.SetProjection(projection) 70 | outband = outdata.GetRasterBand(1) 71 | # TODO: Use global nodata value? 72 | outband.SetNoDataValue(-1000) 73 | outband.WriteArray(arr) 74 | outband.FlushCache() 75 | 76 | 77 | def download_image(url): 78 | """ 79 | Downloads a URL into an Image object 80 | """ 81 | r = requests.get( 82 | url, 83 | headers={ 84 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0", 85 | "Referer": "https://maps.stamen.com/", 86 | "Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", 87 | }, 88 | ) 89 | if r.status_code >= 300: 90 | raise ValueError( 91 | "Cannot download URL %s (%s): %s" % (url, r.status_code, r.content) 92 | ) 93 | return PIL.Image.open(io.BytesIO(r.content)) 94 | 95 | 96 | def save_geotiff(image, x1, y1, x2, y2, path, proj="EPSG:4326"): 97 | """ 98 | Saves an image as a GeoTIFF with the given coordinates as the corners. 99 | """ 100 | # Save image out 101 | temp_path = path + ".temp.png" 102 | image.save(temp_path) 103 | # Use gdal_translate to add GCPs 104 | subprocess.check_call( 105 | [ 106 | "gdal_translate", 107 | "-a_ullr", 108 | str(x1), 109 | str(y1), 110 | str(x2), 111 | str(y2), 112 | "-a_srs", 113 | proj, 114 | "-of", 115 | "GTiff", 116 | temp_path, 117 | path, 118 | ] 119 | ) 120 | # Delete temp file 121 | os.unlink(temp_path) 122 | 123 | 124 | def save_png(image, path): 125 | temp_path = path 126 | image.save(path) 127 | -------------------------------------------------------------------------------- /landcarve/commands/tileimage.py: -------------------------------------------------------------------------------- 1 | import click 2 | import time 3 | import PIL.Image 4 | from concurrent.futures import ThreadPoolExecutor 5 | 6 | from landcarve.cli import main 7 | from landcarve.utils.coords import latlong_to_xy, xy_to_latlong 8 | from landcarve.utils.io import download_image, save_geotiff, save_png 9 | 10 | 11 | @main.command() 12 | @click.option("--zoom", type=int, default=13) 13 | @click.option("--invert-y/--no-invert-y", default=False) 14 | @click.option("--delay", type=float, default=0) 15 | @click.option("--concurrency", type=int, default=5) 16 | @click.option("--tilesize", type=int, default=256) 17 | @click.option("--raw", type=bool, is_flag=True, default=False) 18 | @click.argument("coords") 19 | @click.argument("output_path") 20 | @click.argument("xyz_url") 21 | def tileimage( 22 | coords, output_path, xyz_url, zoom, invert_y, delay, tilesize, raw, concurrency 23 | ): 24 | """ 25 | Fetches tiles from an XYZ server and outputs a georeferenced image (of whole tiles) 26 | """ 27 | tile_size = (tilesize, tilesize) 28 | # Extract coordinates 29 | lat1, long1, lat2, long2 = [float(n) for n in coords.lstrip(",").split(",")] 30 | # Turn those into tile coordinates, ensuring correct ordering 31 | if raw: 32 | x1, y1, x2, y2 = int(lat1), int(long1), int(lat2), int(long2) 33 | else: 34 | x1, y2 = latlong_to_xy(lat1, long1, zoom) 35 | x2, y1 = latlong_to_xy(lat2, long2, zoom) 36 | if x1 > x2: 37 | x2, x1 = x1, x2 38 | if y1 > y2: 39 | y2, y1 = y1, y2 40 | x_size = x2 - x1 + 1 41 | y_size = y2 - y1 + 1 42 | click.echo(f"X range: {x1} - {x2} ({x_size}) Y range: {y1} - {y2} ({y_size})") 43 | # Make a canvas that will fit them all 44 | image = PIL.Image.new("RGB", (tile_size[0] * x_size, tile_size[1] * y_size)) 45 | # Download in parallel 46 | executor = ThreadPoolExecutor(max_workers=concurrency) 47 | futures = [] 48 | images = {} 49 | for x in range(x1, x2 + 1): 50 | for y in range(y1, y2 + 1): 51 | # If invert-y mode is on, flip image download path 52 | if invert_y: 53 | max_y = 2**zoom 54 | url = ( 55 | xyz_url.replace("{x}", str(x)) 56 | .replace("{y}", str(max_y - y)) 57 | .replace("{z}", str(zoom)) 58 | ) 59 | else: 60 | url = ( 61 | xyz_url.replace("{x}", str(x)) 62 | .replace("{y}", str(y)) 63 | .replace("{z}", str(zoom)) 64 | ) 65 | futures.append(executor.submit(download_tile, url, images, x, y, delay)) 66 | click.echo("") 67 | # Show download progress 68 | while futures: 69 | futures = [f for f in futures if not f.done()] 70 | print_progress_grid(x1, y1, x2, y2, images) 71 | time.sleep(1) 72 | executor.shutdown() 73 | # Build image 74 | click.echo("Building image...") 75 | for (x, y), tile in images.items(): 76 | assert tile.size == tile_size, f"Downloaded tile has wrong size {tile.size}" 77 | image.paste(tile, ((x - x1) * tile_size[0], (y - y1) * tile_size[1])) 78 | # Save the image 79 | if raw: 80 | save_png(image, output_path) 81 | else: 82 | # Work out the lat/long bounds of the downloaded tiles 83 | top_left = xy_to_latlong(x1, y1, zoom) 84 | bottom_right = xy_to_latlong(x2 + 1, y2 + 1, zoom) 85 | save_geotiff( 86 | image, 87 | top_left[1], 88 | top_left[0], 89 | bottom_right[1], 90 | bottom_right[0], 91 | output_path, 92 | ) 93 | 94 | 95 | def download_tile(url, images, x, y, delay): 96 | images[x, y] = download_image(url) 97 | time.sleep(delay) 98 | 99 | 100 | def print_progress_grid(x1, y1, x2, y2, images, wipe_previous=False): 101 | for y in range(y1, y2 + 1): 102 | click.echo("[", nl=False) 103 | for x in range(x1, x2 + 1): 104 | if (x, y) in images: 105 | click.echo("#", nl=False) 106 | else: 107 | click.echo(".", nl=False) 108 | click.echo("]") 109 | click.echo() 110 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Landcarve 2 | ========= 3 | 4 | A collection of tools for making 3D models and similar things out of GIS data. 5 | Designed to be run as a pipeline, though the individual tools can be run too. 6 | 7 | 8 | General Use 9 | ----------- 10 | 11 | Define a pipeline in a text file, with one command per line (there are 12 | examples in the ``examples/`` directory). The commands should not have the 13 | ``landcarve`` prefix on them; for example:: 14 | 15 | # Decimate down to a small size 16 | decifit --xy-steps=50 17 | 18 | # Render to an STL 19 | realise --xy-scale=0.01 --z-scale=0.01 --base=0.2 --solid 20 | 21 | Then, run the pipeline with an input and output path, like so:: 22 | 23 | landcarve pipeline national-parks.txt input.geotiff output.stl 24 | 25 | You can read more about the individual commands below. If you want to get going 26 | directly with an example, download the .asc file linked from the 27 | ``examples/london-tiles.txt`` pipeline, and run:: 28 | 29 | landcarve pipeline examples/london-tiles.txt tq3780_DSM_1m.asc output.stl 30 | 31 | 32 | Installation 33 | ------------ 34 | 35 | This isn't on PyPI yet, so clone the repository and run ``pip install -e .``. 36 | 37 | Installing GDAL can be a particular pain; if your OS offers it, I highly 38 | recommend installing a Python 3 GDAL package from there. On Ubuntu/Debian, this 39 | is ``python3-gdal``. 40 | 41 | 42 | Tips 43 | ---- 44 | 45 | There is no true "right scale" for geographic data due to map projections, 46 | so you need to take care if you want to get things coming out at an exact scale, 47 | or consistent with other prints. 48 | 49 | Generally, the horizontal (X & Y) scale is determined by the number of 50 | rows/columns in your data, and all the tools will try and maintain its aspect 51 | ratio. ``decifit`` allows you to shrink it down while maintaining the ratio, 52 | while ``realise`` will simply map one column/row to one unit in the final model 53 | unless you use ``--xy-scale``. 54 | 55 | The vertical (Z) scale is more flexible, as generally printing with the same 56 | scale on Z as you have on X and Y will result in things that look too flat - 57 | the sense of perception of height we have for models is weird. 58 | 59 | There's two ways of dealing with Z - always expanding it to a certain height 60 | in the resulting model (``zfit``), or scaling it consistently with a factor 61 | (``--z-scale`` on ``realise``). 62 | 63 | ``zfit`` is for small, one-off items that aren't 64 | going to be directly compared to each other, and are large-scale geography - 65 | this is what I use for National Park miniatures, for example, as you always 66 | want to see the geographic detail, and I don't want the same scale across all 67 | the parks (otherwise the mountainous ones will cause the rest to look basically 68 | flat). 69 | 70 | The ``--z-scale`` option, on the other hand, is for when you want to print 71 | a set of tiles that will all sit next to each other. Keep it the same, and your 72 | heights will all line up. 73 | 74 | If you're printing an area that is all at elevation, you will need to either use 75 | ``zfit`` (which will auto-trim to the lowest point you have), or pass ``--minimum`` 76 | to ``realise`` to set the "base level" of your model. Anything below the minimum 77 | will be rendered as flat; this is also important if you have holes in your 78 | model that go below sea level (e.g. excavations). 79 | 80 | Finally, realise that the runtime (and memory usage) of this code goes up 81 | rather quickly as you increase the size of the grid being used. 82 | Always use ``decifit`` as the first element in a pipeline, and try to keep 83 | under 1000 in each dimension; 500 tends to be a good tradeoff. 84 | 85 | 86 | Commands 87 | -------- 88 | 89 | 90 | bulkget 91 | ~~~~~~~ 92 | 93 | Takes a text file containing a list of URLs and downloads only the "interesting" 94 | ones to a local folder. Designed for use with the USGS National Map download 95 | feature. 96 | 97 | 98 | decifit 99 | ~~~~~~~ 100 | 101 | Options: 102 | * ``--xy-steps``: Size to fit the raster within. Default: 1000 103 | 104 | Takes an input raster and fits it within certain limits of X and Y, 105 | so there's at most ``--xy-steps`` rows and columns. Does not touch Z/values. 106 | 107 | It will *preserve aspect ratios* - non-square inputs are scaled to the size of 108 | their longest edge. 109 | 110 | 111 | fixnodata 112 | ~~~~~~~~~ 113 | 114 | Options: 115 | * ``--nodata``: NODATA boundary for input data. Default: 0 116 | 117 | All the rest of the tools in the suite assume a NODATA value of -1000. If you 118 | have source data that is not aligned, use this pipeline step to set anything 119 | equal or lower to the value of ``--nodata`` you pass to the internal NODATA 120 | value. 121 | 122 | 123 | lasdem 124 | ~~~~~~ 125 | 126 | Options: 127 | * ``--snap``: Quantization factor in projection units (XY symmetrical). Default: 1 128 | * ``--void-distance``: How far to search for void-filling neighbours. Default: 10 129 | 130 | Takes one or more LAS (or LAZ) files, thins them (by highest return elevation), 131 | and turns them into a GeoTIFF DEM. 132 | 133 | 134 | realise 135 | ~~~~~~~ 136 | 137 | Options: 138 | * ``--xy-scale``: Scale factor for STL model in x/y axes. Default: 1 139 | * ``--z-scale``: Scale factor for STL model in z axis. Default: 1 140 | * ``--minimum``: Level to cut off detail and assume as base of model. Default: 0 141 | * ``--base``: Thickness of the base below the bottom of the model. Default: 1 142 | * ``--simplify/--no-simplify``: If simplification should be run. Default: ``--simplify`` 143 | * ``--solid/--not-solid``: If the model should be forced to a square tile with no holes. Default: ``--not-solid`` 144 | 145 | The ``realise`` step takes a heightmap and renders it out as an STL file. 146 | 147 | By default, the STL will have a dimension matching that of the input grid in all 148 | dimensions - so if your input is a 500x500 heightmap with values from 0 - 50, 149 | the resulting STL will have a dimension of 500x500x50. 150 | 151 | To scale this linearly, use ``--xy-scale`` and ``--z-scale``. 152 | 153 | If all points on your model are above a certain elevation, use ``--minimum`` set 154 | at that elevation to shift the whole model downwards. The value of minimum will 155 | be what ends up at zero height on the model, on top of the base thickness. Any 156 | features that are below the minimum (but that have data) will be rendered flat. 157 | 158 | ``--base`` sets the thickness of the base of the model in output units. It's 159 | recommended you have a base as most forms of manufacturing will need one. 160 | 161 | Simplification is run on the STL model to try and merge flat areas together; if 162 | you don't want this, pass ``--no-simplify``. The resulting model will have a lot 163 | more polygons, but you'll save the slow simplification step. The built-in 164 | simplification is quite basic; you may want to run it through another program 165 | and do a shape-preserving simplification if your model is too detailed to load 166 | into a slicer/pathing tool. 167 | 168 | By default, areas that are set as NODATA in your heightmap will not be rendered 169 | with a base; this is to allow non-rectangular outputs from the model. If your 170 | goal is a set of tiles, though, set ``--solid`` to ensure you get a base; this 171 | will help make sure your output is perfectly square. 172 | 173 | 174 | smooth 175 | ~~~~~~ 176 | 177 | Options: 178 | * ``--factor``: Smoothing factor. Default: 1 179 | 180 | Smooths heightmap data to remove jagged heights caused by reflections or laser 181 | errors. Only use if your data is not already cleaned up. 182 | 183 | The higher the factor, the more the model is smoothed. 184 | 185 | 186 | zfit 187 | ~~~~ 188 | 189 | Options: 190 | * ``--fit``: New target height. Default: 1 191 | 192 | Re-scales the Z axis (value) data so that it ranges between 0 and the value 193 | passed for ``--fit``. As well as scaling the Z axis, this also includes shifting 194 | the whole model down so the lowest value is the new 0 (for data which is 195 | entirely at elevation). 196 | 197 | Models printed using this will not have the same Z scale as each other. Only 198 | use this for models that are not meant to be joined together. 199 | -------------------------------------------------------------------------------- /landcarve/commands/lasdem.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import sys 3 | 4 | import click 5 | import laspy.file 6 | import numpy 7 | from osgeo import osr 8 | 9 | from landcarve.cli import main 10 | from landcarve.constants import NODATA 11 | from landcarve.utils.io import array_to_raster 12 | from landcarve.utils.stats import mean, pstdev, clip 13 | 14 | 15 | @main.command() 16 | @click.option("-s", "--snap", default=1, type=int, help="Snap/thinning resolution") 17 | @click.option( 18 | "-d", 19 | "--void-distance", 20 | default=10, 21 | type=int, 22 | help="Max distance to try and fill voids from", 23 | ) 24 | @click.option( 25 | "-z", 26 | "--z-limit", 27 | default=4000, 28 | type=int, 29 | help="Maximum elevation to trust; discard anything above", 30 | ) 31 | @click.option( 32 | "-s", 33 | "--despeckle", 34 | default=1, 35 | type=int, 36 | help="Despeckle factor; higher is stronger. 0 to disable.", 37 | ) 38 | @click.option( 39 | "--ignore-header-range", 40 | type=bool, 41 | default=False, 42 | ) 43 | @click.argument("input_paths", nargs=-1) 44 | @click.argument("output_path") 45 | def lasdem( 46 | input_paths, 47 | output_path, 48 | snap, 49 | void_distance, 50 | z_limit, 51 | despeckle, 52 | ignore_header_range, 53 | ): 54 | """ 55 | Turns a raw .las or .laz file into a DEM 56 | """ 57 | # Check there's valid input due to nargs 58 | if not input_paths: 59 | click.echo("You must provide at least one input file") 60 | sys.exit(1) 61 | 62 | # Work out the projection 63 | projection = None 64 | las_files = [] 65 | with click.progressbar(length=len(input_paths), label="Opening files") as bar: 66 | for input_path in input_paths: 67 | bar.update(1) 68 | las = laspy.file.File(input_path, mode="r") 69 | las_files.append(las) 70 | las_projection = "unknown" 71 | for vlr in las.header.vlrs: 72 | if vlr.record_id == 34735: # GeoTIFF tag format 73 | num_tags = vlr.parsed_body[3] 74 | for i in range(num_tags): 75 | key_id = vlr.parsed_body[4 + (i * 4)] 76 | offset = vlr.parsed_body[7 + (i * 4)] 77 | if key_id == 3072: 78 | srs = osr.SpatialReference() 79 | srs.ImportFromEPSG(offset) 80 | las_projection = srs.ExportToWkt() 81 | break 82 | if projection is None or projection == las_projection: 83 | projection = las_projection 84 | else: 85 | click.echo( 86 | f"Mismatched projections - {las} does not match the first file" 87 | ) 88 | 89 | # Calculate the bounds of all files (initial values are a bit stupid) 90 | click.echo(f"{len(input_paths)} file(s) provided") 91 | min_x, max_x, min_y, max_y = 1000000000, -1000000000, 1000000000, -1000000000 92 | num_points = 0 93 | for las in las_files: 94 | num_points += las.points.shape[0] 95 | if ignore_header_range: 96 | min_x = min(min_x, las.x.min()) 97 | max_x = max(max_x, las.x.max()) 98 | min_y = min(min_y, las.y.min()) 99 | max_y = max(max_y, las.y.max()) 100 | else: 101 | min_x = min(min_x, las.header.min[0]) 102 | max_x = max(max_x, las.header.max[0]) 103 | min_y = min(min_y, las.header.min[1]) 104 | max_y = max(max_y, las.header.max[1]) 105 | 106 | # Calculate the size of the final array 107 | x_index = functools.lru_cache(maxsize=10240)(lambda v: int((v - min_x) // snap)) 108 | y_index = functools.lru_cache(maxsize=10240)(lambda v: int((v - min_y) // snap)) 109 | x_size = x_index(max_x) + 1 110 | y_size = y_index(max_y) + 1 111 | 112 | # Print some diagnostic info 113 | click.echo(f"Snap divisor: {snap}") 114 | click.echo(f"X range: {min_x} - {max_x} Y range: {min_y} - {max_y}") 115 | click.echo(f"Final DEM size {x_size}x{y_size}") 116 | 117 | # Create a new array to hold the data 118 | arr = numpy.full((y_size, x_size), NODATA, dtype=numpy.float) 119 | ignored_points = 0 120 | 121 | # For each point, bucket it into the right array coord 122 | with click.progressbar(length=num_points, label="Thinning") as bar: 123 | n = 0 124 | for las in las_files: 125 | points = numpy.vstack((las.x, las.y, las.z)).T 126 | for i, (x, y, z) in enumerate(points): 127 | if x < min_x or x > max_x or y < min_y or y > max_y: 128 | ignored_points += 1 129 | continue 130 | if z <= z_limit: 131 | new_x = x_index(x) 132 | new_y = y_index(y) 133 | arr[new_y][new_x] = max(arr[new_y][new_x], z) 134 | n += 1 135 | if n > 1000: 136 | bar.update(1000) 137 | n = 0 138 | 139 | if ignored_points: 140 | click.echo(f"Ignored {ignored_points} points") 141 | 142 | # Scan for all voids in the array 143 | voids = set() 144 | with click.progressbar(length=arr.shape[0], label="Discovering voids") as bar: 145 | for y in range(arr.shape[0]): 146 | bar.update(1) 147 | for x in range(arr.shape[1]): 148 | if arr[y][x] <= NODATA: 149 | voids.add((x, y)) 150 | 151 | # Fill any voids by finding their nearest neighbour 152 | num_voids = len(voids) 153 | with click.progressbar(length=void_distance, label="Filling voids") as bar: 154 | for _ in range(void_distance): 155 | bar.update(1) 156 | for x, y in list(voids): 157 | z = find_nearest_neighbour(arr, x, y) 158 | if z > NODATA: 159 | arr[y][x] = z 160 | voids.remove((x, y)) 161 | click.echo("Removed %s / %s voids" % (num_voids - len(voids), num_voids)) 162 | 163 | # Despeckle any single pixels that are weirdly high/low 164 | if despeckle: 165 | with click.progressbar(length=arr.shape[0], label="Despeckling") as bar: 166 | for y in range(arr.shape[0]): 167 | bar.update(1) 168 | for x in range(arr.shape[1]): 169 | # Find its four direct neighbours and weight on their values 170 | others = get_neighbours(arr, x, y) 171 | if len(others) < 3: 172 | continue 173 | m = mean(others) 174 | s = pstdev(others) 175 | limit = s * (1 / despeckle) 176 | if abs(arr[y][x] - m) > limit: 177 | arr[y][x] = find_nearest_neighbour(arr, x, y) 178 | 179 | # Write out a TIF 180 | array_to_raster( 181 | arr, 182 | output_path, 183 | offset_and_pixel=(min_x, min_y, snap, snap), 184 | projection=projection, 185 | ) 186 | 187 | 188 | def get_neighbours(arr, x, y): 189 | """ 190 | Finds the nearest non-NODATA value to x, y 191 | """ 192 | values = [] 193 | for dx, dy in [ 194 | (1, 0), 195 | (0, 1), 196 | (-1, 0), 197 | (0, -1), 198 | ]: 199 | new_x = x + dx 200 | new_y = y + dy 201 | if ( 202 | new_x >= 0 203 | and new_x < arr.shape[1] 204 | and new_y >= 0 205 | and new_y < arr.shape[0] 206 | and arr[new_y][new_x] > NODATA 207 | ): 208 | values.append(arr[new_y][new_x]) 209 | return values 210 | 211 | 212 | def find_nearest_neighbour(arr, x, y): 213 | """ 214 | Finds the nearest non-NODATA value to x, y 215 | """ 216 | for dx, dy in [ 217 | (1, 0), 218 | (0, 1), 219 | (-1, 0), 220 | (0, -1), 221 | (1, 1), 222 | (1, -1), 223 | (-1, -1), 224 | (-1, 1), 225 | ]: 226 | new_x = x + dx 227 | new_y = y + dy 228 | if ( 229 | new_x >= 0 230 | and new_x < arr.shape[1] 231 | and new_y >= 0 232 | and new_y < arr.shape[0] 233 | and arr[new_y][new_x] > NODATA 234 | ): 235 | return arr[new_y][new_x] 236 | return NODATA 237 | -------------------------------------------------------------------------------- /landcarve/commands/realise.py: -------------------------------------------------------------------------------- 1 | from asyncio import base_events 2 | import collections 3 | 4 | import click 5 | import numpy 6 | import struct 7 | import trimesh.base 8 | import trimesh.creation 9 | 10 | from landcarve.cli import main 11 | from landcarve.constants import NODATA 12 | from landcarve.utils.io import raster_to_array 13 | 14 | 15 | @main.command() 16 | @click.argument("input_path", default="-") 17 | @click.argument("output_path") 18 | @click.option("--xy-scale", default=1.0, help="X/Y scale to use") 19 | @click.option("--z-scale", default=1.0, help="Z scale to use") 20 | @click.option("--z-scale-reduction", default=1.0, help="Z scale reduction per 100m") 21 | @click.option( 22 | "--minimum", 23 | default=0.0, 24 | type=float, 25 | help="Minimum depth (zero point) in elevation units", 26 | ) 27 | @click.option( 28 | "--maximum", 29 | default=9999.0, 30 | type=float, 31 | help="Maxiumum depth (for flat slices) in elevation units", 32 | ) 33 | @click.option("--base", default=1.0, help="Base thickness (in mm)") 34 | @click.option( 35 | "--simplify/--no-simplify", default=True, help="Apply simplification to final model" 36 | ) 37 | @click.option("--solid/--not-solid", default=False, help="Force a solid, square base") 38 | @click.option("--flipy/--no-flipy", default=False, help="Flip model Y axis") 39 | @click.option( 40 | "--thin/--not-thin", 41 | default=False, 42 | help="No solid base, just a thin surface of thickness 'base'", 43 | ) 44 | @click.option( 45 | "--slices", default="", help="Slice points (in elevation units) for multiple STLs" 46 | ) 47 | @click.pass_context 48 | def realise( 49 | ctx, 50 | input_path, 51 | output_path, 52 | xy_scale, 53 | z_scale, 54 | z_scale_reduction, 55 | minimum, 56 | maximum, 57 | base, 58 | simplify, 59 | solid, 60 | thin, 61 | flipy, 62 | slices, 63 | ): 64 | """ 65 | Turns a DEM array into a 3D model. 66 | """ 67 | # Load the file using GDAL 68 | arr = raster_to_array(input_path) 69 | if flipy: 70 | arr = numpy.flipud(arr) 71 | # Open the target STL file 72 | mesh = Mesh(scale=(xy_scale, xy_scale, z_scale), z_reduction=z_scale_reduction) 73 | # Apply the maximum constraint if there is one 74 | if maximum < 9999: 75 | arr = numpy.vectorize(lambda x: min(maximum, x), otypes=[object])(arr) 76 | # Apply the minimum constraint 77 | if solid: 78 | arr = numpy.vectorize( 79 | lambda x: max(0, x - minimum) if x > NODATA else 0, otypes=[object] 80 | )(arr) 81 | else: 82 | arr = numpy.vectorize( 83 | lambda x: max(0, x - minimum) if x > minimum else None, otypes=[object] 84 | )(arr) 85 | # Work out bounds and print them 86 | max_value = 0 87 | for index, value in numpy.ndenumerate(arr): 88 | if value and value > max_value: 89 | max_value = value 90 | print( 91 | f"X size: {(arr.shape[1]-1)*xy_scale:.2f} Y size: {(arr.shape[0]-1)*xy_scale:.2f} Z size: {max_value*z_scale:.2f}" 92 | ) 93 | # For each value in the array, output appropriate polygons 94 | bottom = 0 - (base / z_scale) 95 | with click.progressbar(length=arr.shape[0], label="Calculating mesh") as bar: 96 | for index, value in numpy.ndenumerate(arr): 97 | if index[1] == 0: 98 | bar.update(1) 99 | if value is not None: 100 | # Work out the neighbour values 101 | # Arranged like so: 102 | # tl t tr 103 | # l c---r 104 | # bl b br 105 | c = (index[0], index[1], value) 106 | t = get_neighbour_value((index[0], index[1] - 1), arr) 107 | tr = get_neighbour_value((index[0] + 1, index[1] - 1), arr) 108 | tl = get_neighbour_value((index[0] - 1, index[1] - 1), arr) 109 | l = get_neighbour_value((index[0] - 1, index[1]), arr) 110 | r = get_neighbour_value((index[0] + 1, index[1]), arr) 111 | bl = get_neighbour_value((index[0] - 1, index[1] + 1), arr) 112 | b = get_neighbour_value((index[0], index[1] + 1), arr) 113 | br = get_neighbour_value((index[0] + 1, index[1] + 1), arr) 114 | if thin: 115 | bottom = base / z_scale 116 | # Centre-Right-Bottom triangle 117 | if r[2] is not None and b[2] is not None: 118 | mesh.add_surface(c, r, b, bottom, thin=thin) 119 | # Add diagonal edge if BR is nonexistent 120 | if br[2] is None: 121 | mesh.add_edge(b, r, bottom, thin=thin) 122 | # Top edge 123 | if t[2] is None and tr[2] is None: 124 | mesh.add_edge(r, c, bottom, thin=thin) 125 | # Left edge 126 | if l[2] is None and bl[2] is None: 127 | mesh.add_edge(c, b, bottom, thin=thin) 128 | # Top-centre-left triangle 129 | if t[2] is not None and l[2] is not None: 130 | mesh.add_surface(t, c, l, bottom, thin=thin) 131 | # Add diagonal edge if TL is nonexistent 132 | if tl[2] is None: 133 | mesh.add_edge(t, l, bottom, thin=thin) 134 | # Right edge 135 | if r[2] is None and tr[2] is None: 136 | mesh.add_edge(c, t, bottom, thin=thin) 137 | # Bottom edge 138 | if b[2] is None and bl[2] is None: 139 | mesh.add_edge(l, c, bottom, thin=thin) 140 | # Top-right-center triangle (if tr doesn't exist) 141 | if t[2] is not None and r[2] is not None and tr[2] is None: 142 | mesh.add_surface(t, r, c, bottom, thin=thin) 143 | # Also implies there must be an edge there 144 | mesh.add_edge(r, t, bottom, thin=thin) 145 | # See if it needs a left edge 146 | if l[2] is None and tl[2] is None: 147 | mesh.add_edge(t, c, bottom, thin=thin) 148 | # Bottom edge 149 | if b[2] is None and br[2] is None: 150 | mesh.add_edge(c, r, bottom, thin=thin) 151 | # Left-center-bottom triangle (if bl doesn't exist) 152 | if l[2] is not None and b[2] is not None and bl[2] is None: 153 | mesh.add_surface(l, c, b, bottom, thin=thin) 154 | # Also implies there must be an edge there 155 | mesh.add_edge(l, b, bottom, thin=thin) 156 | # See if it needs a right edge 157 | if r[2] is None and br[2] is None: 158 | mesh.add_edge(b, c, bottom, thin=thin) 159 | # And a top edge 160 | if t[2] is None and tr[2] is None: 161 | mesh.add_edge(c, l, bottom, thin=thin) 162 | # Simplify 163 | if simplify: 164 | click.echo("Simplifying mesh [", err=True, nl=False) 165 | total_removed = 0 166 | while True: 167 | removed = mesh.simplify() 168 | click.echo(".", nl=False) 169 | if not removed: 170 | break 171 | total_removed += removed 172 | click.echo("] %i vertices removed" % total_removed) 173 | tmesh = mesh.to_trimesh() 174 | # Slice 175 | if slices: 176 | click.echo("Slicing mesh [", err=True, nl=False) 177 | bounds = [ 178 | [tmesh.bounds[0][0] - 1, tmesh.bounds[0][2] - 1, -1], 179 | [tmesh.bounds[1][0] + 1, tmesh.bounds[1][1] + 1, -1], 180 | ] 181 | slices = [float(val.strip()) for val in slices.strip().split(",")] + [9999] 182 | for slice in slices: 183 | click.echo(f"{slice} ", nl=False) 184 | # Create a box to slice by 185 | bounds[0][2] = bounds[1][2] 186 | bounds[1][2] = (slice - minimum) * z_scale 187 | box = trimesh.creation.box(bounds=bounds) 188 | slice_mesh = trimesh.boolean.intersection([tmesh, box]) 189 | slice_mesh.export(output_path.replace(".stl", f".{slice}.stl")) 190 | click.echo("]") 191 | # All done! 192 | click.echo("Writing STL...", err=True) 193 | tmesh.export(output_path) 194 | 195 | 196 | def get_neighbour_value(index, arr): 197 | """ 198 | Gets a neighbour value. Puts None in place for NODATA or edge of array. 199 | """ 200 | if ( 201 | index[0] < 0 202 | or index[0] >= arr.shape[0] 203 | or index[1] < 0 204 | or index[1] >= arr.shape[1] 205 | ): 206 | return (index[0], index[1], None) 207 | else: 208 | value = arr[index] 209 | return (index[0], index[1], value) 210 | 211 | 212 | class Mesh: 213 | """ 214 | Represents a mesh of the geography. 215 | """ 216 | 217 | def __init__(self, scale, z_reduction=1): 218 | self.scale = scale or (1, 1, 1) 219 | self.z_reduction = z_reduction 220 | # Dict of (x, y, z): index 221 | self.vertices = collections.OrderedDict() 222 | # List of (v1, v2, v3, normal) 223 | self.faces: list[tuple[tuple[float, float, float], float, float, float]] = [] 224 | 225 | def vertex_index(self, vertex): 226 | """ 227 | Returns the vertex's index, adding it if needed 228 | """ 229 | assert len(vertex) == 3 230 | z_units = vertex[2] / 100.0 231 | z_scale = self.scale[2] * (self.z_reduction**z_units) 232 | vertex = ( 233 | vertex[0] * self.scale[0], 234 | vertex[1] * self.scale[1], 235 | vertex[2] * z_scale, 236 | ) 237 | if vertex not in self.vertices: 238 | self.vertices[vertex] = len(self.vertices) 239 | return self.vertices[vertex] 240 | 241 | def add_triangle(self, point1, point2, point3): 242 | """ 243 | Adds a single triangle 244 | """ 245 | # Get vertex indices 246 | i1 = self.vertex_index(point1) 247 | i2 = self.vertex_index(point2) 248 | i3 = self.vertex_index(point3) 249 | # Calculate normal (clockwise) 250 | u = (point2[0] - point1[0], point2[1] - point1[1], point2[2] - point1[2]) 251 | v = (point3[0] - point1[0], point3[1] - point1[1], point3[2] - point1[2]) 252 | normal = ( 253 | (u[1] * v[2]) - (u[2] * v[1]), 254 | (u[2] * v[0]) - (u[0] * v[2]), 255 | (u[0] * v[1]) - (u[1] * v[0]), 256 | ) 257 | normal_magnitude = ( 258 | (normal[0] ** 2) + (normal[1] ** 2) + (normal[2] ** 2) 259 | ) ** 0.5 260 | normal = ( 261 | normal[0] / normal_magnitude, 262 | normal[1] / normal_magnitude, 263 | normal[2] / normal_magnitude, 264 | ) 265 | # Add face 266 | self.faces.append((normal, i1, i2, i3)) 267 | 268 | def add_quad(self, point1, point2, point3, point4): 269 | """ 270 | Adds a quad to the file, made out of two facets. Pass vertices in 271 | clockwise order. 272 | """ 273 | self.add_triangle(point1, point2, point4) 274 | self.add_triangle(point2, point3, point4) 275 | 276 | def add_surface(self, point1, point2, point3, bottom, thin=False): 277 | """ 278 | Adds a facet with a matching flat bottom polygon. 279 | Points should be clockwise looking from the top. 280 | """ 281 | self.add_triangle( 282 | (point1[0], point1[1], point1[2]), 283 | (point2[0], point2[1], point2[2]), 284 | (point3[0], point3[1], point3[2]), 285 | ) 286 | if thin: 287 | self.add_triangle( 288 | (point1[0], point1[1], point1[2] - bottom), 289 | (point3[0], point3[1], point3[2] - bottom), 290 | (point2[0], point2[1], point2[2] - bottom), 291 | ) 292 | else: 293 | self.add_triangle( 294 | (point1[0], point1[1], bottom), 295 | (point2[0], point2[1], bottom), 296 | (point3[0], point3[1], bottom), 297 | ) 298 | 299 | def add_edge(self, point1, point2, bottom, thin=False): 300 | """ 301 | Adds a quad to form an edge between the two vertices. 302 | Vertices should be left, right looking from the outside of the model. 303 | """ 304 | if thin: 305 | self.add_quad( 306 | (point1[0], point1[1], point1[2]), 307 | (point2[0], point2[1], point2[2]), 308 | (point2[0], point2[1], point2[2] - bottom), 309 | (point1[0], point1[1], point1[2] - bottom), 310 | ) 311 | else: 312 | self.add_quad( 313 | (point1[0], point1[1], point1[2]), 314 | (point2[0], point2[1], point2[2]), 315 | (point2[0], point2[1], bottom), 316 | (point1[0], point1[1], bottom), 317 | ) 318 | 319 | def to_trimesh(self): 320 | """ 321 | Returns a PyMesh representation of us 322 | """ 323 | # Sort vertices by their index 324 | vertices_by_index = [(i, v) for v, i in self.vertices.items()] 325 | vertices_by_index.sort() 326 | vertices = numpy.array([v for i, v in vertices_by_index]) 327 | # Extract faces into triples 328 | faces = numpy.array([[v1, v2, v3] for n, v1, v2, v3 in self.faces]) 329 | 330 | return trimesh.base.Trimesh(vertices, faces) 331 | 332 | def simplify(self): 333 | """ 334 | Simplifies the mesh via edge-merging. Goes through all edges, and sees 335 | if all faces attached to that edge have the same normal. If so, collapses 336 | it. 337 | """ 338 | # Create a map of vertex indexes to the normals of the faces attached to them, 339 | # and vertices to their neighbours 340 | non_flat_vertices = set() 341 | vertex_face_normals = {} 342 | vertex_neighbours = {v: [] for v in self.vertices.values()} 343 | for normal, v1, v2, v3 in self.faces: 344 | # Work out if it has flat normals 345 | for v in (v1, v2, v3): 346 | if v not in non_flat_vertices: 347 | if v in vertex_face_normals: 348 | if vertex_face_normals[v] != normal: 349 | non_flat_vertices.add(v) 350 | del vertex_face_normals[v] 351 | else: 352 | vertex_face_normals[v] = normal 353 | # Add it to the neighbour graph 354 | vertex_neighbours[v1].extend([v2, v3]) 355 | vertex_neighbours[v2].extend([v1, v3]) 356 | vertex_neighbours[v3].extend([v1, v2]) 357 | # Go through edges, and remove those whose normals match. 358 | # Keep track of tainted vertices that we can't touch this iteration. 359 | tainted_vertices = set() 360 | merged_vertices = {} 361 | for index, vertex in enumerate(self.vertices): 362 | # Skip non-flat vertices 363 | if index not in vertex_face_normals: 364 | continue 365 | # Skip vertices whose neighbours were already touched 366 | if index in tainted_vertices: 367 | continue 368 | # Skip vertices which have non-flat neighbours 369 | if not all( 370 | neighbour in vertex_face_normals 371 | for neighbour in vertex_neighbours[index] 372 | ): 373 | continue 374 | # See if there's a neighbour we can merge with 375 | for neighbour in vertex_neighbours[index]: 376 | if ( 377 | neighbour in vertex_face_normals 378 | and vertex_face_normals[neighbour] == vertex_face_normals[index] 379 | ): 380 | # Mark them for merge 381 | merged_vertices[index] = neighbour 382 | # Mark them as tainted so we don't revisit them 383 | tainted_vertices.add(index) 384 | tainted_vertices.add(neighbour) 385 | break 386 | # Rewrite mesh with the new vertices and faces 387 | new_vertices = collections.OrderedDict() 388 | new_faces = [] 389 | vertex_map = {} # Maps old index to new one 390 | # First, write all unmerged vertices out 391 | for index, vertex in enumerate(self.vertices): 392 | if index not in merged_vertices: 393 | vertex_map[index] = len(new_vertices) 394 | new_vertices[vertex] = len(new_vertices) 395 | # Then, add mappings for the merged vertices 396 | for vertex, merged_to in merged_vertices.items(): 397 | vertex_map[vertex] = vertex_map[merged_to] 398 | # Finally, rewrite all the faces, removing those that are now zero sized 399 | for normal, v1, v2, v3 in self.faces: 400 | new1 = vertex_map[v1] 401 | new2 = vertex_map[v2] 402 | new3 = vertex_map[v3] 403 | # Skip face if it's now zero size 404 | if new1 == new2 or new2 == new3 or new3 == new1: 405 | continue 406 | new_faces.append((normal, new1, new2, new3)) 407 | self.vertices = new_vertices 408 | self.faces = new_faces 409 | return len(merged_vertices) 410 | 411 | def save(self, path): 412 | """ 413 | Saves the mesh as an STL file 414 | """ 415 | # Invert vertices to be mapped by index (well, a list) 416 | vertex_list = list(self.vertices.keys()) 417 | # Write STL file 418 | with open(path, "wb") as fh: 419 | # Write STL header 420 | fh.write(b" " * 80) # Textual header 421 | fh.write(struct.pack(b" self.size[0]) or ( 456 | offset[1] + piece.size[1] > self.size[1] 457 | ): 458 | return False 459 | # Initial quick pass 460 | for dx in range(0, piece.size[0], int(piece.size[0] / 10)): 461 | for dy in range(0, piece.size[1], int(piece.size[1] / 10)): 462 | if piece.image.getpixel((dx, dy))[3]: 463 | if self.image.getpixel((offset[0] + dx, offset[1] + dy))[3]: 464 | return False 465 | # Full pass 466 | for dx in range(0, piece.size[0]): 467 | for dy in range(0, piece.size[1]): 468 | if piece.image.getpixel((dx, dy))[3]: 469 | if self.image.getpixel((offset[0] + dx, offset[1] + dy))[3]: 470 | return False 471 | return True 472 | --------------------------------------------------------------------------------