From e8a2c64c57f1477be12393087dfffd9f9fc791b8 Mon Sep 17 00:00:00 2001 From: Wurschdhaud Date: Tue, 14 Jul 2026 20:16:45 +0200 Subject: [PATCH 1/5] new lib: avf --- .../AVF.pushbutton/icon.png | Bin 0 -> 997 bytes .../AVF.pushbutton/script.py | 79 +++ .../Developer Examples.panel/bundle.yaml | 3 +- pyrevitlib/pyrevit/revit/__init__.py | 3 +- pyrevitlib/pyrevit/revit/avf.py | 569 ++++++++++++++++++ 5 files changed, 652 insertions(+), 2 deletions(-) create mode 100644 extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/AVF.pushbutton/icon.png create mode 100644 extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/AVF.pushbutton/script.py create mode 100644 pyrevitlib/pyrevit/revit/avf.py diff --git a/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/AVF.pushbutton/icon.png b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/AVF.pushbutton/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..989ae44d566746a7397495d2ac52f87a6d456f4f GIT binary patch literal 997 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGooCO|{#S9GG!XV7ZFl&wk0|WDP zPZ!6KiaBrZSaypz%CvoSKYBnpfF(8dLzIh)NdSv!l&hh@A1=0}SDjKbDjFB<6mQy? zu}m#aqQiYf1#6SIR7L!QPF2>e7CO|IL7?!=9`PI|9$9HjYRnGar_qqL)8!}6ngE8YMH4?UL|jOdK6!xG@QDnAo9ebe2FY5+ zd5q5+z9wo}&0sQkCeon0oFT`OHQ_YSRzJoYMO+7_1v6}$!?fWkb}1zw_>|81Q|;tyyhxQXAc zSn2YcZF+ZXPPV3YW4Y{}joTAfCQhBku--c3u|p-R&a$+*@lV!GEqm(Ts`w@K)t_qu zSsy)Ly#8>a+_^?*S@?x>erHj_|L@nE6Y4&|F!Ljv2%-4E`7ppW*OAJ{`Q$}pqxPPl`BBG z6y51Je)zUA*d?s^%CmiIk4E6}6&|v6XU?Y9FPV_Yv8#2VjQh1kK!w|`Sj~{Jvj3*~ z=KYP#HHimio{-*s>FKr|`!(uVX81N+*XKVv`!>UYp*>^8JTvuz_`~k|XP+wjmz!Y1 zYpiBdrcnCxx@7O_ROSjUpGAs))?~yc-Fn4#Rx2vsdF^L`C;Rtj<(GE4pRv)bPZypFT`>LEOJ3oo_Y=$JFG%~d=U>su|BNy$Zecf;y`30zY?{Y!`5jl$!(?M~_KKKAkKebluP^iN>n_||x<~AZF3UafHx>U2V}n0M z1x}s5c)RJ}qAxEDiZic&>~GjA+W%S8UHI_vZD#7VZ`ZN@bkCS|^UlF9XGJx3>a5++ z`?s{NrXZF{Va@ZCclUjk_whCMN{7IA>EqLq@)abR9D(_k!PC{x JWt~$(698l$$*2GT literal 0 HcmV?d00001 diff --git a/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/AVF.pushbutton/script.py b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/AVF.pushbutton/script.py new file mode 100644 index 000000000..42f841da6 --- /dev/null +++ b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/AVF.pushbutton/script.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +"""Developer example: pick an element, display a number on it via AVF. + +Minimal end-to-end smoke test for pyrevit.revit.avf. Pick any element with +solid geometry visible in the active view, type in a number, choose a +display style (text marker or colored surface), and it gets painted onto +the element. +""" + +from pyrevit import revit, forms +from pyrevit.revit import avf + +doc = revit.doc +view = revit.active_view + +# 1. Pick a single element in the model +with forms.WarningBar(title="Select an element to label"): + element = revit.pick_element() + +if not element: + forms.alert("Nothing selected.", exitscript=True) + +# 2. Ask for a value to display +value_str = forms.ask_for_string( + default="42", + prompt="Value to display on the selected element:", + title="AVF Demo", +) + +if value_str is None: + forms.alert("Cancelled.", exitscript=True) + +try: + value = float(value_str) +except ValueError: + forms.alert("'{}' is not a number.".format(value_str), exitscript=True) + +# 3. Pick a display style, get-or-create it, and assign it to the view. +style_choice = forms.CommandSwitchWindow.show( + ["Text Marker", "Colored Surface"], + message="Pick an AVF display style:", +) + +if style_choice is None: + forms.alert("Cancelled.", exitscript=True) + +if style_choice == "Colored Surface": + style = avf.get_or_create_colored_surface_display_style( + doc, "pyRevitAVF_Demo_ColoredSurface" + ) +else: + style = avf.get_or_create_marker_display_style(doc, "pyRevitAVF_Demo_Marker") + +with revit.Transaction("Set AVF Display Style"): + view.AnalysisDisplayStyleId = style.Id + +results = avf.display_avf_values( + {element: value}, + view, + schema_name="pyRevitAVF_Demo", + schema_desc="AVF pick-and-display demo", +) + +elem_id_val = results.keys()[0] +success, sfp_id = results[elem_id_val] + +if success: + forms.alert( + "Displayed value {} on element ID {}.\n\n" + "Run again on other elements, or use " + "avf.clear_avf_results(view) to remove all markers from " + "this view.".format(value, elem_id_val) + ) +else: + forms.alert( + "Could not find a viewer-facing face on the selected element.\n" + "Try an element with solid geometry (wall, floor, generic model), " + "or adapt the script to pass use_bbox_center=True." + ) diff --git a/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/bundle.yaml b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/bundle.yaml index e760cd62b..245267dcd 100644 --- a/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/bundle.yaml +++ b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/bundle.yaml @@ -14,4 +14,5 @@ layout: - Settings Window - Modeless - DockablePane - - ExportIFC \ No newline at end of file + - ExportIFC + - AVF \ No newline at end of file diff --git a/pyrevitlib/pyrevit/revit/__init__.py b/pyrevitlib/pyrevit/revit/__init__.py index 888a25f21..048875b0b 100644 --- a/pyrevitlib/pyrevit/revit/__init__.py +++ b/pyrevitlib/pyrevit/revit/__init__.py @@ -50,7 +50,8 @@ _perfmark("pyrevit.revit:after features") from pyrevit.revit import bim360 from pyrevit.revit import dc3dserver -_perfmark("pyrevit.revit:after bim360/dc3dserver (exit)") +from pyrevit.revit import avf +_perfmark("pyrevit.revit:after bim360/dc3dserver/avf (exit)") #pylint: disable=W0703,C0302,C0103 diff --git a/pyrevitlib/pyrevit/revit/avf.py b/pyrevitlib/pyrevit/revit/avf.py new file mode 100644 index 000000000..99f110236 --- /dev/null +++ b/pyrevitlib/pyrevit/revit/avf.py @@ -0,0 +1,569 @@ +# -*- coding: utf-8 -*- +"""Analysis Visualization Framework (AVF) helpers. + +Utilities for displaying arbitrary numeric values on elements in a view, +using Revit's SpatialFieldManager / AVF. Useful for heatmaps, KPI overlays, +QA/QC markers, or any "paint a number onto elements" workflow. + +Typical usage:: + + from pyrevit import revit + from pyrevit.revit import avf + + values = {wall1: 12.5, wall2: 44.0} + # use get_or_create_colored_surface_display_style(...) instead for a + # gradient/heatmap look rather than text markers + style = avf.get_or_create_marker_display_style(revit.doc, "MyTool_AVF") + with revit.Transaction("Show values"): + revit.active_view.AnalysisDisplayStyleId = style.Id + results = avf.display_avf_values(values, revit.active_view, unit="ea") + +Notes / known limitations: + - Face selection uses the largest planar-or-curved face whose normal + opposes the given view direction. Non-planar faces are sampled at + their UV-bounding-box center, which is an approximation for + strongly curved faces (cylinders, etc.). + - SpatialFieldManager results (the actual point/value data) are NOT + saved with the document - they live only for the current session + and are gone on reopen even without calling ``clear_avf_results``. + What IS saved with the model is the AnalysisDisplayStyle itself and + its assignment to a view (``View.AnalysisDisplayStyleId``), so a + view can "remember" how it should render results without remembering + the results. Call ``clear_avf_results`` to remove results within a + session (e.g. before recomputing). + - Two display styles are provided: ``get_or_create_marker_display_style`` + (text markers, good for discrete/inspectable values) and + ``get_or_create_colored_surface_display_style`` (gradient heatmap, + good for continuous/comparative values). + - Requires an open transaction; all public write functions here open + their own transaction unless already inside one is fine (Revit + allows nested calls as long as *some* transaction is open - callers + driving multiple lib calls per click should wrap them in a single + outer transaction for performance). +""" + +from pyrevit import DB +from pyrevit import PyRevitException +from pyrevit.revit import Transaction, query +from pyrevit.framework import List, System +from pyrevit.coreutils.logger import get_logger +from pyrevit.compat import get_elementid_value_func + +get_elementid_value = get_elementid_value_func() + +mlogger = get_logger(__name__) + +__all__ = ( + "get_face_facing_direction", + "clear_avf_results", + "display_avf_values", + "display_avf_values_at_points", + "update_avf_values", + "remove_avf_primitive", + "get_or_create_marker_display_style", + "get_or_create_colored_surface_display_style", +) + + +def get_face_facing_direction( + element, + direction, + detail_level=DB.ViewDetailLevel.Coarse, +): + """Find the largest face of ``element`` that faces the viewer. + + "Faces the viewer" means the face's outward normal roughly opposes + ``direction`` (dot product < 0), which is the correct test when + ``direction`` is a view's look direction (View.ViewDirection points + from the eye into the model, so a front-facing face's normal points + the other way). + + Args: + element (DB.Element): Element to inspect. + direction (DB.XYZ): Direction the viewer is looking, e.g. + ``view.ViewDirection``. + detail_level (DB.ViewDetailLevel, optional): Geometry detail + level to extract. Defaults to Coarse. + + Returns: + DB.Face: The best-matching face, or None if the element has no + solid geometry, or no face is oriented toward the viewer. + """ + # compute_references=True so callers can use the Reference-based + # AddSpatialFieldPrimitive(Reference) overload - the one used in + # Autodesk's own AVF sample - falling back to the Face+Transform + # overload only when a face has no resolvable Reference. + geom_elem = query.get_geometry( + element, compute_references=True, detail_level=detail_level + ) + if not geom_elem: + return None + + direction = direction.Normalize() + + best_face = None + best_area = -1.0 + + for geom_obj in geom_elem: + if not isinstance(geom_obj, DB.Solid) or not geom_obj.Faces: + continue + for face in geom_obj.Faces: + area = face.Area + if area <= 0 or area <= best_area: + continue + bbox = face.GetBoundingBox() + uv_center = DB.UV( + (bbox.Min.U + bbox.Max.U) / 2.0, + (bbox.Min.V + bbox.Max.V) / 2.0, + ) + try: + normal = face.ComputeNormal(uv_center).Normalize() + except Exception: + continue + if normal.DotProduct(direction) < 0: + best_area = area + best_face = face + + return best_face + + +def _get_or_create_sfm(view, number_of_measurements=1): + """Get the existing SpatialFieldManager for a view, or create one. + + Args: + view (DB.View): View to attach the SFM to. Must be a view type + that supports AVF (3D, plan, section, elevation, detail). + number_of_measurements (int, optional): Length of the value + array in each ValueAtPoint. Defaults to 1. + + Returns: + DB.Analysis.SpatialFieldManager + """ + sfm = DB.Analysis.SpatialFieldManager.GetSpatialFieldManager(view) + if sfm is None: + try: + sfm = DB.Analysis.SpatialFieldManager.CreateSpatialFieldManager( + view, number_of_measurements + ) + except Exception as ex: + raise PyRevitException( + "Could not create a SpatialFieldManager for view '{}'. " + "Does this view type support AVF? ({})".format(view.Name, ex) + ) + return sfm + + +def _get_or_create_schema(sfm, schema_name, schema_desc, units, visible=True): + """Get an existing AVF result schema by name, or register a new one. + + Args: + sfm (DB.Analysis.SpatialFieldManager): Manager to register on. + schema_name (str): Name for the schema. + schema_desc (str): Description for the schema. + units (str or list[tuple[str, float]]): Either a single unit + label (display-only, no conversion applied to your values), + or a list of ``(unit_name, multiplier)`` pairs to let the + viewer toggle between multiple display units of the same + underlying value - e.g. ``[("Feet", 1), ("Inches", 12)]``. + visible (bool, optional): Whether the schema is visible in the + view's AVF legend/toggle list. Defaults to True. + + Returns: + int: Schema id (result index), for use with + ``UpdateSpatialFieldPrimitive``. + """ + for sid in sfm.GetRegisteredResults(): + existing_schema = sfm.GetResultSchema(sid) + if existing_schema.Name == schema_name: + return sid + + if isinstance(units, basestring): + unit_pairs = [(units, 1)] + else: + unit_pairs = list(units) + + unit_name_list = List[System.String]() + unit_multiplier_list = List[System.Double]() + for unit_name, multiplier in unit_pairs: + unit_name_list.Add(unit_name) + unit_multiplier_list.Add(System.Double(multiplier)) + + schema = DB.Analysis.AnalysisResultSchema(schema_name, schema_desc) + schema.SetUnits(unit_name_list, unit_multiplier_list) + schema.IsVisible = visible + return sfm.RegisterResult(schema) + + +def clear_avf_results(view): + """Clear all AVF results from a view. + + Args: + view (DB.View): The view to clear results from. + + Note: + AVF result data is not saved with the document at all, so this + is only needed to clear results within the current session (e.g. + before recomputing). The AnalysisDisplayStyle assigned to the + view is unaffected and does persist with the model. + """ + sfm = DB.Analysis.SpatialFieldManager.GetSpatialFieldManager(view) + if sfm: + with Transaction("Clear AVF Results"): + sfm.Clear() + + +def _make_field_values(value): + val_double_list = List[System.Double]() + val_double_list.Add(System.Double(value)) + val_at_point = DB.Analysis.ValueAtPoint(val_double_list) + val_list = List[DB.Analysis.ValueAtPoint]() + val_list.Add(val_at_point) + return DB.Analysis.FieldValues(val_list) + + +def display_avf_values( + element_value_dict, + view, + schema_name="pyRevitAVF", + schema_desc="AVF values from pyRevit", + unit="Value", + use_bbox_center=False, + visible=True, +): + """Display numeric values on multiple elements in a view. + + Args: + element_value_dict (dict[DB.Element, float]): Element -> value. + view (DB.View): View to display values in. + schema_name (str, optional): AVF schema name. Reused if a schema + with this name already exists on the view. Defaults to + "pyRevitAVF". + schema_desc (str, optional): AVF schema description. + unit (str or list[tuple[str, float]], optional): Single unit + label, or a list of ``(unit_name, multiplier)`` pairs if you + want the viewer to be able to toggle between multiple display + units (see ``_get_or_create_schema``). Unit names must be non-empty + and unique, as required by the Revit API. For dimensionless values, + use a descriptive label such as ``"Value"`` or ``"Unitless"``. + Defaults to ``"Value"``. + use_bbox_center (bool, optional): Use the element's bounding-box + center instead of a face point as the insertion point. + Useful for elements without usable solid faces. Defaults + to False. + visible (bool, optional): Whether the schema is visible/toggle-able + in the view. Defaults to True. + + Returns: + dict[int, tuple]: element-id-value -> (success, sfp_id). ``sfp_id`` + is None on failure. Feed this straight into + ``update_avf_values`` for cheap repeated updates. + """ + results = {} + + with Transaction("Display AVF Values"): + sfm = _get_or_create_sfm(view) + schema_id = _get_or_create_schema( + sfm, schema_name, schema_desc, unit, visible=visible + ) + + for element, value in element_value_dict.items(): + elem_id = get_elementid_value(element.Id) + try: + if use_bbox_center: + bbox = element.get_BoundingBox(view) + if bbox is None: + mlogger.warning( + "Element ID {} has no bounding box in view " + "'{}' (not visible?)".format(elem_id, view.Name) + ) + results[elem_id] = (False, None) + continue + bb_center = (bbox.Min + bbox.Max) / 2.0 + sfp_id = sfm.AddSpatialFieldPrimitive() + xyz_list = List[DB.XYZ]() + xyz_list.Add(bb_center) + domain_pts = DB.Analysis.FieldDomainPointsByXYZ(xyz_list) + else: + face = get_face_facing_direction(element, view.ViewDirection) + if face is None: + mlogger.warning( + "No viewer-facing face found for element ID: " + "{}".format(elem_id) + ) + results[elem_id] = (False, None) + continue + + bbox = face.GetBoundingBox() + uv_center = DB.UV( + (bbox.Min.U + bbox.Max.U) / 2.0, + (bbox.Min.V + bbox.Max.V) / 2.0, + ) + if face.Reference is not None: + sfp_id = sfm.AddSpatialFieldPrimitive(face.Reference) + else: + sfp_id = sfm.AddSpatialFieldPrimitive( + face, DB.Transform.Identity + ) + uv_list = List[DB.UV]() + uv_list.Add(uv_center) + domain_pts = DB.Analysis.FieldDomainPointsByUV(uv_list) + + vals = _make_field_values(value) + sfm.UpdateSpatialFieldPrimitive(sfp_id, domain_pts, vals, schema_id) + results[elem_id] = (True, sfp_id) + + except Exception as ex: + mlogger.exception( + "Error processing element ID {}: {}".format(elem_id, ex) + ) + results[elem_id] = (False, None) + + return results + + +def update_avf_values( + primitive_dict, + element_value_dict, + view, + schema_name="pyRevitAVF", + schema_desc="AVF values from pyRevit", + unit="", + visible=True, +): + """Update values on primitives previously created by ``display_avf_values``. + + Cheaper than re-running ``display_avf_values`` since it reuses the + existing primitive/face instead of re-searching geometry. + + Args: + primitive_dict (dict[int, tuple]): Output of ``display_avf_values``, + i.e. element-id-value -> (success, sfp_id). + element_value_dict (dict[DB.Element, float]): Element -> new value. + view (DB.View): View the primitives were created in. + schema_name (str, optional): Must match the schema used originally. + schema_desc (str, optional): AVF schema description. + unit (str or list[tuple[str, float]], optional): Must match what + was used when the schema was first created. + visible (bool, optional): Whether the schema is visible. Defaults + to True. + + Returns: + dict[int, bool]: element-id-value -> success. + """ + results = {} + + with Transaction("Update AVF Values"): + sfm = _get_or_create_sfm(view) + schema_id = _get_or_create_schema( + sfm, schema_name, schema_desc, unit, visible=visible + ) + + for element, new_value in element_value_dict.items(): + elem_id = get_elementid_value(element.Id) + + entry = primitive_dict.get(elem_id) + if not entry or not entry[0] or entry[1] is None: + mlogger.error( + "No primitive data found for element ID: {}".format(elem_id) + ) + results[elem_id] = False + continue + + sfp_id = entry[1] + try: + vals = _make_field_values(new_value) + sfm.UpdateSpatialFieldPrimitive(sfp_id, None, vals, schema_id) + results[elem_id] = True + except Exception as ex: + mlogger.exception( + "Error updating element ID {}: {}".format(elem_id, ex) + ) + results[elem_id] = False + + return results + + +def remove_avf_primitive(view, sfp_id): + """Remove a single AVF primitive from a view. + + Args: + view (DB.View): View the primitive belongs to. + sfp_id (int): Primitive index, as returned by ``display_avf_values``. + + Returns: + bool: True if removed, False if no SFM/primitive was found. + """ + sfm = DB.Analysis.SpatialFieldManager.GetSpatialFieldManager(view) + if sfm is None: + return False + with Transaction("Remove AVF Primitive"): + try: + sfm.RemoveSpatialFieldPrimitive(sfp_id) + return True + except Exception as ex: + mlogger.debug("Could not remove AVF primitive {}: {}".format(sfp_id, ex)) + return False + + +def get_or_create_marker_display_style(doc, style_name, show_legend=False): + """Get an existing marker-style AnalysisDisplayStyle, or create one. + + Creates a style that shows values as text markers (no color gradient), + which is a good default for discrete/inspectable values. + + Args: + doc (DB.Document): Document to search/create in. + style_name (str): Name of the display style. + show_legend (bool, optional): Show the AVF legend in views using + this style. Defaults to False. + + Returns: + DB.Analysis.AnalysisDisplayStyle: The existing or newly created + style. + """ + existing = query.get_elements_by_class( + DB.Analysis.AnalysisDisplayStyle, doc=doc + ) + for style in existing: + if style.Name == style_name: + return style + + with Transaction("Create Analysis Display Style"): + color_settings = DB.Analysis.AnalysisDisplayColorSettings() + legend_settings = DB.Analysis.AnalysisDisplayLegendSettings() + legend_settings.ShowLegend = show_legend + + marker_text_settings = DB.Analysis.AnalysisDisplayMarkersAndTextSettings() + marker_text_settings.TextLabelType = ( + DB.Analysis.AnalysisDisplayStyleMarkerTextLabelType.ShowAll + ) + + style = DB.Analysis.AnalysisDisplayStyle.CreateAnalysisDisplayStyle( + doc, style_name, marker_text_settings, color_settings, legend_settings + ) + + return style + + +def get_or_create_colored_surface_display_style( + doc, + style_name, + min_color=(200, 0, 200), + max_color=(255, 205, 0), + show_legend=True, + show_grid_lines=True, + number_of_steps=10, + rounding=0.05, +): + """Get an existing colored-surface AnalysisDisplayStyle, or create one. + + Creates a gradient/heatmap style, generally a better fit than + ``get_or_create_marker_display_style`` for continuous or comparative + values (e.g. KPI heatmaps) rather than discrete inspectable ones. + + Args: + doc (DB.Document): Document to search/create in. + style_name (str): Name of the display style. + min_color (tuple[int, int, int], optional): RGB for the low end + of the gradient. Defaults to purple. + max_color (tuple[int, int, int], optional): RGB for the high end + of the gradient. Defaults to orange. + show_legend (bool, optional): Show the AVF legend. Defaults to + True (usually what you want for a gradient, unlike markers). + show_grid_lines (bool, optional): Show grid lines on the colored + surface. Defaults to True. + number_of_steps (int, optional): Number of discrete color steps + in the legend/gradient. Defaults to 10. + rounding (float, optional): Legend value rounding. Defaults to 0.05. + + Returns: + DB.Analysis.AnalysisDisplayStyle: The existing or newly created + style. + """ + existing = query.get_elements_by_class( + DB.Analysis.AnalysisDisplayStyle, doc=doc + ) + for style in existing: + if style.Name == style_name: + return style + + with Transaction("Create Analysis Display Style"): + surface_settings = DB.Analysis.AnalysisDisplayColoredSurfaceSettings() + surface_settings.ShowGridLines = show_grid_lines + + color_settings = DB.Analysis.AnalysisDisplayColorSettings() + color_settings.MinColor = DB.Color(*min_color) + color_settings.MaxColor = DB.Color(*max_color) + + legend_settings = DB.Analysis.AnalysisDisplayLegendSettings() + legend_settings.ShowLegend = show_legend + legend_settings.NumberOfSteps = number_of_steps + legend_settings.Rounding = rounding + + style = DB.Analysis.AnalysisDisplayStyle.CreateAnalysisDisplayStyle( + doc, style_name, surface_settings, color_settings, legend_settings + ) + + return style + + +def display_avf_values_at_points( + point_value_pairs, + view, + schema_name="pyRevitAVF", + schema_desc="AVF values from pyRevit", + unit="", + visible=True, + max_points_per_primitive=500, +): + """Display values at arbitrary XYZ points, not tied to any element. + + Useful for point-cloud style overlays (e.g. a sampled grid) where + there's no host element/face to anchor the value to. Per Revit's own + guidance, points are chunked into multiple primitives of at most + ``max_points_per_primitive`` points each, rather than one giant + primitive, for performance. + + Args: + point_value_pairs (list[tuple[DB.XYZ, float]]): Points and their + values. + view (DB.View): View to display values in. + schema_name (str, optional): AVF schema name. + schema_desc (str, optional): AVF schema description. + unit (str or list[tuple[str, float]], optional): See + ``_get_or_create_schema``. + visible (bool, optional): Whether the schema is visible. Defaults + to True. + max_points_per_primitive (int, optional): Chunk size. Defaults to + 500, per Revit's documented recommendation. + + Returns: + list[int]: The primitive ids created (one per chunk). + """ + sfp_ids = [] + + with Transaction("Display AVF Point Values"): + sfm = _get_or_create_sfm(view) + schema_id = _get_or_create_schema( + sfm, schema_name, schema_desc, unit, visible=visible + ) + + for start in range(0, len(point_value_pairs), max_points_per_primitive): + chunk = point_value_pairs[start:start + max_points_per_primitive] + + xyz_list = List[DB.XYZ]() + val_list = List[DB.Analysis.ValueAtPoint]() + for point, value in chunk: + xyz_list.Add(point) + val_double_list = List[System.Double]() + val_double_list.Add(System.Double(value)) + val_list.Add(DB.Analysis.ValueAtPoint(val_double_list)) + + domain_pts = DB.Analysis.FieldDomainPointsByXYZ(xyz_list) + vals = DB.Analysis.FieldValues(val_list) + + sfp_id = sfm.AddSpatialFieldPrimitive() + sfm.UpdateSpatialFieldPrimitive(sfp_id, domain_pts, vals, schema_id) + sfp_ids.append(sfp_id) + + return sfp_ids From 05f7da537dd753a397fecfbf82a303c72e07beb9 Mon Sep 17 00:00:00 2001 From: Wurschdhaud Date: Tue, 14 Jul 2026 20:43:23 +0200 Subject: [PATCH 2/5] add correct defaults, rerun black --- pyrevitlib/pyrevit/revit/avf.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/pyrevitlib/pyrevit/revit/avf.py b/pyrevitlib/pyrevit/revit/avf.py index 99f110236..4b80b47f4 100644 --- a/pyrevitlib/pyrevit/revit/avf.py +++ b/pyrevitlib/pyrevit/revit/avf.py @@ -327,7 +327,7 @@ def update_avf_values( view, schema_name="pyRevitAVF", schema_desc="AVF values from pyRevit", - unit="", + unit="Value", visible=True, ): """Update values on primitives previously created by ``display_avf_values``. @@ -421,9 +421,7 @@ def get_or_create_marker_display_style(doc, style_name, show_legend=False): DB.Analysis.AnalysisDisplayStyle: The existing or newly created style. """ - existing = query.get_elements_by_class( - DB.Analysis.AnalysisDisplayStyle, doc=doc - ) + existing = query.get_elements_by_class(DB.Analysis.AnalysisDisplayStyle, doc=doc) for style in existing: if style.Name == style_name: return style @@ -480,9 +478,7 @@ def get_or_create_colored_surface_display_style( DB.Analysis.AnalysisDisplayStyle: The existing or newly created style. """ - existing = query.get_elements_by_class( - DB.Analysis.AnalysisDisplayStyle, doc=doc - ) + existing = query.get_elements_by_class(DB.Analysis.AnalysisDisplayStyle, doc=doc) for style in existing: if style.Name == style_name: return style @@ -512,7 +508,7 @@ def display_avf_values_at_points( view, schema_name="pyRevitAVF", schema_desc="AVF values from pyRevit", - unit="", + unit="Value", visible=True, max_points_per_primitive=500, ): @@ -549,7 +545,7 @@ def display_avf_values_at_points( ) for start in range(0, len(point_value_pairs), max_points_per_primitive): - chunk = point_value_pairs[start:start + max_points_per_primitive] + chunk = point_value_pairs[start : start + max_points_per_primitive] xyz_list = List[DB.XYZ]() val_list = List[DB.Analysis.ValueAtPoint]() From 55852d929055ffc1457fa9a7440392f0ac2fc355 Mon Sep 17 00:00:00 2001 From: "devloai[bot]" <168258904+devloai[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:43:45 +0000 Subject: [PATCH 3/5] Add AVF unit tests for first-call schema unit handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix the empty-unit trap where update_avf_values and display_avf_values_at_points defaulted unit to '' — registering an AVF schema with an empty unit name raises ArgumentsInconsistentException from AnalysisResultSchema.SetUnits when they are the first call on a view. - Default unit to 'Value' in both functions (matching display_avf_values) - Sanitize empty unit names in _get_or_create_schema as a safety net - Add Revit-hosted regression tests exercising both functions as first calls (no prior schema) plus an explicit empty-unit case - Add DevTools 'AVF Module Tests' pushbutton to run them --- .../AVF Module Tests.pushbutton/bundle.yaml | 1 + .../AVF Module Tests.pushbutton/script.py | 12 +++ pyrevitlib/pyrevit/revit/avf.py | 5 + pyrevitlib/pyrevit/unittests/test_avf.py | 97 +++++++++++++++++++ 4 files changed, 115 insertions(+) create mode 100644 extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Unit Tests.pulldown/AVF Module Tests.pushbutton/bundle.yaml create mode 100644 extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Unit Tests.pulldown/AVF Module Tests.pushbutton/script.py create mode 100644 pyrevitlib/pyrevit/unittests/test_avf.py diff --git a/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Unit Tests.pulldown/AVF Module Tests.pushbutton/bundle.yaml b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Unit Tests.pulldown/AVF Module Tests.pushbutton/bundle.yaml new file mode 100644 index 000000000..1ca81f778 --- /dev/null +++ b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Unit Tests.pulldown/AVF Module Tests.pushbutton/bundle.yaml @@ -0,0 +1 @@ +context: doc-project diff --git a/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Unit Tests.pulldown/AVF Module Tests.pushbutton/script.py b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Unit Tests.pulldown/AVF Module Tests.pushbutton/script.py new file mode 100644 index 000000000..7966bcfc1 --- /dev/null +++ b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Unit Tests.pulldown/AVF Module Tests.pushbutton/script.py @@ -0,0 +1,12 @@ +"""Run pyrevit.revit.avf unit tests (requires open project document). + +Best run with an AVF-capable active view (e.g. a 3D view); tests that need +AVF are skipped automatically if the active view does not support it. +""" + + +from pyrevit.unittests import test_avf +from pyrevit.unittests.runner import run_module_tests + + +run_module_tests(test_avf) diff --git a/pyrevitlib/pyrevit/revit/avf.py b/pyrevitlib/pyrevit/revit/avf.py index 4b80b47f4..d65809ac4 100644 --- a/pyrevitlib/pyrevit/revit/avf.py +++ b/pyrevitlib/pyrevit/revit/avf.py @@ -185,6 +185,11 @@ def _get_or_create_schema(sfm, schema_name, schema_desc, units, visible=True): unit_name_list = List[System.String]() unit_multiplier_list = List[System.Double]() for unit_name, multiplier in unit_pairs: + # AnalysisResultSchema.SetUnits raises ArgumentsInconsistentException + # if any unit name is empty, so fall back to a safe display label + # for dimensionless/unlabeled values. + if not unit_name: + unit_name = "Value" unit_name_list.Add(unit_name) unit_multiplier_list.Add(System.Double(multiplier)) diff --git a/pyrevitlib/pyrevit/unittests/test_avf.py b/pyrevitlib/pyrevit/unittests/test_avf.py new file mode 100644 index 000000000..3f955125c --- /dev/null +++ b/pyrevitlib/pyrevit/unittests/test_avf.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +"""Revit-hosted tests for pyrevit.revit.avf schema/unit handling. + +Run from Revit via the pyRevit DevTools "AVF Module Tests" button +(doc-project context, with an AVF-capable active view such as a 3D view). + +Regression coverage for the empty-unit trap: ``update_avf_values`` and +``display_avf_values_at_points`` must be safe to call as the *first* call on a +view (before ``display_avf_values`` has registered a schema, or after +``clear_avf_results``/on a fresh view). They must not register an AVF schema +with an empty unit name, which Revit's ``AnalysisResultSchema.SetUnits`` +rejects with ``ArgumentsInconsistentException``. +""" + +import time +import unittest + +from pyrevit import DB, PyRevitException, revit +from pyrevit.revit import avf + + +def _unique_schema_name(prefix): + """Return a schema name that won't collide with an existing one. + + Guarantees the ``_get_or_create_schema`` "register a new schema" branch + is exercised (i.e. a genuine first call), rather than reusing a schema a + previous run/tool already registered on the view. + """ + return "{}_{}".format(prefix, int(time.time() * 1000)) + + +class AvfSchemaUnitTests(unittest.TestCase): + """Regression tests for AVF schema unit handling on first calls.""" + + def setUp(self): + if revit.doc.IsFamilyDocument: + self.skipTest("Requires model document, not family document") + self.view = revit.active_view + + def _run_or_skip_no_avf(self, func): + """Run ``func`` but skip the test if the active view can't do AVF.""" + try: + return func() + except PyRevitException as ex: + self.skipTest("Active view does not support AVF: {}".format(ex)) + + def test_display_points_default_unit_first_call(self): + """display_avf_values_at_points registers a schema on a first call. + + With the default unit this must not raise ArgumentsInconsistentException. + """ + origin = DB.XYZ(0, 0, 0) + sfp_ids = self._run_or_skip_no_avf( + lambda: avf.display_avf_values_at_points( + [(origin, 1.0)], + self.view, + schema_name=_unique_schema_name("pyRevitAVF_Test_Points"), + ) + ) + self.assertEqual(len(sfp_ids), 1) + + def test_update_values_default_unit_first_call(self): + """update_avf_values registers a schema on a first call. + + With the default unit this must not raise ArgumentsInconsistentException, + even when there are no primitives/elements to update yet. + """ + results = self._run_or_skip_no_avf( + lambda: avf.update_avf_values( + {}, + {}, + self.view, + schema_name=_unique_schema_name("pyRevitAVF_Test_Update"), + ) + ) + self.assertEqual(results, {}) + + def test_explicit_empty_unit_is_sanitized(self): + """An explicit empty unit is sanitized rather than raising.""" + origin = DB.XYZ(0, 0, 0) + sfp_ids = self._run_or_skip_no_avf( + lambda: avf.display_avf_values_at_points( + [(origin, 1.0)], + self.view, + schema_name=_unique_schema_name("pyRevitAVF_Test_EmptyUnit"), + unit="", + ) + ) + self.assertEqual(len(sfp_ids), 1) + + def tearDown(self): + # AVF results are session-only and not saved with the document, but + # clear them anyway so repeated test runs start from a clean view. + try: + avf.clear_avf_results(self.view) + except Exception: + pass From 576001d0cde605a75de74c00e40c4c2ab7ec135f Mon Sep 17 00:00:00 2001 From: Wurschdhaud Date: Tue, 14 Jul 2026 20:50:49 +0200 Subject: [PATCH 4/5] Revert "Add AVF unit tests for first-call schema unit handling" This reverts commit 55852d929055ffc1457fa9a7440392f0ac2fc355. --- .../AVF Module Tests.pushbutton/bundle.yaml | 1 - .../AVF Module Tests.pushbutton/script.py | 12 --- pyrevitlib/pyrevit/revit/avf.py | 5 - pyrevitlib/pyrevit/unittests/test_avf.py | 97 ------------------- 4 files changed, 115 deletions(-) delete mode 100644 extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Unit Tests.pulldown/AVF Module Tests.pushbutton/bundle.yaml delete mode 100644 extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Unit Tests.pulldown/AVF Module Tests.pushbutton/script.py delete mode 100644 pyrevitlib/pyrevit/unittests/test_avf.py diff --git a/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Unit Tests.pulldown/AVF Module Tests.pushbutton/bundle.yaml b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Unit Tests.pulldown/AVF Module Tests.pushbutton/bundle.yaml deleted file mode 100644 index 1ca81f778..000000000 --- a/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Unit Tests.pulldown/AVF Module Tests.pushbutton/bundle.yaml +++ /dev/null @@ -1 +0,0 @@ -context: doc-project diff --git a/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Unit Tests.pulldown/AVF Module Tests.pushbutton/script.py b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Unit Tests.pulldown/AVF Module Tests.pushbutton/script.py deleted file mode 100644 index 7966bcfc1..000000000 --- a/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Unit Tests.pulldown/AVF Module Tests.pushbutton/script.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Run pyrevit.revit.avf unit tests (requires open project document). - -Best run with an AVF-capable active view (e.g. a 3D view); tests that need -AVF are skipped automatically if the active view does not support it. -""" - - -from pyrevit.unittests import test_avf -from pyrevit.unittests.runner import run_module_tests - - -run_module_tests(test_avf) diff --git a/pyrevitlib/pyrevit/revit/avf.py b/pyrevitlib/pyrevit/revit/avf.py index d65809ac4..4b80b47f4 100644 --- a/pyrevitlib/pyrevit/revit/avf.py +++ b/pyrevitlib/pyrevit/revit/avf.py @@ -185,11 +185,6 @@ def _get_or_create_schema(sfm, schema_name, schema_desc, units, visible=True): unit_name_list = List[System.String]() unit_multiplier_list = List[System.Double]() for unit_name, multiplier in unit_pairs: - # AnalysisResultSchema.SetUnits raises ArgumentsInconsistentException - # if any unit name is empty, so fall back to a safe display label - # for dimensionless/unlabeled values. - if not unit_name: - unit_name = "Value" unit_name_list.Add(unit_name) unit_multiplier_list.Add(System.Double(multiplier)) diff --git a/pyrevitlib/pyrevit/unittests/test_avf.py b/pyrevitlib/pyrevit/unittests/test_avf.py deleted file mode 100644 index 3f955125c..000000000 --- a/pyrevitlib/pyrevit/unittests/test_avf.py +++ /dev/null @@ -1,97 +0,0 @@ -# -*- coding: utf-8 -*- -"""Revit-hosted tests for pyrevit.revit.avf schema/unit handling. - -Run from Revit via the pyRevit DevTools "AVF Module Tests" button -(doc-project context, with an AVF-capable active view such as a 3D view). - -Regression coverage for the empty-unit trap: ``update_avf_values`` and -``display_avf_values_at_points`` must be safe to call as the *first* call on a -view (before ``display_avf_values`` has registered a schema, or after -``clear_avf_results``/on a fresh view). They must not register an AVF schema -with an empty unit name, which Revit's ``AnalysisResultSchema.SetUnits`` -rejects with ``ArgumentsInconsistentException``. -""" - -import time -import unittest - -from pyrevit import DB, PyRevitException, revit -from pyrevit.revit import avf - - -def _unique_schema_name(prefix): - """Return a schema name that won't collide with an existing one. - - Guarantees the ``_get_or_create_schema`` "register a new schema" branch - is exercised (i.e. a genuine first call), rather than reusing a schema a - previous run/tool already registered on the view. - """ - return "{}_{}".format(prefix, int(time.time() * 1000)) - - -class AvfSchemaUnitTests(unittest.TestCase): - """Regression tests for AVF schema unit handling on first calls.""" - - def setUp(self): - if revit.doc.IsFamilyDocument: - self.skipTest("Requires model document, not family document") - self.view = revit.active_view - - def _run_or_skip_no_avf(self, func): - """Run ``func`` but skip the test if the active view can't do AVF.""" - try: - return func() - except PyRevitException as ex: - self.skipTest("Active view does not support AVF: {}".format(ex)) - - def test_display_points_default_unit_first_call(self): - """display_avf_values_at_points registers a schema on a first call. - - With the default unit this must not raise ArgumentsInconsistentException. - """ - origin = DB.XYZ(0, 0, 0) - sfp_ids = self._run_or_skip_no_avf( - lambda: avf.display_avf_values_at_points( - [(origin, 1.0)], - self.view, - schema_name=_unique_schema_name("pyRevitAVF_Test_Points"), - ) - ) - self.assertEqual(len(sfp_ids), 1) - - def test_update_values_default_unit_first_call(self): - """update_avf_values registers a schema on a first call. - - With the default unit this must not raise ArgumentsInconsistentException, - even when there are no primitives/elements to update yet. - """ - results = self._run_or_skip_no_avf( - lambda: avf.update_avf_values( - {}, - {}, - self.view, - schema_name=_unique_schema_name("pyRevitAVF_Test_Update"), - ) - ) - self.assertEqual(results, {}) - - def test_explicit_empty_unit_is_sanitized(self): - """An explicit empty unit is sanitized rather than raising.""" - origin = DB.XYZ(0, 0, 0) - sfp_ids = self._run_or_skip_no_avf( - lambda: avf.display_avf_values_at_points( - [(origin, 1.0)], - self.view, - schema_name=_unique_schema_name("pyRevitAVF_Test_EmptyUnit"), - unit="", - ) - ) - self.assertEqual(len(sfp_ids), 1) - - def tearDown(self): - # AVF results are session-only and not saved with the document, but - # clear them anyway so repeated test runs start from a clean view. - try: - avf.clear_avf_results(self.view) - except Exception: - pass From d4d4c9ea784ffe003a5c9f9ab691e737a4d4252b Mon Sep 17 00:00:00 2001 From: Wurschdhaud Date: Tue, 14 Jul 2026 21:45:24 +0200 Subject: [PATCH 5/5] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- pyrevitlib/pyrevit/revit/avf.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/pyrevitlib/pyrevit/revit/avf.py b/pyrevitlib/pyrevit/revit/avf.py index 4b80b47f4..936f556e2 100644 --- a/pyrevitlib/pyrevit/revit/avf.py +++ b/pyrevitlib/pyrevit/revit/avf.py @@ -177,17 +177,32 @@ def _get_or_create_schema(sfm, schema_name, schema_desc, units, visible=True): if existing_schema.Name == schema_name: return sid - if isinstance(units, basestring): + try: + text_types = (basestring,) # noqa: F821 + except NameError: # Python 3 + text_types = (str,) + + if isinstance(units, text_types): unit_pairs = [(units, 1)] else: unit_pairs = list(units) + if not unit_pairs: + raise PyRevitException( + "Units must be a non-empty string or a non-empty list of (unit_name, multiplier) pairs." + ) + + unit_names = [unit_name for unit_name, _ in unit_pairs] + if any(not name for name in unit_names) or len(set(unit_names)) != len(unit_names): + raise PyRevitException( + "Unit names must be non-empty and unique. Got: {}".format(unit_names) + ) + unit_name_list = List[System.String]() unit_multiplier_list = List[System.Double]() for unit_name, multiplier in unit_pairs: unit_name_list.Add(unit_name) unit_multiplier_list.Add(System.Double(multiplier)) - schema = DB.Analysis.AnalysisResultSchema(schema_name, schema_desc) schema.SetUnits(unit_name_list, unit_multiplier_list) schema.IsVisible = visible @@ -536,10 +551,10 @@ def display_avf_values_at_points( Returns: list[int]: The primitive ids created (one per chunk). """ - sfp_ids = [] + if max_points_per_primitive <= 0: + raise PyRevitException("max_points_per_primitive must be > 0") with Transaction("Display AVF Point Values"): - sfm = _get_or_create_sfm(view) schema_id = _get_or_create_schema( sfm, schema_name, schema_desc, unit, visible=visible )