Epost

Generering av dybdeetiketter for 3D-tekst i Blender

Denne veiledningen viser hvordan du klargjør dybdedata for videre bruk i 3D-visualisering, for eksempel i Blender eller webbaserte løsninger. Arbeidsflyten tar utgangspunkt i rå dybdepunkter og optimaliserer datasettet gjennom filtrering, generalisering og konvertering til et format som egner seg for bruk i Blender.

Se også tidligere relevante veiledninger:

  1. Hvordan lage en 3D batymetrisk havbunnsmodell fra QGIS
  2. Hvordan lage konturlinjer i QGIS

Grunnleggende

Før du starter, må du hente dybdepunkt. I dette tilfellet bruker vi Kartverkets WFS-tjeneste: Sjøkart – dybdedata WFS

Du må også lage en utstrekning (extent) over området. Dette kan gjøres enten ved bruk av et shapefile-lag eller et grid i QGIS. Eksporter deretter ønsket dybdepumkt området til en GeoPackage.

Steg 1 – Eksporter lag

  1. Høyreklikk på dybdepunkter-laget
  2. Velg Export → Save Features As…
  3. Sett følgende:
    • Format: GeoPackage
    • Filnavn: Valgfri plassering
    • CRS: Kontroller at den er korrekt
  4. Under Fields to export:
    • Fjern alle felt
    • Behold kun:
      • ID
      • dybde
  5. Aktiver:
    • Include Z-dimension
  6. Sett extent «Calculate from layer»

Klikk OK

Anbefalt: Når dybdepunktene er ferdig eksportert, kan du slette WFS laget for dybdepunkt. Dette anbefales for å unngå forvirring videre i arbeidet.

Steg 2 – Opprett etikettfelt

  1. Høyreklikk laget → Open Attribute Table
  2. Åpne Field Calculator
  3. Opprett nytt felt:
    • Navn: label
    • Type: Text (string)
  4. Bruk uttrykket (expression):
"id" || ' – ' || round("dybde", 1) || ' m'

Klikk OK også Lagre endringer

Hvorfor dette? Dette gjør dybdeetikettene mer konsistente og lettere å lese ved å runde verdiene til en desimal (feks. 12.66 m → 12.6 m).

Steg 3 – Lag grid baserte felt

Etter steg 2 har vi ofte for mange punkter, noe som gjør datasettet rotete og tungt å jobbe med.

Ved å dele området inn i et rutenett (grid) kan vi redusere antall punkter på en kontrollert måte, samtidig som vi bevarer de viktigste dybdedataene.

Åpne Field Calculator igjen og opprett:

1. cell_x

  • Type: Integer (32bit)
  • Uttrykk:
floor($x / 200)

2. cell_y

  • Type: Integer (32bit)
  • Uttrykk:
floor($y / 200)

3. cell_id

  • Type: Text (string)
  • Uttrykk:
floor($x / 200) || '_' || floor($y / 200)

Klikk Update All og lagre.

Fjern duplikater

  1. Åpne Processing Toolbox
  2. Søk etter:
    • Delete duplicates by attribute
  3. Sett:
    • Input layer: ditt lag
    • Attribute: cell_id

Klikk Run

Hva skjer her? Vi reduserer antall punkter ved å beholde ett punkt per rutenettcelle. Dette gir bedre ytelse, samtidig som de viktigste dybdedataene bevares.

Resultatet er betydelig redusert rot og bedre oversikt.

Steg 4 – Multipart til singlepart

  • Åpne Processing Toolbox
    • Søk: Multipart to Singleparts
  • Velg laget
  • Klikk Run

Steg 5 – Konverter geometri (legg til Z-verdi)

  1. Søk etter: Geometry by expression
  2. Sett:
    • Geometry type: Point
    • ✅ Enable Z dimension
  3. Bruk uttrykket (Expression):
make_point($x, $y, -"dybde")

Klikk Run

Steg 6 – Eksporter sluttresultat

  1. Høyre klikk laget, eksport – Save Features As..
  2. Eksporter laget som Shapefile
  3. Fields to export:
    • id (valgfritt)
    • dybde
    • label
  4. Extent: Calculate from layer
  5. Viktig: IKKE inkluder Z-dimensjon i eksporten

Steg 7 – Import i Blender

  1. Importer shapefilen via BlenderGIS plugin
  2. Klikk Separate Objects
  3. I Shapelayer Collection:
    • Velg alle objekter

Steg 8 – Scripting workspace

Nå som punktene er verifisert og følger terrenget korrekt, er det på tide å gi etikettene dybde (3D-tekst) slik at de blir tydelige i scenen. For dette har vi laget et script som kan kjøres i Blender for å automatisere prosessen.

Scriptet inneholder to viktige konfigurasjoner:

CONFIG = {
    "MODE": "BACK",  # FRONT or BACK
    "SHAPEFILE_COLLECTION": "seabed-texture_v2", # Velg modellnavnet etikettene skal projiseres på (terreng + collision rules)
  1. MODE»: «BACK – lager etiketter som er synlige bakfra.
    Kjør også med FRONT for å gjøre dem synlige fra begge sider (husk å markere punktene på nytt).
  2. SHAPEFILE_COLLECTION – velg modellen etikettene projiseres på.
    • Viktig for at de følger terrenget riktig og unngår feil plassering.
  3. Lim inn scripted nedenfor.
  4. Kjør koden to ganger:
    • Først: BACK
    • Deretter: FRONT

Phyton script:

import bpy
import mathutils

# =========================
# CONFIG
# =========================
CONFIG = {
    "MODE": "FRONT",  # FRONT or BACK
    "TEXT_SIZE": 6.0,
    "DOT_SIZE": 0.25,
    "HEIGHT_OFFSET": 3.0,
    "SURFACE_OFFSET": 0.05,
    "SHAPEFILE_COLLECTION": "seabed-texture_v2", # Velg modellnavnet etikettene skal projiseres på (terreng + collision rules)
    "UPWARD_NORMAL_MIN_Z": 0.3
}

MODE = CONFIG["MODE"]
TEXT_SIZE = CONFIG["TEXT_SIZE"]
DOT_SIZE = CONFIG["DOT_SIZE"]
HEIGHT_OFFSET = CONFIG["HEIGHT_OFFSET"]
SURFACE_OFFSET = CONFIG["SURFACE_OFFSET"]
SHAPEFILE_COLLECTION = CONFIG["SHAPEFILE_COLLECTION"]
UPWARD_NORMAL_MIN_Z = CONFIG["UPWARD_NORMAL_MIN_Z"]

# =========================
# MATERIAL (KEY PART)
# =========================
def get_material(name, color, emission, alpha=1.0):
    mat = bpy.data.materials.get(name)
    if not mat:
        mat = bpy.data.materials.new(name)
        mat.use_nodes = True

        bsdf = mat.node_tree.nodes["Principled BSDF"]
        bsdf.inputs["Base Color"].default_value = color
        bsdf.inputs["Emission Strength"].default_value = emission
        bsdf.inputs["Alpha"].default_value = alpha

        mat.blend_method = 'BLEND'
        mat.use_backface_culling = True  # 🔥 REQUIRED

    return mat

# 🔥 IMPORTANT: separate materials
if MODE == "FRONT":
    text_mat = get_material("DepthTextMat_FRONT", (1,1,1,1), 0.2, 0.9)
else:
    text_mat = get_material("DepthTextMat_BACK", (1,1,1,1), 0.2, 0.9)

dot_mat = get_material("DepthDotMat", (1,1,1,1), 0.4, 1)

# =========================
# TERRAIN
# =========================
terrain_obj = bpy.data.objects[SHAPEFILE_COLLECTION]

depsgraph = bpy.context.evaluated_depsgraph_get()
terrain_eval = terrain_obj.evaluated_get(depsgraph)

terrain_objects = [terrain_eval]
selected_objects = list(bpy.context.selected_objects)

# =========================
# MAIN LOOP
# =========================
for obj in selected_objects:

    if "dybde" not in obj:
        continue

    depth = round(float(obj["dybde"]), 1)
    label = f"{depth} m"

    # ---- TEXT ----
    curve = bpy.data.curves.new(type="FONT", name=f"DepthCurve_{obj.name}_{MODE}")
    curve.body = label
    curve.size = TEXT_SIZE

    text_obj = bpy.data.objects.new(f"DepthLabel_{obj.name}_{MODE}", curve)
    bpy.context.collection.objects.link(text_obj)

    # ---- RAYCAST ----
    origin_world = obj.location + mathutils.Vector((0, 0, 1000))
    direction_world = mathutils.Vector((0, 0, -1))

    hit = False
    closest_loc_world = None
    closest_normal_world = None
    min_distance = float('inf')

    for terrain_eval in terrain_objects:
        mw = terrain_eval.matrix_world
        mw_inv = mw.inverted()

        origin_local = mw_inv @ origin_world
        direction_local = (mw_inv.to_3x3() @ direction_world).normalized()

        result, loc_local, normal_local, _ = terrain_eval.ray_cast(origin_local, direction_local)

        if not result:
            continue

        loc_world = mw @ loc_local
        normal_world = (mw.to_3x3() @ normal_local).normalized()

        if normal_world.z < UPWARD_NORMAL_MIN_Z:
            continue

        dist = (origin_world - loc_world).length
        if dist < min_distance:
            min_distance = dist
            closest_loc_world = loc_world
            closest_normal_world = normal_world
            hit = True

    # ---- POSITION ----
    if hit and closest_normal_world:
        push_dir = closest_normal_world.normalized()
        text_location = closest_loc_world + push_dir * (HEIGHT_OFFSET + SURFACE_OFFSET)
    else:
        text_location = obj.location + mathutils.Vector((0,0,HEIGHT_OFFSET))

    text_obj.location = text_location
    text_obj.data.materials.append(text_mat)

    # Face up
    text_obj.rotation_euler[0] = 1.5708

    # BACK flip
    if MODE == "BACK":
        text_obj.rotation_euler[2] += 3.14159

    # =========================
    # DOT (ONLY FRONT + GEO)
    # =========================
    if MODE == "FRONT":
        if hit and closest_normal_world:
            dot_location = closest_loc_world + closest_normal_world * (DOT_SIZE + SURFACE_OFFSET)
        else:
            dot_location = obj.location + mathutils.Vector((0,0,DOT_SIZE))

        bpy.ops.mesh.primitive_uv_sphere_add(radius=DOT_SIZE, location=dot_location)
        dot = bpy.context.object
        dot.data.materials.append(dot_mat)

print(f"Done ({MODE})")

Resultat

Nu kan modellen eksporteres som GLB eller GLTF, og dybdetekst følger automatisk med.

Tor-Verner