Brighten and dash opening occlusion outline

The opening preview's outline used a single-batch two-pass scheme that
dimmed the occluded back pass via alpha=0.25. The visible front pass also
inherited the source decorator color's modest alpha, so the outline read
as subtle on both sides.

Replace with a CAD hidden-line convention: solid full-alpha front pass on
the visible side, world-space dashed back pass on the occluded side. Both
passes use POLYLINE_UNIFORM_COLOR so depth and line-weight paths match.
The dashed batch is built once per object epoch by a new pure helper
tool.Blender.build_dashed_line_segments (pre-segments edges into world-
space dash chunks), then cached via the existing batch-cache mechanism
under "<uid>_dashed".

The solid front pass is rendered at a slightly wider line width than the
dashed back pass so its halo overpowers Blender's WIRE-display overlay
bias at outline pixels — without the asymmetry the wire's anti-z-fight
forward bias makes the LESS_EQUAL comparison narrowly fail and the
dashed pass wins on visible edges too.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-09 22:49:00 +02:00
parent 6bde619fe6
commit 82465a64a5
3 changed files with 206 additions and 13 deletions
+57 -13
View File
@@ -209,6 +209,17 @@ def _get_cached_world_draw_data(
# handle each call), so they stay drawable across frames.
_batch_cache: dict[tuple[int, str], tuple[int, "gpu.types.GPUBatch"]] = {}
# CAD hidden-line convention for the occluded back-pass: world-space dashes so
# density stays coherent across zoom. Dash + gap = period; dash_width controls
# the "on" portion.
_DASH_PERIOD_METERS: float = 0.20
_DASH_WIDTH_METERS: float = 0.10
# Solid front pass is rendered wider than the dashed back pass so its halo
# overpowers the dashed center on visible edges even when the WIRE-display
# overlay biases the depth buffer at outline pixels.
_DASH_LINE_WIDTH: float = 1.5
_SOLID_LINE_WIDTH: float = 2.5
def _get_cached_batch_or_none(cache_key: tuple[int, str]) -> "gpu.types.GPUBatch | None":
uid = cache_key[0]
@@ -1191,22 +1202,55 @@ class DecorationsHandler:
shader.uniform_float("color", color)
batch.draw(shader)
def _draw_lines_with_occlusion(self, verts, color, edges_indices, occluded_alpha: float = 0.25, cache_key=None):
# One batch, two draws: front pass at full color, occluded pass at
# `occluded_alpha`. Save/restore depth_test matches the pattern in
# bim/module/structural/decorator.py so callers' state survives.
batch = self._get_or_build_batch(self.line_shader, "LINES", verts, edges_indices, cache_key=cache_key)
if batch is None:
def _draw_lines_with_occlusion(self, verts, color, edges_indices, cache_key=None):
# Two-pass CAD hidden-line convention. Both passes use POLYLINE_UNIFORM_COLOR.
#
# The solid front pass is rendered WIDER than the dashed back pass so it
# produces a halo around the line center, beyond the depth-bias zone that
# Blender's overlay engine writes when an opening is set to WIRE display.
# Without the width difference, the wire bias makes the center-pixel
# ``LESS_EQUAL`` comparison fail (line ends up slightly behind the biased
# wire depth) so the solid pass would lose to the dashed back pass even
# on visible edges. The halo gives the solid pass enough screen-space to
# overpower the dashed pattern visually.
#
# Dashed renders first at the standard width so the solid overlay's wider
# halo cleanly hides it on visible edges; on occluded edges the solid
# ``LESS_EQUAL`` pass fails against the wall depth and the dashed remains.
front_batch = self._get_or_build_batch(self.line_shader, "LINES", verts, edges_indices, cache_key=cache_key)
if front_batch is None:
return
dashed_cache_key = (cache_key[0], cache_key[1] + "_dashed") if cache_key is not None else None
dash_batch = None
if dashed_cache_key is not None:
dash_batch = _get_cached_batch_or_none(dashed_cache_key)
if dash_batch is None:
dash_verts, dash_edges = tool.Blender.build_dashed_line_segments(
verts, edges_indices, _DASH_PERIOD_METERS, _DASH_WIDTH_METERS
)
dash_batch = self._get_or_build_batch(self.line_shader, "LINES", dash_verts, dash_edges)
if dash_batch is not None and dashed_cache_key is not None:
_store_batch_in_cache(dashed_cache_key, dash_batch)
original_depth_test = gpu.state.depth_test_get()
front_color = list(color)
front_color[3] = 1.0
self.line_shader.uniform_float("color", front_color)
if dash_batch is not None:
self.line_shader.uniform_float("lineWidth", _DASH_LINE_WIDTH)
gpu.state.depth_test_set("ALWAYS")
dash_batch.draw(self.line_shader)
self.line_shader.uniform_float("lineWidth", _SOLID_LINE_WIDTH)
gpu.state.depth_test_set("LESS_EQUAL")
self.line_shader.uniform_float("color", color)
batch.draw(self.line_shader)
gpu.state.depth_test_set("GREATER")
dimmed = list(color)
dimmed[3] = occluded_alpha
self.line_shader.uniform_float("color", dimmed)
batch.draw(self.line_shader)
front_batch.draw(self.line_shader)
# Restore the per-iteration default set at the top of __call__ so
# subsequent draws (the HalfSpaceSolid arrow, future call-sites) are
# not silently affected by the front-pass width override.
self.line_shader.uniform_float("lineWidth", 2.0)
gpu.state.depth_test_set(original_depth_test)
def __call__(self, context):
+42
View File
@@ -22,6 +22,7 @@ from __future__ import annotations
import contextlib
import importlib
import math
import os
import platform
import subprocess
@@ -2260,6 +2261,47 @@ class Blender(bonsai.core.tool.Blender):
tris = [[loop.vert.index for loop in tri] for tri in bm.calc_loop_triangles()]
draw_batch("TRIS", world_vert_coords, color, tris)
@classmethod
def build_dashed_line_segments(
cls,
world_verts: Sequence[Sequence[float]],
edges_indices: Sequence[Sequence[int]],
dash_period: float,
dash_width: float,
) -> tuple[list[tuple[float, float, float]], list[tuple[int, int]]]:
"""Pre-segment edges into world-space dash chunks for a vanilla LINES batch.
Each input edge is sliced into segments of length ``dash_width`` spaced
``dash_period`` apart (dash phase resets per-edge). The result is a fresh
``(verts, edges)`` pair that draws as dashes through any standard line
shader — letting both passes of a visible/occluded outline reuse the
same shader so depth values match exactly across passes.
"""
new_verts: list[tuple[float, float, float]] = []
new_edges: list[tuple[int, int]] = []
if dash_period <= 0 or dash_width <= 0:
return new_verts, new_edges
n = len(world_verts)
for i, j in edges_indices:
if not (0 <= i < n and 0 <= j < n) or i == j:
continue
v0 = world_verts[i]
v1 = world_verts[j]
dx, dy, dz = v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]
edge_length = math.sqrt(dx * dx + dy * dy + dz * dz)
if edge_length == 0.0:
continue
ux, uy, uz = dx / edge_length, dy / edge_length, dz / edge_length
t = 0.0
while t < edge_length:
t_end = min(t + dash_width, edge_length)
idx = len(new_verts)
new_verts.append((v0[0] + ux * t, v0[1] + uy * t, v0[2] + uz * t))
new_verts.append((v0[0] + ux * t_end, v0[1] + uy * t_end, v0[2] + uz * t_end))
new_edges.append((idx, idx + 1))
t += dash_period
return new_verts, new_edges
@classmethod
def extract_error_reports(cls, exception: RuntimeError) -> list[str]:
"""Extracts error report lines from a runtime exception during operator execution.
@@ -0,0 +1,107 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Tests for the world-space dashed-line segmentation helper in tool.Blender.
The helper slices each input edge into world-space dash chunks so callers can
build a vanilla LINES batch (any shader, including ``POLYLINE_UNIFORM_COLOR``)
that renders as dashes. Sharing the front-pass shader for the occluded back
pass is what keeps depth values coherent between the visible / occluded
outlines a custom dashed shader against a builtin solid shader produces
inter-pass z-fighting and the wrong portion of the outline ends up dashed."""
import math
import types
import bpy
import pytest
import bonsai.tool as tool
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
class TestBuildDashedLineSegments:
def test_unit_edge_produces_expected_dash_count(self):
verts, edges = tool.Blender.build_dashed_line_segments(
[(0.0, 0.0, 0.0), (1.0, 0.0, 0.0)],
[(0, 1)],
dash_period=0.20,
dash_width=0.10,
)
assert len(edges) == 5
assert len(verts) == 10
def test_each_dash_runs_dash_width_along_the_edge(self):
verts, edges = tool.Blender.build_dashed_line_segments(
[(0.0, 0.0, 0.0), (1.0, 0.0, 0.0)],
[(0, 1)],
dash_period=0.20,
dash_width=0.10,
)
for i, j in edges:
dx = verts[j][0] - verts[i][0]
assert math.isclose(dx, 0.10, abs_tol=1e-9)
def test_dash_phase_resets_per_input_edge(self):
verts, edges = tool.Blender.build_dashed_line_segments(
[(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (1.0, 0.0, 0.0), (1.0, 1.0, 0.0)],
[(0, 1), (2, 3)],
dash_period=0.20,
dash_width=0.10,
)
first_dash_start = verts[edges[0][0]]
second_edge_first_dash_start = verts[edges[5][0]]
assert math.isclose(first_dash_start[0], 0.0, abs_tol=1e-9)
assert math.isclose(second_edge_first_dash_start[1], 0.0, abs_tol=1e-9)
def test_trailing_partial_dash_is_clamped_to_edge_end(self):
verts, edges = tool.Blender.build_dashed_line_segments(
[(0.0, 0.0, 0.0), (0.25, 0.0, 0.0)],
[(0, 1)],
dash_period=0.20,
dash_width=0.10,
)
last_x = verts[edges[-1][1]][0]
assert last_x <= 0.25 + 1e-9
def test_zero_length_edge_emits_no_dashes(self):
verts, edges = tool.Blender.build_dashed_line_segments(
[(0.0, 0.0, 0.0), (0.0, 0.0, 0.0)],
[(0, 1)],
dash_period=0.20,
dash_width=0.10,
)
assert verts == []
assert edges == []
def test_invalid_dash_parameters_return_empty(self):
assert tool.Blender.build_dashed_line_segments(
[(0.0, 0.0, 0.0), (1.0, 0.0, 0.0)], [(0, 1)], dash_period=0.0, dash_width=0.10
) == ([], [])
assert tool.Blender.build_dashed_line_segments(
[(0.0, 0.0, 0.0), (1.0, 0.0, 0.0)], [(0, 1)], dash_period=0.20, dash_width=-0.10
) == ([], [])