diff --git a/docs/modules/usage/attachments/openapi.yml b/docs/modules/usage/attachments/openapi.yml index 35042987..734875fc 100644 --- a/docs/modules/usage/attachments/openapi.yml +++ b/docs/modules/usage/attachments/openapi.yml @@ -1129,7 +1129,15 @@ paths: description: successful operation /v0.7.0/draw//.: get: - description: Draw an entity + description: 'Draw an entity. For infrastructure, format gltf returns a glTF + 2.0 JSON scene (model/gltf+json) with a single embedded buffer, one shared + unit cube, rack carcass panels, a textured room floor, per-equipment fill + and border materials, and node extras + for Slurm-web and similar consumers. Honored + drawing parameters for gltf: infrastructure.equipment_tags, ghost_unselected, + discard_empty_racks, other_racks, rack.u_height, rack.pane_width. Image-only + parameters (dimensions, margin, axonometric, colors, labels, pixel_perfect, + door_depth) are ignored.' parameters: - description: Type of entity to draw in: path @@ -1146,7 +1154,7 @@ paths: required: true schema: type: string - - description: Format of the generated image + - description: Format of the generated image or glTF scene in: path name: format required: true @@ -1156,6 +1164,7 @@ paths: - png - svg - pdf + - gltf type: string - description: Control if entities are represented perfectly aligned on pixels with concession on relative dimensions correctess. This parameter has no @@ -1445,6 +1454,9 @@ paths: image/svg+xml: schema: type: string + model/gltf+json: + schema: + type: object multipart/form-data: schema: format: binary @@ -1471,7 +1483,15 @@ paths: $ref: '#/components/schemas/Error' description: Unable to load drawing parameters schema. post: - description: Draw an entity + description: 'Draw an entity. For infrastructure, format gltf returns a glTF + 2.0 JSON scene (model/gltf+json) with a single embedded buffer, one shared + unit cube, rack carcass panels, a textured room floor, per-equipment fill + and border materials, and node extras + for Slurm-web and similar consumers. Honored + drawing parameters for gltf: infrastructure.equipment_tags, ghost_unselected, + discard_empty_racks, other_racks, rack.u_height, rack.pane_width. Image-only + parameters (dimensions, margin, axonometric, colors, labels, pixel_perfect, + door_depth) are ignored.' parameters: - description: Type of entity to draw in: path @@ -1488,7 +1508,7 @@ paths: required: true schema: type: string - - description: Format of the generated image + - description: Format of the generated image or glTF scene in: path name: format required: true @@ -1498,6 +1518,7 @@ paths: - png - svg - pdf + - gltf type: string - allowEmptyValue: true description: Dump equipments and racks coordinates in diagrams. When this @@ -1539,6 +1560,9 @@ paths: image/svg+xml: schema: type: string + model/gltf+json: + schema: + type: object multipart/form-data: schema: format: binary diff --git a/docs/modules/usage/pages/racksdb.adoc b/docs/modules/usage/pages/racksdb.adoc index 78c81370..68cb0c9f 100644 --- a/docs/modules/usage/pages/racksdb.adoc +++ b/docs/modules/usage/pages/racksdb.adoc @@ -289,8 +289,9 @@ 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 output. Possible values are _png_, _svg_, _pdf_, + and _gltf_ (infrastructure only; see xref:rest.adoc#infrastructure-gltf-export[]). + Default value is _png_. [.cli-opt]#*--parameters*=#[.cli-optval]##_PARAMETERS_##:: Path to YAML xref:drawparams.adoc[drawing parameters] file. With special value diff --git a/docs/modules/usage/pages/rest.adoc b/docs/modules/usage/pages/rest.adoc index c23e83de..f6dc868b 100644 --- a/docs/modules/usage/pages/rest.adoc +++ b/docs/modules/usage/pages/rest.adoc @@ -5,6 +5,85 @@ datacenter rooms and infrastructures racks. This REST API is served by xref:racksdb-web.adoc[`racksdb-web` command]. This page contains the reference documentation of this API. +[[infrastructure-gltf-export]] +== Infrastructure glTF export + +`GET` and `POST` `/v{api-version}/draw/infrastructure/.gltf` return one glTF +2.0 JSON document (`Content-Type: model/gltf+json`). The binary buffer is embedded as +a base64 data URI on the single `buffers[0].uri` entry; the document does not reference +an external `.bin` file. Request bodies and query drawing parameters work the same way +as PNG draw, including blueprint default drawing parameters merged under the request. + +Drawing parameters that only affect 2D images are ignored for glTF: +`dimensions`, `margin`, `axonometric`, `colors`, labels, label offsets, and +`pixel_perfect`. Parameters that are honored include +`infrastructure.equipment_tags`, `infrastructure.ghost_unselected`, +`infrastructure.discard_empty_racks`, `infrastructure.other_racks`, `rack.u_height` +(default 44.45 mm), and `rack.pane_width` (default 40 mm). `rack.door_depth` is not +used as a geometry size. + +=== Coordinate system + +The scene uses glTF Y-up coordinates in meters (database millimeters are multiplied by +0.001). +X is the room width axis, +Y is up, and +Z is the room depth axis (the 2D room +diagram downward axis). Row translation is `(position.width, 0, position.depth)`. The +database row rotation is clockwise on the floor plan; the row node Y rotation is the +negation of `position.rotation`. Rack positions along a row follow the same slot-width +summing as room diagrams (`RoomDrawer`), without elevation `rack.spacing`. After each +rack node transform, local +Z is the door face (a 180° Y rotation on the rack node +when `row.reversed` is true). + +When an infrastructure spans racks in more than one room, each room is a separate +scene root with its own rectangular floor slab. Room nodes are placed side by side +along +X in infrastructure layout order, separated by a fixed 2 m gap. Rows, racks, and +floor tiles stay in each room's local frame and move with that room's translation. + +=== Shared unit cube + +The file defines one mesh geometry (unit cube from -0.5 to 0.5 on each axis, with +normals and texture coordinates). Devices reference it through the `equipment` mesh. +Scale equal to the object size in meters is set on a mesh child node; the named parent +node carries translation, rotation, and `extras`, with scale `(1, 1, 1)`. + +Each rack carcass is the same cube instanced as panels: left and right side panels +(`rack.pane_width` thick), plus top and bottom sheets. Local +Z, the door face, and +the rear stay open. A rack that is not part of the infrastructure (`other_racks`) +also has a front panel. Panel nodes are unnamed mesh children with `extras.category` +`rack-part` and `extras.part` (`left`, `right`, `top`, `bottom`, or `front`). The +rack material is a very dark gray. + +The room floor is a thin slab of that cube. Its material embeds +`racksdb/drawers/assets/floor-tile-solid.png` (`images[0]`, repeat wrap) and +`KHR_texture_transform` so the tile repeats every 0.6 m across the room. In front of +each rack door, 0.6 m cells on that same grid use +`racksdb/drawers/assets/floor-tile-perforated.png` (`images[1]`) on the even checker +cells of the aisle strip outside the door face. Perforated floor nodes use +`extras.category` `floor` and `extras.part` `perforated`. + +=== Node hierarchy and extras + +Hierarchy: `room` → floor and `row` → `rack` → panels and `equipment`. Folded equipment +names are expanded. `GLTFLoader` copies node `extras` to `object.userData`. + +*Equipment* `extras`: `category` (`node`, `storage`, `network`, or `misc`), `type`, +`tags`, `selected`, `size` `[width, height, depth]` in meters, `rack`, `slot` +`{"u", "x"}`. Fill and border colors come from glTF materials on the equipment mesh +children (same drawing `colors.equipments` rules as 2D elevation diagrams: +`background` / `border` when selected, `ghost` when shown as a ghost). The solid fills +that size, so neighboring devices touch. A line outline is drawn on the same edges so +stacked units stay distinct without a gap. +Depth is the rack depth minus 10 cm, removed at the rear; the front face stays on +the door. + +*Rack* `extras`: `category` `rack`, `type`, `in_infrastructure`, `reversed`, `slots`, +`size`. + +*Row* `extras`: `category` `row`, `reversed`, `rotation` (clockwise degrees as stored). + +*Room* `extras`: `category` `room`, `datacenter`, `size` `[width, 0, depth]`. + +Runtime state (CPU, RAM, GPU, Slurm status, and similar) is not included in the file. + [subs=attributes] ++++ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 312a921e..328a094e 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,12 @@ "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 + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz", @@ -1435,6 +1443,12 @@ "dev": true, "license": "MIT" }, + "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 + }, "node_modules/@types/eslint": { "version": "8.44.7", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.7.tgz", @@ -1498,11 +1512,37 @@ "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 + }, + "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, + "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 + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "6.7.4", "dev": true, @@ -3180,6 +3220,12 @@ "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 + }, "node_modules/file-entry-cache": { "version": "6.0.1", "dev": true, @@ -4025,6 +4071,12 @@ "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 + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -5484,6 +5536,11 @@ "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==" + }, "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..e27c33fb --- /dev/null +++ b/frontend/src/components/InfrastructureScene3D.vue @@ -0,0 +1,600 @@ + + + + + diff --git a/frontend/src/composables/RacksDBAPI.ts b/frontend/src/composables/RacksDBAPI.ts index 42a75922..9af19a5e 100644 --- a/frontend/src/composables/RacksDBAPI.ts +++ b/frontend/src/composables/RacksDBAPI.ts @@ -173,6 +173,28 @@ export interface MiscEquipment { slot: number } +export interface GltfEquipmentUserData { + category: 'node' | 'storage' | 'network' | 'misc' + type: string + tags: string[] + selected: boolean + size: [number, number, number] + rack: string + slot: { u: number; x: number } +} + +export interface GltfRoot { + asset: { version: string } + scene: number + scenes: Array<{ nodes?: number[] }> + nodes: Array> + meshes?: Array + materials?: Array + accessors?: Array + bufferViews?: Array + buffers: Array<{ uri: string; byteLength: number }> +} + export function useRacksDBAPI(http: AxiosInstance) { async function racksDBGet(ressource: string): Promise { try { @@ -221,5 +243,26 @@ export function useRacksDBAPI(http: AxiosInstance) { }) } - return { racksDBGet, datacenters, infrastructures, racks, infrastructureImageSvg, roomImageSvg } + async function infrastructureGltf(infrastructure: string): Promise { + try { + const response = await http.post(`/draw/infrastructure/${infrastructure}.gltf`, {}, { + responseType: 'json', + headers: { Accept: 'model/gltf+json' } + }) + return response.data as GltfRoot + } catch (error: any) { + console.error(`Error getting glTF for ${infrastructure}`, error) + throw error + } + } + + return { + racksDBGet, + datacenters, + infrastructures, + racks, + infrastructureImageSvg, + roomImageSvg, + infrastructureGltf + } } diff --git a/frontend/src/lib/infrastructureScene3d.ts b/frontend/src/lib/infrastructureScene3d.ts new file mode 100644 index 00000000..c68aca3a --- /dev/null +++ b/frontend/src/lib/infrastructureScene3d.ts @@ -0,0 +1,183 @@ +/* Copyright (c) 2025 Rackslab + * + * This file is part of RacksDB. + * + * SPDX-License-Identifier: MIT */ + +import type { Mesh, Object3D, PerspectiveCamera } from 'three' +import { Box3, Vector3 } from 'three' +import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' + +export type RackFocus = { + center: Vector3 + size: Vector3 + radius: number +} + +const MIN_ORBIT_DISTANCE = 0.4 +const MIN_POLAR_ANGLE = 0.12 +const MAX_POLAR_ANGLE = Math.PI / 2 - 0.08 +export const TURNTABLE_SPEED = 0.25 + +export const CAMERA_MODE_TRANSITION_DURATION_S = 0.9 + +export type CameraTransition = { + fromPosition: Vector3 + toPosition: Vector3 + fromTarget: Vector3 + toTarget: Vector3 + elapsed: number + duration: number +} + +function easeOutCubic(t: number): number { + return 1 - (1 - t) ** 3 +} + +export function turntableCameraPosition( + focus: RackFocus, + angle: number, + radius: number, + height: number +): Vector3 { + return new Vector3( + focus.center.x + Math.sin(angle) * radius, + height, + focus.center.z + Math.cos(angle) * radius + ) +} + +export function computeRackFocusBox(root: Object3D): RackFocus { + const rackFocusBox = new Box3() + let hasRack = false + + root.updateMatrixWorld(true) + root.traverse((child) => { + if (child.userData?.category !== 'rack') { + return + } + for (const rackChild of child.children) { + const mesh = rackChild as Mesh + if (!mesh.isMesh) { + continue + } + const box = new Box3().setFromObject(mesh) + if (!box.isEmpty()) { + rackFocusBox.union(box) + hasRack = true + } + } + }) + + if (!hasRack) { + rackFocusBox.setFromObject(root) + } + + const center = new Vector3() + const size = new Vector3() + rackFocusBox.getCenter(center) + rackFocusBox.getSize(size) + const radius = Math.max(size.x, size.y, size.z) / 2 + + return { + center, + size, + radius: Math.max(radius, 0.5) + } +} + +export function applyFreeCameraDefaults( + camera: PerspectiveCamera, + controls: OrbitControls, + focus: RackFocus +): { minDistance: number; maxDistance: number } { + const minDistance = MIN_ORBIT_DISTANCE + const maxDistance = Math.max(focus.radius * 6, 5) + + controls.target.copy(focus.center) + controls.minDistance = minDistance + controls.maxDistance = maxDistance + controls.minPolarAngle = MIN_POLAR_ANGLE + controls.maxPolarAngle = MAX_POLAR_ANGLE + controls.enablePan = true + controls.screenSpacePanning = true + + camera.position.set( + focus.center.x, + focus.center.y + focus.size.y * 0.35 + 0.3, + focus.center.z + focus.radius + 1.2 + ) + camera.lookAt(focus.center) + controls.update() + + return { minDistance, maxDistance } +} + +export function defaultTurntableRadius(focus: RackFocus, maxDistance: number): number { + return Math.min(Math.max(focus.radius * 2.5, MIN_ORBIT_DISTANCE + 0.5), maxDistance) +} + +export function defaultTurntableHeight(focus: RackFocus): number { + return focus.center.y + focus.size.y * 0.35 +} + +export function updateTurntableCamera( + camera: PerspectiveCamera, + focus: RackFocus, + angle: number, + radius: number, + height: number +): void { + camera.position.copy(turntableCameraPosition(focus, angle, radius, height)) + camera.lookAt(focus.center) +} + +export function createTurntableEntryTransition( + camera: PerspectiveCamera, + controls: OrbitControls, + focus: RackFocus, + turntableAngle: number, + turntableRadius: number, + turntableHeight: number, + duration = CAMERA_MODE_TRANSITION_DURATION_S +): CameraTransition { + return { + fromPosition: camera.position.clone(), + toPosition: turntableCameraPosition( + focus, + turntableAngle, + turntableRadius, + turntableHeight + ), + fromTarget: controls.target.clone(), + toTarget: focus.center.clone(), + elapsed: 0, + duration + } +} + +export function stepCameraTransition( + camera: PerspectiveCamera, + transition: CameraTransition, + delta: number +): boolean { + transition.elapsed += delta + const linear = Math.min(1, transition.elapsed / transition.duration) + const eased = easeOutCubic(linear) + const lookTarget = new Vector3().lerpVectors( + transition.fromTarget, + transition.toTarget, + eased + ) + camera.position.lerpVectors(transition.fromPosition, transition.toPosition, eased) + camera.lookAt(lookTarget) + return linear >= 1 +} + +export function clampTurntableRadius( + radius: number, + minDistance: number, + maxDistance: number +): number { + return Math.min(Math.max(radius, minDistance), maxDistance) +} diff --git a/frontend/src/views/InfrastructureDetailsView.vue b/frontend/src/views/InfrastructureDetailsView.vue index e723307b..eb73ea8b 100644 --- a/frontend/src/views/InfrastructureDetailsView.vue +++ b/frontend/src/views/InfrastructureDetailsView.vue @@ -7,18 +7,23 @@ SPDX-License-Identifier: MIT -->