From 82465a64a59a2ac666449afa334097c194db4d3d Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 9 Jun 2026 22:49:00 +0200 Subject: [PATCH] Brighten and dash opening occlusion outline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 "_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. --- src/bonsai/bonsai/bim/module/model/opening.py | 70 +++++++++--- src/bonsai/bonsai/tool/blender.py | 42 +++++++ .../test/tool/test_blender_dashed_line.py | 107 ++++++++++++++++++ 3 files changed, 206 insertions(+), 13 deletions(-) create mode 100644 src/bonsai/test/tool/test_blender_dashed_line.py diff --git a/src/bonsai/bonsai/bim/module/model/opening.py b/src/bonsai/bonsai/bim/module/model/opening.py index 4921da8bbc..c457b3144f 100644 --- a/src/bonsai/bonsai/bim/module/model/opening.py +++ b/src/bonsai/bonsai/bim/module/model/opening.py @@ -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): diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index 94aa316871..6d5bf2ae5d 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -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. diff --git a/src/bonsai/test/tool/test_blender_dashed_line.py b/src/bonsai/test/tool/test_blender_dashed_line.py new file mode 100644 index 0000000000..29694dd367 --- /dev/null +++ b/src/bonsai/test/tool/test_blender_dashed_line.py @@ -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 . +# +# 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 + ) == ([], [])