Skip to content

Note

Click here to download the full example code

live scene

bt.plot3d.Scene stacks layers in one 3D view. layers colored by the same variable share its color map, range and color bar, so a composite and the blocks around it show one grade in one color, and null values never draw, in any representation. in Jupyter, Colab or VS Code the scene is a widget whose panel, filters, sections and clicks sync back to python; from a script show() opens it as a self-contained page in the web browser and save writes that page. screenshot renders the scene to a PNG in headless Chromium, as the widget last showed it.

import sys
from pathlib import Path

HERE = Path(__file__).parent if "__file__" in globals() else Path.cwd()
sys.path.insert(0, str(HERE.parents[1]))
import boitata as bt
import numpy as np
from common import GRAY, LIGHT, show

zinc composites of the stacked sulphide lenses inform a grid rotated with them, by inverse distance within 60 m. both layers carry ZN_PCT, so they share one color bar. without clim its range would run from the lowest to the highest value of any layer; here the first layer fixes it at 0 to 10 % and the later ones follow. the blocks with no composite in reach are null and never draw; a filter keeps the blocks above 2 % from the start, and the layer's filter panel edits it while viewing.

data = bt.datasets.stacked_sulphide_lenses()
holes = bt.Drillholes.from_tables(data)
lenses = [data[f"lens_{i}"] for i in (1, 2, 3)]
composites = holes.composite(2.0, ["ZN_PCT"])
grid = bt.BlockModel.from_extents(*lenses, size=(20, 20, 10), buffer=20, rotation=(22.5, 0.0, 55.0))
search = bt.Search(radius=60, min_samples=1, max_samples=12)
idw = bt.InverseDistance(search, power=2).fit(composites, "ZN_PCT")
grid = grid.with_column("ZN_PCT", idw.predict(grid))
lo, hi = grid.bounds
near = np.all((composites.coords > lo) & (composites.coords < hi), axis=1)

scene = bt.plot3d.Scene()
scene.add(composites.filter(near), "ZN_PCT", name="composites", point_size=4, clim=(0, 10), label="Zn (%)")
scene.add(grid, "ZN_PCT", name="grid", opacity=0.6, filter={"ZN_PCT": (2, None)})
for i, lens in enumerate(lenses, 1):
    scene.add(lens, name=f"lens {i}", representation="wireframe", color=LIGHT, opacity=0.3)
scene.view(azimuth=305, dip=30)
print(f"{np.isnan(grid['ZN_PCT']).mean():.0%} of {len(grid):,} blocks null")
print(f"filters: {scene.filters}")
show(scene, "grade", "Zn composites and the grid blocks above 2 %, on one scale")

Out:

18% of 25,004 blocks null
filters: {'grid': {'ZN_PCT': (2.0, None)}}

text columns share a category list the same way. each composite inside a lens takes its name, the others stay null and do not draw; the sub-blocks of the lenses carry the same names in domain, here renamed lens so both layers color by one variable and every lens keeps its color across them.

inside = [lens.contains(composites.coords) for lens in lenses]
names = np.select(inside, [f"lens {i}" for i in (1, 2, 3)], "")
composites = composites.with_column("lens", [n or None for n in names])
blocks = bt.BlockModel.from_meshes(
    grid.origin,
    grid.size,
    grid.count,
    [(lens, "inside", f"lens {i}") for i, lens in enumerate(lenses, 1)],
    subgrid=2,
    fill="host",
    rotation=tuple(grid.rotation),
)
blocks = blocks.filter(blocks["domain"] != "host")
blocks = blocks.with_column("lens", blocks["domain"])

scene = bt.plot3d.Scene()
scene.add(blocks, "lens", name="sub-blocks", representation="wireframe", opacity=0.3)
scene.add(composites, "lens", name="composites", point_size=5)
scene.view(azimuth=305, dip=30)
print(f"{len(blocks):,} sub-blocks, {(names != '').sum():,} of {len(composites):,} composites in a lens")
show(scene, "lenses", "Lens of each composite and sub-block, one color per lens")

Out:

3,233 sub-blocks, 1,135 of 13,312 composites in a lens

drill holes with intervals draw one segment per interval, split at the survey stations so it follows the trace; unassayed ground has no interval and leaves a gap, as would a null ZN_PCT. holes without intervals draw their traces instead. "tubes" turns the segments into shaded cylinders, radius meters wide. here the holes collared within 30 m of a north-south line through the middle: thin gray traces, thick assays on top.

x = data["collars"]["X"]
fence = data["collars"].filter(np.abs(x - np.median(x)) < 30)
fence_holes = bt.Drillholes(fence, data["surveys"], data["assays"])
scene = bt.plot3d.Scene()
scene.add(bt.Drillholes(fence, data["surveys"]), name="traces", color=GRAY, line_width=1)
scene.add(fence_holes, "ZN_PCT", name="assays", representation="tubes", radius=4, clim=(0, 5), label="Zn (%)")
for i, lens in enumerate(lenses, 1):
    scene.add(lens, name=f"lens {i}", representation="wireframe", color=LIGHT, opacity=0.2)
scene.view(azimuth=300, dip=20)
print(f"{len(fence_holes.holes)} of {len(holes.holes)} holes, {len(fence_holes.samples()):,} intervals")
show(scene, "holes", "Zn assays down a fence of holes, as tubes")

Out:

21 of 289 holes, 1,000 intervals

inspecting and reading back

a click on a block, point, interval or triangle opens a card with every column sent for its row, its coordinates, and for a block its size, and outlines it; Esc or a click on empty space closes it. in a notebook picked reads the last click back, while filters and sections follow the panel's edits and the cuts drawn in the view, both ways: setting them updates the widgets shown. columns= limits what a layer sends, and so what the card lists. from a script nothing is clicked, so picked is None.

scene = bt.plot3d.Scene()
scene.add(grid, "ZN_PCT", name="grid", columns=["ZN_PCT"], clim=(0, 10), label="Zn (%)")
scene.filters = {"grid": {"ZN_PCT": (4, None)}}
scene.view(azimuth=305, dip=30)
print(f"picked: {scene.picked}")
print(f"filters: {scene.filters}")
show(scene, "rich", "Blocks above 4 % Zn, set through `filters`")

Out:

picked: None
filters: {'grid': {'ZN_PCT': (4.0, None)}}

Total running time of the script: ( 0 minutes 42.066 seconds)

Download Python source code: example_02_21.py

Download Jupyter notebook: example_02_21.ipynb

Gallery generated by mkdocs-gallery