From 1eb99e4dc60332f9be603c9fa635c8179611e306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Palancher?= Date: Fri, 13 Nov 2026 09:07:42 +0100 Subject: [PATCH 01/16] refactor(draw): split cairo specific code out of base drawer Drawer base class mixed logic required by all representations (database, drawing parameters, coloring rules) with logic specific to 2D images rendered with cairo (surfaces, Pango text labels, image coordinates). Restrict Drawer to the logic independent of the output format and move cairo and Pango specific attributes and methods, along with ImagePoint, into new Drawer2D class in base2d module, inherited by existing room and infrastructure drawers. This prepares the support of representations of other natures, starting with 3D representations of infrastructures in glTF format, which must share the database and coloring rules logic with 2D drawers without depending on cairo, Pango and images coordinates. Assisted-by: Claude Opus 5.5 --- racksdb/drawers/axonometric_infrastructure.py | 2 +- racksdb/drawers/base.py | 89 +-------------- racksdb/drawers/base2d.py | 104 ++++++++++++++++++ racksdb/drawers/infrastructure.py | 4 +- racksdb/drawers/room.py | 4 +- racksdb/tests/drawers/test_base.py | 2 +- 6 files changed, 113 insertions(+), 92 deletions(-) create mode 100644 racksdb/drawers/base2d.py diff --git a/racksdb/drawers/axonometric_infrastructure.py b/racksdb/drawers/axonometric_infrastructure.py index a92d41bd..3848a266 100644 --- a/racksdb/drawers/axonometric_infrastructure.py +++ b/racksdb/drawers/axonometric_infrastructure.py @@ -11,7 +11,7 @@ import cairo -from .base import ImagePoint +from .base2d import ImagePoint from .infrastructure import InfrastructureDrawer, EquipmentCoordinate from ..errors import RacksDBDrawingError diff --git a/racksdb/drawers/base.py b/racksdb/drawers/base.py index d31b1d61..74f3d17e 100644 --- a/racksdb/drawers/base.py +++ b/racksdb/drawers/base.py @@ -4,25 +4,10 @@ # # SPDX-License-Identifier: MIT -from typing import Union import logging -import cairo -import gi - -from .coordinates import CoordinateDumperFactory -from ..errors import RacksDBDrawingError from ..generic.db import GenericDB -gi.require_version("Pango", "1.0") -gi.require_version("PangoCairo", "1.0") - -# This import is not at the top of the file because gi requires the version to be -# specified with the calls above before importing the modules from repository, thus the -# ruff QA exception on E402. -from gi.repository import Pango, PangoCairo # noqa: E402 - - logger = logging.getLogger(__name__) @@ -40,49 +25,14 @@ def default_equipment_colorset(parameters): ) -class ImagePoint: - def __init__(self, x, y): - self.x = x - self.y = y - - class Drawer: - def __init__( - self, db, file, output_format, parameters, coordinates_fh, coordinates_format - ): + """Base class of all drawers, independent of the output format.""" + + def __init__(self, db, file, output_format, parameters): self.db = db self.file = file self.output_format = output_format self.parameters = parameters - self.coordinates_fh = coordinates_fh - self.coordinates_format = coordinates_format - self.coordinates = {} - self.surface = None - self.ctx = None - - def init_ctx(self, width, height): - if self.output_format == "png": - self.surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) - elif self.output_format == "svg": - self.surface = cairo.SVGSurface(self.file, width, height) - elif self.output_format == "pdf": - self.surface = cairo.PDFSurface(self.file, width, height) - else: - raise RacksDBDrawingError(f"Unsupported image format {self.output_format}") - self.ctx = cairo.Context(self.surface) - - def write(self): - if self.output_format == "png": - self.surface.write_to_png(self.file) - elif self.output_format in ["svg", "pdf"]: - self.surface.finish() - self.surface.flush() - if self.coordinates_fh is not None: - self.coordinates_fh.write( - CoordinateDumperFactory.create(self.coordinates_format).dump( - self.coordinates - ) - ) def _find_rack_colorset(self, rack): """Return the rack matching coloring rule defined in drawing parameters or the @@ -132,36 +82,3 @@ def _find_equipment_colorset(self, equipment): continue return rule return default_equipment_colorset(self.parameters) - - def _print_text( - self, - text: str, - max_width: Union[int, None] = None, - max_height: Union[int, None] = None, - shift_x: bool = False, - shift_y: bool = False, - ) -> None: - """Print text label in context. The text size can possibly be responsive to - maximum width and height. The boolean shift_x and shift_y can be set to True in - order to shift the position of the text label by its height vertically or - horizontally respectively.""" - layout = PangoCairo.create_layout(self.ctx) - layout.set_alignment(Pango.Alignment.CENTER) - # Try multiple decreasing size until it fits into optional max width/height - for size in range(12, 4, -1): - layout.set_font_description( - Pango.font_description_from_string(f"Sans Serif Normal {size}") - ) - layout.set_markup(text, -1) - _, extents = layout.get_pixel_extents() - tw, th = extents.width, extents.height - # If max_{width/height} is set, text label width/height must be smaller. - if (max_width is None or tw < max_width) and ( - max_height is None or th < max_height - ): - break - if shift_x: - self.ctx.rel_move_to(-th, 0) - if shift_y: - self.ctx.rel_move_to(0, -th) - PangoCairo.show_layout(self.ctx, layout) diff --git a/racksdb/drawers/base2d.py b/racksdb/drawers/base2d.py new file mode 100644 index 00000000..10293cc9 --- /dev/null +++ b/racksdb/drawers/base2d.py @@ -0,0 +1,104 @@ +# Copyright (c) 2022-2023 Rackslab +# +# This file is part of RacksDB. +# +# SPDX-License-Identifier: MIT + +from typing import Union +import logging + +import cairo +import gi + +from .base import Drawer +from .coordinates import CoordinateDumperFactory +from ..errors import RacksDBDrawingError + +gi.require_version("Pango", "1.0") +gi.require_version("PangoCairo", "1.0") + +# This import is not at the top of the file because gi requires the version to be +# specified with the calls above before importing the modules from repository, thus the +# ruff QA exception on E402. +from gi.repository import Pango, PangoCairo # noqa: E402 + + +logger = logging.getLogger(__name__) + + +class ImagePoint: + def __init__(self, x, y): + self.x = x + self.y = y + + +class Drawer2D(Drawer): + """Base class of drawers of 2D images with cairo, with optional coordinates of + represented entities in images.""" + + def __init__( + self, db, file, output_format, parameters, coordinates_fh, coordinates_format + ): + super().__init__(db, file, output_format, parameters) + self.coordinates_fh = coordinates_fh + self.coordinates_format = coordinates_format + self.coordinates = {} + self.surface = None + self.ctx = None + + def init_ctx(self, width, height): + if self.output_format == "png": + self.surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) + elif self.output_format == "svg": + self.surface = cairo.SVGSurface(self.file, width, height) + elif self.output_format == "pdf": + self.surface = cairo.PDFSurface(self.file, width, height) + else: + raise RacksDBDrawingError(f"Unsupported image format {self.output_format}") + self.ctx = cairo.Context(self.surface) + + def write(self): + if self.output_format == "png": + self.surface.write_to_png(self.file) + elif self.output_format in ["svg", "pdf"]: + self.surface.finish() + self.surface.flush() + if self.coordinates_fh is not None: + self.coordinates_fh.write( + CoordinateDumperFactory.create(self.coordinates_format).dump( + self.coordinates + ) + ) + + def _print_text( + self, + text: str, + max_width: Union[int, None] = None, + max_height: Union[int, None] = None, + shift_x: bool = False, + shift_y: bool = False, + ) -> None: + """Print text label in context. The text size can possibly be responsive to + maximum width and height. The boolean shift_x and shift_y can be set to True in + order to shift the position of the text label by its height vertically or + horizontally respectively.""" + layout = PangoCairo.create_layout(self.ctx) + layout.set_alignment(Pango.Alignment.CENTER) + # Try multiple decreasing size until it fits into optional max width/height + for size in range(12, 4, -1): + layout.set_font_description( + Pango.font_description_from_string(f"Sans Serif Normal {size}") + ) + layout.set_markup(text, -1) + _, extents = layout.get_pixel_extents() + tw, th = extents.width, extents.height + # If max_{width/height} is set, text label width/height must be smaller. + if (max_width is None or tw < max_width) and ( + max_height is None or th < max_height + ): + break + if shift_x: + self.ctx.rel_move_to(-th, 0) + if shift_y: + self.ctx.rel_move_to(0, -th) + PangoCairo.show_layout(self.ctx, layout) diff --git a/racksdb/drawers/infrastructure.py b/racksdb/drawers/infrastructure.py index e4c7f2a7..f42bb900 100644 --- a/racksdb/drawers/infrastructure.py +++ b/racksdb/drawers/infrastructure.py @@ -11,7 +11,7 @@ import cairo -from .base import Drawer, ImagePoint +from .base2d import Drawer2D, ImagePoint from ..errors import RacksDBDrawingError logger = logging.getLogger(__name__) @@ -63,7 +63,7 @@ def _serialized(self): return [self.x, self.y, self.width, self.height] -class InfrastructureDrawer(Drawer): +class InfrastructureDrawer(Drawer2D): def __init__( self, db, diff --git a/racksdb/drawers/room.py b/racksdb/drawers/room.py index d17aabf3..3c4376bd 100644 --- a/racksdb/drawers/room.py +++ b/racksdb/drawers/room.py @@ -9,7 +9,7 @@ import cairo -from .base import Drawer, ImagePoint +from .base2d import Drawer2D, ImagePoint from ..errors import RacksDBDrawingError logger = logging.getLogger(__name__) @@ -93,7 +93,7 @@ def _serialized(self): return [self.x, self.y, self.width, self.height, self.angle] -class RoomDrawer(Drawer): +class RoomDrawer(Drawer2D): def __init__( self, db, diff --git a/racksdb/tests/drawers/test_base.py b/racksdb/tests/drawers/test_base.py index 8a263f6d..26f54b6e 100644 --- a/racksdb/tests/drawers/test_base.py +++ b/racksdb/tests/drawers/test_base.py @@ -67,7 +67,7 @@ def setUp(self): } loader = DBDictsLoader(parameters_raw) self.parameters = DrawingParameters.load(loader, self.drawings_schema_path) - self.drawer = Drawer({}, "output", "format", self.parameters, None, "yaml") + self.drawer = Drawer({}, "output", "format", self.parameters) def test_matching_equipment_coloring_rule(self): # Load defaults equipment colorset From 36d7bd04fcc899784adf47e7f1dbcf1553673a3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Palancher?= Date: Fri, 13 Nov 2026 09:08:19 +0100 Subject: [PATCH 02/16] refactor(draw): share infrastructure lookup in base drawer Move the lookup of the infrastructure to represent in database, with the error raised when it is not found, from infrastructure drawer constructor to the base Drawer class. All representations of infrastructures, whatever their output format, start with this lookup. Sharing it in base Drawer class lets upcoming 3D drawer of infrastructures, and possibly other drawers in the future, reuse it with the same behavior and error message as existing 2D drawers. Assisted-by: Claude Opus 5.5 --- racksdb/drawers/base.py | 9 +++++++++ racksdb/drawers/infrastructure.py | 9 +-------- racksdb/tests/drawers/test_base.py | 26 +++++++++++++++++++++++++- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/racksdb/drawers/base.py b/racksdb/drawers/base.py index 74f3d17e..621701aa 100644 --- a/racksdb/drawers/base.py +++ b/racksdb/drawers/base.py @@ -6,6 +6,7 @@ import logging +from ..errors import RacksDBDrawingError from ..generic.db import GenericDB logger = logging.getLogger(__name__) @@ -34,6 +35,14 @@ def __init__(self, db, file, output_format, parameters): self.output_format = output_format self.parameters = parameters + def _find_infrastructure(self, name): + """Return the infrastructure with the given name. Raise RacksDBDrawingError if + not found in database.""" + for infrastructure in self.db.infrastructures: + if infrastructure.name == name: + return infrastructure + raise RacksDBDrawingError(f"Unable to find infrastructure {name} in database") + def _find_rack_colorset(self, rack): """Return the rack matching coloring rule defined in drawing parameters or the default rack color set. diff --git a/racksdb/drawers/infrastructure.py b/racksdb/drawers/infrastructure.py index f42bb900..018c1b34 100644 --- a/racksdb/drawers/infrastructure.py +++ b/racksdb/drawers/infrastructure.py @@ -77,14 +77,7 @@ def __init__( super().__init__( db, file, output_format, parameters, coordinates_fh, coordinates_format ) - self.infrastructure = None - for infrastructure in self.db.infrastructures: - if infrastructure.name == name: - self.infrastructure = infrastructure - if self.infrastructure is None: - raise RacksDBDrawingError( - f"Unable to find infrastructure {name} in database" - ) + self.infrastructure = self._find_infrastructure(name) # List of rack rows used by the infrastructure self.rack_rows = [] # List of racks used by the insfrastructure diff --git a/racksdb/tests/drawers/test_base.py b/racksdb/tests/drawers/test_base.py index 26f54b6e..e8f69ab7 100644 --- a/racksdb/tests/drawers/test_base.py +++ b/racksdb/tests/drawers/test_base.py @@ -6,6 +6,7 @@ import unittest +from racksdb import RacksDB from racksdb.drawers.base import ( Drawer, default_equipment_colorset, @@ -13,8 +14,9 @@ ) from racksdb.drawers.parameters import DrawingParameters from racksdb.generic.db import DBDictsLoader +from racksdb.errors import RacksDBDrawingError -from ..lib.common import drawing_schema_path +from ..lib.common import drawing_schema_path, schema_path, db_path class FakeEquipmentType: @@ -149,3 +151,25 @@ def test_matching_rack_coloring_rule(self): self.drawer._find_rack_colorset(FakeRack("type2", None)).pane, defaults.pane, ) + + +class TestDrawerDatabase(unittest.TestCase): + def setUp(self): + try: + self.drawings_schema_path = drawing_schema_path() + self.db = RacksDB.load(schema=schema_path(), db=db_path()) + except FileNotFoundError as err: + self.skipTest(err) + self.drawer = Drawer( + self.db, + "output", + "format", + DrawingParameters.load(DBDictsLoader({}), self.drawings_schema_path), + ) + + def test_find_infrastructure(self): + self.assertEqual(self.drawer._find_infrastructure("mercury").name, "mercury") + with self.assertRaisesRegex( + RacksDBDrawingError, "^Unable to find infrastructure fail in database$" + ): + self.drawer._find_infrastructure("fail") From 5dd381f625ea01941bedabcbba45da247b40f4c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Palancher?= Date: Fri, 13 Nov 2026 09:09:15 +0100 Subject: [PATCH 03/16] refactor(draw): share infrastructure racks selection in base drawer The selection of racks and rows to represent according to equipment_tags, discard_empty_racks and other_racks drawing parameters was duplicated in the draw() methods of infrastructure and axonometric infrastructure drawers, and the check of equipment tags was duplicated in their methods drawing equipment. Move this logic into base Drawer class, in _select_infrastructure_racks(), _part_contains_selected_equipment() and new _equipment_selected() methods, used by both 2D infrastructure drawers. Besides removing duplicated code, this ensures upcoming 3D drawer of infrastructures, and any other future representation of infrastructures, honours drawing parameters exactly as 2D drawers do, with a single place to fix or extend this selection logic. Assisted-by: Claude Opus 5.5 --- racksdb/drawers/axonometric_infrastructure.py | 41 ++---------- racksdb/drawers/base.py | 64 +++++++++++++++++++ racksdb/drawers/infrastructure.py | 58 ++--------------- racksdb/tests/drawers/test_base.py | 28 ++++++++ 4 files changed, 102 insertions(+), 89 deletions(-) diff --git a/racksdb/drawers/axonometric_infrastructure.py b/racksdb/drawers/axonometric_infrastructure.py index 3848a266..fdcef838 100644 --- a/racksdb/drawers/axonometric_infrastructure.py +++ b/racksdb/drawers/axonometric_infrastructure.py @@ -13,7 +13,6 @@ from .base2d import ImagePoint from .infrastructure import InfrastructureDrawer, EquipmentCoordinate -from ..errors import RacksDBDrawingError logger = logging.getLogger(__name__) @@ -195,12 +194,8 @@ def _equipment_tl(self, equipment) -> ImagePoint: return tl def _draw_rack_equipment(self, equipment): - # If equipment_tags drawing parameters is set, check the equipment has at least - # one matching associated tag. - if hasattr(self.parameters.infrastructure, "equipment_tags") and not any( - equipment_tag in self.parameters.infrastructure.equipment_tags - for equipment_tag in equipment.tags - ): + # Check the equipment is selected with equipment_tags drawing parameter. + if not self._equipment_selected(equipment): # If ghost_unselected is true, represent this equipment ghosted. if self.parameters.infrastructure.ghost_unselected: self._draw_equipment_ghost(equipment) @@ -519,36 +514,10 @@ def draw(self): self.parameters.dimensions.height, ) - def add_rack(rack): - # Racks objects are compared by names, as the same rack can be - # represented by distinct objects. - if all(rack.name != selected.name for selected in self.racks): - self.racks.append(rack) - # Get list of racks and rows used by the infrastructure - for part in self.infrastructure.layout: - # If discard_empty_racks is True, skip parts without equipment selected for - # representation in the diagram. - if ( - self.parameters.infrastructure.discard_empty_racks - and not self._part_contains_selected_equipment(part) - ): - continue - # If other_racks is True, select all racks in part rack row. - if self.parameters.infrastructure.other_racks: - for rack in part.rack.row.racks: - add_rack(rack) - # Else select all racks in part. - else: - add_rack(part.rack) - if part.rack.row not in self.rack_rows: - self.rack_rows.append(part.rack.row) - - if not self.racks: - raise RacksDBDrawingError( - "Unable to find racks to draw with filters provided in drawing " - "parameters" - ) + self.racks, self.rack_rows = self._select_infrastructure_racks( + self.infrastructure + ) self._check_collisions() diff --git a/racksdb/drawers/base.py b/racksdb/drawers/base.py index 621701aa..99c60337 100644 --- a/racksdb/drawers/base.py +++ b/racksdb/drawers/base.py @@ -4,6 +4,7 @@ # # SPDX-License-Identifier: MIT +from itertools import chain import logging from ..errors import RacksDBDrawingError @@ -43,6 +44,69 @@ def _find_infrastructure(self, name): return infrastructure raise RacksDBDrawingError(f"Unable to find infrastructure {name} in database") + def _equipment_selected(self, equipment) -> bool: + """Return True if the equipment is selected for representation with the + equipment_tags drawing parameter, ie. if this parameter is unset or if the + equipment has at least one matching tag, False otherwise.""" + if not hasattr(self.parameters.infrastructure, "equipment_tags"): + return True + return any( + equipment_tag in self.parameters.infrastructure.equipment_tags + for equipment_tag in equipment.tags + ) + + def _part_contains_selected_equipment(self, part) -> bool: + """Return True if the infrastructure part contains equipment that is selected + for representation, False otherwise.""" + + # If equipment_tags is unset, all equipment is represented, then it is True. + if not hasattr(self.parameters.infrastructure, "equipment_tags"): + return True + # Iterate over all equipment to check if at least one has matching tag. + for equipment in chain(part.nodes, part.storage, part.network, part.misc): + if self._equipment_selected(equipment): + return True + # No matching equipment found, return False. + return False + + def _select_infrastructure_racks(self, infrastructure): + """Return the tuple of lists (racks, rows) of racks and racks rows of the + infrastructure to represent according to drawing parameters. Raise + RacksDBDrawingError if no rack is selected.""" + racks = [] + rows = [] + + def add_rack(rack): + # Racks objects are compared by names, as the same rack can be + # represented by distinct objects. + if all(rack.name != selected.name for selected in racks): + racks.append(rack) + + for part in infrastructure.layout: + # If discard_empty_racks is True, skip parts without equipment selected for + # representation. + if ( + self.parameters.infrastructure.discard_empty_racks + and not self._part_contains_selected_equipment(part) + ): + continue + # If other_racks is True, select all racks in part rack row. + if self.parameters.infrastructure.other_racks: + for rack in part.rack.row.racks: + add_rack(rack) + # Else select all racks in part. + else: + add_rack(part.rack) + if part.rack.row not in rows: + rows.append(part.rack.row) + + if not racks: + raise RacksDBDrawingError( + "Unable to find racks to draw with filters provided in drawing " + "parameters" + ) + return racks, rows + def _find_rack_colorset(self, rack): """Return the rack matching coloring rule defined in drawing parameters or the default rack color set. diff --git a/racksdb/drawers/infrastructure.py b/racksdb/drawers/infrastructure.py index 018c1b34..c038131c 100644 --- a/racksdb/drawers/infrastructure.py +++ b/racksdb/drawers/infrastructure.py @@ -12,7 +12,6 @@ import cairo from .base2d import Drawer2D, ImagePoint -from ..errors import RacksDBDrawingError logger = logging.getLogger(__name__) @@ -95,23 +94,6 @@ def _rack_in_infrastructure(self, rack) -> bool: return False - def _part_contains_selected_equipment(self, part) -> bool: - """Return True if the infrastructure part contains equipment that is selected - for representation in the diagram, False otherwise.""" - - # If equipment_tags is unset, all equipment is represented, then it is True. - if not hasattr(self.parameters.infrastructure, "equipment_tags"): - return True - # Iterate over all equipment to check if at least one has matching tag. - for equipment in chain(part.nodes, part.storage, part.network, part.misc): - if any( - equipment_tag in self.parameters.infrastructure.equipment_tags - for equipment_tag in equipment.tags - ): - return True - # No matching equipment found, return False. - return False - def _rack_contains_selected_equipment(self, rack) -> bool: """Return True if the given rack contains equipment that is selected for representation in the diagram, False otherwise.""" @@ -367,12 +349,8 @@ def _draw_equipment_ghost(self, equipment): self.ctx.fill() def _draw_rack_equipment(self, equipment): - # If equipment_tags drawing parameters is set, check the equipment has at least - # one matching associated tag. - if hasattr(self.parameters.infrastructure, "equipment_tags") and not any( - equipment_tag in self.parameters.infrastructure.equipment_tags - for equipment_tag in equipment.tags - ): + # Check the equipment is selected with equipment_tags drawing parameter. + if not self._equipment_selected(equipment): # If ghost_unselected is true, represent this equipment ghosted. if self.parameters.infrastructure.ghost_unselected: self._draw_equipment_ghost(equipment) @@ -596,36 +574,10 @@ def draw(self): self.parameters.dimensions.height, ) - def add_rack(rack): - # Racks objects are compared by names, as the same rack can be - # represented by distinct objects. - if all(rack.name != selected.name for selected in self.racks): - self.racks.append(rack) - # Get list of racks and rows used by the infrastructure - for part in self.infrastructure.layout: - # If discard_empty_racks is True, skip parts without equipment selected for - # representation in the diagram. - if ( - self.parameters.infrastructure.discard_empty_racks - and not self._part_contains_selected_equipment(part) - ): - continue - # If other_racks is True, select all racks in part rack row. - if self.parameters.infrastructure.other_racks: - for rack in part.rack.row.racks: - add_rack(rack) - # Else select all racks in part. - else: - add_rack(part.rack) - if part.rack.row not in self.rack_rows: - self.rack_rows.append(part.rack.row) - - if not self.racks: - raise RacksDBDrawingError( - "Unable to find racks to draw with filters provided in drawing " - "parameters" - ) + self.racks, self.rack_rows = self._select_infrastructure_racks( + self.infrastructure + ) self._check_collisions() diff --git a/racksdb/tests/drawers/test_base.py b/racksdb/tests/drawers/test_base.py index e8f69ab7..27ab8106 100644 --- a/racksdb/tests/drawers/test_base.py +++ b/racksdb/tests/drawers/test_base.py @@ -173,3 +173,31 @@ def test_find_infrastructure(self): RacksDBDrawingError, "^Unable to find infrastructure fail in database$" ): self.drawer._find_infrastructure("fail") + + def select_racks(self, name, parameters): + """Return the names of racks and rows of the given infrastructure selected + with the given drawing parameters.""" + self.drawer.parameters = DrawingParameters.load( + DBDictsLoader(parameters), self.drawings_schema_path + ) + racks, rows = self.drawer._select_infrastructure_racks( + self.drawer._find_infrastructure(name) + ) + return [rack.name for rack in racks], [row.name for row in rows] + + def test_select_infrastructure_racks(self): + self.assertEqual( + self.select_racks("mercury", {}), + (["R1-A01", "R1-A02", "R2-A02", "R2-A03"], ["R1", "R2"]), + ) + + def test_select_infrastructure_racks_discard_empty_racks(self): + parameters = {"infrastructure": {"equipment_tags": ["compute"]}} + self.assertEqual( + self.select_racks("mercury", parameters), (["R1-A01", "R1-A02"], ["R1"]) + ) + parameters["infrastructure"]["discard_empty_racks"] = False + self.assertEqual( + self.select_racks("mercury", parameters), + (["R1-A01", "R1-A02", "R2-A02", "R2-A03"], ["R1", "R2"]), + ) From 7064aea9e4ff129b7a06e76fa32a78730514d391 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Palancher?= Date: Fri, 13 Nov 2026 09:09:46 +0100 Subject: [PATCH 04/16] refactor(draw): share rack position in row in base drawer Move _rack_preceding_widths() method, which yields the widths of racks and empty slots before a rack in its row, from room drawer to base Drawer class, without modification. This method defines how racks are positioned in their rows, including the width assumed for empty slots. Any representation placing racks in rooms, starting with upcoming 3D representation of infrastructures, must follow the same rule to stay consistent with rooms diagrams. Sharing it in base Drawer class avoids diverging implementations. Assisted-by: Claude Opus 5.5 --- racksdb/drawers/base.py | 22 ++++++++++++++++++++++ racksdb/drawers/room.py | 22 ---------------------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/racksdb/drawers/base.py b/racksdb/drawers/base.py index 99c60337..c9fd2cd4 100644 --- a/racksdb/drawers/base.py +++ b/racksdb/drawers/base.py @@ -107,6 +107,28 @@ def add_rack(rack): ) return racks, rows + def _rack_preceding_widths(self, rack): + """Yield the widths (in mm) of all racks and empty slots in rack row + before the given rack.""" + filled_slots = {} + for row_rack in rack.row.racks: + if row_rack.slot < rack.slot: + yield row_rack.type.width + filled_slots[row_rack.slot] = row_rack + # filling empty slots with previous rack width + for slot in range(rack.slot): + if slot not in filled_slots: + logger.debug("Row %s slot %d is not filled", rack.row.name, slot) + last_rack_width = 0 + reversed_slot = slot + for reversed_slot in range(slot, 0, -1): + if reversed_slot in filled_slots: + last_rack_width = filled_slots[reversed_slot].type.width + break + if not (last_rack_width): + last_rack_width = self.db.types.racks.first().width + yield last_rack_width + def _find_rack_colorset(self, rack): """Return the rack matching coloring rule defined in drawing parameters or the default rack color set. diff --git a/racksdb/drawers/room.py b/racksdb/drawers/room.py index 3c4376bd..a5c93e4c 100644 --- a/racksdb/drawers/room.py +++ b/racksdb/drawers/room.py @@ -130,28 +130,6 @@ def _racks_row_tl(self, row): self.parameters.margin.top + int(row.position.depth * self.ratio), ) - def _rack_preceding_widths(self, rack): - """Yield the widths (in mm) of all racks and empty slots in rack row - before the given rack.""" - filled_slots = {} - for row_rack in rack.row.racks: - if row_rack.slot < rack.slot: - yield row_rack.type.width - filled_slots[row_rack.slot] = row_rack - # filling empty slots with previous rack width - for slot in range(rack.slot): - if slot not in filled_slots: - logger.debug("Row %s slot %d is not filled", rack.row.name, slot) - last_rack_width = 0 - reversed_slot = slot - for reversed_slot in range(slot, 0, -1): - if reversed_slot in filled_slots: - last_rack_width = filled_slots[reversed_slot].type.width - break - if not (last_rack_width): - last_rack_width = self.db.types.racks.first().width - yield last_rack_width - def _rack_tl(self, rack): # Start from top-left corner of the rack row, which is (0,0) after # context matrix translation. From 9aefe7457ca1ce0274c0d2dd695cf7c3ad998485 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Palancher?= Date: Fri, 13 Nov 2026 09:10:22 +0100 Subject: [PATCH 05/16] refactor(draw): gather collisions detection in dedicated module Move racks footprints overlap detection (polygons_overlap()) out of room drawer module and equipment overlap detection (equipment_overlap()) out of infrastructure drawer module, along with the detection of colliding equipment in a list of racks extracted from infrastructure drawer as colliding_equipment(), into new collisions module. These functions are pure geometry, independent of any rendering. Gathering them in a dedicated module lets upcoming 3D drawer of infrastructures, and future drawers or other consumers of datacenters geometry, detect collisions exactly as 2D drawers do without importing 2D drawers modules and their cairo dependency. Collisions warnings are now emitted by racksdb.drawers.collisions logger. Assisted-by: Claude Opus 5.5 --- racksdb/drawers/collisions.py | 139 ++++++++++++++++++ racksdb/drawers/infrastructure.py | 58 +------- racksdb/drawers/room.py | 66 +-------- .../test_axonometric_infrastructure.py | 6 +- racksdb/tests/drawers/test_infrastructure.py | 11 +- racksdb/tests/drawers/test_room.py | 3 +- 6 files changed, 154 insertions(+), 129 deletions(-) create mode 100644 racksdb/drawers/collisions.py diff --git a/racksdb/drawers/collisions.py b/racksdb/drawers/collisions.py new file mode 100644 index 00000000..69df5c6a --- /dev/null +++ b/racksdb/drawers/collisions.py @@ -0,0 +1,139 @@ +# Copyright (c) 2026 Rackslab +# +# This file is part of RacksDB. +# +# SPDX-License-Identifier: MIT + +"""Collisions detection between racks in rooms and between equipment in racks, +shared by 2D and 3D drawers.""" + +import logging +import math + +logger = logging.getLogger(__name__) + +# Float epsilon (in mm) used to absorb trigonometric rounding errors in +# collision detection, so that racks sharing an edge are not reported as +# colliding. This is not a physical tolerance. +POLYGON_EPSILON = 1e-6 + + +def _project(polygon, axis): + """Project polygon points on the line passing through the origin (0, 0) in the + direction of the unit vector axis (x, y). Each projection is the dot product of + the point with axis, ie. the signed distance from the origin to the point + shadow on this line. Return the min and max projections, ie. the interval + covered by the polygon shadow on this line.""" + values = [point[0] * axis[0] + point[1] * axis[1] for point in polygon] + return min(values), max(values) + + +def polygons_overlap(a, b): + """Return True if convex polygons a and b (lists of (x, y) points) strictly + overlap, using the separating axis theorem. Polygons only touching on an + edge or a corner are not considered overlapping.""" + # Quick check on axis-aligned bounding boxes: on each dimension (0 for x, 1 for + # y), if the maximum coordinate of a polygon is lower than (or equal to) the + # minimum coordinate of the other polygon, their bounding boxes are disjoint and + # the polygons cannot overlap. This avoids the more expensive check below for + # most pairs of distant polygons. + for dim in (0, 1): + if ( + max(p[dim] for p in a) <= min(p[dim] for p in b) + POLYGON_EPSILON + or max(p[dim] for p in b) <= min(p[dim] for p in a) + POLYGON_EPSILON + ): + return False + # Separating axis theorem: two convex polygons do not overlap if and only if + # there is an axis on which their projections are disjoint, and it is enough to + # test the axes perpendicular to the edges of both polygons. For each edge, + # compute its unit normal (ie. perpendicular) vector, project all points of both + # polygons on this axis and compare the resulting intervals. If they are disjoint + # (or only touch), the axis separates the polygons and they do not overlap. If no + # separating axis is found, the polygons overlap. + for polygon in (a, b): + for idx, point in enumerate(polygon): + # Get the following point (x, y) in polygon, wrapping around to the first + # point after the last one, so that point -> next_point is the polygon + # edge that closes the shape. + next_point = polygon[(idx + 1) % len(polygon)] + # Compute the normal vector (x, y) of the edge, perpendicular to it. For an + # edge vector (dx, dy) = next_point - point, the normal vector is the edge + # vector rotated by 90°, ie. (-dy, dx). + axis = (point[1] - next_point[1], next_point[0] - point[0]) + # Compute the length of the normal vector, ie. sqrt(x² + y²), equal to the + # length of the edge. + length = math.hypot(*axis) + # Skip degenerated zero-length edges, when two consecutive points are + # identical. + if length == 0: + continue + # Normalize the normal vector, ie. divide it by its length to get a unit + # vector (x, y) of length 1 in the same direction, so that projections on + # this axis are distances in mm comparable with POLYGON_EPSILON. + axis = (axis[0] / length, axis[1] / length) + min_a, max_a = _project(a, axis) + min_b, max_b = _project(b, axis) + if max_a <= min_b + POLYGON_EPSILON or max_b <= min_a + POLYGON_EPSILON: + return False + return True + + +# Float epsilon used to absorb rounding errors on equipment fractional widths (eg. +# third of rack width) in collision detection, so that equipment just next to each +# other are not reported as colliding. This is not a physical tolerance. +EQUIPMENT_EPSILON = 1e-9 + + +def _equipment_extent(equipment): + """Return the tuple (bottom, top, left, right) of the space occupied by the + equipment in its rack. Bottom and top are expressed in rack units relatively to + the rack first slot, left and right are expressed as fractions of the rack + width.""" + bottom = equipment.position.height + left = equipment.position.width * equipment.type.width + return ( + bottom, + bottom + equipment.type.height, + left, + left + equipment.type.width, + ) + + +def equipment_overlap(a, b): + """Return True if equipment a and b, assumed to be in the same rack, strictly + overlap. Equipment only sharing a border (ie. stacked or side by side) are not + considered overlapping.""" + a_bottom, a_top, a_left, a_right = _equipment_extent(a) + b_bottom, b_top, b_left, b_right = _equipment_extent(b) + return ( + a_bottom < b_top - EQUIPMENT_EPSILON + and b_bottom < a_top - EQUIPMENT_EPSILON + and a_left < b_right - EQUIPMENT_EPSILON + and b_left < a_right - EQUIPMENT_EPSILON + ) + + +def colliding_equipment(racks): + """Detect collisions between equipment in the given racks, emit warnings and + return the set of names of colliding equipment. Equipment of all infrastructures + are considered, not only the equipment of a specific infrastructure.""" + result = set() + for rack in racks: + equipments = rack.equipment + # Compare each equipment with all equipment after it in the list, so that + # every pair of equipment is checked exactly once. + for idx, equipment_a in enumerate(equipments): + for equipment_b in equipments[idx + 1 :]: + if equipment_overlap(equipment_a, equipment_b): + logger.warning( + "Collision detected in rack %s between equipment %s " + "(infrastructure %s) and %s (infrastructure %s)", + rack.name, + equipment_a.name, + equipment_a.infrastructure.name, + equipment_b.name, + equipment_b.infrastructure.name, + ) + result.add(equipment_a.name) + result.add(equipment_b.name) + return result diff --git a/racksdb/drawers/infrastructure.py b/racksdb/drawers/infrastructure.py index c038131c..1b4b0248 100644 --- a/racksdb/drawers/infrastructure.py +++ b/racksdb/drawers/infrastructure.py @@ -12,43 +12,10 @@ import cairo from .base2d import Drawer2D, ImagePoint +from .collisions import colliding_equipment logger = logging.getLogger(__name__) -# Float epsilon used to absorb rounding errors on equipment fractional widths (eg. -# third of rack width) in collision detection, so that equipment just next to each -# other are not reported as colliding. This is not a physical tolerance. -EPSILON = 1e-9 - - -def _equipment_extent(equipment): - """Return the tuple (bottom, top, left, right) of the space occupied by the - equipment in its rack. Bottom and top are expressed in rack units relatively to - the rack first slot, left and right are expressed as fractions of the rack - width.""" - bottom = equipment.position.height - left = equipment.position.width * equipment.type.width - return ( - bottom, - bottom + equipment.type.height, - left, - left + equipment.type.width, - ) - - -def equipment_overlap(a, b): - """Return True if equipment a and b, assumed to be in the same rack, strictly - overlap. Equipment only sharing a border (ie. stacked or side by side) are not - considered overlapping.""" - a_bottom, a_top, a_left, a_right = _equipment_extent(a) - b_bottom, b_top, b_left, b_right = _equipment_extent(b) - return ( - a_bottom < b_top - EPSILON - and b_bottom < a_top - EPSILON - and a_left < b_right - EPSILON - and b_left < a_right - EPSILON - ) - class EquipmentCoordinate: def __init__(self, x, y, width, height): @@ -120,27 +87,8 @@ def _rack_must_be_represented(self, rack) -> bool: def _check_collisions(self): """Detect collisions between equipment in represented racks, emit warnings - and record names of colliding equipment. Equipment of all infrastructures - are considered, not only the equipment of the represented infrastructure.""" - self._colliding_equipment = set() - for rack in self.racks: - equipments = rack.equipment - # Compare each equipment with all equipment after it in the list, so that - # every pair of equipment is checked exactly once. - for idx, equipment_a in enumerate(equipments): - for equipment_b in equipments[idx + 1 :]: - if equipment_overlap(equipment_a, equipment_b): - logger.warning( - "Collision detected in rack %s between equipment %s " - "(infrastructure %s) and %s (infrastructure %s)", - rack.name, - equipment_a.name, - equipment_a.infrastructure.name, - equipment_b.name, - equipment_b.infrastructure.name, - ) - self._colliding_equipment.add(equipment_a.name) - self._colliding_equipment.add(equipment_b.name) + and record names of colliding equipment.""" + self._colliding_equipment = colliding_equipment(self.racks) def _rack_row_width(self, row) -> int: """Return rack row width in mm""" diff --git a/racksdb/drawers/room.py b/racksdb/drawers/room.py index a5c93e4c..cbb2ab92 100644 --- a/racksdb/drawers/room.py +++ b/racksdb/drawers/room.py @@ -10,75 +10,11 @@ import cairo from .base2d import Drawer2D, ImagePoint +from .collisions import polygons_overlap from ..errors import RacksDBDrawingError logger = logging.getLogger(__name__) -# Float epsilon (in mm) used to absorb trigonometric rounding errors in -# collision detection, so that racks sharing an edge are not reported as -# colliding. This is not a physical tolerance. -EPSILON = 1e-6 - - -def _project(polygon, axis): - """Project polygon points on the line passing through the origin (0, 0) in the - direction of the unit vector axis (x, y). Each projection is the dot product of - the point with axis, ie. the signed distance from the origin to the point - shadow on this line. Return the min and max projections, ie. the interval - covered by the polygon shadow on this line.""" - values = [point[0] * axis[0] + point[1] * axis[1] for point in polygon] - return min(values), max(values) - - -def polygons_overlap(a, b): - """Return True if convex polygons a and b (lists of (x, y) points) strictly - overlap, using the separating axis theorem. Polygons only touching on an - edge or a corner are not considered overlapping.""" - # Quick check on axis-aligned bounding boxes: on each dimension (0 for x, 1 for - # y), if the maximum coordinate of a polygon is lower than (or equal to) the - # minimum coordinate of the other polygon, their bounding boxes are disjoint and - # the polygons cannot overlap. This avoids the more expensive check below for - # most pairs of distant polygons. - for dim in (0, 1): - if ( - max(p[dim] for p in a) <= min(p[dim] for p in b) + EPSILON - or max(p[dim] for p in b) <= min(p[dim] for p in a) + EPSILON - ): - return False - # Separating axis theorem: two convex polygons do not overlap if and only if - # there is an axis on which their projections are disjoint, and it is enough to - # test the axes perpendicular to the edges of both polygons. For each edge, - # compute its unit normal (ie. perpendicular) vector, project all points of both - # polygons on this axis and compare the resulting intervals. If they are disjoint - # (or only touch), the axis separates the polygons and they do not overlap. If no - # separating axis is found, the polygons overlap. - for polygon in (a, b): - for idx, point in enumerate(polygon): - # Get the following point (x, y) in polygon, wrapping around to the first - # point after the last one, so that point -> next_point is the polygon - # edge that closes the shape. - next_point = polygon[(idx + 1) % len(polygon)] - # Compute the normal vector (x, y) of the edge, perpendicular to it. For an - # edge vector (dx, dy) = next_point - point, the normal vector is the edge - # vector rotated by 90°, ie. (-dy, dx). - axis = (point[1] - next_point[1], next_point[0] - point[0]) - # Compute the length of the normal vector, ie. sqrt(x² + y²), equal to the - # length of the edge. - length = math.hypot(*axis) - # Skip degenerated zero-length edges, when two consecutive points are - # identical. - if length == 0: - continue - # Normalize the normal vector, ie. divide it by its length to get a unit - # vector (x, y) of length 1 in the same direction, so that projections on - # this axis are distances in mm comparable with EPSILON. - axis = (axis[0] / length, axis[1] / length) - min_a, max_a = _project(a, axis) - min_b, max_b = _project(b, axis) - if max_a <= min_b + EPSILON or max_b <= min_a + EPSILON: - return False - return True - class RackCoordinate: def __init__(self, x, y, width, height, angle): diff --git a/racksdb/tests/drawers/test_axonometric_infrastructure.py b/racksdb/tests/drawers/test_axonometric_infrastructure.py index 279a7f64..8a5927e0 100644 --- a/racksdb/tests/drawers/test_axonometric_infrastructure.py +++ b/racksdb/tests/drawers/test_axonometric_infrastructure.py @@ -92,7 +92,7 @@ def test_draw_other_racks(self): drawer = AxonometricInfrastructureDrawer( self.db, "mercury", filename, "png", self.parameters, None, "yaml" ) - with self.assertLogs("racksdb.drawers.infrastructure", "WARNING") as cm: + with self.assertLogs("racksdb.drawers.collisions", "WARNING") as cm: drawer.draw() self.assertTrue(filename.exists()) self.assertEqual( @@ -112,13 +112,13 @@ def test_draw_collision(self): drawer = AxonometricInfrastructureDrawer( self.db, "sharednet", filename, "png", self.parameters, None, "yaml" ) - with self.assertLogs("racksdb.drawers.infrastructure", "WARNING") as cm: + with self.assertLogs("racksdb.drawers.collisions", "WARNING") as cm: drawer.draw() self.assertTrue(filename.exists()) self.assertEqual( cm.output, [ - "WARNING:racksdb.drawers.infrastructure:Collision detected in rack " + "WARNING:racksdb.drawers.collisions:Collision detected in rack " "R2-A02 between equipment swnas01 (infrastructure mercury) and " "netmercurysw03 (infrastructure sharednet)" ], diff --git a/racksdb/tests/drawers/test_infrastructure.py b/racksdb/tests/drawers/test_infrastructure.py index c35da16e..82f9ac12 100644 --- a/racksdb/tests/drawers/test_infrastructure.py +++ b/racksdb/tests/drawers/test_infrastructure.py @@ -11,7 +11,8 @@ from types import SimpleNamespace from racksdb import RacksDB -from racksdb.drawers.infrastructure import InfrastructureDrawer, equipment_overlap +from racksdb.drawers.collisions import equipment_overlap +from racksdb.drawers.infrastructure import InfrastructureDrawer from racksdb.drawers.parameters import DrawingParameters from racksdb.generic.db import DBDictsLoader from racksdb.errors import RacksDBDrawingError @@ -216,7 +217,7 @@ def test_draw_other_racks(self): drawer = InfrastructureDrawer( self.db, "mercury", filename, "png", self.parameters, None, "yaml" ) - with self.assertLogs("racksdb.drawers.infrastructure", "WARNING") as cm: + with self.assertLogs("racksdb.drawers.collisions", "WARNING") as cm: drawer.draw() self.assertTrue(filename.exists()) self.assertEqual( @@ -244,7 +245,7 @@ def _equipment_definition(self, infrastructure, rack, name): def _draw_with_logs(self, drawer): with tempfile.TemporaryDirectory() as tmpdir: drawer.file = Path(tmpdir) / "output.png" - with self.assertLogs("racksdb.drawers.infrastructure", "WARNING") as cm: + with self.assertLogs("racksdb.drawers.collisions", "WARNING") as cm: drawer.draw() self.assertTrue(drawer.file.exists()) return cm.output @@ -284,7 +285,7 @@ def test_collision_other_infrastructure(self): self.assertEqual( output, [ - "WARNING:racksdb.drawers.infrastructure:Collision detected in " + "WARNING:racksdb.drawers.collisions:Collision detected in " "rack R2-A02 between equipment swnas01 (infrastructure " "mercury) and netmercurysw03 (infrastructure sharednet)" ], @@ -301,7 +302,7 @@ def test_collision_same_infrastructure(self): ) output = self._draw_with_logs(drawer) self.assertIn( - "WARNING:racksdb.drawers.infrastructure:Collision detected in rack R2-A03 " + "WARNING:racksdb.drawers.collisions:Collision detected in rack R2-A03 " "between equipment mesrv0004 (infrastructure mercury) and mekvm01 " "(infrastructure mercury)", output, diff --git a/racksdb/tests/drawers/test_room.py b/racksdb/tests/drawers/test_room.py index 03879caa..6a61715d 100644 --- a/racksdb/tests/drawers/test_room.py +++ b/racksdb/tests/drawers/test_room.py @@ -10,7 +10,8 @@ from pathlib import Path from racksdb import RacksDB -from racksdb.drawers.room import RoomDrawer, polygons_overlap +from racksdb.drawers.collisions import polygons_overlap +from racksdb.drawers.room import RoomDrawer from racksdb.drawers.parameters import DrawingParameters from racksdb.generic.db import DBDictsLoader from racksdb.errors import RacksDBDrawingError From 593f38be69da188f61ca4fde31de5f194d744607 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Palancher?= Date: Fri, 13 Nov 2026 09:11:37 +0100 Subject: [PATCH 06/16] feat(draw): generate 3D infrastructures in glTF 2.0 formats Add GltfInfrastructureDrawer to represent infrastructures in 3D, in glTF JSON (gltf) and binary (glb) formats. The scene graph follows database structure (room, racks row, rack, equipment) with metadata in node extras. Rooms have textured raised floors with perforated tiles in front of racks doors. Equipment tags filtering, ghosts, other racks, coloring rules and collisions highlighting drawing parameters are honored. Assisted-by: Claude Opus 5.5 --- CHANGELOG.md | 6 + pyproject.toml | 3 + racksdb/drawers/__init__.py | 8 +- .../drawers/assets/floor-tile-perforated.png | Bin 0 -> 33951 bytes racksdb/drawers/assets/floor-tile-solid.png | Bin 0 -> 10525 bytes racksdb/drawers/gltf.py | 793 ++++++++++++++++++ 6 files changed, 809 insertions(+), 1 deletion(-) create mode 100644 racksdb/drawers/assets/floor-tile-perforated.png create mode 100644 racksdb/drawers/assets/floor-tile-solid.png create mode 100644 racksdb/drawers/gltf.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b0711759..a2d89ef9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,12 @@ and this project adheres to - Add infrastructure `collisions` boolean drawing parameter, enabled by default. When enabled, RacksDB highlights equipment colliding with other equipment with red frames in infrastructure diagrams. + - Generate 3D representations of infrastructures in glTF 2.0 JSON (`gltf`) + and binary (`glb`) formats, with racks and equipment in their datacenter + rooms on raised floors with perforated tiles in cold aisles. Nodes of the + scene are named after database objects with metadata in their extras + (#164). +- pkgs: Install floor textures of 3D representations with the Python package. - docs: - Mention support of Fedora 44. - Document `equipment` attribute of `RacksDBRack` objects in Python library diff --git a/pyproject.toml b/pyproject.toml index 27e99436..46a7930d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,9 @@ racksdb-web = "racksdb.web.app:RacksDBWebApp.run" [tool.setuptools.packages.find] include = ["racksdb*"] +[tool.setuptools.package-data] +"racksdb.drawers" = ["assets/*.png"] + [tool.setuptools.data-files] "share/racksdb/schemas" = [ "schemas/racksdb.yml", diff --git a/racksdb/drawers/__init__.py b/racksdb/drawers/__init__.py index fb268f8f..9c040afe 100644 --- a/racksdb/drawers/__init__.py +++ b/racksdb/drawers/__init__.py @@ -7,5 +7,11 @@ from .room import RoomDrawer from .infrastructure import InfrastructureDrawer from .axonometric_infrastructure import AxonometricInfrastructureDrawer +from .gltf import GltfInfrastructureDrawer -__all__ = ["RoomDrawer", "InfrastructureDrawer", "AxonometricInfrastructureDrawer"] +__all__ = [ + "RoomDrawer", + "InfrastructureDrawer", + "AxonometricInfrastructureDrawer", + "GltfInfrastructureDrawer", +] diff --git a/racksdb/drawers/assets/floor-tile-perforated.png b/racksdb/drawers/assets/floor-tile-perforated.png new file mode 100644 index 0000000000000000000000000000000000000000..f085f5feaa369827a67d09c0c9863590b1852929 GIT binary patch literal 33951 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 O00K`}KbLh*2~7YI3qEB4 literal 0 HcmV?d00001 diff --git a/racksdb/drawers/assets/floor-tile-solid.png b/racksdb/drawers/assets/floor-tile-solid.png new file mode 100644 index 0000000000000000000000000000000000000000..9820bab888fbeee2c5c71429d9e9b690826a1320 GIT binary patch literal 10525 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 literal 0 HcmV?d00001 diff --git a/racksdb/drawers/gltf.py b/racksdb/drawers/gltf.py new file mode 100644 index 00000000..2766c698 --- /dev/null +++ b/racksdb/drawers/gltf.py @@ -0,0 +1,793 @@ +# Copyright (c) 2026 Rackslab +# +# This file is part of RacksDB. +# +# SPDX-License-Identifier: MIT + +"""3D representations of infrastructures in glTF 2.0 format. + +The scene uses meters, the Y axis is up. In rooms, the X axis follows room width and +the Z axis follows room depth, so that a scene viewed from above matches room maps. +The scene graph follows the database structure: room → racks row → rack → +equipment. Every node representing a database object is named after this object +and carries metadata in its extras. Geometry is attached to unnamed child nodes, so +that nodes of database objects are never scaled. +""" + +import base64 +from functools import lru_cache +import json +import logging +import math +from pathlib import Path +import struct +from typing import Optional + +from .base import Drawer +from .collisions import colliding_equipment, polygons_overlap +from ..errors import RacksDBDrawingError + +logger = logging.getLogger(__name__) + +FORMATS = ("gltf", "glb") + +MM_TO_M = 0.001 + +# Raised floor tiles +FLOOR_TILE_MM = 600 +FLOOR_THICKNESS_M = 0.3 +# Keep floor top surface slightly below racks bottom to avoid z-fighting. +FLOOR_CLEARANCE_M = 0.002 +# Lift perforated tiles slightly above floor top surface to avoid z-fighting. +PERFORATED_TILE_LIFT_M = 0.0005 +FLOOR_SIDE_COLOR = (0.34, 0.36, 0.40, 1.0) +FLOOR_ROUGHNESS = 0.8 +# Horizontal gap between rooms when an infrastructure spans multiple rooms. +ROOMS_GAP_M = 2.0 + +# Racks and equipment +# Thickness of top and bottom sheets of racks carcass. +RACK_SHELL_THICKNESS_M = 0.008 +# Inner faces of racks side panes are stepped back by this clearance, so they are +# not coplanar with equipment sides. +RACK_PANE_CLEARANCE_M = 0.001 +RACK_ROUGHNESS = 0.85 +# Equipment depth is not defined in database. Equipment are represented as deep +# as the rack minus this inset at the rear. +EQUIPMENT_REAR_INSET_M = 0.1 +EQUIPMENT_ROUGHNESS = 1.0 +# Ghost equipment opacity, when ghost color is fully opaque. +GHOST_ALPHA = 0.4 +COLLISION_COLOR = (1.0, 0.0, 0.0, 1.0) + +EQUIPMENT_CATEGORIES = ( + ("nodes", "node"), + ("storage", "storage"), + ("network", "network"), + ("misc", "misc"), +) + +ASSETS_DIR = Path(__file__).parent / "assets" + +# glTF constants +GLTF_FLOAT = 5126 +GLTF_UNSIGNED_SHORT = 5123 +GLTF_ARRAY_BUFFER = 34962 +GLTF_ELEMENT_ARRAY_BUFFER = 34963 +GLTF_MODE_LINES = 1 +GLTF_LINEAR = 9729 +GLTF_LINEAR_MIPMAP_LINEAR = 9987 +GLTF_REPEAT = 10497 + +GLB_MAGIC = 0x46546C67 # "glTF" +GLB_VERSION = 2 +GLB_CHUNK_JSON = 0x4E4F534A # "JSON" +GLB_CHUNK_BIN = 0x004E4942 # "BIN\0" + +# Unit cube centered on origin, with flat normals: 4 vertices per face, 6 faces in +# order +X, -X, +Y, -Y, +Z, -Z. +_CUBE_FACES = ( + ((1, 0, 0), ((1, -1, -1), (1, 1, -1), (1, 1, 1), (1, -1, 1))), + ((-1, 0, 0), ((-1, -1, 1), (-1, 1, 1), (-1, 1, -1), (-1, -1, -1))), + ((0, 1, 0), ((-1, 1, -1), (-1, 1, 1), (1, 1, 1), (1, 1, -1))), + ((0, -1, 0), ((-1, -1, 1), (-1, -1, -1), (1, -1, -1), (1, -1, 1))), + ((0, 0, 1), ((1, -1, 1), (1, 1, 1), (-1, 1, 1), (-1, -1, 1))), + ((0, 0, -1), ((-1, -1, -1), (-1, 1, -1), (1, 1, -1), (1, -1, -1))), +) +CUBE_POSITIONS = [ + tuple(c * 0.5 for c in vertex) for _, vertices in _CUBE_FACES for vertex in vertices +] +CUBE_NORMALS = [normal for normal, vertices in _CUBE_FACES for _ in vertices] +# Texture coordinates of the top (+Y) face are mapped on the X and Z axis, with +# (0, 0) on the (-X, -Z) corner, so floor textures are aligned on rooms origin. +# Other faces are not textured. +CUBE_UVS = [(x + 0.5, z + 0.5) for x, _, z in CUBE_POSITIONS] +CUBE_TOP_INDICES = [8, 9, 10, 8, 10, 11] +CUBE_INDICES = [ + index + for face in range(6) + for index in ( + 4 * face, + 4 * face + 1, + 4 * face + 2, + 4 * face, + 4 * face + 2, + 4 * face + 3, + ) +] +CUBE_SHELL_INDICES = [index for index in CUBE_INDICES if index not in range(8, 12)] +# Unit cube corners and edges, to outline equipment. +EDGES_POSITIONS = [ + (x, y, z) for z in (-0.5, 0.5) for y in (-0.5, 0.5) for x in (-0.5, 0.5) +] +EDGES_INDICES = [ + *(0, 1, 2, 3, 4, 5, 6, 7), # along X + *(0, 2, 1, 3, 4, 6, 5, 7), # along Y + *(0, 4, 1, 5, 2, 6, 3, 7), # along Z +] + + +def _m(value_mm: float) -> float: + """Convert value in millimeters to meters.""" + return value_mm * MM_TO_M + + +def _linear(color): + """Convert sRGB color with alpha channel, as defined in drawing parameters, into + linear RGB as expected in glTF materials base color factors.""" + + def channel(value): + if value <= 0.04045: + return value / 12.92 + return ((value + 0.055) / 1.055) ** 2.4 + + return tuple(round(channel(value), 6) for value in color[:3]) + (color[3],) + + +def _y_rotation(degrees: float): + """Return quaternion of a rotation around the Y axis by the given angle in + degrees, clockwise when viewed from above (ie. from +Y).""" + half = math.radians(-degrees) / 2 + return [0.0, math.sin(half), 0.0, math.cos(half)] + + +def _round(value): + """Recursively round floats in value to micrometers, to keep glTF JSON compact + and free of floating point noise.""" + if isinstance(value, float): + return round(value, 6) + 0.0 # + 0.0 turns -0.0 into 0.0 + if isinstance(value, list): + return [_round(item) for item in value] + if isinstance(value, dict): + return {key: _round(item) for key, item in value.items()} + return value + + +# The lru_cache decorator memoizes the function: the result of the first call with +# a given argument is stored and returned directly on subsequent calls with the +# same argument. With maxsize=None, the cache is unbounded. It is used here to read +# floor textures files from disk only once per process, as they are embedded in +# every generated glTF file (ie. on every request in REST API). The equivalent +# functools.cache decorator is not used as it requires Python >= 3.9. +@lru_cache(maxsize=None) +def _asset(name: str) -> bytes: + return (ASSETS_DIR / name).read_bytes() + + +class GltfBuilder: + """Assemble a glTF 2.0 document with a single binary buffer, serialized either in + a glTF JSON file with the buffer embedded as data URI or in a GLB binary file.""" + + def __init__(self): + self.blob = bytearray() + self.buffer_views = [] + self.accessors = [] + self.images = [] + self.samplers = [] + self.textures = [] + self.materials = [] + self.meshes = [] + self.nodes = [] + self.roots = [] + self.extensions_used = set() + self._materials_cache = {} + + def add_buffer_view(self, data: bytes, target: Optional[int] = None) -> int: + # Align all buffer views on 4 bytes boundaries, as required for vertex + # attributes. + self.blob.extend(b"\x00" * (-len(self.blob) % 4)) + buffer_view = { + "buffer": 0, + "byteOffset": len(self.blob), + "byteLength": len(data), + } + if target is not None: + buffer_view["target"] = target + self.blob.extend(data) + self.buffer_views.append(buffer_view) + return len(self.buffer_views) - 1 + + def add_vectors(self, vectors, bounds: bool = False) -> int: + """Add an accessor of float vectors (VEC2 or VEC3) and return its index. + When bounds is True, min and max values are set on the accessor, as required + for vertex positions.""" + size = len(vectors[0]) + data = b"".join(struct.pack(f"<{size}f", *vector) for vector in vectors) + accessor = { + "bufferView": self.add_buffer_view(data, GLTF_ARRAY_BUFFER), + "componentType": GLTF_FLOAT, + "count": len(vectors), + "type": f"VEC{size}", + } + if bounds: + accessor["min"] = [float(min(v[i] for v in vectors)) for i in range(size)] + accessor["max"] = [float(max(v[i] for v in vectors)) for i in range(size)] + self.accessors.append(accessor) + return len(self.accessors) - 1 + + def add_indices(self, indices) -> int: + """Add an accessor of vertex indices and return its index.""" + data = struct.pack(f"<{len(indices)}H", *indices) + self.accessors.append( + { + "bufferView": self.add_buffer_view(data, GLTF_ELEMENT_ARRAY_BUFFER), + "componentType": GLTF_UNSIGNED_SHORT, + "count": len(indices), + "type": "SCALAR", + } + ) + return len(self.accessors) - 1 + + def add_texture(self, png: bytes) -> int: + """Add repeated texture with the given PNG image and return its index.""" + if not self.samplers: + self.samplers.append( + { + "magFilter": GLTF_LINEAR, + "minFilter": GLTF_LINEAR_MIPMAP_LINEAR, + "wrapS": GLTF_REPEAT, + "wrapT": GLTF_REPEAT, + } + ) + self.images.append( + {"bufferView": self.add_buffer_view(png), "mimeType": "image/png"} + ) + self.textures.append({"sampler": 0, "source": len(self.images) - 1}) + return len(self.textures) - 1 + + def add_material( + self, + name: str, + color, + roughness: float, + texture: Optional[int] = None, + texture_scale=None, + ) -> int: + """Add PBR material with given linear RGBA color and return its index. + Identical materials are added only once.""" + key = (name, tuple(color), roughness, texture, texture_scale) + if key in self._materials_cache: + return self._materials_cache[key] + pbr = { + "baseColorFactor": list(color), + "metallicFactor": 0.0, + "roughnessFactor": roughness, + } + if texture is not None: + pbr["baseColorTexture"] = {"index": texture} + if texture_scale is not None: + pbr["baseColorTexture"]["extensions"] = { + "KHR_texture_transform": {"scale": list(texture_scale)} + } + self.extensions_used.add("KHR_texture_transform") + material = {"name": name, "pbrMetallicRoughness": pbr, "doubleSided": True} + if color[3] < 1.0: + material["alphaMode"] = "BLEND" + self.materials.append(material) + self._materials_cache[key] = len(self.materials) - 1 + return self._materials_cache[key] + + def add_mesh(self, name: str, primitives) -> int: + self.meshes.append({"name": name, "primitives": primitives}) + return len(self.meshes) - 1 + + def add_node(self, node: dict, parent: Optional[int] = None) -> int: + """Add node, as a child of parent node or as a scene root if parent is None, + and return its index.""" + self.nodes.append(_round(node)) + index = len(self.nodes) - 1 + if parent is None: + self.roots.append(index) + else: + self.nodes[parent].setdefault("children", []).append(index) + return index + + def _document(self, buffer: dict) -> dict: + document = { + "asset": {"version": "2.0", "generator": "RacksDB"}, + "scene": 0, + "scenes": [{"nodes": self.roots}], + "nodes": self.nodes, + "meshes": self.meshes, + "materials": self.materials, + "accessors": self.accessors, + "bufferViews": self.buffer_views, + "buffers": [buffer], + } + if self.textures: + document["samplers"] = self.samplers + document["images"] = self.images + document["textures"] = self.textures + if self.extensions_used: + document["extensionsUsed"] = sorted(self.extensions_used) + return document + + def gltf(self) -> bytes: + """Return glTF JSON document with binary buffer embedded as data URI.""" + uri = "data:application/octet-stream;base64," + base64.b64encode( + self.blob + ).decode("ascii") + return json.dumps( + self._document({"byteLength": len(self.blob), "uri": uri}), + separators=(",", ":"), + ).encode() + + def glb(self) -> bytes: + """Return GLB binary file, with the JSON chunk padded with spaces and the + binary chunk padded with zeros to 4 bytes boundaries.""" + content = json.dumps( + self._document({"byteLength": len(self.blob)}), separators=(",", ":") + ).encode() + content += b" " * (-len(content) % 4) + blob = bytes(self.blob) + b"\x00" * (-len(self.blob) % 4) + length = 12 + 8 + len(content) + 8 + len(blob) + return b"".join( + [ + struct.pack(" int: + """Return index of unit cube mesh with the given sRGB color, added once.""" + key = ("box", name, tuple(color)) + if key not in self._meshes_cache: + material = self.builder.add_material(name, _linear(color), roughness) + self._meshes_cache[key] = self.builder.add_mesh( + name, + [ + { + "attributes": self._cube, + "indices": self._cube_indices, + "material": material, + } + ], + ) + return self._meshes_cache[key] + + def _edges_mesh(self, name: str, color) -> int: + """Return index of unit cube edges mesh with the given sRGB color, added + once.""" + key = ("edges", name, tuple(color)) + if key not in self._meshes_cache: + material = self.builder.add_material( + name, _linear(color), EQUIPMENT_ROUGHNESS + ) + self._meshes_cache[key] = self.builder.add_mesh( + name, + [ + { + "attributes": self._edges, + "indices": self._edges_indices, + "mode": GLTF_MODE_LINES, + "material": material, + } + ], + ) + return self._meshes_cache[key] + + def _add_geometry(self, parent, mesh, part, scale, translation=None): + node = {"mesh": mesh, "scale": [float(value) for value in scale]} + if translation is not None: + node["translation"] = [float(value) for value in translation] + node["extras"] = {"part": part} + self.builder.add_node(node, parent) + + # Rooms and floor + + def _rooms(self): + """Return the list of rooms of the represented racks, in the order of the + infrastructure layout.""" + rooms = {} + for rack in self.racks: + rooms.setdefault((rack.datacenter.name, rack.room.name), rack.room) + return list(rooms.values()) + + def _room_racks(self, room): + """Return the list of represented racks in the given room.""" + return [ + rack + for rack in self.racks + if rack.room.name == room.name + and rack.datacenter.name == room.datacenter.name + ] + + def _add_floor(self, parent, room): + width = _m(room.dimensions.width) + depth = _m(room.dimensions.depth) + tile = _m(FLOOR_TILE_MM) + material = self.builder.add_material( + "floor", + (1.0, 1.0, 1.0, 1.0), + FLOOR_ROUGHNESS, + texture=self._floor_texture, + texture_scale=(round(width / tile, 6), round(depth / tile, 6)), + ) + mesh = self.builder.add_mesh( + "floor", + [ + { + "attributes": self._textured_cube, + "indices": self._cube_top_indices, + "material": material, + }, + { + "attributes": self._cube, + "indices": self._cube_shell_indices, + "material": self._floor_side_material, + }, + ], + ) + self._add_geometry( + parent, + mesh, + "floor", + (width, FLOOR_THICKNESS_M, depth), + (width / 2, -FLOOR_CLEARANCE_M - FLOOR_THICKNESS_M / 2, depth / 2), + ) + + def _rack_polygon(self, rack, z_min: float, z_max: float): + """Return the list of the 4 corners (x, z) in mm in room coordinates of the + area covered by the rack width between z_min and z_max, in mm in the racks + row depth axis.""" + offset = sum(self._rack_preceding_widths(rack)) + local = [ + (offset, z_min), + (offset + rack.type.width, z_min), + (offset + rack.type.width, z_max), + (offset, z_max), + ] + angle = math.radians(rack.row.position.rotation) + cos = math.cos(angle) + sin = math.sin(angle) + return [ + ( + rack.row.position.width + x * cos - z * sin, + rack.row.position.depth + x * sin + z * cos, + ) + for x, z in local + ] + + def _rack_footprint(self, rack): + """Return the rack footprint polygon in room coordinates, in mm.""" + return self._rack_polygon(rack, 0, rack.type.depth) + + def _rack_aisle(self, rack): + """Return the polygon in room coordinates, in mm, of the tile-deep area in + front of the rack door.""" + if rack.row.reversed: + return self._rack_polygon(rack, -FLOOR_TILE_MM, 0) + return self._rack_polygon( + rack, rack.type.depth, rack.type.depth + FLOOR_TILE_MM + ) + + def _perforated_tiles(self, room, racks): + """Return the sorted list of floor tiles (ix, iz) indexes that are perforated + in the room. Perforated tiles are in front of racks doors, every other tile in + checkerboard pattern, except tiles inside racks footprints or outside of the + room.""" + footprints = [self._rack_footprint(rack) for rack in racks] + max_ix = room.dimensions.width // FLOOR_TILE_MM + max_iz = room.dimensions.depth // FLOOR_TILE_MM + tiles = set() + for rack in racks: + aisle = self._rack_aisle(rack) + xs = [point[0] for point in aisle] + zs = [point[1] for point in aisle] + for ix in range( + max(math.floor(min(xs) / FLOOR_TILE_MM), 0), + min(math.ceil(max(xs) / FLOOR_TILE_MM), max_ix), + ): + for iz in range( + max(math.floor(min(zs) / FLOOR_TILE_MM), 0), + min(math.ceil(max(zs) / FLOOR_TILE_MM), max_iz), + ): + if (ix + iz) % 2 or (ix, iz) in tiles: + continue + x0, z0 = ix * FLOOR_TILE_MM, iz * FLOOR_TILE_MM + x1, z1 = x0 + FLOOR_TILE_MM, z0 + FLOOR_TILE_MM + if not polygons_overlap( + [(x0, z0), (x1, z0), (x1, z1), (x0, z1)], aisle + ): + continue + # Small square around tile center, to check it is not under a + # rack. + cx, cz = (x0 + x1) / 2, (z0 + z1) / 2 + center = [ + (cx - 1, cz - 1), + (cx + 1, cz - 1), + (cx + 1, cz + 1), + (cx - 1, cz + 1), + ] + if any(polygons_overlap(center, fp) for fp in footprints): + continue + tiles.add((ix, iz)) + return sorted(tiles) + + def _add_perforated_tiles(self, parent, room, racks): + tile = _m(FLOOR_TILE_MM) + for ix, iz in self._perforated_tiles(room, racks): + self._add_geometry( + parent, + self._perforated_tile_mesh, + "perforated-tile", + (tile, FLOOR_THICKNESS_M, tile), + ( + (ix + 0.5) * tile, + -FLOOR_CLEARANCE_M + PERFORATED_TILE_LIFT_M - FLOOR_THICKNESS_M / 2, + (iz + 0.5) * tile, + ), + ) + + # Racks + + def _add_rack(self, parent, rack): + width = _m(rack.type.width) + height = _m(rack.type.height) + depth = _m(rack.type.depth) + offset = _m(sum(self._rack_preceding_widths(rack))) + in_infrastructure = rack.name in self._infrastructure_racks + node = { + "name": rack.name, + "translation": [offset + width / 2, height / 2, depth / 2], + "extras": { + "category": "rack", + "type": rack.type.id, + "tags": list(getattr(rack, "tags", [])), + "infrastructure": in_infrastructure, + "size": [width, height, depth], + }, + } + # Racks doors face +Z, rotate racks of reversed rows by 180° around Y. + if rack.row.reversed: + node["rotation"] = [0.0, 1.0, 0.0, 0.0] + index = self.builder.add_node(node, parent) + + mesh = self._box_mesh( + "rack", self._find_rack_colorset(rack).frame, RACK_ROUGHNESS + ) + pane = min(_m(self.parameters.rack.pane_width), width / 2) + pane -= min(RACK_PANE_CLEARANCE_M, pane / 2) + shell = min(RACK_SHELL_THICKNESS_M, height / 2, depth / 2) + inner_width = width - 2 * pane + panels = [ + ("left", (pane, height, depth), (-width / 2 + pane / 2, 0, 0)), + ("right", (pane, height, depth), (width / 2 - pane / 2, 0, 0)), + ("top", (inner_width, shell, depth), (0, height / 2 - shell / 2, 0)), + ("bottom", (inner_width, shell, depth), (0, -height / 2 + shell / 2, 0)), + ] + # Rack is not in the infrastructure, it is represented because other_racks + # is enabled. Close its door, as in 2D infrastructure diagrams. + if not in_infrastructure: + panels.append( + ( + "door", + (inner_width, height - 2 * shell, shell), + (0, 0, depth / 2 - shell / 2), + ) + ) + for part, scale, translation in panels: + self._add_geometry(index, mesh, part, scale, translation) + + if in_infrastructure: + for part in self.infrastructure.layout: + if part.rack.name != rack.name: + continue + for attribute, category in EQUIPMENT_CATEGORIES: + for equipment in getattr(part, attribute): + self._add_equipment(index, rack, equipment, category) + + # Equipment + + def _add_equipment(self, parent, rack, equipment, category): + selected = self._equipment_selected(equipment) + if not selected and not self.parameters.infrastructure.ghost_unselected: + return + u_height = self.parameters.rack.u_height + pane = self.parameters.rack.pane_width + rack_width = rack.type.width + width = equipment.type.width * (rack_width - 2 * pane) + height = equipment.type.height * u_height + left = pane + equipment.position.width * width + bottom = equipment.position.height * u_height + depth = max(_m(rack.type.depth) - EQUIPMENT_REAR_INSET_M, 0.01) + size = [_m(width), _m(height), depth] + colliding = equipment.name in self._colliding_equipment + + colorset = self._find_equipment_colorset(equipment) + if selected: + fill = colorset.background + border = colorset.border + else: + # Fully opaque ghost color would render solid ghosts. + fill = border = colorset.ghost + if fill[3] == 1.0: + fill = border = fill[:3] + (GHOST_ALPHA,) + if colliding and self.parameters.infrastructure.collisions: + border = COLLISION_COLOR + + index = self.builder.add_node( + { + "name": equipment.name, + # Equipment front face is aligned with the rack door, at +Z. + "translation": [ + _m(left + width / 2 - rack_width / 2), + _m(bottom + height / 2) - _m(rack.type.height) / 2, + (_m(rack.type.depth) - depth) / 2, + ], + "extras": { + "category": category, + "type": equipment.type.id, + "tags": list(equipment.tags), + "rack": rack.name, + "slot": { + "height": equipment.position.height, + "width": equipment.position.width, + }, + "selected": selected, + "colliding": colliding, + "size": size, + }, + }, + parent, + ) + self._add_geometry( + index, self._box_mesh("equipment", fill, EQUIPMENT_ROUGHNESS), "body", size + ) + self._add_geometry( + index, self._edges_mesh("equipment-edges", border), "edges", size + ) + + # Main + + def draw(self): + self.racks, _ = self._select_infrastructure_racks(self.infrastructure) + self._infrastructure_racks = { + part.rack.name for part in self.infrastructure.layout + } + self._colliding_equipment = colliding_equipment(self.racks) + + self.builder = GltfBuilder() + self._init_geometry() + + room_offset = 0.0 + for room in self._rooms(): + room_racks = self._room_racks(room) + room_index = self.builder.add_node( + { + "name": room.name, + "translation": [room_offset, 0.0, 0.0], + "extras": { + "category": "room", + "datacenter": room.datacenter.name, + "size": [ + _m(room.dimensions.width), + 0.0, + _m(room.dimensions.depth), + ], + }, + } + ) + room_offset += _m(room.dimensions.width) + ROOMS_GAP_M + self._add_floor(room_index, room) + self._add_perforated_tiles(room_index, room, room_racks) + + rows = {} + for rack in room_racks: + rows.setdefault(rack.row.name, rack.row) + for row in rows.values(): + row_node = { + "name": row.name, + "translation": [ + _m(row.position.width), + 0.0, + _m(row.position.depth), + ], + "extras": {"category": "row", "reversed": bool(row.reversed)}, + } + if row.position.rotation: + row_node["rotation"] = _y_rotation(row.position.rotation) + row_index = self.builder.add_node(row_node, room_index) + for rack in room_racks: + if rack.row.name == row.name: + self._add_rack(row_index, rack) + + if self.output_format == "glb": + content = self.builder.glb() + else: + content = self.builder.gltf() + if hasattr(self.file, "write"): + self.file.write(content) + else: + with open(self.file, "wb") as fh: + fh.write(content) From d03387a418db26e9ccf208d85e41b8d3a7e3ed9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Palancher?= Date: Thu, 12 Nov 2026 17:14:15 +0100 Subject: [PATCH 07/16] feat: support gltf and glb formats in draw CLI and REST API Add shared drawer_class() factory to select drawer according to entity, format and drawing parameters, used by both racksdb CLI and REST API. Assisted-by: Claude Opus 5.5 --- CHANGELOG.md | 3 ++ racksdb/drawers/__init__.py | 29 ++++++++++++++++- racksdb/exec.py | 65 +++++++++++++++++-------------------- racksdb/views.py | 19 ++++++++--- racksdb/web/app.py | 41 ++++++++--------------- 5 files changed, 89 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2d89ef9..197748fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,9 @@ and this project adheres to rooms on raised floors with perforated tiles in cold aisles. Nodes of the scene are named after database objects with metadata in their extras (#164). +- cli: Support `gltf` and `glb` formats in `draw infrastructure` command (#164). +- web: Support `gltf` and `glb` formats in infrastructures draw endpoint + (#164). - pkgs: Install floor textures of 3D representations with the Python package. - docs: - Mention support of Fedora 44. diff --git a/racksdb/drawers/__init__.py b/racksdb/drawers/__init__.py index 9c040afe..ef1d81e3 100644 --- a/racksdb/drawers/__init__.py +++ b/racksdb/drawers/__init__.py @@ -7,11 +7,38 @@ from .room import RoomDrawer from .infrastructure import InfrastructureDrawer from .axonometric_infrastructure import AxonometricInfrastructureDrawer -from .gltf import GltfInfrastructureDrawer +from .gltf import GltfInfrastructureDrawer, FORMATS as GLTF_FORMATS +from ..errors import RacksDBDrawingError + + +def drawer_class(entity, output_format, parameters, coordinates=False): + """Return the drawer class to represent the given entity type in the given output + format with the drawing parameters. Raise RacksDBDrawingError if this combination + is not supported.""" + if output_format in GLTF_FORMATS: + if entity != "infrastructure": + raise RacksDBDrawingError( + f"Format {output_format} is only supported for infrastructures" + ) + if coordinates: + raise RacksDBDrawingError( + f"Coordinates are not supported with {output_format} format" + ) + return GltfInfrastructureDrawer + if entity == "infrastructure": + if parameters.axonometric.enabled: + return AxonometricInfrastructureDrawer + return InfrastructureDrawer + if entity == "room": + return RoomDrawer + raise RacksDBDrawingError(f"Unable to draw entity {entity}") + __all__ = [ "RoomDrawer", "InfrastructureDrawer", "AxonometricInfrastructureDrawer", "GltfInfrastructureDrawer", + "GLTF_FORMATS", + "drawer_class", ] diff --git a/racksdb/exec.py b/racksdb/exec.py index 15bbc01b..771afe4f 100644 --- a/racksdb/exec.py +++ b/racksdb/exec.py @@ -23,7 +23,7 @@ from .generic.dumpers import DBDumperFactory, SchemaDumperFactory from . import RacksDB from .env import RacksDBEnv, env_or_default -from .drawers import InfrastructureDrawer, AxonometricInfrastructureDrawer, RoomDrawer +from .drawers import drawer_class from .drawers.parameters import DrawingParameters from .errors import RacksDBError from .views import RacksDBViews @@ -257,16 +257,6 @@ def _dump_view(self): def _run_draw(self): file = f"{self.args.name}.{self.args.format}" - # Handle coordinates opts - if self.args.coordinates is False: - coordinates_file = None - elif self.args.coordinates is True: - coordinates_file = f"coordinates.{self.args.coordinates_format}" - else: - coordinates_file = self.args.coordinates - coordinates_fh = ( - open(coordinates_file, "w+") if coordinates_file is not None else None - ) try: if self.args.parameters is None: db_loader = DBDictsLoader() @@ -282,30 +272,35 @@ def _run_draw(self): except DBFormatError as err: logger.critical("Unable to load drawing parameters: %s", str(err)) sys.exit(1) - if self.args.entity == "infrastructure": - if parameters.axonometric.enabled: - draw_infra_class = AxonometricInfrastructureDrawer - else: - draw_infra_class = InfrastructureDrawer - drawer = draw_infra_class( - self.db, - self.args.name, - file, - self.args.format, - parameters, - coordinates_fh, - self.args.coordinates_format, - ) - elif self.args.entity == "room": - drawer = RoomDrawer( - self.db, - self.args.name, - file, - self.args.format, - parameters, - coordinates_fh, - self.args.coordinates_format, - ) + + # Select drawer class before opening coordinates file, to avoid leaving an + # empty coordinates file when the combination is not supported. + draw_class = drawer_class( + self.args.entity, + self.args.format, + parameters, + coordinates=self.args.coordinates is not False, + ) + + # Handle coordinates opts + if self.args.coordinates is False: + coordinates_file = None + elif self.args.coordinates is True: + coordinates_file = f"coordinates.{self.args.coordinates_format}" + else: + coordinates_file = self.args.coordinates + coordinates_fh = ( + open(coordinates_file, "w+") if coordinates_file is not None else None + ) + drawer = draw_class( + self.db, + self.args.name, + file, + self.args.format, + parameters, + coordinates_fh, + self.args.coordinates_format, + ) drawer.draw() logger.info("Generated image file %s", file) if self.args.coordinates: diff --git a/racksdb/views.py b/racksdb/views.py index 991ca25e..5a733d69 100644 --- a/racksdb/views.py +++ b/racksdb/views.py @@ -116,7 +116,10 @@ class RacksDBViews(DBViewSet): DBAction( name="draw", path="/draw//.", - description="Draw an entity", + description=( + "Draw an entity. Infrastructures can also be represented in 3D with " + "glTF 2.0 formats (gltf and glb)." + ), methods=["POST", "GET"], parameters=[ DBActionParameter( @@ -128,8 +131,11 @@ class RacksDBViews(DBViewSet): DBActionParameter("name", description="Name of entity", required=True), DBActionParameter( "format", - description="Format of the generated image", - choices=["png", "svg", "pdf"], + description=( + "Format of the generated image. Formats gltf and glb are " + "supported for infrastructures only." + ), + choices=["png", "svg", "pdf", "gltf", "glb"], default="png", ), DBActionParameter( @@ -180,14 +186,17 @@ class RacksDBViews(DBViewSet): DBActionResponse("image/png", binary=True), DBActionResponse("image/svg+xml"), DBActionResponse("application/pdf", binary=True), + DBActionResponse("model/gltf+json"), + DBActionResponse("model/gltf-binary", binary=True), DBActionResponse("multipart/form-data", binary=True), ], errors=[ DBActionError( 400, "Unsupported entity, unable to load drawing parameters, " - "unsupported image format, unsupported coordinates format " - "or unable to load requested entity in database.", + "unsupported image format, unsupported coordinates format, " + "coordinates requested with 3D format or unable to load " + "requested entity in database.", ), DBActionError(415, "Unsupported drawing parameters format."), DBActionError(500, "Unable to load drawing parameters schema."), diff --git a/racksdb/web/app.py b/racksdb/web/app.py index 121ec4a3..326580c2 100644 --- a/racksdb/web/app.py +++ b/racksdb/web/app.py @@ -26,7 +26,7 @@ from ..generic.dumpers import DBDumperFactory, SchemaDumperFactory from ..generic.schema import Schema, SchemaFileLoader, SchemaDefinedTypeLoader from ..generic.errors import DBSchemaError, DBFormatError -from ..drawers import InfrastructureDrawer, AxonometricInfrastructureDrawer, RoomDrawer +from ..drawers import drawer_class from ..drawers.parameters import DrawingParameters logger = logging.getLogger(__name__) @@ -100,6 +100,8 @@ class RacksDBWebBlueprint(Blueprint): "png": "image/png", "svg": "image/svg+xml", "pdf": "application/pdf", + "gltf": "model/gltf+json", + "glb": "model/gltf-binary", } def __init__( @@ -238,32 +240,17 @@ def _draw(self, entity, name, format): coordinates_fh = io.StringIO() if with_coordinates else None try: - if entity == "infrastructure": - if parameters.axonometric.enabled: - draw_infra_class = AxonometricInfrastructureDrawer - else: - draw_infra_class = InfrastructureDrawer - drawer = draw_infra_class( - self.db, - name, - file, - format, - parameters, - coordinates_fh, - coordinates_format, - ) - elif entity == "room": - drawer = RoomDrawer( - self.db, - name, - file, - format, - parameters, - coordinates_fh, - coordinates_format, - ) - else: - abort(400, f"Unable to draw entity {entity}") + drawer = drawer_class( + entity, format, parameters, coordinates=with_coordinates + )( + self.db, + name, + file, + format, + parameters, + coordinates_fh, + coordinates_format, + ) drawer.draw() except RacksDBError as err: abort(400, str(err)) From e46e6a631e9cf1faae7bb342d2aeb6eafc00bcbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Palancher?= Date: Thu, 12 Nov 2026 17:14:15 +0100 Subject: [PATCH 08/16] docs: update OpenAPI description Add gltf and glb drawing formats. Assisted-by: Claude Opus 5.5 --- docs/modules/usage/attachments/openapi.yml | 38 +++++++++++++++++----- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/docs/modules/usage/attachments/openapi.yml b/docs/modules/usage/attachments/openapi.yml index 204146f1..162bdb6c 100644 --- a/docs/modules/usage/attachments/openapi.yml +++ b/docs/modules/usage/attachments/openapi.yml @@ -1143,7 +1143,8 @@ paths: description: successful operation /v0.7.0/draw//.: get: - description: Draw an entity + description: Draw an entity. Infrastructures can also be represented in 3D with + glTF 2.0 formats (gltf and glb). parameters: - description: Type of entity to draw in: path @@ -1160,7 +1161,8 @@ paths: required: true schema: type: string - - description: Format of the generated image + - description: Format of the generated image. Formats gltf and glb are supported + for infrastructures only. in: path name: format required: true @@ -1170,6 +1172,8 @@ paths: - png - svg - pdf + - gltf + - glb type: string - description: Control if entities are represented perfectly aligned on pixels with concession on relative dimensions correctess. This parameter has no @@ -1485,6 +1489,13 @@ paths: image/svg+xml: schema: type: string + model/gltf+json: + schema: + type: string + model/gltf-binary: + schema: + format: binary + type: string multipart/form-data: schema: format: binary @@ -1496,8 +1507,8 @@ paths: schema: $ref: '#/components/schemas/Error' description: Unsupported entity, unable to load drawing parameters, unsupported - image format, unsupported coordinates format or unable to load requested - entity in database. + image format, unsupported coordinates format, coordinates requested with + 3D format or unable to load requested entity in database. '415': content: application/json: @@ -1511,7 +1522,8 @@ paths: $ref: '#/components/schemas/Error' description: Unable to load drawing parameters schema. post: - description: Draw an entity + description: Draw an entity. Infrastructures can also be represented in 3D with + glTF 2.0 formats (gltf and glb). parameters: - description: Type of entity to draw in: path @@ -1528,7 +1540,8 @@ paths: required: true schema: type: string - - description: Format of the generated image + - description: Format of the generated image. Formats gltf and glb are supported + for infrastructures only. in: path name: format required: true @@ -1538,6 +1551,8 @@ paths: - png - svg - pdf + - gltf + - glb type: string - allowEmptyValue: true description: Dump equipments and racks coordinates in diagrams. When this @@ -1579,6 +1594,13 @@ paths: image/svg+xml: schema: type: string + model/gltf+json: + schema: + type: string + model/gltf-binary: + schema: + format: binary + type: string multipart/form-data: schema: format: binary @@ -1590,8 +1612,8 @@ paths: schema: $ref: '#/components/schemas/Error' description: Unsupported entity, unable to load drawing parameters, unsupported - image format, unsupported coordinates format or unable to load requested - entity in database. + image format, unsupported coordinates format, coordinates requested with + 3D format or unable to load requested entity in database. '415': content: application/json: From 7b7a0777c8ae78aa661f939c7b0456b18b99b999 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Palancher?= Date: Thu, 12 Nov 2026 18:40:15 +0100 Subject: [PATCH 09/16] tests: cover 3D infrastructures in glTF formats Cover glTF drawer, drawer class selection, CLI and REST API with gltf and glb formats. Assisted-by: Claude Opus 5.5 --- racksdb/tests/drawers/test_gltf.py | 621 ++++++++++++++++++++++++++++ racksdb/tests/test_exec.py | 60 +++ racksdb/tests/web/test_blueprint.py | 56 +++ 3 files changed, 737 insertions(+) create mode 100644 racksdb/tests/drawers/test_gltf.py diff --git a/racksdb/tests/drawers/test_gltf.py b/racksdb/tests/drawers/test_gltf.py new file mode 100644 index 00000000..ca21ee67 --- /dev/null +++ b/racksdb/tests/drawers/test_gltf.py @@ -0,0 +1,621 @@ +# Copyright (c) 2026 Rackslab +# +# This file is part of RacksDB. +# +# SPDX-License-Identifier: MIT + +import base64 +from itertools import chain +import io +import json +import struct +import unittest + +from racksdb import RacksDB +from racksdb.drawers import ( + drawer_class, + AxonometricInfrastructureDrawer, + GltfInfrastructureDrawer, + InfrastructureDrawer, + RoomDrawer, +) +from racksdb.drawers.gltf import ASSETS_DIR, _linear, GLB_MAGIC +from racksdb.drawers.parameters import DrawingParameters +from racksdb.drawers.room import RoomDrawer as _RoomDrawer +from racksdb.generic.db import DBDictsLoader +from racksdb.errors import RacksDBDrawingError + +from ..lib.common import drawing_schema_path, schema_path, db_path + + +def parse_glb(content: bytes): + """Return the tuple (document, binary chunk) of GLB content.""" + magic, version, length = struct.unpack_from(" Date: Thu, 12 Nov 2026 17:24:47 +0100 Subject: [PATCH 10/16] feat(ui): add 3D representation of infrastructures Add 2D/3D toggle in infrastructure details view. The 3D view loads the infrastructure glb representation in an interactive three.js viewer, loaded dynamically, with orbit controls, equipment highlighting and label on hover, view reset and fullscreen mode. Assisted-by: Claude Opus 5.5 --- CHANGELOG.md | 7 +- frontend/package-lock.json | 155 +++--- frontend/package.json | 2 + .../src/components/InfrastructureScene3D.vue | 166 +++++++ frontend/src/composables/RacksDBAPI.ts | 22 +- frontend/src/lib/scene3d.ts | 465 ++++++++++++++++++ .../src/views/InfrastructureDetailsView.vue | 34 +- .../components/InfrastructureScene3D.spec.ts | 142 ++++++ frontend/tests/composables/RacksDBAPI.spec.ts | 31 ++ frontend/tests/lib/scene3d.spec.ts | 196 ++++++++ .../views/InfrastructureDetailsView.spec.ts | 39 ++ 11 files changed, 1163 insertions(+), 96 deletions(-) create mode 100644 frontend/src/components/InfrastructureScene3D.vue create mode 100644 frontend/src/lib/scene3d.ts create mode 100644 frontend/tests/components/InfrastructureScene3D.spec.ts create mode 100644 frontend/tests/composables/RacksDBAPI.spec.ts create mode 100644 frontend/tests/lib/scene3d.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 197748fb..27600144 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,12 @@ and this project adheres to - cli: Support `gltf` and `glb` formats in `draw infrastructure` command (#164). - web: Support `gltf` and `glb` formats in infrastructures draw endpoint (#164). -- pkgs: Install floor textures of 3D representations with the Python package. +- ui: Add 2D/3D toggle in infrastructure details page, with an interactive 3D + viewer of infrastructures based on three.js, with a turntable mode with the + camera orbiting automatically around the racks (#164). +- pkgs: + - Install floor textures of 3D representations with the Python package. + - Add dependency on three.js in web UI. - docs: - Mention support of Fedora 44. - Document `equipment` attribute of `RacksDBRack` objects in Python library diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 312a921e..0f7d5a27 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@heroicons/vue": "^2.1.1", "axios": "^1.6.2", "leaflet": "^1.9.4", + "three": "^0.186.1", "vue": "^3.4", "vue-router": "^4.2.4" }, @@ -22,6 +23,7 @@ "@types/jsdom": "^21.1.3", "@types/leaflet": "^1.9.8", "@types/node": "^18.17.17", + "@types/three": "^0.186.0", "@vitejs/plugin-vue": "^5.2.3", "@vue/eslint-config-prettier": "^8.0.0", "@vue/eslint-config-typescript": "^12.0.0", @@ -428,6 +430,13 @@ "node": ">=20.19.0" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz", @@ -981,18 +990,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", - "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", @@ -1435,17 +1432,12 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/eslint": { - "version": "8.44.7", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.7.tgz", - "integrity": "sha512-f5ORu2hcBbKei97U73mf+l9t4zTGl74IqZ0GQk4oVea/VS8tQZYkUveSYojk+frraAVYId0V2WC9O4PTNru2FQ==", + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } + "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", @@ -1498,11 +1490,40 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.186.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.186.0.tgz", + "integrity": "sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.3", + "meshoptimizer": "~1.1.1" + } + }, "node_modules/@types/tough-cookie": { "version": "4.0.3", "dev": true, "license": "MIT" }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "6.7.4", "dev": true, @@ -2310,14 +2331,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/bundle-name": { "version": "3.0.0", "dev": true, @@ -3180,6 +3193,13 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "6.0.1", "dev": true, @@ -3749,17 +3769,6 @@ "dev": true, "license": "ISC" }, - "node_modules/jiti": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", - "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", - "dev": true, - "optional": true, - "peer": true, - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, "node_modules/js-beautify": { "version": "1.14.9", "dev": true, @@ -4025,6 +4034,13 @@ "node": ">= 8" } }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "dev": true, + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -5180,17 +5196,6 @@ "node": ">=8" } }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5199,18 +5204,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, "node_modules/spdx-correct": { "version": "3.2.0", "dev": true, @@ -5430,34 +5423,6 @@ "jiti": "bin/jiti.js" } }, - "node_modules/terser": { - "version": "5.24.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.24.0.tgz", - "integrity": "sha512-ZpGR4Hy3+wBEzVEnHvstMvqpD/nABNelQn/z2r0fjVWGQsN3bpOLzQlqDxmb4CDZnXq5lpjnQ+mHQLAOpfM5iw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/text-table": { "version": "0.2.0", "dev": true, @@ -5484,6 +5449,12 @@ "node": ">=0.8" } }, + "node_modules/three": { + "version": "0.186.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.186.1.tgz", + "integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 19fb8b95..a3dc6c08 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "@heroicons/vue": "^2.1.1", "axios": "^1.6.2", "leaflet": "^1.9.4", + "three": "^0.186.1", "vue": "^3.4", "vue-router": "^4.2.4" }, @@ -27,6 +28,7 @@ "@types/jsdom": "^21.1.3", "@types/leaflet": "^1.9.8", "@types/node": "^18.17.17", + "@types/three": "^0.186.0", "@vitejs/plugin-vue": "^5.2.3", "@vue/eslint-config-prettier": "^8.0.0", "@vue/eslint-config-typescript": "^12.0.0", diff --git a/frontend/src/components/InfrastructureScene3D.vue b/frontend/src/components/InfrastructureScene3D.vue new file mode 100644 index 00000000..2222705d --- /dev/null +++ b/frontend/src/components/InfrastructureScene3D.vue @@ -0,0 +1,166 @@ + + + + + diff --git a/frontend/src/composables/RacksDBAPI.ts b/frontend/src/composables/RacksDBAPI.ts index 42a75922..5a9080bd 100644 --- a/frontend/src/composables/RacksDBAPI.ts +++ b/frontend/src/composables/RacksDBAPI.ts @@ -221,5 +221,25 @@ export function useRacksDBAPI(http: AxiosInstance) { }) } - return { racksDBGet, datacenters, infrastructures, racks, infrastructureImageSvg, roomImageSvg } + async function infrastructureGlb(infrastructure: string): Promise { + try { + const response = await http.post(`/draw/infrastructure/${infrastructure}.glb`, undefined, { + responseType: 'arraybuffer' + }) + return response.data + } catch (error: any) { + console.error(`Error getting 3D representation of ${infrastructure}`, error) + throw error + } + } + + return { + racksDBGet, + datacenters, + infrastructures, + racks, + infrastructureImageSvg, + roomImageSvg, + infrastructureGlb + } } diff --git a/frontend/src/lib/scene3d.ts b/frontend/src/lib/scene3d.ts new file mode 100644 index 00000000..8c8058c3 --- /dev/null +++ b/frontend/src/lib/scene3d.ts @@ -0,0 +1,465 @@ +/* +Copyright (c) 2026 Rackslab + +This file is part of RacksDB. + +SPDX-License-Identifier: MIT +*/ + +/* Interactive 3D viewer of infrastructures glTF representations generated by + * RacksDB. This module imports three.js, it is meant to be loaded dynamically to keep + * three.js out of the main bundle. */ + +import { + AmbientLight, + Box3, + Color, + DirectionalLight, + HemisphereLight, + MathUtils, + Mesh, + MeshStandardMaterial, + Object3D, + PerspectiveCamera, + Raycaster, + Scene, + Vector2, + Vector3, + WebGLRenderer, + type Material +} from 'three' +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' +import { GLTFLoader, type GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js' + +export const EQUIPMENT_CATEGORIES = new Set(['node', 'storage', 'network', 'misc']) + +const CAMERA_FOV = 45 +const HIGHLIGHT_INTENSITY = 0.4 +// Turntable camera rotation speed, in radians per second +export const TURNTABLE_SPEED = 0.25 +// Duration of eased camera transitions, in seconds +export const TRANSITION_DURATION = 0.9 + +export type CameraMode = 'free' | 'turntable' + +export interface HoveredEquipment { + name: string + category: string + type: string + rack: string +} + +export interface Focus { + center: Vector3 + size: Vector3 + radius: number +} + +/* Return the closest ancestor of object (including itself) representing an + * equipment, or null if not found. */ +export function findEquipment(object: Object3D | null): Object3D | null { + let current = object + while (current) { + if (EQUIPMENT_CATEGORIES.has(current.userData?.category)) { + return current + } + current = current.parent + } + return null +} + +/* Return the bounding box center, size and radius of all racks in root, or of the + * whole root if it does not contain racks. */ +export function computeFocus(root: Object3D): Focus { + root.updateMatrixWorld(true) + const box = new Box3() + root.traverse((object) => { + if (object.userData?.category === 'rack') { + box.union(new Box3().setFromObject(object)) + } + }) + if (box.isEmpty()) { + box.setFromObject(root) + } + const center = box.getCenter(new Vector3()) + const size = box.getSize(new Vector3()) + return { center, size, radius: Math.max(size.length() / 2, 0.5) } +} + +/* Return camera position to view the whole focus area from above, with the given + * vertical field of view in degrees. The azimuth is the angle in radians of the + * camera around the vertical axis of the focus area, 0 being the front (+Z) of the + * area. */ +export function cameraPosition( + focus: Focus, + fov: number = CAMERA_FOV, + azimuth: number = 0 +): Vector3 { + const distance = (focus.radius / Math.sin(MathUtils.degToRad(fov) / 2)) * 1.1 + const elevation = MathUtils.degToRad(30) + const horizontal = distance * Math.cos(elevation) + return new Vector3( + focus.center.x + horizontal * Math.sin(azimuth), + focus.center.y + distance * Math.sin(elevation), + focus.center.z + horizontal * Math.cos(azimuth) + ) +} + +/* Return the azimuth in radians of the given position around the vertical axis of + * the focus area, 0 being the front (+Z) of the area. */ +export function cameraAzimuth(position: Vector3, focus: Focus): number { + return Math.atan2(position.x - focus.center.x, position.z - focus.center.z) +} + +export function easeInOutCubic(t: number): number { + return t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2 +} + +/* Eased transition of camera position and target. */ +export interface CameraTransition { + fromPosition: Vector3 + toPosition: Vector3 + fromTarget: Vector3 + toTarget: Vector3 + elapsed: number + duration: number +} + +export function createTransition( + fromPosition: Vector3, + toPosition: Vector3, + fromTarget: Vector3, + toTarget: Vector3, + duration: number = TRANSITION_DURATION +): CameraTransition { + return { + fromPosition: fromPosition.clone(), + toPosition: toPosition.clone(), + fromTarget: fromTarget.clone(), + toTarget: toTarget.clone(), + elapsed: 0, + duration + } +} + +/* Advance transition by delta seconds and write the resulting camera position and + * target in the given vectors. Return true when the transition is finished. */ +export function stepTransition( + transition: CameraTransition, + delta: number, + position: Vector3, + target: Vector3 +): boolean { + transition.elapsed += delta + const progress = + transition.duration > 0 ? Math.min(transition.elapsed / transition.duration, 1) : 1 + const eased = easeInOutCubic(progress) + position.lerpVectors(transition.fromPosition, transition.toPosition, eased) + target.lerpVectors(transition.fromTarget, transition.toTarget, eased) + return progress >= 1 +} + +function prefersReducedMotion(): boolean { + return ( + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ) +} + +/* Return the name of the glTF node of the given object. Three.js sanitizes object + * names, the original name is retrieved from the glTF document when possible. */ +export function originalName(gltf: GLTF, object: Object3D): string { + const index = gltf.parser.associations.get(object)?.nodes + if (index !== undefined) { + const name = gltf.parser.json.nodes?.[index]?.name + if (name) { + return name + } + } + return object.name +} + +export function parseGlb(data: ArrayBuffer): Promise { + return new Promise((resolve, reject) => { + new GLTFLoader().parse(data, '', resolve, reject) + }) +} + +export class InfrastructureViewer { + private renderer: WebGLRenderer + private scene = new Scene() + private camera = new PerspectiveCamera(CAMERA_FOV, 1, 0.05, 1000) + private controls: OrbitControls + private focus: Focus + private raycaster = new Raycaster() + private pointer: Vector2 | null = null + private hovered: Object3D | null = null + // Original materials of highlighted meshes + private highlighted = new Map() + private resizeObserver: ResizeObserver | null = null + private animationFrame = 0 + private lastFrame = 0 + private mode: CameraMode = 'free' + // Current eased camera transition, controls are disabled while it is running. + private transition: CameraTransition | null = null + // Point looked by camera when controls are disabled + private lookTarget = new Vector3() + // Turntable camera azimuth and time since turntable rotation started + private turntableAngle = 0 + private turntableTime = 0 + + constructor( + private container: HTMLElement, + private gltf: GLTF, + private onHover: (equipment: HoveredEquipment | null) => void, + private onModeChange: (mode: CameraMode) => void = () => {} + ) { + this.renderer = new WebGLRenderer({ antialias: true }) + this.renderer.setPixelRatio(window.devicePixelRatio) + const canvas = this.renderer.domElement + canvas.classList.add('block', 'h-full', 'w-full') + container.appendChild(canvas) + + this.scene.background = new Color(0xf3f4f6) + this.scene.add(new AmbientLight(0xffffff, 0.8)) + this.scene.add(new HemisphereLight(0xf5f7ff, 0x8a9099, 0.5)) + const key = new DirectionalLight(0xffffff, 0.8) + key.position.set(5, 14, 8) + this.scene.add(key) + const fill = new DirectionalLight(0xffffff, 0.35) + fill.position.set(-6, 9, -4) + this.scene.add(fill) + this.scene.add(gltf.scene) + + const anisotropy = this.renderer.capabilities.getMaxAnisotropy() + gltf.scene.traverse((object) => { + const mesh = object as Mesh + if (mesh.isMesh) { + for (const material of [mesh.material].flat()) { + const map = (material as MeshStandardMaterial).map + if (map) map.anisotropy = anisotropy + } + } + }) + + // These listeners are registered before the controls, so they are called + // first and re-enable the controls in time for the controls to handle the same + // events. + canvas.addEventListener('pointerdown', this.onPointerDown) + canvas.addEventListener('pointermove', this.onPointerMove) + canvas.addEventListener('pointerleave', this.onPointerLeave) + + this.controls = new OrbitControls(this.camera, canvas) + this.controls.enableDamping = true + this.controls.screenSpacePanning = true + // Keep camera above the floor + this.controls.maxPolarAngle = Math.PI / 2 - 0.05 + this.focus = computeFocus(gltf.scene) + this.controls.maxDistance = this.focus.radius * 10 + this.camera.position.copy(cameraPosition(this.focus)) + this.controls.target.copy(this.focus.center) + this.controls.update() + + if (typeof ResizeObserver !== 'undefined') { + this.resizeObserver = new ResizeObserver(() => this.resize()) + this.resizeObserver.observe(container) + } + this.resize() + this.animate() + } + + /* Move camera with an eased transition to its initial position, in free mode. */ + resetCamera() { + this.setModeValue('free') + this.startTransition(cameraPosition(this.focus), this.focus.center) + } + + /* Switch camera mode. In turntable mode, the camera moves with an eased + * transition to the orbit around the racks and rotates. In free mode, the camera + * stops where it is and it is controlled by the user. */ + setMode(mode: CameraMode) { + if (mode === 'turntable') { + if (this.mode === 'turntable') return + this.setModeValue('turntable') + this.turntableAngle = cameraAzimuth(this.camera.position, this.focus) + this.turntableTime = 0 + this.startTransition( + cameraPosition(this.focus, CAMERA_FOV, this.turntableAngle), + this.focus.center + ) + return + } + if (this.mode === 'free' && !this.transition) return + this.transition = null + this.setModeValue('free') + this.enableControls(this.lookTarget) + } + + resize() { + const { clientWidth: width, clientHeight: height } = this.container + if (!width || !height) return + this.camera.aspect = width / height + this.camera.updateProjectionMatrix() + this.renderer.setSize(width, height, false) + } + + dispose() { + cancelAnimationFrame(this.animationFrame) + this.resizeObserver?.disconnect() + const canvas = this.renderer.domElement + canvas.removeEventListener('pointerdown', this.onPointerDown) + canvas.removeEventListener('pointermove', this.onPointerMove) + canvas.removeEventListener('pointerleave', this.onPointerLeave) + this.setHovered(null) + this.controls.dispose() + this.scene.traverse((object) => { + const mesh = object as Mesh + if (mesh.geometry) mesh.geometry.dispose() + if (mesh.material) { + for (const material of [mesh.material].flat()) { + ;(material as MeshStandardMaterial).map?.dispose() + material.dispose() + } + } + }) + this.renderer.dispose() + canvas.remove() + } + + private setModeValue(mode: CameraMode) { + if (mode === this.mode) return + this.mode = mode + this.onModeChange(mode) + } + + /* Start eased transition of camera from its current position and target to the + * given position and target, with controls disabled. */ + private startTransition(position: Vector3, target: Vector3) { + // When a transition is already running, the controls are already disabled and + // the camera looks at lookTarget. + const fromTarget = this.controls.enabled ? this.controls.target : this.lookTarget + this.transition = createTransition( + this.camera.position, + position, + fromTarget, + target, + prefersReducedMotion() ? 0 : TRANSITION_DURATION + ) + this.lookTarget.copy(fromTarget) + this.controls.enabled = false + } + + /* Give camera control back to the user, looking at the given target. */ + private enableControls(target: Vector3) { + if (this.controls.enabled) return + // Discard controls inertia remaining from user moves before controls were + // disabled: an update without damping consumes it, then camera is restored. + const position = this.camera.position.clone() + this.controls.enableDamping = false + this.controls.update() + this.controls.enableDamping = true + this.camera.position.copy(position) + this.controls.target.copy(target) + this.controls.enabled = true + this.controls.update() + } + + private animate = () => { + this.animationFrame = requestAnimationFrame(this.animate) + const now = performance.now() + // Limit delta to avoid jumps when the page was in background. + const delta = this.lastFrame ? Math.min((now - this.lastFrame) / 1000, 0.1) : 0 + this.lastFrame = now + if (this.pointer) { + this.pick(this.pointer) + this.pointer = null + } + if (this.transition) { + const finished = stepTransition(this.transition, delta, this.camera.position, this.lookTarget) + this.camera.lookAt(this.lookTarget) + if (finished) { + this.transition = null + if (this.mode === 'free') this.enableControls(this.lookTarget) + } + } else if (this.mode === 'turntable') { + // Ease in rotation speed during the first second. + this.turntableTime += delta + this.turntableAngle += + TURNTABLE_SPEED * easeInOutCubic(Math.min(this.turntableTime, 1)) * delta + this.camera.position.copy(cameraPosition(this.focus, CAMERA_FOV, this.turntableAngle)) + this.lookTarget.copy(this.focus.center) + this.camera.lookAt(this.lookTarget) + } else { + this.controls.update() + } + this.renderer.render(this.scene, this.camera) + } + + /* Stop turntable and transitions as soon as the user clicks in the scene. */ + private onPointerDown = () => { + this.setMode('free') + } + + private onPointerMove = (event: PointerEvent) => { + const rect = this.renderer.domElement.getBoundingClientRect() + this.pointer = new Vector2( + ((event.clientX - rect.left) / rect.width) * 2 - 1, + -((event.clientY - rect.top) / rect.height) * 2 + 1 + ) + } + + private onPointerLeave = () => { + this.pointer = null + this.setHovered(null) + } + + private pick(pointer: Vector2) { + this.raycaster.setFromCamera(pointer, this.camera) + // Only consider meshes, equipment edges are lines with large picking threshold. + const hit = this.raycaster + .intersectObject(this.gltf.scene, true) + .find((intersection) => (intersection.object as Mesh).isMesh) + this.setHovered(hit ? findEquipment(hit.object) : null) + } + + private setHovered(equipment: Object3D | null) { + if (equipment === this.hovered) return + for (const [mesh, material] of this.highlighted) { + ;(mesh.material as Material).dispose() + mesh.material = material + } + this.highlighted.clear() + this.hovered = equipment + this.renderer.domElement.style.cursor = equipment ? 'pointer' : '' + if (!equipment) { + this.onHover(null) + return + } + equipment.traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh) return + const original = mesh.material as MeshStandardMaterial + // Materials are shared between equipment, highlight a copy. + const material = original.clone() + material.emissive = original.color.clone() + material.emissiveIntensity = HIGHLIGHT_INTENSITY + this.highlighted.set(mesh, original) + mesh.material = material + }) + this.onHover({ + name: originalName(this.gltf, equipment), + category: equipment.userData.category, + type: equipment.userData.type, + rack: equipment.userData.rack + }) + } +} + +export async function createViewer( + container: HTMLElement, + data: ArrayBuffer, + onHover: (equipment: HoveredEquipment | null) => void, + onModeChange?: (mode: CameraMode) => void +): Promise { + return new InfrastructureViewer(container, await parseGlb(data), onHover, onModeChange) +} diff --git a/frontend/src/views/InfrastructureDetailsView.vue b/frontend/src/views/InfrastructureDetailsView.vue index e723307b..2b129104 100644 --- a/frontend/src/views/InfrastructureDetailsView.vue +++ b/frontend/src/views/InfrastructureDetailsView.vue @@ -7,8 +7,9 @@ SPDX-License-Identifier: MIT -->