https://github.com/minorua/Qgis2threejs
Tip revision: c29151dd8221c6226a744ec5bcb25cb49b8175ff authored by Minoru Akagi on 31 January 2024, 01:41:12 UTC
version 2.7.3
version 2.7.3
Tip revision: c29151d
buildvector.py
# -*- coding: utf-8 -*-
# (C) 2014 Minoru Akagi
# SPDX-License-Identifier: GPL-2.0-or-later
# begin: 2014-01-16
import json
import random
from PyQt5.QtCore import QVariant
from PyQt5.QtGui import QColor
from qgis.core import (QgsCoordinateTransform, QgsExpression, QgsFeatureRequest, QgsGeometry, QgsProject, QgsRenderContext)
from .conf import DEF_SETS, FEATURES_PER_BLOCK, DEBUG_MODE
from .buildlayer import LayerBuilder
from .datamanager import MaterialManager, ModelManager
from .geometry import VectorGeometry, PointGeometry, LineGeometry, PolygonGeometry, TINGeometry
from .q3dconst import LayerType, PropertyID as PID
from .tools import css_color, hex_color, int_color, logMessage, parseFloat, parseInt
from .propwidget import PropertyWidget, ColorWidgetFunc, OpacityWidgetFunc, ColorTextureWidgetFunc
from .vectorobject import ObjectType
LayerType2GeomClass = {
LayerType.POINT: PointGeometry,
LayerType.LINESTRING: LineGeometry,
LayerType.POLYGON: PolygonGeometry
}
def json_default(o):
if isinstance(o, QVariant):
return repr(o)
raise TypeError(repr(o) + " is not JSON serializable")
class Feature:
def __init__(self, vlayer, geom, props, attrs=None):
self.layerType = vlayer.type
self.ot = vlayer.ot
self.geom = geom # an instance of QgsGeometry
self.props = props # a dict
self.attributes = attrs # a list or None
self.material = self.model = None
def clipGeometry(self, extent):
r = extent.rotation()
if r:
self.geom.rotate(r, extent.center())
self.geom = self.geom.clipped(extent.unrotatedRect())
if r:
self.geom.rotate(-r, extent.center())
return self.geom
def geometry(self, z_func, mapTo3d, useZM=VectorGeometry.NotUseZM, baseExtent=None, grid=None):
alt = self.prop(PID.ALT, 0)
zf = lambda x, y: z_func(x, y) + alt
transform_func = mapTo3d.transform
if self.layerType != LayerType.POLYGON:
return LayerType2GeomClass[self.layerType].fromQgsGeometry(self.geom, zf, transform_func, useZM=useZM)
objType = type(self.ot)
if objType == ObjectType.Polygon:
return TINGeometry.fromQgsGeometry(self.geom, zf, transform_func,
drop_z=(useZM == VectorGeometry.NotUseZM))
if objType == ObjectType.Extruded:
return PolygonGeometry.fromQgsGeometry(self.geom, zf, transform_func,
useCentroidHeight=True,
centroidPerPolygon=True)
# Overlay
border = bool(self.prop(PID.C2) is not None)
if grid is None:
# absolute z coordinate
g = TINGeometry.fromQgsGeometry(self.geom, zf, transform_func, drop_z=True)
if border:
g.bnds_list = PolygonGeometry.fromQgsGeometry(self.geom, zf, transform_func).toLineGeometryList()
return g
# relative to DEM
transform_func = mapTo3d.transform
if baseExtent.rotation():
self.geom.rotate(baseExtent.rotation(), baseExtent.center())
polys = grid.splitPolygon(self.geom)
g = TINGeometry.fromQgsGeometry(polys, zf, transform_func, use_earcut=True)
if border:
bnds = grid.segmentizeBoundaries(self.geom)
g.bnds_list = [LineGeometry.fromQgsGeometry(bnd, zf, transform_func, useZM=VectorGeometry.UseZ) for bnd in bnds]
return g
def prop(self, pid, def_val=None):
return self.props.get(pid, def_val)
def hasProp(self, pid):
return pid in self.props
class VectorLayer:
def __init__(self, settings, layer, materialManager, modelManager):
"""layer: Layer object"""
self.settings = settings
self.renderContext = QgsRenderContext.fromMapSettings(settings.mapSettings)
self.type = layer.type
self.mapLayer = layer.mapLayer
self.name = layer.name
self.properties = layer.properties
self.expressionContext = self.mapLayer.createExpressionContext()
otc = ObjectType.typeByName(self.properties.get("comboBox_ObjectType"), layer.type)
if otc == ObjectType.ModelFile:
self.ot = otc(settings, modelManager)
elif otc:
self.ot = otc(settings, materialManager)
else:
self.ot = None
logMessage("Shape type not found: {} ({})".format(self.properties.get("comboBox_ObjectType"), self.name), error=True)
self.materialManager = materialManager
self.modelManager = modelManager
self.colorNames = [] # for random color
self.transform = QgsCoordinateTransform(self.mapLayer.crs(), settings.crs, QgsProject.instance())
self.onlyIntersecting = self.properties.get("radioButton_IntersectingFeatures", False)
# attributes
self.writeAttrs = self.properties.get("checkBox_ExportAttrs", False)
self.hasLabel = self.properties.get("checkBox_Label", False)
self.fieldIndices = []
self.fieldNames = []
if self.writeAttrs:
for index, field in enumerate(self.mapLayer.fields()):
if field.editorWidgetSetup().type() != "Hidden":
self.fieldIndices.append(index)
self.fieldNames.append(field.displayName())
# expressions
self._exprs = {}
self.pids = [PID.ALT] + self.ot.pids
if self.hasLabel:
self.pids += [PID.LBLH, PID.LBLTXT]
# animation
self.anim_exprs = None
if self.type == LayerType.LINESTRING and otc in [ObjectType.Line, ObjectType.ThickLine]:
groups = list(self.settings.groupsWithExpressions(layer.layerId))
if groups:
kf = groups[0].get("keyframes", [{}])[0]
self.anim_exprs = {
PID.DLY: QgsExpression(str(kf.get("delay", 0))),
PID.DUR: QgsExpression(str(kf.get("duration", DEF_SETS.ANM_DURATION)))
}
def features(self, request=None):
mapTo3d = self.settings.mapTo3d()
be = self.settings.baseExtent()
beGeom = be.geometry()
rotation = be.rotation()
fields = self.mapLayer.fields()
attrs = None
# initialize symbol rendering, and then get features (geometry, attributes, color, etc.)
self.renderer = self.mapLayer.renderer().clone()
self.renderer.startRender(self.renderContext, self.mapLayer.fields())
for f in self.mapLayer.getFeatures(request or QgsFeatureRequest()):
# geometry
geom = f.geometry()
if geom is None:
logMessage("Null geometry skipped: " + self.name)
continue
geom = QgsGeometry(geom)
# coordinate transformation - layer crs to project crs
if geom.transform(self.transform) != 0:
logMessage("Failed to transform geometry: " + self.name)
continue
if rotation and self.onlyIntersecting:
# if map is rotated, check whether geometry intersects with the base extent
if not beGeom.intersects(geom):
continue
# set feature to expression context
self.expressionContext.setFeature(f)
# properties
props = self.evaluateProperties(f, self.pids)
if self.anim_exprs:
for pid, expr in self.anim_exprs.items():
props[pid] = expr.evaluate(self.expressionContext)
# attributes
if self.writeAttrs:
attrs = [fields[i].displayString(f.attribute(i)) for i in self.fieldIndices]
# label
if self.hasLabel:
props[PID.LBLH] *= mapTo3d.zScale
yield Feature(self, geom, props, attrs)
self.renderer.stopRender(self.renderContext)
def evaluateProperties(self, feat, pids):
d = {}
for pid in pids:
name = PID.PID_NAME_DICT[pid]
p = self.properties.get(name)
if p is None:
continue
val = None
if isinstance(p, str):
val = self.evaluateExpression(p, feat)
elif isinstance(p, dict):
val = self.evaluatePropertyWidget(name, feat)
if val is not None:
d[pid] = val
return d
def evaluateExpression(self, expr_str, f):
if expr_str not in self._exprs:
self._exprs[expr_str] = QgsExpression(expr_str)
self.expressionContext.setFeature(f)
return self._exprs[expr_str].evaluate(self.expressionContext)
def evaluatePropertyWidget(self, name, feat):
wv = self.properties.get(name)
if not wv:
return None
t = wv["type"]
if t == PropertyWidget.COLOR:
return self.readFillColor(wv, feat)
if t == PropertyWidget.OPACITY:
return self.readOpacity(wv, feat)
if t in (PropertyWidget.EXPRESSION, PropertyWidget.LABEL_HEIGHT):
expr = wv["editText"] or "0"
val = self.evaluateExpression(expr, feat)
if val is None:
logMessage("Failed to evaluate expression: {} ({})".format(expr, self.name))
elif isinstance(val, str):
val = parseFloat(val)
if val is None:
logMessage("Cannot parse '{}' as a float value. ({})".format(expr, self.name))
return val or 0
if t == PropertyWidget.OPTIONAL_COLOR:
return self.readBorderColor(wv, feat)
if t == PropertyWidget.CHECKBOX:
return wv["checkBox"]
if t == PropertyWidget.COMBOBOX:
return wv["comboData"]
if t == PropertyWidget.FILEPATH:
expr = wv["editText"]
val = self.evaluateExpression(expr, feat)
if val is None:
logMessage("Failed to evaluate expression: " + expr)
return val or ""
if t == PropertyWidget.COLOR_TEXTURE:
comboData = wv.get("comboData")
if comboData == ColorTextureWidgetFunc.MAP_CANVAS:
return comboData
if comboData == ColorTextureWidgetFunc.LAYER:
return wv.get("layerIds", [])
return self.readFillColor(wv, feat)
logMessage("Widget type {} not found.".format(t))
return None
def readFillColor(self, vals, f):
return self._readColor(vals, f)
def readBorderColor(self, vals, f):
return self._readColor(vals, f, isBorder=True)
# read color from COLOR or OPTIONAL_COLOR widget
def _readColor(self, wv, f, isBorder=False):
mode = wv["comboData"]
if mode is None:
return None
if mode == ColorWidgetFunc.EXPRESSION:
val = self.evaluateExpression(wv["editText"], f)
try:
if isinstance(val, str):
a = val.split(",")
if len(a) >= 3:
a = [max(0, min(int(c), 255)) for c in a[:3]]
return "0x{:02x}{:02x}{:02x}".format(a[0], a[1], a[2])
return val.replace("#", "0x")
raise
except:
logMessage("Wrong color value: {}".format(val))
return "0"
if mode == ColorWidgetFunc.RANDOM or f is None:
self.colorNames = self.colorNames or QColor.colorNames()
color = random.choice(self.colorNames)
self.colorNames.remove(color)
return hex_color(QColor(color).name(), prefix="0x")
# feature color
symbols = self.renderer.symbolsForFeature(f, self.renderContext)
if not symbols:
logMessage("Symbol for feature not found. Please use a simple renderer for {0}.".format(self.name))
return "0"
symbol = symbols[0]
if isBorder:
sl = symbol.symbolLayer(0)
if sl:
return sl.strokeColor().name().replace("#", "0x")
return symbol.color().name().replace("#", "0x")
def readOpacity(self, wv, f):
if wv["comboData"] == OpacityWidgetFunc.EXPRESSION:
try:
val = self.evaluateExpression(wv["editText"], f)
return min(max(0, val), 100) / 100
except:
logMessage("Wrong opacity value: {}".format(val))
return 1
symbols = self.renderer.symbolsForFeature(f, self.renderContext)
if not symbols:
logMessage("Symbol for feature not found. Please use a simple renderer for {0}.".format(self.name))
return 1
symbol = symbols[0]
return self.mapLayer.opacity() * symbol.opacity()
@classmethod
def toFloat(cls, val):
try:
return float(val)
except Exception as e:
logMessage('{0} (value: {1})'.format(e.message, str(val)))
return 0
# functions to read values from height widget (z coordinate)
def useZ(self):
return self.properties.get("radioButton_zValue", False)
def useM(self):
return self.properties.get("radioButton_mValue", False)
def isHeightRelativeToDEM(self):
return self.properties.get("comboBox_altitudeMode") is not None
class FeatureBlockBuilder:
def __init__(self, settings, vlayer, jsLayerId, pathRoot=None, urlRoot=None, useZM=VectorGeometry.NotUseZM, z_func=None, grid=None):
self.settings = settings
self.vlayer = vlayer
self.jsLayerId = jsLayerId
self.pathRoot = pathRoot
self.urlRoot = urlRoot
self.useZM = useZM
self.z_func = z_func
self.grid = grid
self.blockIndex = None
self.startFIdx = None
self.features = []
def clone(self):
return FeatureBlockBuilder(self.settings, self.vlayer, self.jsLayerId,
self.pathRoot, self.urlRoot,
self.useZM, self.z_func, self.grid)
def setBlockIndex(self, index):
self.blockIndex = index
def setFeatures(self, features):
self.features = features
def build(self):
be = self.settings.baseExtent()
obj_geom_func = self.vlayer.ot.geometry
mapTo3d = self.settings.mapTo3d()
feats = []
for f in self.features:
d = {}
d["geom"] = obj_geom_func(f, f.geometry(self.z_func, mapTo3d, self.useZM, be, self.grid))
if f.material is not None:
d["mtl"] = f.material
elif f.model is not None:
d["model"] = f.model
if f.attributes is not None:
d["prop"] = f.attributes
text = f.prop(PID.LBLTXT)
if text is not None and text != "":
d["lbl"] = str(text)
d["lh"] = f.prop(PID.LBLH)
if f.hasProp(PID.DLY):
d["anim"] = {
"delay": parseInt(f.prop(PID.DLY)),
"duration": parseInt(f.prop(PID.DUR))
}
if DEBUG_MODE:
logMessage("dly: {}, dur: {}".format(d["anim"]["delay"], d["anim"]["duration"]))
feats.append(d)
data = {
"type": "block",
"layer": self.jsLayerId,
"block": self.blockIndex,
"features": feats,
"featureCount": len(feats),
"startIndex": self.startFIdx
}
if self.pathRoot is not None:
with open(self.pathRoot + "{0}.json".format(self.blockIndex), "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2 if DEBUG_MODE else None, default=json_default)
url = self.urlRoot + "{0}.json".format(self.blockIndex)
return {"url": url, "featureCount": len(feats)}
else:
return data
class VectorLayerBuilder(LayerBuilder):
type2str = {
LayerType.POINT: "point",
LayerType.LINESTRING: "line",
LayerType.POLYGON: "polygon"
}
def __init__(self, settings, layer, imageManager, pathRoot=None, urlRoot=None, progress=None, log=None):
LayerBuilder.__init__(self, settings, layer, imageManager, pathRoot, urlRoot, progress, log)
self.materialManager = MaterialManager(imageManager, settings.materialType())
self.modelManager = ModelManager(settings)
self.clipExtent = None
vl = VectorLayer(settings, layer, self.materialManager, self.modelManager)
if vl.ot:
self.log("Object type is {}.".format(vl.ot.name))
else:
logMessage("Object type not found", error=True)
self.vlayer = vl
def build(self, build_blocks=False, cancelSignal=None):
if self.layer.mapLayer is None or self.vlayer.ot is None:
return
vlayer = self.vlayer
objType = type(vlayer.ot)
be = self.settings.baseExtent()
p = self.layer.properties
# feature request
request = QgsFeatureRequest()
if p.get("radioButton_IntersectingFeatures", False):
request.setFilterRect(vlayer.transform.transformBoundingBox(be.boundingBox(),
QgsCoordinateTransform.ReverseTransform))
# geometry for clipping
if p.get("checkBox_Clip") and objType != ObjectType.Polygon:
self.clipExtent = be.clone().scale(0.9999) # clip to slightly smaller extent than map canvas extent
self.features = []
data = {}
# materials/models
if objType == ObjectType.ModelFile:
for feat in vlayer.features(request):
feat.model = vlayer.ot.model(feat)
self.features.append(feat)
data["models"] = self.modelManager.build(self.pathRoot is not None,
base64=self.settings.jsonSerializable)
self.log("This layer has reference to 3D model file(s). If there are relevant files, you need to copy them to data directory for this export.", warning=True)
else:
for feat in vlayer.features(request):
feat.material = vlayer.ot.material(feat)
self.features.append(feat)
data["materials"] = self.materialManager.buildAll(self.pathRoot, self.urlRoot,
base64=self.settings.jsonSerializable)
if build_blocks:
self._startBuildBlocks(cancelSignal)
nf = 0
blocks = []
for builder in self.subBuilders():
if self.canceled:
break
b = builder.build()
nf += b["featureCount"]
blocks.append(b)
self._endBuildBlocks(cancelSignal)
nb = len(blocks)
if nb > 1:
self.log("{} features were splitted into {} parts.".format(nf, nb))
else:
self.log("{} feature{}.".format(nf, "s" if nf > 1 else ""))
data["blocks"] = blocks
d = {
"type": "layer",
"id": self.layer.jsLayerId,
"properties": self.layerProperties(),
"data": data
}
if self.canceled:
return None
if DEBUG_MODE:
d["PROPERTIES"] = p
return d
def layerProperties(self):
p = LayerBuilder.layerProperties(self)
p["type"] = self.type2str.get(self.layer.type)
p["objType"] = self.vlayer.ot.name
if self.vlayer.writeAttrs:
p["propertyNames"] = self.vlayer.fieldNames
if self.vlayer.hasLabel:
label = {
"relative": bool(self.properties.get("labelHeightWidget", {}).get("comboData", 0) == 1),
"font": self.properties.get("comboBox_FontFamily", ""),
"size": self.properties.get("slider_FontSize", 3) - 3,
"color": css_color(self.properties.get("colorButton_Label", DEF_SETS.LABEL_COLOR))
}
if self.properties.get("checkBox_Outline"):
label["olcolor"] = css_color(self.properties.get("colorButton_OtlColor", DEF_SETS.OTL_COLOR))
if self.properties.get("groupBox_Background"):
label["bgcolor"] = css_color(self.properties.get("colorButton_BgColor", DEF_SETS.BG_COLOR))
if self.properties.get("groupBox_Conn"):
label["cncolor"] = int_color(self.properties.get("colorButton_ConnColor", DEF_SETS.CONN_COLOR))
if self.properties.get("checkBox_Underline"):
label["underline"] = True
p["label"] = label
# object-type-specific properties
# p.update(self.vlayer.ot.layerProperties(self.settings, self))
return p
def subBuilders(self):
if self.vlayer.ot is None:
return
objType = type(self.vlayer.ot)
z_func = lambda x, y: 0
grid = None
p = self.vlayer.properties
if p.get("radioButton_zValue"):
useZM = VectorGeometry.UseZ
elif p.get("radioButton_mValue"):
useZM = VectorGeometry.UseM
else:
useZM = VectorGeometry.NotUseZM
if self.vlayer.isHeightRelativeToDEM():
demLayerId = p.get("comboBox_altitudeMode")
demProvider = self.settings.demProviderByLayerId(demLayerId)
if objType == ObjectType.Overlay:
# get the grid segments of the DEM layer which polygons overlay
dem_seg = self.settings.demGridSegments(demLayerId)
# prepare a grid geometry
grid = demProvider.readAsGridGeometry(dem_seg.width() + 1, dem_seg.height() + 1, self.settings.baseExtent())
else:
z_func = demProvider.readValue # readValue(x, y)
builder = FeatureBlockBuilder(self.settings, self.vlayer, self.layer.jsLayerId, self.pathRoot, self.urlRoot,
useZM, z_func, grid)
one_per_block = (objType == ObjectType.Overlay
and self.vlayer.isHeightRelativeToDEM()
and self.settings.isPreview)
bIndex = startFIdx = 0
feats = []
for f in self.features or []:
if self.clipExtent and self.layer.type != LayerType.POINT:
if f.clipGeometry(self.clipExtent) is None:
continue
# skip if geometry is empty or null
if f.geom.isEmpty() or f.geom.isNull():
if not self.clipExtent:
logMessage("empty/null geometry skipped")
continue
feats.append(f)
if len(feats) == FEATURES_PER_BLOCK or one_per_block:
b = builder.clone()
b.setBlockIndex(bIndex)
b.setFeatures(feats)
b.startFIdx = startFIdx
yield b
bIndex += 1
startFIdx += len(feats)
feats = []
if len(feats) or bIndex == 0:
builder.setBlockIndex(bIndex)
builder.setFeatures(feats)
builder.startFIdx = startFIdx
yield builder