diff --git a/CHANGELOG.md b/CHANGELOG.md index b0711759..aa709255 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,10 +23,30 @@ 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). +- cli: Support `gltf` and `glb` formats in `draw infrastructure` command (#164). +- web: Support `gltf` and `glb` formats in infrastructures draw endpoint + (#164). +- 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 API reference. + - Add page about 3D representations of infrastructures with glTF scenes + conventions and nodes metadata. + - Mention `gltf` and `glb` formats in `racksdb` manpage. + - Mention 3D view of infrastructures in web UI page. + - Mention 3D representations of infrastructures in diagrams section of + overview page. ### Fixed - lib: diff --git a/README.md b/README.md index 2963f6c9..41b96731 100644 --- a/README.md +++ b/README.md @@ -73,8 +73,8 @@ RacksDB provides multiple interfaces with its

RacksDB can generate graphical representations of your datacenter rooms and your -infrastructures with their racks and equipment, including axonometric views for -3D representations: +infrastructures with their racks and equipment, including axonometric views and +[3D representations](https://docs.rackslab.io/racksdb/usage/3d.html):

- - + diff --git a/docs/modules/overview/pages/overview.adoc b/docs/modules/overview/pages/overview.adoc index bef0bf38..66426383 100644 --- a/docs/modules/overview/pages/overview.adoc +++ b/docs/modules/overview/pages/overview.adoc @@ -74,17 +74,24 @@ synchronized with database content. == Diagrams RacksDB can be used to generate diagrams of your datacenter rooms and the racks -with all their equipments, including _axonometric views_ for 3D representations. +with all their equipments, including _axonometric views_, and +xref:usage:3d.adoc[3D representations] of your infrastructures. image::overview:racksdb_diagrams.png[RacksDB Diagrams,xref=image$racksdb_diagrams.svg] These diagrams can be generated in various formats (PNG, SVG, PDF) and be customized with many xref:usage:drawparams.adoc[drawing parameters]. +The 3D representations of infrastructures are generated in standard glTF 2.0 +format, with your racks and equipment in their datacenter rooms. They can be +explored interactively in the xref:usage:ui.adoc[Web UI] or imported in any +3D software. + [sidebar] -- .More links * xref:usage:racksdb.adoc#draw[`racksdb` CLI drawing feature] * xref:usage:drawparams.adoc[Drawing parameters howto and reference documentation] +* xref:usage:3d.adoc[3D representations of infrastructures] -- 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: diff --git a/docs/modules/usage/nav.adoc b/docs/modules/usage/nav.adoc index b2b2d0a2..9950cafc 100644 --- a/docs/modules/usage/nav.adoc +++ b/docs/modules/usage/nav.adoc @@ -2,6 +2,7 @@ ** xref:racksdb.adoc[`racksdb`] ** xref:racksdb-web.adoc[`racksdb-web`] * xref:drawparams.adoc[] +* xref:3d.adoc[] * xref:lib.adoc[] * xref:rest.adoc[] * xref:ui.adoc[] diff --git a/docs/modules/usage/pages/3d.adoc b/docs/modules/usage/pages/3d.adoc new file mode 100644 index 00000000..71ba986b --- /dev/null +++ b/docs/modules/usage/pages/3d.adoc @@ -0,0 +1,149 @@ += 3D Representations + +RacksDB can generate 3D representations of infrastructures in +https://www.khronos.org/gltf/[glTF 2.0] format, the standard format for 3D +scenes supported by most 3D software (_ex:_ Blender) and web 3D libraries +(_ex:_ three.js, Babylon.js). These representations contain all the rooms and +racks used by the infrastructure, with its equipment at their positions in the +racks. + +image::shadowed/3d_fullscreen.webp[3D Representation of Infrastructure,800] + +== Formats + +Two variants of glTF format are supported: + +`glb`:: + Binary format, in a single file with all the data. This is the most compact + variant, it is recommended in most cases. Its media type is + `model/gltf-binary`. + +`gltf`:: + JSON format, with all binary data (geometry, textures) embedded in base64 + encoded URI. Its media type is `model/gltf+json`. + +These formats are supported for infrastructures only. Coordinates cannot be +requested with these formats. + +== Usage + +With xref:racksdb.adoc#draw[`racksdb` CLI], select the format with `--format` +option. For example: + +[source,console] +---- +$ racksdb draw infrastructure --name=mercury --format=glb +---- + +This command generates file `mercury.glb` in current directory. + +With xref:rest.adoc[REST API], use the format as extension of the requested +resource. For example: + +[source,console,subs=attributes] +---- +$ curl -X POST http://localhost:5000/{api-version}/draw/infrastructure/mercury.glb \ + --output mercury.glb +---- + +The xref:ui.adoc#_infrastructure_details[web UI] also displays these 3D +representations in an interactive viewer. + +== Drawing Parameters + +The following xref:drawparams.adoc[drawing parameters] are honored in 3D +representations: + +* `infrastructure.equipment_tags` +* `infrastructure.ghost_unselected`, ghosted equipment are semi-transparent +* `infrastructure.discard_empty_racks` +* `infrastructure.other_racks`, racks not used by the infrastructure are + represented with closed doors +* `infrastructure.collisions`, edges of colliding equipment are red +* `rack.u_height` +* `rack.pane_width` +* `colors.equipments`, with `background`, `border` and `ghost` colors +* `colors.racks`, with `frame` color + +Other drawing parameters are specific to images and they are ignored. + +== Scene + +The scene uses meters as unit, with Y axis pointing 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. Racks doors face +Z in racks local coordinates. + +The rooms have raised floors with 600mm tiles. Every other tile in front of +racks doors is perforated, to represent cold aisles. When the infrastructure is +spread over multiple rooms, the rooms are placed side by side along the X axis, +separated by 2 meters. + +Equipment depth is not defined in database. Equipment are represented as deep +as their rack, minus 10cm at the rear. + +The glTF scene graph follows the structure of the database. Every glTF node +representing a database object (room, row, rack or equipment) is named after +this object and has metadata in its `extras` property, with the `category` key +giving the type of object. For equipment, this key contains the category of +equipment in RacksDB (`node`, `storage`, `network` or `misc`). + +[cols="1,2,1,4"] +|=== +|Object|Category|Parent|Extras + +|Room +|`room` +|_scene_ +|`datacenter`: name of datacenter + +`size`: room width, height (always 0) and depth in meters + +|Row +|`row` +|Room +|`reversed`: boolean, true if racks doors face the top of room maps + +|Rack +|`rack` +|Row +|`type`: rack type + +`tags`: list of rack tags + +`infrastructure`: boolean, false if the rack is represented only because of +`other_racks` drawing parameter + +`size`: rack width, height and depth in meters + +|Equipment +|`node`, `storage`, `network` or `misc` +|Rack +|`type`: equipment type + +`tags`: list of equipment tags + +`rack`: name of rack + +`slot`: object with `height` and `width` keys, the position of equipment in +rack + +`selected`: boolean, false for ghosted equipment + +`colliding`: boolean, true if equipment collides with other equipment + +`size`: equipment width, height and depth in meters +|=== + +The geometry of database objects is attached to unnamed child glTF nodes, which +have a `part` key in their `extras` instead of `category`. For example, this is +an excerpt of the scene graph of `mercury` infrastructure, with the `extras` of +the glTF nodes: + +---- +noisy {"category": "room", …} +├── (unnamed) {"part": "floor"} +├── R1 {"category": "row", …} +│ └── R1-A01 {"category": "rack", …} +│ ├── (unnamed) {"part": "left"} +│ ├── … +│ └── mecn0001 {"category": "node", …} +│ ├── (unnamed) {"part": "body"} +│ └── (unnamed) {"part": "edges"} +└── … +---- + +NOTE: Some glTF loaders modify the names of glTF nodes. For example, three.js +removes characters `[]./:` and replaces whitespaces with underscores, so +equipment `sw1.example.org` is named `sw1exampleorg`. Original names can be +retrieved with `parser.associations` and `parser.json` properties of the loaded +glTF object. diff --git a/docs/modules/usage/pages/drawparams.adoc b/docs/modules/usage/pages/drawparams.adoc index d9d7ed28..13635c2f 100644 --- a/docs/modules/usage/pages/drawparams.adoc +++ b/docs/modules/usage/pages/drawparams.adoc @@ -2,8 +2,9 @@ RacksDB offers the possibity to generate graphical representations of datacenter room and infrastructures with their racks and equipments in various images -formats. The images are generated with various default drawing parameters that -user are able to customize for their requirements. +formats, and xref:3d.adoc[3D representations] of infrastructures. The images +are generated with various default drawing parameters that user are able to +customize for their requirements. The xref:#usage[first section] of this page describes how to specify drawing parameters with both RacksDB xref:racksdb.adoc[CLI] and diff --git a/docs/modules/usage/pages/racksdb.adoc b/docs/modules/usage/pages/racksdb.adoc index 78c81370..cbe49d41 100644 --- a/docs/modules/usage/pages/racksdb.adoc +++ b/docs/modules/usage/pages/racksdb.adoc @@ -280,8 +280,9 @@ Get all tags associated to racks in datacenter _paris_. === Draw commands The [.cli-opt]#*draw*# command is used to generate image file with graphical -representations of database entities. This command is used in combination with a -sub-command to indicate the type of database entity. +representations of database entities, or 3D representations of +infrastructures. This command is used in combination with a sub-command to +indicate the type of database entity. This command accepts the following options: @@ -289,8 +290,11 @@ This command accepts the following options: Name of the entity to represent. This option is required. [.cli-opt]#*--format*=#[.cli-optval]##_FORMAT_##:: - File format of the generated image file. Possible values are _png_, _svg_ and - _pdf_. Default value is _png_. + File format of the generated image file. Possible values are _png_, _svg_, + _pdf_, _gltf_ and _glb_. Default value is _png_. Formats _gltf_ and _glb_ + produce xref:3d.adoc[3D representations] in glTF 2.0 format, they are + supported for infrastructures only and they cannot be combined with + [.cli-opt]#*--coordinates*# option. [.cli-opt]#*--parameters*=#[.cli-optval]##_PARAMETERS_##:: Path to YAML xref:drawparams.adoc[drawing parameters] file. With special value @@ -316,7 +320,8 @@ The following sub-commands are available: [.cli-opt]#*infrastructure*#:: Draw racks of an infrastructure, grouped by rows. The front side of the racks - are represented with the equipments of the infrastructure. + are represented with the equipments of the infrastructure. With _gltf_ and + _glb_ formats, the racks are represented in 3D in their datacenter rooms. [.cli-opt]#*room*#:: @@ -347,6 +352,13 @@ Generate PDF image file `tiger.pdf` with racks and equipments used in _tiger_ infrastructure with custom xref:drawparams.adoc[drawing parameters] defined in `custom.yaml`. +[source,console] +$ racksdb draw infrastructure --name=tiger --format=glb + +[.cli-example-desc] +Generate 3D representation of _tiger_ infrastructure in binary glTF file +`tiger.glb`. + [source,console] $ racksdb draw room --name=noisy --parameters=- < custom.yaml diff --git a/docs/modules/usage/pages/ui.adoc b/docs/modules/usage/pages/ui.adoc index 5acfdf28..bfe31c39 100644 --- a/docs/modules/usage/pages/ui.adoc +++ b/docs/modules/usage/pages/ui.adoc @@ -57,6 +57,17 @@ In *Infrastructure Details* page, a graphical representation of the infrastructure is displayed at the top. Users can *click on this thumbnail* to display a larger image. +Users can switch to the xref:3d.adoc[3D representation] of the infrastructure +with the *2D/3D toggle* above the image. In the 3D view, users can rotate the +camera around the racks with mouse left button, pan with right button and zoom +with mouse wheel. The name, type and rack of equipment are displayed when the +mouse hovers it. Buttons are available to reset the view, to switch to +fullscreen mode and to enable the *turntable mode*. In turntable mode, the +camera orbits automatically around the racks until users click in the 3D view, +giving them back the control of the camera. + +image::shadowed/3d_fullscreen.webp[UI Infrastructure 3D View,800] + The page contains a table of all infrastructure equipment grouped by rack, including their _name_, _category_, _model_ and _tags_. 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 -->