Hacer un globo SVG

Lo que vas a crear

En este tutorial, te mostraré cómo tomar un mapa SVG y proyectarlo en un globo terráqueo, como un vector. Para llevar a cabo las transformaciones matemáticas necesarias para proyectar el mapa en una esfera, debemos usar las secuencias de comandos de Python para leer los datos del mapa y traducirlos a la imagen de un globo. Este tutorial asume que estás ejecutando Python 3.4, la última versión disponible de Python.

Inkscape tiene algún tipo de API de Python que se puede usar para hacer una variedad de cosas. Sin embargo, como solo nos interesa transformar formas, es más fácil escribir un programa independiente que lee e imprime archivos SVG por su cuenta..

1. Formato del mapa

El tipo de mapa que queremos se llama un mapa equirectangular. En un mapa equirectangular, la longitud y latitud de un lugar corresponde a su X y y Posición en el mapa. Se puede encontrar un mapa del mundo equirectangular en Wikimedia Commons (aquí hay una versión con estados de EE. UU.).

Las coordenadas SVG se pueden definir de varias maneras. Por ejemplo, pueden ser relativos al punto previamente definido, o definirse absolutamente desde el origen. Para hacer nuestras vidas más fáciles, queremos convertir las coordenadas en el mapa a la forma absoluta. Inkscape puede hacer esto. Ir a las preferencias de Inkscape (bajo la Editar menú) y bajo De entrada y salida > Salida SVG, conjunto Formato de cadena de ruta a Absoluto.

Inkscape no convertirá automáticamente las coordenadas; Tienes que realizar algún tipo de transformación en los caminos para que eso suceda. La forma más fácil de hacerlo es simplemente seleccionar todo y moverlo hacia arriba y hacia atrás con solo presionar cada una de las flechas hacia arriba y hacia abajo. Luego vuelve a guardar el archivo.

2. Comience su guión de Python

Crea un nuevo archivo de Python. Importar los siguientes módulos:

importar sistemas importar importar importar hora de importación tiempo de importación fecha de importación número como np importar xml.etree.ElementTree como ET

Necesitará instalar NumPy, una biblioteca que le permite realizar ciertas operaciones vectoriales como el producto punto y el producto cruzado.

3. La matemática de la proyección en perspectiva.

Proyectar un punto en el espacio tridimensional en una imagen 2D implica encontrar un vector desde la cámara al punto, y luego dividir ese vector en tres vectores perpendiculares. 

Los dos vectores parciales perpendiculares al vector de la cámara (la dirección hacia la que mira la cámara) se convierten en el X y y Coordenadas de una imagen proyectada ortogonalmente. El vector parcial paralelo al vector de cámara se convierte en algo llamado z Distancia del punto. Para convertir una imagen ortogonal en una imagen en perspectiva, divida cada X y y coordenada por el z distancia.

En este punto, tiene sentido definir ciertos parámetros de la cámara. Primero, necesitamos saber dónde se encuentra la cámara en el espacio 3D. Almacenar su X, y, y z coordenadas en un diccionario.

camera = 'x': -15, 'y': 15, 'z': 30

El globo terráqueo se ubicará en el origen, por lo que tiene sentido orientar la cámara hacia él. Eso significa que el vector de dirección de la cámara será el opuesto a la posición de la cámara.

cameraForward = 'x': -1 * camera ['x'], 'y': -1 * camera ['y'], 'z': -1 * camera ['z']

No solo es suficiente para determinar en qué dirección está la cámara, sino que también es necesario determinar la rotación de la cámara. Para ello, definiendo un vector perpendicular a la cameraForward vector.

cameraPerpendicular = 'x': cameraForward ['y'], 'y': -1 * cameraForward ['x'], 'z': 0

1. Definir funciones útiles de vectores

Será muy útil tener ciertas funciones vectoriales definidas en nuestro programa. Definir una función de magnitud vectorial:

#magnitud de un vector 3D def sumOfSquares (vector): vector de retorno ['x'] ** 2 + vector ['y'] ** 2 + vector ['z'] ** magnitud de 2 def (vector): matemática de retorno .sqrt (sumOfSquares (vector))

Necesitamos poder proyectar un vector sobre otro. Debido a que esta operación involucra un producto de puntos, es mucho más fácil usar la biblioteca NumPy. NumPy, sin embargo, toma vectores en forma de lista, sin los identificadores explícitos 'x', 'y', 'z', por lo que necesitamos una función para convertir nuestros vectores en vectores NumPy.

#convierte el vector de diccionario para listar el vector def vectorToList (vector): return [vector ['x'], vector ['y'], vector ['z']]
#proyecta u en v def vectorProject (u, v): devuelve np.dot (vectorToList (v), vectorToList (u)) / magnitud (v)

Es bueno tener una función que nos dé un vector unitario en la dirección de un vector dado:

#get unit vector def unitVector (vector): magVector = magnitud (vector) return 'x': vector ['x'] / magVector, 'y': vector ['y'] / magVector, 'z': vector [ 'z'] / magVector

Finalmente, necesitamos poder tomar dos puntos y encontrar un vector entre ellos:

#Calcula el vector desde dos puntos, forma del diccionario def findVector (origen, punto): retorno 'x': punto ['x'] - origen ['x'], 'y': punto ['y'] - origen [ 'y'], 'z': punto ['z'] - origen ['z']

2. Definir ejes de cámara

Ahora solo falta terminar de definir los ejes de la cámara. Ya tenemos dos de estos ejes.-cameraForward y cámaraPerpendicular, correspondiente a la z distancia y X coordenada de la imagen de la cámara. 

Ahora solo necesitamos el tercer eje, definido por un vector que representa el y coordenada de la imagen de la cámara. Podemos encontrar este tercer eje tomando el producto cruzado de esos dos vectores, usando NumPy-np.cross (vectorToList (cameraForward), vectorToList (cameraPerpendicular)).

El primer elemento en el resultado corresponde a la X componente; el segundo al y componente, y el tercero a la z Componente, por lo que el vector producido viene dado por:

# Calcula el vector del plano del horizonte (señala hacia arriba) cameraHorizon = 'x': np.cross (vectorToList (cameraForward), vectorToList (cameraPerpendicular)) [0], 'y': np.cross (vectorToList (cameraForward), vectorToList (cameraPerpendicular )) [1], 'z': np.cross (vectorToList (cameraForward), vectorToList (cameraPerpendicular)) [2]

3. Proyecto a ortogonal

Para encontrar el ortogonal. X, y, y z distancia, primero encontramos el vector que une la cámara y el punto en cuestión, y luego lo proyectamos en cada uno de los tres ejes de cámara definidos anteriormente:

def physicalProjection (point): pointVector = findVector (camera, point) #pointVector es un vector que comienza desde la cámara y termina en un punto en cuestión return 'x': vectorProject (pointVector, cameraPerpendicular), 'y': vectorProject (pointVector , cameraHorizon), 'z': vectorProject (pointVector, cameraForward)

Un punto (gris oscuro) que se proyecta sobre los tres ejes de la cámara (gris). X es rojo, y es verde, y z es azul.

4. Proyecto a Perspectiva

La proyección en perspectiva simplemente toma el X y y de la proyección ortogonal, y divide cada coordenada por el z distancia. Esto hace que las cosas que están más lejos se vean más pequeñas que las que están más cerca de la cámara.. 

Porque dividiendo por z produce coordenadas muy pequeñas, multiplicamos cada coordenada por un valor correspondiente a la distancia focal de la cámara.

longitud focal = 1000
# dibuja puntos en el sensor de la cámara usando xDistance, yDistance y zDistance def perspectiveProjection (pCoords): scaleFactor = focalLength / pCoords ['z'] return 'x': pCoords ['x'] * scaleFactor, 'y': pCoords [ 'y'] * factor de escala

5. Convertir las coordenadas esféricas en coordenadas rectangulares

La tierra es una esfera. Por lo tanto, nuestras coordenadas, latitud y longitud, son coordenadas esféricas. Así que tenemos que escribir una función que convierta las coordenadas esféricas en coordenadas rectangulares (así como definir un radio de la Tierra y proporcionar el π constante):

radio = 10 pi = 3.14159
# convierte coordenadas esféricas en coordenadas rectangulares def sphereToRect (r, a, b): return 'x': r * math.sin (b * pi / 180) * math.cos (a * pi / 180), 'y' : r * math.sin (b * pi / 180) * math.sin (a * pi / 180), 'z': r * math.cos (b * pi / 180)

Podemos lograr un mejor rendimiento almacenando algunos cálculos utilizados más de una vez:

# convierte coordenadas esféricas en coordenadas rectangulares def sphereToRect (r, a, b): aRad = math.radians (a) bRad = math.radians (b) r_sin_b = r * math.sin (bRad) return 'x': r_sin_b * math.cos (aRad), 'y': r_sin_b * math.sin (aRad), 'z': r * math.cos (bRad)

Podemos escribir algunas funciones compuestas que combinarán todos los pasos anteriores en una función, yendo directamente de coordenadas esféricas o rectangulares a imágenes en perspectiva:

#funciones para trazar puntos def rectPlot (coordenada): return perspectiveProjection (physicalProjection (coordenada)) def spherePlot (coordenada, sRadius): return rectPlot (sphereToRect (sRadius, coordenada ['long'], coordenada ['lat'])

4. Rendering a SVG

Nuestro script debe poder escribir en un archivo SVG. Así que debería comenzar con:

f = abrir ('globe.svg', 'w') f.write ('\norte\norte')

Y termina con:

f.write ('')

Producir un archivo SVG vacío pero válido. Dentro de ese archivo, el script debe poder crear objetos SVG, por lo que definiremos dos funciones que le permitirán dibujar puntos y polígonos SVG:

#Draws SVG circle object def svgCircle (coordenada, circleRadius, color): f.write ('\ n ') #Draws SVG polygon node def polyNode (coordenada): f.write (str (coordenada [' x '] + 400) +', '+ str (coordenada [' y '] + 400) + ")

Podemos probar esto representando una cuadrícula esférica de puntos:

# DIBUJAR GRID para x en rango (72): para y en rango (36): svgCircle (spherePlot ('long': 5 * x, 'lat': 5 ​​* y, radio), 1, '#ccc' )

Este script, cuando se guarda y ejecuta, debería producir algo como esto:


5. Transformar los datos del mapa SVG

Para leer un archivo SVG, una secuencia de comandos debe poder leer un archivo XML, ya que SVG es un tipo de XML. Por eso importamos. xml.etree.ElementTree. Este módulo le permite cargar el XML / SVG en un script como una lista anidada:

tree = ET.parse ('BlankMap Equirectangular states.svg') root = tree.getroot ()

Puede navegar a un objeto en el SVG a través de los índices de la lista (por lo general, debe mirar el código fuente del archivo de mapa para comprender su estructura). En nuestro caso, cada país se encuentra en raíz [4] [0] [X] [norte], dónde X es el número del país, que comienza con 1, y n representa los diversos subpaths que describen el país. Los contornos reales del país se almacenan en el re atributo, accesible a través de raíz [4] [0] [X] [norte] .attrib ['d'].

1. Construye Loops

No podemos simplemente recorrer este mapa porque contiene un elemento "ficticio" al principio que se debe omitir. Por lo tanto, debemos contar el número de objetos "de país" y restar uno para deshacernos del muñeco. Luego hacemos un bucle a través de los objetos restantes.

países = len (raíz [4] [0]) - 1 para x en rango (países): raíz [4] [0] [x + 1]

Algunos objetos de país incluyen varias rutas, por lo que luego iteramos a través de cada ruta en cada país:

países = len (raíz [4] [0]) - 1 para x en rango (países): para ruta en raíz [4] [0] [x + 1]:

Dentro de cada ruta, hay contornos separados separados por los caracteres 'Z M' en el re cadena, por lo que dividimos el re cadena a lo largo de ese delimitador y iterar a través aquellos.

países = len (raíz [4] [0]) - 1 para x en rango (países): para ruta en root [4] [0] [x + 1]: para k en re.split ('Z M', ruta.attrib ['d']):

Luego dividimos cada contorno por los delimitadores 'Z', 'L' o 'M' para obtener la coordenada de cada punto en el camino:

para x en rango (países): para ruta en raíz [4] [0] [x + 1]: para k en re.split ('Z M', path.attrib ['d']): para i en re .split ('Z | M | L', k):

Luego eliminamos todos los caracteres no numéricos de las coordenadas y los dividimos por la mitad a lo largo de las comas, dando las latitudes y longitudes. Si ambos existen, los almacenamos en un esfera coordinadas diccionario (en el mapa, las coordenadas de latitud van de 0 a 180 °, pero queremos que vayan de -90 ° a 90 °, norte y sur, por lo que restamos 90 °).

para x en rango (países): para ruta en raíz [4] [0] [x + 1]: para k en re.split ('Z M', path.attrib ['d']): para i en re .split ('Z | M | L', k): breakup = re.split (',', re.sub ("[^ - 0123456789.,]", "", i)) si breakup [0] y desglose [1]: sphereCoordinates =  sphereCoordinates ['long'] = float (breakup [0]) sphereCoordinates ['lat'] = float (breakup [1]) - 90

Entonces si lo probamos trazando algunos puntos (svgCircle (spherePlot (sphereCoordinates, radio), 1, '# 333')), obtenemos algo como esto:

2. Resolver para oclusión

Esto no distingue entre los puntos en el lado cercano del globo y los puntos en el lado lejano del globo. Si solo queremos imprimir puntos en el lado visible del planeta, debemos ser capaces de averiguar en qué lado del planeta hay un punto dado.. 

Podemos hacer esto calculando los dos puntos en la esfera donde un rayo de la cámara al punto se intersecaría con la esfera. Esta función implementa la fórmula para resolver las distancias a esos dos puntos.-dCerca de y dFar:

cameraDistanceSquare = sumOfSquares (camera) #distance del centro del globo a la cámara def distanceToPoint (spherePoint): point = sphereToRect (radio, spherePoint ['long'], spherePoint ['lat']) ray = findVector (camera, point) return vectorProject ( rayo, cámara hacia adelante)
def occlude (spherePoint): point = sphereToRect (radio, spherePoint ['long'], spherePoint ['lat']) ray = findVector (cámara, punto) d1 = magnitud (ray) # distancia de la cámara al punto dot_l = np. punto ([rayo ['x'] / d1, rayo ['y'] / d1, rayo ['z'] / d1], vectorToList (cámara)) #dot producto de vector unidad de cámara a punto y determinante de vector de cámara = math.sqrt (abs ((dot_l) ** 2 - cameraDistanceSquare + radius ** 2)) dNear = - (dot_l) + determinant dFar = - (dot_l) - determinant

Si la distancia real al punto, d1, es menor o igual que ambos de estas distancias, entonces el punto está en el lado cercano de la esfera. Debido a los errores de redondeo, se incluye un poco de margen de maniobra en esta operación:

 si d1 - 0.0000000001 <= dNear and d1 - 0.0000000001 <= dFar : return True else: return False

El uso de esta función como condición debe restringir la representación a puntos cercanos:

 si ocluye (esferaCoordinadas): svgCircle (esferaParcela (esferaCoordinadas, radio), 1, '# 333')

6. Hacer países sólidos

Por supuesto, los puntos no son verdaderas formas cerradas y rellenas, solo dan la ilusión de formas cerradas. Dibujar países llenos reales requiere un poco más de sofisticación. En primer lugar, tenemos que imprimir la totalidad de todos los países visibles. 

Podemos hacerlo creando un interruptor que se active cada vez que un país contenga un punto visible, mientras tanto almacena temporalmente las coordenadas de ese país. Si el interruptor está activado, el país se dibuja, utilizando las coordenadas almacenadas. También dibujaremos polígonos en lugar de puntos..

para x en rango (países): para ruta en raíz [4] [0] [x + 1]: para k en re.split ('Z M', path.attrib ['d']): countryIsVisible = False country = [] para i en re.split ('Z | M | L', k): breakup = re.split (',', re.sub ("[^ - 0123456789.,]", "", i) ) si ruptura [0] y ruptura [1]: sphereCoordinates =  sphereCoordinates ['long'] = float (breakup [0]) sphereCoordinates ['lat'] = float (breakup [1]) - 90 #DRAW COUNTRY si ocluir (esferaCoordinadas): country.append ([esferaCoordinadas, radio]) countryIsVisible = True else: country.append ([esferaCoordinates, radio]) si countryIsVisible: f.write ('\ n \ n ')

Es difícil decirlo, pero los países en el borde del mundo se pliegan sobre sí mismos, lo que no queremos (eche un vistazo a Brasil).

1. Traza el disco de la tierra

Para hacer que los países se representen correctamente en los bordes del globo, primero debemos trazar el disco del globo terráqueo con un polígono (el disco que se ve desde los puntos es una ilusión óptica). El disco está perfilado por el borde visible del globo: un círculo. Las siguientes operaciones calculan el radio y el centro de este círculo, así como la distancia del plano que contiene el círculo desde la cámara y el centro del globo..

#TRACE LIMB limbRadius = math.sqrt (radio ** 2 - radio ** 4 / cameraDistanceSquare) cx = camera ['x'] * radio ** 2 / cameraDistanceSquare cy = camera ['y'] * radio ** 2 / cameraDistanceSquare cz = camera ['z'] * radio ** 2 / cameraDistanceSquare planeDistance = magnitud (cámara) * (1 - radio ** 2 / cameraDistanceSquare) planeDisplacement = math.sqrt (cx ** 2 + cy ** 2 + cz ** 2)

La tierra y la cámara (punto gris oscuro) vistos desde arriba. La línea rosa representa el borde visible de la tierra. Solo el sector sombreado es visible para la cámara..

Luego, para graficar un círculo en ese plano, construimos dos ejes paralelos a ese plano:

#trade & negate x e y para obtener un vector perpendicular unitVectorCamera = unitVector (camera) aV = unitVector ('x': -unitVectorCamera ['y'], 'y': unitVectorCamera ['x'], 'z': 0) bV = np.cross (vectorToList (aV), vectorToList (unitVectorCamera))

Luego simplemente graficamos esos ejes en incrementos de 2 grados para trazar un círculo en ese plano con ese radio y centro (ver esta explicación para las matemáticas):

para t en el rango (180): theta = math.radians (2 * t) cosT = math.cos (theta) sinT = math.sin (theta) limbPoint = 'x': cx + limbRadius * (cosT * aV [ 'x'] + sinT * bV [0]), 'y': cy + limbRadius * (cosT * aV ['y'] + sinT * bV [1]), 'z': cz + limbRadius * (cosT * aV ['z'] + sinT * bV [2])

Luego simplemente encapsulamos todo eso con el código de dibujo del polígono:

f.write ('')

También creamos una copia de ese objeto para usarla más adelante como una máscara de recorte para todos nuestros países:

f.write ('')

Eso debería darte esto:

2. Clipping en el disco

Usando el disco recién calculado, podemos modificar nuestro más declaración en el código de trazado del país (para cuando las coordenadas están en el lado oculto del globo) para trazar esos puntos en algún lugar fuera del disco:

 else: tangentscale = (radio + planeDisplacement) / (pi * 0.5) rr = 1 + abs (math.tan ((distanceToPoint (distanceCoordinates) - planeDistance) / tangentscale)) country.append ([sphereCoordinates, radius * rr])

Esto utiliza una curva tangente para levantar los puntos ocultos sobre la superficie de la Tierra, dando la apariencia de que se extienden a su alrededor:

Esto no es completamente matemático (se descompone si la cámara no está orientada al centro del planeta), pero es simple y funciona la mayor parte del tiempo. Luego simplemente agregando clip-path = "url (#clipglobe)" Para el código de dibujo de polígono, podemos recortar los países al borde del globo:

 si countryIsVisible: f.write ('

¡Espero que disfrutes este tutorial! Diviértete con tus globos vectoriales.!